← Canyon Run results

Canyon Run

Minimax m3 · composer

Initial view of Canyon Run

Reviews & generation details

Reviews

Human review · Jul 17, 2026 · rubric 1

Historical path-based review. Association with these exact bytes is unverified.

Visual design
4 / 5
UX / feel
4 / 5
Works
4 / 5
Prompt spirit
4 / 5
Delight
4 / 5

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 48,488 bytes · SHA-256 dd1a816eba28
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Solar Drift — Canyon Racer</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
/* ── css: theme ── */
:root {
  --orange: #ff8a3c;
  --magenta: #ff4d8f;
  --silver: #d8e2ff;
  --deep: #1a0820;
  --cyan: #6ff0ff;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0410; font-family: 'Courier New', monospace; color: var(--silver); user-select: none; }
canvas#game { position: fixed; inset: 0; display: block; }

#hud { position: fixed; inset: 0; pointer-events: none; z-index: 5; }

#hud-top {
  position: absolute; top: 24px; left: 0; right: 0;
  display: flex; justify-content: space-between; align-items: flex-start;
  padding: 0 36px;
}
#score-block, #multiplier-block, #speed-block {
  text-align: left; line-height: 1;
}
#score-label, #mult-label, #speed-label {
  font-size: 11px; letter-spacing: 4px; opacity: 0.55; margin-bottom: 8px;
}
#score-value {
  font-size: 38px; font-weight: 700; letter-spacing: 2px;
  text-shadow: 0 0 18px rgba(255,138,60,0.5);
}
#mult-value {
  font-size: 64px; font-weight: 800; letter-spacing: -2px;
  color: var(--magenta);
  text-shadow: 0 0 24px rgba(255,77,143,0.7);
  transition: transform 0.15s ease-out, color 0.2s;
}
#mult-value.pulse { transform: scale(1.25); color: #fff; }
#speed-value { font-size: 28px; font-weight: 700; letter-spacing: 1px; }
#speed-bar {
  width: 180px; height: 3px; background: rgba(255,255,255,0.1);
  margin-top: 8px; position: relative;
}
#speed-bar-fill {
  position: absolute; left: 0; top: 0; bottom: 0;
  background: linear-gradient(90deg, var(--orange), var(--magenta));
  width: 0%; transition: width 0.1s;
  box-shadow: 0 0 10px var(--orange);
}

#energy-block {
  position: absolute; bottom: 36px; left: 50%; transform: translateX(-50%);
  width: 380px; text-align: center;
}
#energy-bar {
  width: 100%; height: 8px; background: rgba(255,255,255,0.07);
  border: 1px solid rgba(111,240,255,0.25);
  position: relative; overflow: hidden;
}
#energy-fill {
  position: absolute; left: 0; top: 0; bottom: 0;
  background: linear-gradient(90deg, #ff4d8f, #ff8a3c, #ffd66f);
  width: 100%;
  box-shadow: 0 0 14px var(--orange);
  transition: width 0.18s;
}
#energy-label { font-size: 10px; letter-spacing: 6px; opacity: 0.5; margin-top: 8px; }

#boost-block {
  position: absolute; bottom: 36px; right: 36px;
  width: 220px; text-align: right;
}
#boost-bar {
  width: 100%; height: 4px; background: rgba(255,255,255,0.07);
  border: 1px solid rgba(255,138,60,0.3);
  position: relative; overflow: hidden;
}
#boost-fill {
  position: absolute; left: 0; top: 0; bottom: 0;
  background: linear-gradient(90deg, #6ff0ff, #d8e2ff);
  width: 0%;
  box-shadow: 0 0 12px var(--cyan);
  transition: width 0.15s;
}
#boost-label { font-size: 10px; letter-spacing: 6px; opacity: 0.5; margin-top: 8px; }

#nearmiss-flash {
  position: fixed; inset: 0; pointer-events: none; z-index: 4;
  box-shadow: inset 0 0 0 0px rgba(255,77,143,0);
  transition: box-shadow 0.4s;
}
#nearmiss-flash.active { box-shadow: inset 0 0 200px 30px rgba(255,77,143,0.35); transition: box-shadow 0.05s; }

#gameover {
  position: fixed; inset: 0; z-index: 10;
  display: none; align-items: center; justify-content: center;
  background: rgba(10,4,16,0.85);
  backdrop-filter: blur(8px);
}
#gameover.show { display: flex; }
#gameover-inner {
  text-align: center; padding: 48px 64px;
  border: 1px solid rgba(255,138,60,0.3);
  background: linear-gradient(135deg, rgba(26,8,32,0.9), rgba(10,4,16,0.9));
  box-shadow: 0 0 60px rgba(255,77,143,0.2);
}
#gameover h1 {
  font-size: 56px; letter-spacing: 8px; margin-bottom: 32px;
  color: var(--magenta); text-shadow: 0 0 24px rgba(255,77,143,0.6);
}
.stat {
  display: flex; justify-content: space-between; gap: 64px;
  font-size: 13px; letter-spacing: 3px; opacity: 0.6;
  margin: 10px 0; padding: 10px 0;
  border-bottom: 1px solid rgba(255,255,255,0.08);
}
.stat b { font-size: 24px; opacity: 1; color: var(--orange); letter-spacing: 1px; }
#restart-btn {
  margin-top: 32px; padding: 14px 40px;
  background: transparent; color: var(--silver);
  border: 1px solid var(--orange); font-family: inherit;
  font-size: 14px; letter-spacing: 6px; cursor: pointer;
  transition: all 0.2s;
}
#restart-btn:hover { background: var(--orange); color: var(--deep); box-shadow: 0 0 24px var(--orange); }

#title-screen {
  position: fixed; inset: 0; z-index: 12;
  display: flex; align-items: center; justify-content: center;
  background: radial-gradient(ellipse at center, #2a0e2e 0%, #0a0410 80%);
}
#title-screen.hide { display: none; }
#title-inner { text-align: center; }
#title-inner h1 {
  font-size: 84px; letter-spacing: 14px;
  background: linear-gradient(90deg, #ff8a3c, #ff4d8f, #6ff0ff);
  -webkit-background-clip: text; background-clip: text; color: transparent;
  margin-bottom: 12px; font-weight: 800;
}
#title-inner p { font-size: 13px; letter-spacing: 4px; opacity: 0.6; margin: 6px 0; }
#title-inner .keys { font-size: 11px; opacity: 0.4; margin-top: 4px; }
#start-btn {
  margin-top: 36px; padding: 16px 56px;
  background: transparent; color: var(--orange);
  border: 1px solid var(--orange); font-family: inherit;
  font-size: 16px; letter-spacing: 8px; cursor: pointer;
  transition: all 0.2s;
}
#start-btn:hover { background: var(--orange); color: var(--deep); box-shadow: 0 0 32px var(--orange); }
/* ── css: fx ── */
/* Vignette overlay for cinematic feel */
#vignette {
  position: fixed; inset: 0; z-index: 3; pointer-events: none;
  background: radial-gradient(ellipse at center,
    transparent 40%,
    rgba(0,0,0,0.4) 90%,
    rgba(0,0,0,0.7) 100%);
}

