[object Object]

← back to Warp Starfield

Warp starfield tunnel: working self-contained build

7e3e351c0be435a8034f2478f4021473c159966d · 2026-07-24 20:43:44 -0700 · Steve

Built up from the Model Arena starting artifact. Kept perspective star
projection + motion-blur streaks; fixed resize star-pool bug, added live
FPS/speed HUD, hyperspace boost, tab-blur pause, reduced-motion support.

Files touched

Diff

commit 7e3e351c0be435a8034f2478f4021473c159966d
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jul 24 20:43:44 2026 -0700

    Warp starfield tunnel: working self-contained build
    
    Built up from the Model Arena starting artifact. Kept perspective star
    projection + motion-blur streaks; fixed resize star-pool bug, added live
    FPS/speed HUD, hyperspace boost, tab-blur pause, reduced-motion support.
---
 .gitignore |   8 ++
 README.md  |  44 ++++++++
 index.html | 358 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 410 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..00c1231
--- /dev/null
+++ b/README.md
@@ -0,0 +1,44 @@
+# Warp Starfield Tunnel
+
+A single self-contained HTML file: a procedurally generated warp-speed starfield
+tunnel rendered on a 2D canvas at 60fps. No dependencies, no build step — open
+`index.html` in any modern browser.
+
+## Controls
+
+| Input | Action |
+|-------|--------|
+| **Move mouse** | Steer the camera (banks the vanishing point) |
+| **Scroll wheel** | Change cruise speed |
+| **Hold click / Space** | Hyperspace boost (speed surge, tighter trails, pink glow) |
+| **↑ / ↓ arrows** | Nudge speed up / down |
+| **Touch: one-finger drag** | Steer + boost |
+| **Touch: two-finger vertical** | Change speed |
+
+The HUD shows live **FPS** and **speed** so the 60fps target is verifiable.
+
+## What it does
+
+- **Perspective star projection** — each star has a 3D position; `x*FOV/z` projects
+  it to screen, so stars streak outward from the vanishing point as `z` shrinks.
+- **Motion blur** — instead of clearing the frame, a translucent black wash is
+  drawn each frame; faster speed = lighter wash = longer streaks.
+- **Adaptive density** — star count scales with the viewport area and is rebuilt
+  on resize.
+- **Depth-aware shading** — nearer stars are larger, brighter, and get a bright
+  core dot; distant stars twinkle. Color shifts hotter/whiter with speed.
+- **Frame-rate independent** — motion is scaled by a clamped delta-time, so it
+  runs the same on 60/120/144Hz displays.
+- **Pauses when the tab is hidden** and respects `prefers-reduced-motion`.
+
+## Origin
+
+Built up from a Model Arena challenge starting artifact — kept the sound core
+(perspective projection + motion-blur streaks) and fixed/extended it:
+
+- Star pool is now **rebuilt on resize** (was computed once and never adjusted).
+- Added a **live FPS + speed HUD** (the challenge calls for verifiable 60fps).
+- Added **hyperspace boost**, arrow-key speed, and idle-fade chrome.
+- **Pauses on tab blur** and resets the clock so returning doesn't jump.
+- Tuned steering so the vanishing-point shift and world-drift no longer
+  double-lurch; removed dead code.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..3850404
--- /dev/null
+++ b/index.html
@@ -0,0 +1,358 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+<meta name="theme-color" content="#000000">
+<title>Warp Starfield Tunnel</title>
+<style>
+  html, body {
+    margin: 0;
+    padding: 0;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+    background: #000;
+    cursor: crosshair;
+    -webkit-tap-highlight-color: transparent;
+  }
+  canvas { display: block; }
+
+  #hud {
+    position: fixed;
+    left: 14px;
+    bottom: 12px;
+    font: 11px/1.6 "SF Mono", Menlo, Consolas, monospace;
+    color: rgba(160, 200, 255, 0.55);
+    pointer-events: none;
+    user-select: none;
+    letter-spacing: 0.5px;
+    text-shadow: 0 0 6px rgba(80, 140, 255, 0.4);
+    white-space: nowrap;
+  }
+  #hud b { color: rgba(200, 225, 255, 0.9); font-weight: 600; }
+
+  #stats {
+    position: fixed;
+    right: 14px;
+    bottom: 12px;
+    font: 11px/1.6 "SF Mono", Menlo, Consolas, monospace;
+    color: rgba(160, 200, 255, 0.45);
+    pointer-events: none;
+    user-select: none;
+    text-align: right;
+    text-shadow: 0 0 6px rgba(80, 140, 255, 0.35);
+  }
+  #stats .val { color: rgba(200, 225, 255, 0.85); }
+
+  #speedbar {
+    position: fixed;
+    left: 14px;
+    bottom: 46px;
+    width: 160px;
+    height: 3px;
+    background: rgba(120, 160, 255, 0.15);
+    border-radius: 2px;
+    overflow: hidden;
+    pointer-events: none;
+  }
+  #speedfill {
+    height: 100%;
+    width: 20%;
+    background: linear-gradient(90deg, #3af, #aef);
+    border-radius: 2px;
+    box-shadow: 0 0 8px rgba(90, 180, 255, 0.8);
+    transition: background 0.25s;
+  }
+  #speedfill.boost { background: linear-gradient(90deg, #f6a, #fdb); box-shadow: 0 0 10px rgba(255, 140, 180, 0.9); }
+
+  /* Fade the chrome out when idle so the field is unobstructed */
+  #hud, #stats, #speedbar { transition: opacity 0.6s; }
+  body.idle #hud, body.idle #stats, body.idle #speedbar { opacity: 0.25; }
+</style>
+</head>
+<body>
+<canvas id="c"></canvas>
+<div id="speedbar"><div id="speedfill"></div></div>
+<div id="hud">WARP DRIVE &mdash; move mouse to steer &middot; scroll to change speed &middot; hold <b>space</b>/click for hyperspace</div>
+<div id="stats"><span class="val" id="fps">--</span> fps &middot; <span class="val" id="spd">--</span> ly/s</div>
+<script>
+(function () {
+  "use strict";
+
+  var canvas = document.getElementById("c");
+  var ctx = canvas.getContext("2d", { alpha: false });
+  var speedfill = document.getElementById("speedfill");
+  var fpsEl = document.getElementById("fps");
+  var spdEl = document.getElementById("spd");
+  var body = document.body;
+
+  var prefersReduced = window.matchMedia &&
+    window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+
+  var W = 0, H = 0, CX = 0, CY = 0, DPR = 1;
+
+  // ---- config ----
+  var DEPTH = 1600;          // z range (near..far)
+  var FOV = 320;             // projection focal length
+  var MIN_SPEED = 2;
+  var MAX_SPEED = 90;
+  var BOOST_SPEED = 150;     // hyperspace target
+  var STAR_DENSITY = 1800;   // one star per this many screen px^2
+
+  var stars = [];
+  var NUM_STARS = 0;
+
+  function targetStarCount() {
+    // scale with screen area, clamped to a sane range
+    return Math.min(1400, Math.max(400, Math.floor((W * H) / STAR_DENSITY)));
+  }
+
+  function spawn(s, randomZ) {
+    // spread wide so steering reveals new stars at the edges
+    s.x = (Math.random() * 2 - 1) * DEPTH * 1.2;
+    s.y = (Math.random() * 2 - 1) * DEPTH * 1.2;
+    s.z = randomZ ? Math.random() * DEPTH : DEPTH;
+    s.px = null;
+    s.py = null;
+    s.hue = 200 + Math.random() * 60 - 30;          // blue-white core palette
+    if (Math.random() < 0.08) s.hue = 30 + Math.random() * 30; // occasional gold star
+    s.mag = 0.5 + Math.random() * 1.0;              // brightness / size multiplier
+    s.tw = Math.random() * 6.283;                   // twinkle phase
+    return s;
+  }
+
+  function buildStars() {
+    NUM_STARS = targetStarCount();
+    if (stars.length > NUM_STARS) {
+      stars.length = NUM_STARS;
+    } else {
+      while (stars.length < NUM_STARS) stars.push(spawn({}, true));
+    }
+  }
+
+  function resize() {
+    DPR = Math.min(window.devicePixelRatio || 1, 2);
+    W = window.innerWidth;
+    H = window.innerHeight;
+    canvas.width = Math.floor(W * DPR);
+    canvas.height = Math.floor(H * DPR);
+    canvas.style.width = W + "px";
+    canvas.style.height = H + "px";
+    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+    CX = W / 2;
+    CY = H / 2;
+    buildStars();
+    // hard reset so we don't smear stale pixels after a resize
+    ctx.fillStyle = "#000";
+    ctx.fillRect(0, 0, W, H);
+  }
+  window.addEventListener("resize", resize);
+  resize();
+
+  // ---- state ----
+  var speed = 16;            // scroll-controlled target speed
+  var curSpeed = 16;         // smoothed speed actually rendered
+  var mouseX = 0, mouseY = 0; // -1..1 steer target
+  var steerX = 0, steerY = 0; // smoothed steer
+  var boosting = false;
+  var boostAmt = 0;          // 0..1 eased boost
+  var idleTimer = null;
+
+  function wake() {
+    body.classList.remove("idle");
+    if (idleTimer) clearTimeout(idleTimer);
+    idleTimer = setTimeout(function () { body.classList.add("idle"); }, 2600);
+  }
+  wake();
+
+  // ---- input: steering ----
+  window.addEventListener("mousemove", function (e) {
+    mouseX = (e.clientX / W) * 2 - 1;
+    mouseY = (e.clientY / H) * 2 - 1;
+    wake();
+  });
+
+  // ---- input: speed via scroll ----
+  window.addEventListener("wheel", function (e) {
+    e.preventDefault();
+    speed += (e.deltaY < 0 ? 1 : -1) * (2 + speed * 0.12);
+    speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, speed));
+    wake();
+  }, { passive: false });
+
+  // ---- input: hyperspace boost (hold) ----
+  function boostOn() { boosting = true; wake(); }
+  function boostOff() { boosting = false; }
+  window.addEventListener("mousedown", boostOn);
+  window.addEventListener("mouseup", boostOff);
+  window.addEventListener("keydown", function (e) {
+    if (e.code === "Space") { e.preventDefault(); boostOn(); }
+    else if (e.key === "ArrowUp")   { speed = Math.min(MAX_SPEED, speed + 6); }
+    else if (e.key === "ArrowDown") { speed = Math.max(MIN_SPEED, speed - 6); }
+  });
+  window.addEventListener("keyup", function (e) {
+    if (e.code === "Space") boostOff();
+  });
+
+  // ---- input: touch (drag steers, two-finger vertical changes speed) ----
+  var lastTouchY = null;
+  window.addEventListener("touchstart", function (e) {
+    if (e.touches.length === 1) boostOn();
+  }, { passive: true });
+  window.addEventListener("touchmove", function (e) {
+    var t = e.touches[0];
+    mouseX = (t.clientX / W) * 2 - 1;
+    mouseY = (t.clientY / H) * 2 - 1;
+    if (e.touches.length === 2) {
+      boostOff();
+      if (lastTouchY !== null) {
+        speed += (lastTouchY - e.touches[1].clientY) * 0.3;
+        speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, speed));
+      }
+      lastTouchY = e.touches[1].clientY;
+    }
+    wake();
+    e.preventDefault();
+  }, { passive: false });
+  window.addEventListener("touchend", function () { lastTouchY = null; boostOff(); });
+  window.addEventListener("contextmenu", function (e) { e.preventDefault(); });
+
+  // ---- pause when tab is hidden (and avoid a huge dt on return) ----
+  var running = true;
+  document.addEventListener("visibilitychange", function () {
+    if (document.hidden) {
+      running = false;
+    } else if (!running) {
+      running = true;
+      lastT = performance.now();
+      requestAnimationFrame(frame);
+    }
+  });
+
+  // ---- fps sampling ----
+  var fpsAccum = 0, fpsFrames = 0, fpsLast = performance.now(), fpsShown = 0;
+
+  var lastT = performance.now();
+
+  function frame(now) {
+    if (!running) return;
+
+    var dt = Math.min((now - lastT) / 16.6667, 3); // normalize to 60fps units, clamp spikes
+    lastT = now;
+
+    // fps meter (updated ~4x/sec)
+    fpsFrames++;
+    if (now - fpsLast >= 250) {
+      fpsShown = Math.round((fpsFrames * 1000) / (now - fpsLast));
+      fpsFrames = 0;
+      fpsLast = now;
+      fpsEl.textContent = fpsShown;
+    }
+
+    // smooth boost + resulting speed target
+    boostAmt += ((boosting ? 1 : 0) - boostAmt) * 0.12 * dt;
+    var speedTarget = speed + (BOOST_SPEED - speed) * boostAmt;
+    curSpeed += (speedTarget - curSpeed) * 0.08 * dt;
+
+    var speedFrac = (curSpeed - MIN_SPEED) / (MAX_SPEED - MIN_SPEED);
+    var displayFrac = Math.min(1.4, speedFrac); // may exceed 1 during boost
+
+    // smooth steering
+    steerX += (mouseX - steerX) * 0.05 * dt;
+    steerY += (mouseY - steerY) * 0.05 * dt;
+
+    // motion blur: translucent black wash — faster = less wash = longer streaks
+    var fade = 0.34 - Math.min(1, speedFrac + boostAmt * 0.6) * 0.26; // 0.34 slow .. 0.08 fast
+    ctx.fillStyle = "rgba(0,0,0," + fade.toFixed(3) + ")";
+    ctx.fillRect(0, 0, W, H);
+
+    // steering: shift the vanishing point toward the mouse for a "banking" feel,
+    // plus a gentle world-space cross-drift (kept subtle so it doesn't lurch)
+    var vpx = CX - steerX * W * 0.28;
+    var vpy = CY - steerY * H * 0.28;
+    var camVX = steerX * curSpeed * 6;
+    var camVY = steerY * curSpeed * 6;
+
+    ctx.lineCap = "round";
+
+    for (var i = 0; i < NUM_STARS; i++) {
+      var s = stars[i];
+
+      s.z -= curSpeed * dt;
+      s.x -= camVX * dt;
+      s.y -= camVY * dt;
+
+      if (s.z <= 1) { spawn(s, false); continue; }
+
+      var k = FOV / s.z;
+      var sx = vpx + s.x * k;
+      var sy = vpy + s.y * k;
+
+      // recycle stars that leave the viewport (with margin)
+      if (sx < -80 || sx > W + 80 || sy < -80 || sy > H + 80) {
+        spawn(s, false);
+        continue;
+      }
+
+      if (s.px !== null) {
+        var depthFrac = 1 - s.z / DEPTH;                 // 0 far .. 1 near
+        var size = (0.3 + depthFrac * 2.2) * s.mag;
+
+        // twinkle only matters for slow, distant stars
+        var twinkle = 1 - 0.25 * (1 - depthFrac) * (0.5 + 0.5 * Math.sin(s.tw += 0.15 * dt));
+        var alpha = Math.min(1, (0.14 + depthFrac * 0.95) * (0.6 + 0.4 * s.mag)) * twinkle;
+
+        // hotter / whiter at speed
+        var sat = Math.max(0, 72 - (speedFrac + boostAmt) * 50);
+        var light = 60 + depthFrac * 30 + Math.min(1, speedFrac + boostAmt) * 10;
+
+        ctx.strokeStyle = "hsla(" + s.hue + "," + sat + "%," + light + "%," + alpha.toFixed(3) + ")";
+        ctx.lineWidth = size;
+        ctx.beginPath();
+        ctx.moveTo(s.px, s.py);
+        ctx.lineTo(sx, sy);
+        ctx.stroke();
+
+        // bright core dot on the nearest stars
+        if (depthFrac > 0.72) {
+          ctx.fillStyle = "hsla(" + s.hue + ",30%,96%," + (alpha * 0.9).toFixed(3) + ")";
+          ctx.beginPath();
+          ctx.arc(sx, sy, size * 0.55, 0, 6.2832);
+          ctx.fill();
+        }
+      }
+
+      s.px = sx;
+      s.py = sy;
+    }
+
+    // central glow that intensifies with speed / boost
+    var glow = Math.min(1, speedFrac + boostAmt * 0.8);
+    if (glow > 0.05) {
+      var glowR = Math.max(60, Math.min(W, H) * (0.1 + glow * 0.4));
+      var g = ctx.createRadialGradient(vpx, vpy, 0, vpx, vpy, glowR);
+      var tint = boostAmt > 0.3 ? "255,170,200" : "150,190,255";
+      g.addColorStop(0, "rgba(" + tint + "," + (0.11 * glow).toFixed(3) + ")");
+      g.addColorStop(1, "rgba(" + tint + ",0)");
+      ctx.fillStyle = g;
+      ctx.fillRect(vpx - glowR, vpy - glowR, glowR * 2, glowR * 2);
+    }
+
+    // HUD readouts
+    speedfill.style.width = (Math.min(1, displayFrac) * 100).toFixed(1) + "%";
+    if (boostAmt > 0.15) speedfill.classList.add("boost");
+    else speedfill.classList.remove("boost");
+    spdEl.textContent = (curSpeed / 3).toFixed(1);
+
+    requestAnimationFrame(frame);
+  }
+
+  // If the user prefers reduced motion, start slow and don't auto-race.
+  if (prefersReduced) { speed = 6; curSpeed = 6; }
+
+  requestAnimationFrame(frame);
+})();
+</script>
+</body>
+</html>

(oldest)  ·  back to Warp Starfield  ·  (newest)