/* Danger warning when close to obstacles */
#danger-warn {
  position: fixed; inset: 0; z-index: 4; pointer-events: none;
  box-shadow: inset 0 0 0 0px rgba(255,60,60,0);
  transition: box-shadow 0.15s;
}
#danger-warn.active {
  box-shadow: inset 0 0 120px 20px rgba(255,30,30,0.4);
  transition: box-shadow 0.05s;
}

/* Speed lines effect */
#speed-lines {
  position: fixed; inset: 0; z-index: 2; pointer-events: none;
  background: radial-gradient(ellipse at center,
    transparent 30%,
    rgba(255,200,100,0.04) 60%,
    rgba(255,200,100,0.12) 100%);
  opacity: 0;
  transition: opacity 0.3s;
}
#speed-lines.active { opacity: 1; }
</style>
</head>
<body>
<div id="app"><div id="hud">
    <div id="hud-top">
      <div id="score-block">
        <div id="score-label">SCORE</div>
        <div id="score-value">0</div>
      </div>
      <div id="multiplier-block">
        <div id="mult-label">× MULTIPLIER</div>
        <div id="mult-value">1.0</div>
      </div>
      <div id="speed-block">
        <div id="speed-label">VELOCITY</div>
        <div id="speed-value">0</div>
        <div id="speed-bar"><div id="speed-bar-fill"></div></div>
      </div>
    </div>
    <div id="energy-block">
      <div id="energy-bar"><div id="energy-fill"></div></div>
      <div id="energy-label">ENERGY</div>
    </div>
    <div id="boost-block">
      <div id="boost-bar"><div id="boost-fill"></div></div>
      <div id="boost-label">TURBO</div>
    </div>
  </div>
  <div id="nearmiss-flash"></div>
  <div id="gameover">
    <div id="gameover-inner">
      <h1>SIGNAL LOST</h1>
      <div class="stat"><span>FINAL SCORE</span><b id="final-score">0</b></div>
      <div class="stat"><span>MAX VELOCITY</span><b id="final-speed">0</b></div>
      <div class="stat"><span>ORBS COLLECTED</span><b id="final-orbs">0</b></div>
      <button id="restart-btn">REBOOT</button>
    </div>
  </div>
  <div id="title-screen">
    <div id="title-inner">
      <h1>SOLAR DRIFT</h1>
      <p>Pitch · Bank · Boost</p>
      <p class="keys">W/S · PITCH &nbsp; A/D · BANK &nbsp; SHIFT · TURBO</p>
      <p class="keys">ARROWS · MOVEMENT &nbsp; SPACE · START</p>
      <button id="start-btn">ENGAGE</button>
    </div>
  </div>
  <div id="vignette"></div>
<div id="danger-warn"></div>
<div id="speed-lines"></div><canvas id="game"></canvas></div>
<script>
'use strict';

// ── const: CONFIG ──
// Tunable constants
const CONFIG = {
  // World
  CHUNK_LENGTH: 70,
  CHUNKS_AHEAD: 4,
  CHUNKS_BEHIND: 2,
  CANYON_HALF_WIDTH: 45,
  CANYON_HEIGHT: 50,
  CANYON_FLOOR_Y: -28,
  // Player
  BASE_SPEED: 90,
  MAX_SPEED: 220,
  BOOST_MULT: 1.65,
  ACCEL_RATE: 18,
  PITCH_RATE: 1.6,
  ROLL_RATE: 2.4,
  PITCH_ACCEL: 90,
  ROLL_TURN: 1.8,
  DRAG: 0.985,
  BOUND_RADIUS: 1.6,
  // Energy
  ENERGY_MAX: 100,
  ENERGY_DECAY: 2.4,
  ORB_RECHARGE: 20,
  // Boost
  BOOST_MAX: 100,
  BOOST_GAIN: 18,
  BOOST_COST: 55,
  // Scoring
  NEARMISS_BASE: 5,
  NEARMISS_RANGE: 10.0,
  // Difficulty
  DIFF_SCALE: 0.000018,
  OBSTACLE_DENSITY: 0.65,
};

// ── function: makeRng ──
// Seeded RNG (mulberry32) for reproducible chunks at a given index
function makeRng(seed) {
  let s = seed >>> 0;
  return function () {
    s = (s + 0x6D2B79F5) >>> 0;
    let t = s;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// ── function: chunkSeed ──
function chunkSeed(index) {
  return (index * 2654435761) ^ 0x9E3779B9;
}

// ── function: rngRange ──
function rngRange(rng, a, b) { return a + (b - a) * rng(); }

// ── function: rngInt ──
function rngInt(rng, a, b) { return Math.floor(a + (b - a + 1) * rng()); }

// ── function: rngPick ──
function rngPick(rng, arr) { return arr[Math.floor(rng() * arr.length)]; }

// ── const: STATE ──
// Global state object — created in main
const STATE = {
  scene: null, camera: null, renderer: null, clock: null,
  player: null, // THREE.Object3D
  playerVel: new THREE.Vector3(0, 0, 0),
  forwardDir: new THREE.Vector3(0, 0, -1),
  rightDir: new THREE.Vector3(1, 0, 0),
  upDir: new THREE.Vector3(0, 1, 0),
  pitch: 0, roll: 0, yaw: 0,
  speed: 0,
  energy: CONFIG.ENERGY_MAX,
  boost: 0,
  boosting: false,
  score: 0,
  multiplier: 1.0,
  multiplierTimer: 0,
  orbs: 0,
  maxSpeed: 0,
  alive: false,
  started: false,
  // Chunks
  chunks: new Map(), // index -> { group, walls, monoliths, squeezes, orbs }
  firstChunkIndex: 0,
  lastChunkIndex: -1,
  // Input
  keys: {},
  // Camera FX
  baseFov: 75,
  shakeAmount: 0, shakeTime: 0,
  // Misc
  trailCooldown: 0,
  lastNearMiss: 0,
  prevPlayerPos: new THREE.Vector3(),
};

// ── const: Audio ──
// Web Audio API — procedural sound effects
const Audio = (() => {
  let ctx = null;
  let master = null;
  let engineOsc = null, engineGain = null, engineFilter = null;
  let windOsc = null, windGain = null;
  let initialized = false;

  function init() {
    if (initialized) return;
    try {
      const AC = window.AudioContext || window.webkitAudioContext;
      ctx = new AC();
      master = ctx.createGain();
      master.gain.value = 0.55;
      master.connect(ctx.destination);
      // Engine hum
      engineOsc = ctx.createOscillator();
      engineGain = ctx.createGain();
      engineFilter = ctx.createBiquadFilter();
      engineFilter.type = 'lowpass';
      engineFilter.frequency.value = 380;
      engineOsc.type = 'sawtooth';
      engineOsc.frequency.value = 70;
      engineGain.gain.value = 0.0;
      engineOsc.connect(engineFilter);
      engineFilter.connect(engineGain);
      engineGain.connect(master);
      engineOsc.start();
      // Wind noise
      windOsc = ctx.createOscillator();
      windGain = ctx.createGain();
      const windFilter = ctx.createBiquadFilter();
      windFilter.type = 'bandpass';
      windFilter.frequency.value = 900;
      windFilter.Q.value = 0.4;
      windOsc.type = 'sawtooth';
      windOsc.frequency.value = 50;
      windGain.gain.value = 0.0;
      windOsc.connect(windFilter);
      windFilter.connect(windGain);
      windGain.connect(master);
      windOsc.start();
      initialized = true;
    } catch (e) { console.warn('Audio init failed', e); }
  }

  function setEngine(speed01, boosting) {
    if (!initialized) return;
    const targetFreq = 60 + speed01 * 180 + (boosting ? 60 : 0);
    engineOsc.frequency.setTargetAtTime(targetFreq, ctx.currentTime, 0.08);
    engineFilter.frequency.setTargetAtTime(280 + speed01 * 1200, ctx.currentTime, 0.1);
    engineGain.gain.setTargetAtTime(0.05 + speed01 * 0.18, ctx.currentTime, 0.1);
    windGain.gain.setTargetAtTime(0.02 + speed01 * 0.14, ctx.currentTime, 0.1);
  }

  function setBoost(active) {
    if (!initialized) return;
    engineGain.gain.setTargetAtTime(active ? 0.32 : 0.18, ctx.currentTime, 0.05);
  }

  function ping(freq, dur, type) {
    if (!initialized) return;
    type = type || 'sine';
    const o = ctx.createOscillator();
    const g = ctx.createGain();
    o.type = type;
    o.frequency.value = freq;
    g.gain.setValueAtTime(0, ctx.currentTime);
    g.gain.linearRampToValueAtTime(0.35, ctx.currentTime + 0.01);
    g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur);
    o.connect(g); g.connect(master);
    o.start();
    o.stop(ctx.currentTime + dur + 0.05);
  }

  function whoosh(intensity) {
    if (!initialized) return;
    const o = ctx.createOscillator();
    const g = ctx.createGain();
    const f = ctx.createBiquadFilter();
    f.type = 'bandpass';
    f.frequency.setValueAtTime(300 + intensity * 1500, ctx.currentTime);
    f.frequency.exponentialRampToValueAtTime(80, ctx.currentTime + 0.35);
    f.Q.value = 1.2;
    o.type = 'sawtooth';
    o.frequency.setValueAtTime(120, ctx.currentTime);
    o.frequency.exponentialRampToValueAtTime(40, ctx.currentTime + 0.35);
    g.gain.setValueAtTime(0, ctx.currentTime);
    g.gain.linearRampToValueAtTime(0.15 + intensity * 0.2, ctx.currentTime + 0.02);
    g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35);
    o.connect(f); f.connect(g); g.connect(master);
    o.start();
    o.stop(ctx.currentTime + 0.4);
  }

  function orb() { ping(880, 0.18, 'triangle'); setTimeout(() => ping(1320, 0.22, 'sine'), 50); }
  function nearMiss() {
    ping(220, 0.4, 'square');
    setTimeout(() => whoosh(0.7), 30);
  }
  function crash() {
    if (!initialized) return;
    const o = ctx.createOscillator();
    const g = ctx.createGain();
    o.type = 'sawtooth';
    o.frequency.setValueAtTime(280, ctx.currentTime);
    o.frequency.exponentialRampToValueAtTime(40, ctx.currentTime + 0.6);
    g.gain.setValueAtTime(0.5, ctx.currentTime);
    g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.6);
    o.connect(g); g.connect(master);
    o.start();
    o.stop(ctx.currentTime + 0.65);
  }

  return { init, setEngine, setBoost, orb, nearMiss, crash };
})();

// ── function: buildCraft ──
// Build player craft — sleek, low-poly geometric ship
function buildCraft() {
  const group = new THREE.Group();

  // Main hull — sharp triangular wedge
  const hullMat = new THREE.MeshStandardMaterial({
    color: 0xd8e2ff, metalness: 0.85, roughness: 0.28, flatShading: true,
  });
  const hull = new THREE.Mesh(
    new THREE.ConeGeometry(0.7, 2.4, 4, 1),
    hullMat
  );
  hull.rotation.x = -Math.PI / 2;
  hull.rotation.y = Math.PI / 4;
  group.add(hull);

  // Top spine
  const spine = new THREE.Mesh(
    new THREE.BoxGeometry(0.18, 0.5, 2.6),
    new THREE.MeshStandardMaterial({ color: 0x4a3a6a, metalness: 0.6, roughness: 0.4, flatShading: true })
  );
  spine.position.y = 0.32;
  group.add(spine);

  // Wings — swept delta
  const wingMat = new THREE.MeshStandardMaterial({
    color: 0xff8a3c, metalness: 0.7, roughness: 0.35, flatShading: true,
    emissive: 0x4a1a00, emissiveIntensity: 0.4,
  });
  const wingGeo = new THREE.BufferGeometry();
  // single delta wing on +X side
  const wingVerts = new Float32Array([
    0, 0, 0.6,    1.4, -0.05, -1.0,    0, 0, -1.2,
    0, 0, 0.6,    0, 0.15, 0.2,        1.4, -0.05, -1.0,
  ]);
  wingGeo.setAttribute('position', new THREE.BufferAttribute(wingVerts, 3));
  wingGeo.computeVertexNormals();
  const wingR = new THREE.Mesh(wingGeo, wingMat);
  group.add(wingR);
  const wingL = wingR.clone();
  wingL.scale.x = -1;
  group.add(wingL);

  // Tail fin
  const fin = new THREE.Mesh(
    new THREE.BoxGeometry(0.06, 0.7, 0.5),
    new THREE.MeshStandardMaterial({ color: 0xff4d8f, metalness: 0.5, roughness: 0.4, flatShading: true, emissive: 0x4a0a1a, emissiveIntensity: 0.6 })
  );
  fin.position.set(0, 0.55, 1.0);
  group.add(fin);

  // Engine glow at back
  const engineMat = new THREE.MeshBasicMaterial({ color: 0x6ff0ff });
  const engineGlow = new THREE.Mesh(new THREE.SphereGeometry(0.22, 8, 8), engineMat);
  engineGlow.position.set(0, 0, 1.25);
  engineGlow.name = 'engine';
  group.add(engineGlow);

  // Engine halo
  const haloMat = new THREE.MeshBasicMaterial({ color: 0x6ff0ff, transparent: true, opacity: 0.4 });
  const halo = new THREE.Mesh(new THREE.SphereGeometry(0.4, 8, 8), haloMat);
  halo.position.set(0, 0, 1.25);
  halo.name = 'halo';
  group.add(halo);

  group.userData.engineGlow = engineGlow;
  group.userData.halo = halo;
  return group;
}

// ── function: initScene ──
// Initialize the Three.js scene
function initScene() {
  const canvas = document.getElementById('game');
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.outputEncoding = THREE.sRGBEncoding;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.05;
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;

  const scene = new THREE.Scene();
  const canvas2 = document.createElement('canvas');
  canvas2.width = 4; canvas2.height = 256;
  const ctx2 = canvas2.getContext('2d');
  const grad = ctx2.createLinearGradient(0, 0, 0, 256);
  grad.addColorStop(0.0, '#1a0830');
  grad.addColorStop(0.35, '#3a1248');
  grad.addColorStop(0.65, '#a83a52');
  grad.addColorStop(0.85, '#ff7a3c');
  grad.addColorStop(1.0, '#ffd680');
  ctx2.fillStyle = grad;
  ctx2.fillRect(0, 0, 4, 256);
  const skyTex = new THREE.CanvasTexture(canvas2);
  skyTex.magFilter = THREE.LinearFilter;
  scene.background = skyTex;
  scene.fog = new THREE.Fog(0x3a1248, 80, 320);

  const camera = new THREE.PerspectiveCamera(STATE.baseFov, window.innerWidth / window.innerHeight, 0.5, 800);
  camera.position.set(0, 0, 0);

  // Lighting
  const sun = new THREE.DirectionalLight(0xffb070, 1.4);
  sun.position.set(60, 40, 30);
  sun.castShadow = true;
  sun.shadow.mapSize.set(512, 512);
  sun.shadow.camera.near = 0.5;
  sun.shadow.camera.far = 250;
  sun.shadow.camera.left = -60;
  sun.shadow.camera.right = 60;
  sun.shadow.camera.top = 60;
  sun.shadow.camera.bottom = -60;
  sun.shadow.bias = -0.0005;
  scene.add(sun);

  const fill = new THREE.DirectionalLight(0x6f4dff, 0.4);
  fill.position.set(-50, 30, -20);
  scene.add(fill);

  const rim = new THREE.DirectionalLight(0x6ff0ff, 0.3);
  rim.position.set(0, -10, 50);
  scene.add(rim);

  scene.add(new THREE.AmbientLight(0x4a2860, 0.5));

  // Distant sun disc
  const sunDisc = new THREE.Mesh(
    new THREE.CircleGeometry(28, 32),
    new THREE.MeshBasicMaterial({ color: 0xffd680, transparent: true, opacity: 0.95 })
  );
  sunDisc.position.set(180, 35, -400);
  sunDisc.lookAt(0, 35, 0);
  scene.add(sunDisc);
  const sunHalo = new THREE.Mesh(
    new THREE.RingGeometry(28, 44, 32),
    new THREE.MeshBasicMaterial({ color: 0xff8a3c, transparent: true, opacity: 0.45, side: THREE.DoubleSide })
  );
  sunHalo.position.copy(sunDisc.position);
  sunHalo.lookAt(0, 35, 0);
  scene.add(sunHalo);

  // Distance haze planes for parallax depth
  const hazeMat = new THREE.MeshBasicMaterial({ color: 0x6a2a4a, transparent: true, opacity: 0.25, side: THREE.DoubleSide });
  for (let i = 0; i < 5; i++) {
    const h = new THREE.Mesh(new THREE.PlaneGeometry(800, 100), hazeMat.clone());
    h.position.set(0, 20, -200 - i * 200);
    h.material.opacity = 0.05 + i * 0.03;
    scene.add(h);
  }

  STATE.renderer = renderer;
  STATE.scene = scene;
  STATE.camera = camera;

  window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });

  return { scene, camera, renderer };
}

// ── function: buildChunk ──
// Build one chunk of canyon: walls, monoliths, squeezes, orbs
function buildChunk(index) {
  const group = new THREE.Group();
  const localZBase = -index * CONFIG.CHUNK_LENGTH;
  group.position.z = localZBase;
  const rng = makeRng(chunkSeed(index));
  const data = { index, group, walls: [], monoliths: [], squeezes: [], orbs: [], colliders: [] };

  // Difficulty scales with distance
  const dist = index * CONFIG.CHUNK_LENGTH;
  const diff = Math.min(1, dist * CONFIG.DIFF_SCALE);
  const wallNarrow = CONFIG.CANYON_HALF_WIDTH * (0.75 + diff * 0.15);
  const wallHeight = CONFIG.CANYON_HEIGHT * (0.7 + diff * 0.4);

  // ---- Canyon walls ----
  const wallMatA = new THREE.MeshStandardMaterial({ color: 0x2a0820, metalness: 0.05, roughness: 0.9, flatShading: true });
  const wallMatB = new THREE.MeshStandardMaterial({ color: 0x4a1230, metalness: 0.05, roughness: 0.9, flatShading: true });
  const wallMatC = new THREE.MeshStandardMaterial({ color: 0x6a1f3a, metalness: 0.05, roughness: 0.9, flatShading: true });

  const slabsPerSide = 5;
  for (let s = 0; s < slabsPerSide; s++) {
    const localZ = -s * (CONFIG.CHUNK_LENGTH / slabsPerSide) + CONFIG.CHUNK_LENGTH / 2;
    const worldZ = localZBase + localZ;
    const centerX = getCenterOffset(worldZ);
    for (let side of [-1, 1]) {
      const w = wallNarrow + rngRange(rng, -3, 3);
      const h = wallHeight + rngRange(rng, -6, 10);
      const tilt = rngRange(rng, -0.18, 0.18);
      const mat = [wallMatA, wallMatB, wallMatC][rngInt(rng, 0, 2)];
      const geo = new THREE.BoxGeometry(6 + rngRange(rng, 0, 6), h, CONFIG.CHUNK_LENGTH / slabsPerSide + 1);
      const mesh = new THREE.Mesh(geo, mat);
      const wx = centerX + side * (w + 3);
      mesh.position.set(wx, CONFIG.CANYON_FLOOR_Y + h / 2, localZ);
      mesh.rotation.z = tilt;
      mesh.castShadow = true;
      mesh.receiveShadow = true;
      group.add(mesh);
      data.walls.push(mesh);
      data.colliders.push({
        type: 'wall', mesh,
        x: wx, y: CONFIG.CANYON_FLOOR_Y + h / 2, z: worldZ,
        hx: 3 + rngRange(rng, 0, 3), hy: h / 2, hz: CONFIG.CHUNK_LENGTH / slabsPerSide / 2 + 0.5,
        rotZ: tilt
      });
    }
  }

  // ---- Floor ----
  const floorMat = new THREE.MeshStandardMaterial({ color: 0x1a0612, metalness: 0.0, roughness: 1.0, flatShading: true });
  const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(wallNarrow * 2.2, CONFIG.CHUNK_LENGTH),
    floorMat
  );
  floor.rotation.x = -Math.PI / 2;
  floor.position.y = CONFIG.CANYON_FLOOR_Y;
  floor.position.z = CONFIG.CHUNK_LENGTH / 2;
  floor.position.x = getCenterOffset(localZBase + CONFIG.CHUNK_LENGTH / 2);
  floor.receiveShadow = true;
  group.add(floor);

  // ---- Floating monoliths — densely placed in the flight corridor ----
  // Early chunks: lots of monoliths. Later: fewer but more dangerous.
  const monoCount = index < 4
    ? rngInt(rng, 3, 5)
    : 2 + rngInt(rng, 0, 2) + (diff > 0.2 ? rngInt(rng, 0, 1) : 0);
  for (let m = 0; m < monoCount; m++) {
    const localZ = -rngRange(rng, 6, CONFIG.CHUNK_LENGTH - 6);
    const worldZ = localZBase + localZ;
    const centerX = getCenterOffset(worldZ);
    // Place pillars across the navigable band so the player must bank
    // 50% directly in the path (within 8m of centerline)
    // 50% off to one side (within 8-20m)
    let x;
    const r = rng();
    if (r < 0.5) {
      x = centerX + rngRange(rng, -8, 8);
    } else {
      const sideSign = rng() < 0.5 ? -1 : 1;
      x = centerX + sideSign * rngRange(rng, 8, 18);
    }
    const y = rngRange(rng, -3, 4);
    const w = rngRange(rng, 1.4, 2.6);
    const h = rngRange(rng, 5, 10);
    const d = rngRange(rng, 3, 6); // deeper in z to extend near-miss window
    const mat = new THREE.MeshStandardMaterial({
      color: rngPick(rng, [0x3a1248, 0x5a1f3a, 0x6a2a4a, 0x4a1a4a, 0x2a0a2a]),
      metalness: 0.4, roughness: 0.5, flatShading: true
    });
    const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat);
    mesh.position.set(x, y, localZ);
    mesh.rotation.y = rngRange(rng, 0, Math.PI);
    mesh.rotation.z = rngRange(rng, -0.2, 0.2);
    mesh.castShadow = true;
    group.add(mesh);
    data.monoliths.push(mesh);
    data.colliders.push({
      type: 'monolith', mesh,
      x, y, z: worldZ, hx: w / 2 + 0.3, hy: h / 2, hz: d / 2 + 0.3
    });
  }

  // ---- Wall spikes: protrude from the walls into the canyon ----
  // Creates near-miss opportunities with the walls themselves
  const spikeCount = rngInt(rng, 0, 2);
  for (let sp = 0; sp < spikeCount; sp++) {
    const localZ = -rngRange(rng, 6, CONFIG.CHUNK_LENGTH - 6);
    const worldZ = localZBase + localZ;
    const centerX = getCenterOffset(worldZ);
    const side = rng() < 0.5 ? -1 : 1;
    const spikeLen = rngRange(rng, 6, 14);
    const spikeW = 4;
    const spikeH = rngRange(rng, 4, 10);
    const geo = new THREE.BoxGeometry(spikeLen, spikeH, 5);
    const mat = new THREE.MeshStandardMaterial({
      color: 0x3a1248, metalness: 0.3, roughness: 0.6, flatShading: true
    });
    const mesh = new THREE.Mesh(geo, mat);
    const wx = centerX + side * (wallNarrow - spikeLen / 2 - 2);
    mesh.position.set(wx, CONFIG.CANYON_FLOOR_Y + spikeH / 2 + 4, localZ);
    mesh.castShadow = true;
    group.add(mesh);
    data.colliders.push({
      type: 'spike', mesh,
      x: wx, y: mesh.position.y, z: worldZ,
      hx: spikeLen / 2 + 0.3, hy: spikeH / 2 + 0.3, hz: 2.8
    });
  }

  // ---- Tight squeezes ----
  if (rng() < 0.5 + diff * 0.3) {
    const localZ = -rngRange(rng, 12, CONFIG.CHUNK_LENGTH - 12);
    const worldZ = localZBase + localZ;
    const centerX = getCenterOffset(worldZ);
    const gap = 6.5 - diff * 2.5 + rngRange(rng, -0.8, 0.8);
    const archH = 9 + rngRange(rng, 0, 3);
    const fullW = wallNarrow;
    for (let side of [-1, 1]) {
      const w = (fullW - gap) / 2 + 3;
      const geo = new THREE.BoxGeometry(w, archH, 4);
      const mat = new THREE.MeshStandardMaterial({
        color: 0x2a0820, metalness: 0.1, roughness: 0.85, flatShading: true
      });
      const mesh = new THREE.Mesh(geo, mat);
      const wx = centerX + side * (fullW - w / 2);
      mesh.position.set(wx, archH / 2 - 3, localZ);
      mesh.castShadow = true;
      mesh.receiveShadow = true;
      group.add(mesh);
      data.squeezes.push(mesh);
      data.colliders.push({
        type: 'squeeze', mesh,
        x: wx, y: mesh.position.y, z: worldZ,
        hx: w / 2 + 0.3, hy: archH / 2 + 0.3, hz: 2.3
      });
    }
  }

  // ---- Orbs ----
  const orbCount = rngInt(rng, 5, 8);
  for (let o = 0; o < orbCount; o++) {
    const localZ = -rngRange(rng, 4, CONFIG.CHUNK_LENGTH - 4);
    const worldZ = localZBase + localZ;
    const centerX = getCenterOffset(worldZ);
    const onPath = rng() < 0.5;
    const ox = onPath
      ? centerX + rngRange(rng, -3, 3)
      : rngRange(rng, -wallNarrow * 0.5, wallNarrow * 0.5);
    const oy = rngRange(rng, -2, 5);
    const orb = makeOrb();
    orb.position.set(ox, oy, localZ);
    group.add(orb);
    data.orbs.push({ mesh: orb, x: ox, y: oy, z: worldZ, alive: true, t: rng() * Math.PI * 2 });
  }

  STATE.scene.add(group);
  STATE.chunks.set(index, data);
  STATE.lastChunkIndex = Math.max(STATE.lastChunkIndex, index);
  return data;
}

// ── function: makeOrb ──
// Glowing energy orb — single mesh with sprite halo to keep draw calls low
function makeOrb() {
  const g = new THREE.Group();
  const core = new THREE.Mesh(
    new THREE.IcosahedronGeometry(0.55, 0),
    new THREE.MeshBasicMaterial({ color: 0xfff0a0 })
  );
  g.add(core);
  const halo = new THREE.Mesh(
    new THREE.SphereGeometry(1.1, 8, 8),
    new THREE.MeshBasicMaterial({ color: 0xff8a3c, transparent: true, opacity: 0.35 })
  );
  g.add(halo);
  g.userData.core = core;
  g.userData.halo = halo;
  return g;
}

// ── function: updateChunks ──
// Manage chunk lifecycle: spawn ahead, despawn behind
function updateChunks() {
  const pz = STATE.player.position.z;
  // Current chunk index based on z (z is negative forward)
  const curIdx = Math.max(0, Math.floor(-pz / CONFIG.CHUNK_LENGTH));
  STATE.firstChunkIndex = curIdx;

  // Spawn forward
  for (let i = curIdx; i <= curIdx + CONFIG.CHUNKS_AHEAD; i++) {
    if (!STATE.chunks.has(i)) buildChunk(i);
  }
  // Despawn behind
  for (const [idx, data] of STATE.chunks) {
    if (idx < curIdx - CONFIG.CHUNKS_BEHIND) {
      // Dispose
      STATE.scene.remove(data.group);
      data.group.traverse(obj => {
        if (obj.geometry) obj.geometry.dispose();
        if (obj.material) {
          if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose());
          else obj.material.dispose();
        }
      });
      STATE.chunks.delete(idx);
    }
  }
}

// ── const: Particles ──
// Particle systems — engine trail + speed debris
const Particles = (() => {
  let trailGeo, trailMat, trailPoints;
  let debrisPool = [];
  const DEBRIS_MAX = 60;
  const TRAIL_MAX = 180;
  const positions = new Float32Array(TRAIL_MAX * 3);
  const colors = new Float32Array(TRAIL_MAX * 3);
  let trailHead = 0, trailCount = 0;

  function init(scene) {
    // Trail as Points
    trailGeo = new THREE.BufferGeometry();
    trailGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    trailGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
    trailMat = new THREE.PointsMaterial({
      size: 0.7, vertexColors: true, transparent: true, opacity: 0.9,
      blending: THREE.AdditiveBlending, depthWrite: false,
      sizeAttenuation: true,
    });
    trailPoints = new THREE.Points(trailGeo, trailMat);
    trailPoints.frustumCulled = false;
    scene.add(trailPoints);

    // Debris pool
    const dGeo = new THREE.BoxGeometry(0.2, 0.2, 1.0);
    const dMat = new THREE.MeshBasicMaterial({ color: 0xffd680, transparent: true, opacity: 0.7 });
    for (let i = 0; i < DEBRIS_MAX; i++) {
      const m = new THREE.Mesh(dGeo, dMat.clone());
      m.visible = false;
      m.userData = { vx: 0, vy: 0, vz: 0, life: 0, max: 0 };
      scene.add(m);
      debrisPool.push(m);
    }
  }

  function emitTrail(x, y, z, intensity, boosting) {
    const idx = trailHead;
    positions[idx * 3] = x;
    positions[idx * 3 + 1] = y;
    positions[idx * 3 + 2] = z;
    if (boosting) {
      colors[idx * 3] = 1.0; colors[idx * 3 + 1] = 0.6; colors[idx * 3 + 2] = 0.15;
    } else {
      const t = intensity;
      colors[idx * 3] = 0.3 + t * 0.3;
      colors[idx * 3 + 1] = 0.7 + t * 0.3;
      colors[idx * 3 + 2] = 1.0;
    }
    trailHead = (trailHead + 1) % TRAIL_MAX;
    if (trailCount < TRAIL_MAX) trailCount++;
  }

  function spawnDebris(camera, count, speed) {
    for (let n = 0; n < count; n++) {
      const m = debrisPool.find(d => !d.visible);
      if (!m) return;
      m.position.set(
        camera.position.x + (Math.random() - 0.5) * 80,
        camera.position.y + (Math.random() - 0.5) * 30,
        camera.position.z - 60 - Math.random() * 30
      );
      m.userData.vx = (Math.random() - 0.5) * 1.5;
      m.userData.vy = (Math.random() - 0.5) * 1.5;
      m.userData.vz = speed * (0.8 + Math.random() * 0.4);
      m.userData.life = 0;
      m.userData.max = 0.6 + Math.random() * 0.4;
      m.material.color.setHex(Math.random() < 0.5 ? 0xff8a3c : 0x6ff0ff);
      m.material.opacity = 0.9;
      m.visible = true;
    }
  }

  function updateTrail(dt) {
    trailGeo.attributes.position.needsUpdate = true;
    trailGeo.attributes.color.needsUpdate = true;
  }

  function updateDebris(dt) {
    for (const m of debrisPool) {
      if (!m.visible) continue;
      m.userData.life += dt;
      m.position.x += m.userData.vx * dt;
      m.position.y += m.userData.vy * dt;
      m.position.z += m.userData.vz * dt;
      m.material.opacity = Math.max(0, 0.9 * (1 - m.userData.life / m.userData.max));
      if (m.userData.life >= m.userData.max) m.visible = false;
    }
  }

  return { init, emitTrail, spawnDebris, updateTrail, updateDebris, trailHead, TRAIL_MAX };
})();

// ── function: checkCollisions ──
// Collision detection: player (sphere) vs OBBs (with optional Z-rotation)
function checkCollisions() {
  const p = STATE.player.position;
  const r = CONFIG.BOUND_RADIUS;
  for (const [, data] of STATE.chunks) {
    for (const c of data.colliders) {
      // World-space AABB check (ignores rotation for monoliths/squeezes — fine for this aesthetic)
      const dx = Math.abs(p.x - c.x);
      const dy = Math.abs(p.y - c.y);
      const dz = Math.abs(p.z - c.z);
      if (dx < c.hx + r && dy < c.hy + r && dz < c.hz + r) {
        return true;
      }
    }
  }
  return false;
}

// ── function: checkOrbPickup ──
// Orb collection: simple sphere-sphere with active orbs
function checkOrbPickup() {
  const p = STATE.player.position;
  const r2 = (CONFIG.BOUND_RADIUS + 1.0) * (CONFIG.BOUND_RADIUS + 1.0);
  for (const [, data] of STATE.chunks) {
    for (const o of data.orbs) {
      if (!o.alive) continue;
      const dx = p.x - o.x, dy = p.y - o.y, dz = p.z - o.z;
      if (dx * dx + dy * dy + dz * dz < r2) {
        o.alive = false;
        o.mesh.visible = false;
        STATE.energy = Math.min(CONFIG.ENERGY_MAX, STATE.energy + CONFIG.ORB_RECHARGE);
        STATE.orbs++;
        STATE.boost = Math.min(CONFIG.BOOST_MAX, STATE.boost + CONFIG.BOOST_GAIN);
        Audio.orb();
      }
    }
  }
}

// ── function: checkNearMiss ──
// Near-miss detection: considers the "passing window" in z, with x/y proximity
function checkNearMiss() {
  const p = STATE.player.position;
  let bestIntensity = 0;
  const speedFactor = STATE.speed / 120;
  // Larger pass window to accommodate lower frame rates
  const passWindow = Math.max(8, speedFactor * 8);
  for (const [, data] of STATE.chunks) {
    for (const c of data.colliders) {
      const dz = Math.abs(p.z - c.z);
      if (dz > passWindow + c.hz) continue;
      const dx = Math.max(0, Math.abs(p.x - c.x) - c.hx);
      const dy = Math.max(0, Math.abs(p.y - c.y) - c.hy);
      // Compute proximity in the x/y plane
      const dPerp = Math.sqrt(dx * dx + dy * dy);
      if (dPerp > CONFIG.NEARMISS_RANGE) continue;
      const intensity = 1 - (dPerp / CONFIG.NEARMISS_RANGE);
      if (intensity > bestIntensity) {
        bestIntensity = intensity;
      }
    }
  }
  if (bestIntensity > 0) {
    const ddt = dt();
    // Punchy multiplier gain
    STATE.multiplier = Math.min(8, STATE.multiplier + bestIntensity * bestIntensity * 1.2 * ddt * 6);
    STATE.multiplierTimer = 1.5;
    // Award points
    const gained = CONFIG.NEARMISS_BASE * bestIntensity * STATE.multiplier * ddt * 15;
    STATE.score += gained;
    if (bestIntensity > 0.3) {
      triggerNearMissFlash(bestIntensity);
      STATE.shakeAmount = Math.max(STATE.shakeAmount, bestIntensity * 0.35);
      if (performance.now() - STATE.lastNearMiss > 120) {
        Audio.nearMiss();
        STATE.lastNearMiss = performance.now();
      }
    }
  }
  // Decay multiplier slowly
  STATE.multiplierTimer -= dt();
  if (STATE.multiplierTimer <= 0) {
    STATE.multiplier = Math.max(1.0, STATE.multiplier - dt() * 0.2);
  }
}

// ── let: _lastTime ──
let _lastTime = performance.now();

// ── function: dt ──
function dt() {
  const now = performance.now();
  const d = (now - _lastTime) / 1000;
  _lastTime = now;
  return Math.min(0.05, d);
}

// ── function: resetDt ──
function resetDt() { _lastTime = performance.now(); }

// ── function: triggerNearMissFlash ──
function triggerNearMissFlash(intensity) {
  const el = document.getElementById('nearmiss-flash');
  if (!el) return;
  el.classList.add('active');
  clearTimeout(triggerNearMissFlash._t);
  triggerNearMissFlash._t = setTimeout(() => el.classList.remove('active'), 120);
  const m = document.getElementById('mult-value');
  if (m) {
    m.classList.add('pulse');
    clearTimeout(triggerNearMissFlash._m);
    triggerNearMissFlash._m = setTimeout(() => m.classList.remove('pulse'), 140);
  }
}

// ── function: updatePlayer ──
// Player flight physics — pitch/bank-based, not axis-based
function updatePlayer(dt) {
  const k = STATE.keys;
  // Pitch input: W/S or Up/Down
  const pitchIn = (k['KeyW'] || k['ArrowUp'] ? 1 : 0) - (k['KeyS'] || k['ArrowDown'] ? 1 : 0);
  // Roll input: A/D or Left/Right (banking)
  const rollIn = (k['KeyA'] || k['ArrowLeft'] ? 1 : 0) - (k['KeyD'] || k['ArrowRight'] ? 1 : 0);
  // Boost: Shift
  STATE.boosting = !!(k['ShiftLeft'] || k['ShiftRight']) && STATE.boost > 0;
  if (STATE.boosting) {
    STATE.boost = Math.max(0, STATE.boost - CONFIG.BOOST_COST * dt);
  }

  // Target speed
  const diff = Math.min(1, STATE.score * CONFIG.DIFF_SCALE);
  const baseMax = CONFIG.MAX_SPEED * (0.55 + diff * 0.55);
  const targetSpeed = (STATE.boosting ? baseMax * CONFIG.BOOST_MULT : baseMax);
  STATE.speed += (targetSpeed - STATE.speed) * Math.min(1, dt * 2.2);
  STATE.maxSpeed = Math.max(STATE.maxSpeed, STATE.speed);

  // Pitch angle (climb/dive) — affects y velocity
  STATE.pitch -= pitchIn * CONFIG.PITCH_RATE * dt;
  STATE.pitch *= 0.96;
  STATE.pitch = Math.max(-0.9, Math.min(0.9, STATE.pitch));

  // Roll (banking) — affects yaw/turn
  STATE.roll -= rollIn * CONFIG.ROLL_RATE * dt;
  STATE.roll *= 0.92;
  // Yaw driven by roll
  STATE.yaw -= STATE.roll * CONFIG.ROLL_TURN * dt;
  // Self-level roll toward 0
  STATE.roll += (0 - STATE.roll) * dt * 1.4;

  // Velocity from orientation
  const cosY = Math.cos(STATE.yaw), sinY = Math.sin(STATE.yaw);
  const cosP = Math.cos(STATE.pitch), sinP = Math.sin(STATE.pitch);
  // Forward vector
  const fx = -sinY * cosP;
  const fy = sinP;
  const fz = -cosY * cosP;
  STATE.forwardDir.set(fx, fy, fz);
  STATE.rightDir.set(cosY, 0, -sinY);
  STATE.upDir.crossVectors(STATE.rightDir, STATE.forwardDir).normalize();

  // Apply forward speed as displacement
  const p = STATE.player.position;
  p.x += fx * STATE.speed * dt;
  p.y += fy * STATE.speed * dt;
  p.z += fz * STATE.speed * dt;

  // Vertical bounds
  const yMax = CONFIG.CANYON_FLOOR_Y + CONFIG.CANYON_HEIGHT * 0.7;
  const yMin = CONFIG.CANYON_FLOOR_Y + 3;
  if (p.y > yMax) p.y = yMax;
  if (p.y < yMin) {
    p.y = yMin;
    STATE.pitch = Math.max(0, STATE.pitch);
  }

  // Lateral bounds — soft, follow the meandering centerline
  const centerX = getCenterOffset(p.z);
  const diffScale = Math.min(1, STATE.score * CONFIG.DIFF_SCALE);
  const wallNarrow = CONFIG.CANYON_HALF_WIDTH * (0.75 + diffScale * 0.15);
  const latMax = centerX + wallNarrow * 0.78;
  const latMin = centerX - wallNarrow * 0.78;
  if (p.x > latMax) {
    p.x = latMax;
    STATE.roll = Math.min(0, STATE.roll); // prevent further right roll
  }
  if (p.x < latMin) {
    p.x = latMin;
    STATE.roll = Math.max(0, STATE.roll); // prevent further left roll
  }

  // Apply orientation to craft mesh
  STATE.player.rotation.set(0, 0, 0);
  STATE.player.rotateY(STATE.yaw);
  STATE.player.rotateX(STATE.pitch);
  STATE.player.rotateZ(STATE.roll);

  // Engine glow pulse
  const engineGlow = STATE.player.userData.engineGlow;
  const halo = STATE.player.userData.halo;
  if (engineGlow) {
    const s = 0.9 + (STATE.speed / CONFIG.MAX_SPEED) * 0.7 + (STATE.boosting ? 0.6 : 0);
    engineGlow.scale.setScalar(s);
    engineGlow.material.color.setHex(STATE.boosting ? 0xff8a3c : 0x6ff0ff);
  }
  if (halo) {
    const s = 1 + Math.sin(performance.now() * 0.012) * 0.15 + (STATE.boosting ? 0.4 : 0);
    halo.scale.setScalar(s);
    halo.material.opacity = 0.3 + (STATE.boosting ? 0.3 : 0);
  }
}

// ── function: updateHUD ──
// Update HUD elements
function updateHUD() {
  const score = Math.floor(STATE.score);
  const speed = Math.floor(STATE.speed);
  document.getElementById('score-value').textContent = score.toLocaleString();
  document.getElementById('speed-value').textContent = speed;
  document.getElementById('mult-value').textContent = STATE.multiplier.toFixed(1);
  document.getElementById('energy-fill').style.width = (STATE.energy / CONFIG.ENERGY_MAX * 100) + '%';
  document.getElementById('boost-fill').style.width = (STATE.boost / CONFIG.BOOST_MAX * 100) + '%';
  document.getElementById('speed-bar-fill').style.width = (STATE.speed / CONFIG.MAX_SPEED * 100) + '%';
}

// ── function: updateCamera ──
// Update camera: follow, shake, FOV pump
function updateCamera(dt) {
  const cam = STATE.camera;
  const p = STATE.player;
  // Third-person chase position — slightly behind & above
  const offset = new THREE.Vector3(0, 3.2, 9.5);
  // Apply roll rotation to offset
  const cs = Math.cos(STATE.roll * 0.5), sn = Math.sin(STATE.roll * 0.5);
  const ox = offset.x * cs - offset.y * sn;
  const oy = offset.x * sn + offset.y * cs;
  const target = new THREE.Vector3(p.position.x + ox, p.position.y + oy, p.position.z + offset.z);
  cam.position.lerp(target, Math.min(1, dt * 6));
  // Look ahead
  const look = new THREE.Vector3(
    p.position.x + STATE.forwardDir.x * 8,
    p.position.y + STATE.forwardDir.y * 4,
    p.position.z + STATE.forwardDir.z * 8
  );
  cam.lookAt(look);

  // FOV pump with speed + boost
  const targetFov = STATE.baseFov + (STATE.speed / CONFIG.MAX_SPEED) * 32 + (STATE.boosting ? 22 : 0);
  cam.fov += (targetFov - cam.fov) * Math.min(1, dt * 3);
  cam.updateProjectionMatrix();

  // Camera shake
  if (STATE.shakeAmount > 0.001) {
    cam.position.x += (Math.random() - 0.5) * STATE.shakeAmount;
    cam.position.y += (Math.random() - 0.5) * STATE.shakeAmount;
    STATE.shakeAmount *= Math.pow(0.001, dt); // decay
  }
}

// ── function: updateOrbs ──
// Animate orbs (rotation, bobbing, pulse)
function updateOrbs(t) {
  for (const [, data] of STATE.chunks) {
    for (const o of data.orbs) {
      if (!o.alive) continue;
      o.t += dt();
      o.mesh.userData.core.scale.setScalar(0.85 + Math.sin(o.t * 4) * 0.15);
      o.mesh.userData.halo.scale.setScalar(1 + Math.sin(o.t * 3) * 0.2);
      o.mesh.rotation.y = o.t * 0.8;
    }
  }
}

// ── function: triggerGameOver ──
// Game over flow
function triggerGameOver() {
  STATE.alive = false;
  Audio.crash();
  document.getElementById('final-score').textContent = Math.floor(STATE.score).toLocaleString();
  document.getElementById('final-speed').textContent = Math.floor(STATE.maxSpeed);
  document.getElementById('final-orbs').textContent = STATE.orbs;
  document.getElementById('gameover').classList.add('show');
}

// ── function: restartGame ──
function restartGame() {
  // Clear chunks
  for (const [, data] of STATE.chunks) {
    STATE.scene.remove(data.group);
    data.group.traverse(obj => {
      if (obj.geometry) obj.geometry.dispose();
      if (obj.material) {
        if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose());
        else obj.material.dispose();
      }
    });
  }
  STATE.chunks.clear();
  STATE.lastChunkIndex = -1;

  // Reset state
  STATE.player.position.set(0, 0, 0);
  STATE.player.rotation.set(0, 0, 0);
  STATE.pitch = 0; STATE.roll = 0; STATE.yaw = 0;
  STATE.speed = 0;
  STATE.energy = CONFIG.ENERGY_MAX;
  STATE.boost = CONFIG.BOOST_MAX * 0.4;
  STATE.score = 0;
  STATE.multiplier = 1.0;
  STATE.multiplierTimer = 0;
  STATE.orbs = 0;
  STATE.maxSpeed = 0;
  STATE.alive = true;
  STATE.shakeAmount = 0;
  resetDt();
  // Pre-build a few chunks
  updateChunks();
  document.getElementById('gameover').classList.remove('show');
}

// ── function: setupInput ──
// Input wiring
function setupInput() {
  window.addEventListener('keydown', e => {
    STATE.keys[e.code] = true;
    if (e.code === 'Space') {
      if (!STATE.started) startGame();
      e.preventDefault();
    }
  });
  window.addEventListener('keyup', e => { STATE.keys[e.code] = false; });
}

// ── function: startGame ──
function startGame() {
  Audio.init();
  document.getElementById('title-screen').classList.add('hide');
  STATE.started = true;
  STATE.alive = true;
  resetDt();
}

// ── function: loop ──
// Main animation loop
function loop() {
  requestAnimationFrame(loop);
  const d = Math.min(0.05, dt());

  // Animate orbs always
  updateOrbs(performance.now() / 1000);
  Particles.updateTrail(d);
  Particles.updateDebris(d);

  if (STATE.started && STATE.alive) {
    updatePlayer(d);
    updateChunks();
    checkOrbPickup();
    checkNearMiss();

    // Energy decay
    STATE.energy -= CONFIG.ENERGY_DECAY * d * (STATE.boosting ? 1.6 : 1);
    if (STATE.energy <= 0) {
      STATE.energy = 0;
      triggerGameOver();
    }

    // Score for distance
    STATE.score += d * (10 * STATE.multiplier);

    // Engine trail
    STATE.trailCooldown -= d;
    if (STATE.trailCooldown <= 0) {
      const p = STATE.player.position;
      const back = STATE.forwardDir.clone().multiplyScalar(-1.4);
      Particles.emitTrail(
        p.x + back.x + (Math.random() - 0.5) * 0.3,
        p.y + back.y,
        p.z + back.z,
        STATE.speed / CONFIG.MAX_SPEED,
        STATE.boosting
      );
      STATE.trailCooldown = 0.04;
    }

    // Speed debris
    const speedRatio = STATE.speed / CONFIG.MAX_SPEED;
    if (speedRatio > 0.5 && Math.random() < (speedRatio - 0.4) * 0.7) {
      Particles.spawnDebris(STATE.camera, 2, STATE.speed);
    }

    // Audio engine
    Audio.setEngine(speedRatio, STATE.boosting);
    Audio.setBoost(STATE.boosting);

    updateCamera(d);
    updateHUD();
    updateDangerWarning();
  } else if (STATE.started) {
    updateCamera(d);
  }

  if (STATE.renderer && STATE.scene && STATE.camera) {
    STATE.renderer.render(STATE.scene, STATE.camera);
  }
}

// ── function: bootGame ──
// Initialize everything and kick off the loop
function bootGame() {
  const { scene, camera, renderer } = initScene();
  // Build player
  const craft = buildCraft();
  craft.position.set(0, 0, 0);
  scene.add(craft);
  STATE.player = craft;

  // Pre-spawn first chunks
  for (let i = 0; i <= CONFIG.CHUNKS_AHEAD; i++) buildChunk(i);

  Particles.init(scene);
  setupInput();

  document.getElementById('start-btn').addEventListener('click', () => {
    Audio.init();
    startGame();
  });
  document.getElementById('restart-btn').addEventListener('click', () => {
    Audio.init();
    restartGame();
  });

  resetDt();
  STATE.clock = new THREE.Clock();
  loop();
}

// ── function: getCenterOffset ──
// Meandering centerline — tight enough to force near-misses with the walls
function getCenterOffset(zWorld) {
  const t = zWorld * 0.0035;
  return Math.sin(t) * 8 + Math.sin(t * 0.43 + 1.3) * 4;
}

// ── function: updateDangerWarning ──
// Update the danger warning overlay based on proximity to obstacles
function updateDangerWarning() {
  const el = document.getElementById('danger-warn');
  const sl = document.getElementById('speed-lines');
  if (!el) return;
  const p = STATE.player.position;
  let minDist = Infinity;
  for (const [, data] of STATE.chunks) {
    for (const c of data.colliders) {
      const dz = Math.abs(p.z - c.z);
      if (dz > 12) continue;
      const dx = Math.max(0, Math.abs(p.x - c.x) - c.hx);
      const dy = Math.max(0, Math.abs(p.y - c.y) - c.hy);
      const d = Math.sqrt(dx * dx + dy * dy);
      if (d < minDist) minDist = d;
    }
  }
  if (minDist < 5) {
    el.classList.add('active');
  } else {
    el.classList.remove('active');
  }
  // Speed lines
  const speedRatio = STATE.speed / CONFIG.MAX_SPEED;
  if (sl) {
    if (speedRatio > 0.75 || STATE.boosting) sl.classList.add('active');
    else sl.classList.remove('active');
  }
}

// ── main ──
window.addEventListener('load', () => {
  window.STATE = STATE;
  window.CONFIG = CONFIG;
  bootGame();
});
</script>
</body>
</html>
<!-- agent-meta {"model":"minimax/minimax-m3","provider":"openrouter","persona":"composer","sessionId":"7330b0be-0800-47ae-a8b2-84c80000481a","tokensIn":6179161,"tokensOut":85571,"tokensTotal":6264732,"turns":97,"toolCalls":130,"failedToolCalls":4,"timestamp":"2026-07-13T21:45:50.216Z"} -->