← Canyon Run results

Canyon Run

Grok 4.5 · legacy

◈Preview unavailable — try playing the result

Browser check: Preview unavailable or timed out. This is a technical check, not a quality review.

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

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 56,795 bytes · SHA-256 5de88dc13d97
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>Canyon Racer — High-Speed Procedural Flight</title>
<style>
  @import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Rajdhani:wght@400;600;700&display=swap');

  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body {
    width: 100%; height: 100%; overflow: hidden;
    background: #0a0612;
    font-family: 'Rajdhani', system-ui, sans-serif;
    color: #f0e6d3;
    cursor: none;
  }
  #c { display: block; width: 100%; height: 100%; }

  /* HUD */
  #hud {
    position: fixed; inset: 0; pointer-events: none;
    z-index: 10;
  }
  .hud-top {
    position: absolute; top: 28px; left: 0; right: 0;
    display: flex; justify-content: center; gap: 48px;
    align-items: flex-start;
  }
  .stat {
    text-align: center;
    min-width: 120px;
  }
  .stat-label {
    font-size: 11px; letter-spacing: 0.28em; text-transform: uppercase;
    color: rgba(240,230,211,0.45); font-weight: 600;
  }
  .stat-value {
    font-family: 'Orbitron', monospace;
    font-size: 28px; font-weight: 700;
    color: #ffc857;
    text-shadow: 0 0 24px rgba(255,180,60,0.45);
    line-height: 1.2;
  }
  #score-val { font-size: 36px; }
  #mult-wrap {
    transition: transform 0.12s ease-out, filter 0.12s;
  }
  #mult-wrap.pulse {
    transform: scale(1.35);
    filter: brightness(1.6) drop-shadow(0 0 12px #ff6b35);
  }
  #mult-val {
    color: #ff6b35;
    font-size: 42px;
  }
  #mult-val::after { content: '×'; font-size: 0.55em; opacity: 0.7; }

  /* Energy bar */
  .energy-wrap {
    position: absolute; bottom: 42px; left: 50%;
    transform: translateX(-50%);
    width: min(340px, 70vw);
  }
  .energy-label {
    font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase;
    color: rgba(240,230,211,0.4); text-align: center; margin-bottom: 6px;
  }
  .energy-track {
    height: 6px; border-radius: 3px;
    background: rgba(255,255,255,0.08);
    border: 1px solid rgba(255,200,100,0.2);
    overflow: hidden; position: relative;
  }
  .energy-fill {
    height: 100%; width: 100%;
    background: linear-gradient(90deg, #c44512, #ff8c2a, #ffd96a);
    box-shadow: 0 0 16px rgba(255,140,40,0.7);
    transition: width 0.08s linear;
    border-radius: 3px;
  }
  .energy-fill.low {
    background: linear-gradient(90deg, #5c1010, #e63946, #ff6b6b);
    animation: energy-crit 0.5s ease-in-out infinite alternate;
  }
  @keyframes energy-crit {
    from { opacity: 0.7; } to { opacity: 1; box-shadow: 0 0 20px #e63946; }
  }

  /* Speed bar */
  .speed-wrap {
    position: absolute; bottom: 72px; left: 50%;
    transform: translateX(-50%);
    width: min(340px, 70vw);
  }
  .speed-track {
    height: 2px; background: rgba(255,255,255,0.06);
    position: relative;
  }
  .speed-fill {
    height: 100%;
    background: linear-gradient(90deg, #4a6fa5, #a8c6f0, #ffffff);
    box-shadow: 0 0 8px rgba(160,200,255,0.6);
    transition: width 0.05s linear;
  }
  .turbo-seg {
    position: absolute; top: -3px; right: 0;
    width: 22%; height: 8px;
    border-left: 1px solid rgba(255,255,255,0.15);
  }
  .turbo-fill {
    position: absolute; top: -3px; right: 0;
    height: 8px; width: 0%;
    background: linear-gradient(90deg, transparent, #7df9ff, #ffffff);
    box-shadow: 0 0 14px #7df9ff;
    transition: width 0.08s linear;
  }

  /* Near-miss flash text */
  #near-miss-fx {
    position: absolute; top: 38%; left: 50%;
    transform: translate(-50%, -50%);
    font-family: 'Orbitron', monospace;
    font-size: 22px; font-weight: 700; letter-spacing: 0.2em;
    color: #7df9ff;
    text-shadow: 0 0 30px #7df9ff;
    opacity: 0; pointer-events: none;
    transition: none;
  }
  #near-miss-fx.show {
    animation: nm-pop 0.7s ease-out forwards;
  }
  @keyframes nm-pop {
    0%   { opacity: 0; transform: translate(-50%, -40%) scale(0.6); }
    20%  { opacity: 1; transform: translate(-50%, -50%) scale(1.15); }
    100% { opacity: 0; transform: translate(-50%, -70%) scale(1); }
  }

  /* Boost indicator */
  #boost-fx {
    position: absolute; inset: 0;
    border: 3px solid transparent;
    pointer-events: none;
    transition: border-color 0.15s, box-shadow 0.15s;
  }
  #boost-fx.active {
    border-color: rgba(125,249,255,0.35);
    box-shadow: inset 0 0 80px rgba(125,249,255,0.12);
  }

  /* Overlay screens */
  .overlay {
    position: fixed; inset: 0; z-index: 20;
    display: flex; flex-direction: column;
    align-items: center; justify-content: center;
    background: radial-gradient(ellipse at center, rgba(20,10,30,0.85) 0%, rgba(5,2,12,0.96) 100%);
    pointer-events: auto;
    cursor: default;
    transition: opacity 0.4s ease;
  }
  .overlay.hidden { opacity: 0; pointer-events: none; }

  .title-main {
    font-family: 'Orbitron', monospace;
    font-weight: 900; font-size: clamp(28px, 6vw, 56px);
    letter-spacing: 0.12em;
    background: linear-gradient(135deg, #ffc857, #ff6b35, #c44569);
    -webkit-background-clip: text; background-clip: text;
    color: transparent;
    text-shadow: none;
    filter: drop-shadow(0 0 40px rgba(255,140,40,0.35));
    margin-bottom: 8px;
  }
  .title-sub {
    font-size: 13px; letter-spacing: 0.45em; text-transform: uppercase;
    color: rgba(240,230,211,0.5); margin-bottom: 40px;
  }
  .btn {
    font-family: 'Orbitron', monospace;
    font-size: 14px; font-weight: 700; letter-spacing: 0.25em;
    text-transform: uppercase;
    padding: 16px 48px;
    border: 1px solid rgba(255,200,100,0.45);
    background: linear-gradient(180deg, rgba(255,140,40,0.18), rgba(255,100,30,0.05));
    color: #ffc857;
    cursor: pointer;
    transition: all 0.2s ease;
    box-shadow: 0 0 30px rgba(255,140,40,0.15);
  }
  .btn:hover {
    background: linear-gradient(180deg, rgba(255,140,40,0.35), rgba(255,100,30,0.12));
    border-color: #ffc857;
    box-shadow: 0 0 50px rgba(255,140,40,0.35);
    transform: translateY(-1px);
  }
  .controls-hint {
    margin-top: 36px;
    font-size: 13px; color: rgba(240,230,211,0.4);
    text-align: center; line-height: 1.9; letter-spacing: 0.05em;
  }
  .controls-hint kbd {
    display: inline-block;
    padding: 2px 8px; margin: 0 2px;
    border: 1px solid rgba(255,255,255,0.15);
    border-radius: 3px;
    font-family: 'Orbitron', monospace; font-size: 11px;
    color: rgba(255,200,100,0.7);
    background: rgba(255,255,255,0.04);
  }
  .go-stats {
    display: grid; grid-template-columns: 1fr 1fr 1fr;
    gap: 32px; margin: 28px 0 40px;
    text-align: center;
  }
  .go-stat-val {
    font-family: 'Orbitron', monospace;
    font-size: 32px; font-weight: 700; color: #ffc857;
  }
  .go-stat-lbl {
    font-size: 11px; letter-spacing: 0.25em; text-transform: uppercase;
    color: rgba(240,230,211,0.4); margin-top: 4px;
  }
  #go-title {
    font-family: 'Orbitron', monospace;
    font-size: 42px; font-weight: 900; letter-spacing: 0.15em;
    color: #e63946;
    text-shadow: 0 0 40px rgba(230,57,70,0.5);
    margin-bottom: 8px;
  }

  /* Crosshair / reticle */
  #reticle {
    position: fixed; width: 28px; height: 28px;
    margin: -14px 0 0 -14px; pointer-events: none; z-index: 5;
    border: 1px solid rgba(255,200,100,0.25);
    border-radius: 50%;
    transition: border-color 0.1s;
  }
  #reticle::before, #reticle::after {
    content: ''; position: absolute; background: rgba(255,200,100,0.35);
  }
  #reticle::before { width: 1px; height: 10px; left: 50%; top: 50%; transform: translate(-50%,-50%); }
  #reticle::after  { height: 1px; width: 10px; left: 50%; top: 50%; transform: translate(-50%,-50%); }
  #reticle.danger { border-color: rgba(255,80,60,0.7); }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud">
  <div class="hud-top">
    <div class="stat">
      <div class="stat-label">Score</div>
      <div class="stat-value" id="score-val">0</div>
    </div>
    <div class="stat" id="mult-wrap">
      <div class="stat-label">Multiplier</div>
      <div class="stat-value" id="mult-val">1.0</div>
    </div>
    <div class="stat">
      <div class="stat-label">Speed</div>
      <div class="stat-value" id="speed-val">0</div>
    </div>
  </div>

  <div id="near-miss-fx">NEAR MISS</div>
  <div id="boost-fx"></div>

  <div class="speed-wrap">
    <div class="speed-track">
      <div class="speed-fill" id="speed-bar"></div>
      <div class="turbo-seg"></div>
      <div class="turbo-fill" id="turbo-bar"></div>
    </div>
  </div>
  <div class="energy-wrap">
    <div class="energy-label">Energy</div>
    <div class="energy-track">
      <div class="energy-fill" id="energy-bar"></div>
    </div>
  </div>
</div>
<div id="reticle"></div>

<!-- Title -->
<div class="overlay" id="title-screen">
  <div class="title-main">CANYON RACER</div>
  <div class="title-sub">High-Speed Procedural Flight</div>
  <button class="btn" id="start-btn">Launch</button>
  <div class="controls-hint">
    <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> / Arrows — Pitch & Bank<br/>
    <kbd>SPACE</kbd> — Turbo Boost &nbsp;·&nbsp; <kbd>MOUSE</kbd> — Aim assist<br/>
    Skim the walls. Collect energy. Never stop.
  </div>
</div>

<!-- Game Over -->
<div class="overlay hidden" id="go-screen">
  <div id="go-title">SIGNAL LOST</div>
  <div class="title-sub">Craft destroyed</div>
  <div class="go-stats">
    <div>
      <div class="go-stat-val" id="go-score">0</div>
      <div class="go-stat-lbl">Final Score</div>
    </div>
    <div>
      <div class="go-stat-val" id="go-maxspd">0</div>
      <div class="go-stat-lbl">Max Speed</div>
    </div>
    <div>
      <div class="go-stat-val" id="go-maxmult">1.0×</div>
      <div class="go-stat-lbl">Peak Mult</div>
    </div>
  </div>
  <button class="btn" id="restart-btn">Reboot</button>
</div>

<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
  }
}
</script>
<script type="module">
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';

// ═══════════════════════════════════════════════════════════
// CONFIG
// ═══════════════════════════════════════════════════════════
const CFG = {
  CHUNK_LEN: 180,
  CHUNK_AHEAD: 6,
  CHUNK_BEHIND: 2,
  CANYON_HALF: 28,
  WALL_H: 55,
  BASE_SPEED: 48,
  MAX_SPEED: 160,
  LATERAL_ACCEL: 95,
  LATERAL_MAX: 42,
  PITCH_ACCEL: 70,
  PITCH_MAX: 28,
  BANK_MAX: 0.55,
  DRAG: 3.2,
  ENERGY_MAX: 100,
  ENERGY_DECAY: 4.2,
  ENERGY_ORB: 18,
  NEAR_MISS_DIST: 4.5,
  NEAR_MISS_MULT_GAIN: 0.15,
  MULT_DECAY: 0.35,
  MULT_MAX: 12,
  TURBO_MAX: 100,
  TURBO_GAIN: 8,
  TURBO_COST: 35,
  TURBO_BOOST: 55,
  COLLIDE_R: 1.15,
  FOV_BASE: 70,
  FOV_BOOST: 95,
  SPAWN_GRACE: 1.6,
  SAFE_OPENING: 70,
};

// Palette — cyber sunset
const PAL = {
  skyTop: 0x2a1548,
  skyBot: 0xe05a3a,
  sun: 0xffb86b,
  wallA: 0x6b4568,
  wallB: 0x8a5470,
  wallC: 0xa85f78,
  craft: 0xe8e8ec,
  craftAccent: 0xff6b35,
  engine: 0x7df9ff,
  orb: 0xffc857,
  mono: 0x7a5a8a,
  ground: 0x3a2848,
};

// ═══════════════════════════════════════════════════════════
// AUDIO (Web Audio — procedural)
// ═══════════════════════════════════════════════════════════
class AudioSys {
  constructor() {
    this.ctx = null;
    this.master = null;
    this.engine = null;
    this.engineGain = null;
    this.boosting = false;
  }
  init() {
    if (this.ctx) return;
    const AC = window.AudioContext || window.webkitAudioContext;
    this.ctx = new AC();
    this.master = this.ctx.createGain();
    this.master.gain.value = 0.35;
    this.master.connect(this.ctx.destination);
    this._startEngine();
  }
  _startEngine() {
    const c = this.ctx;
    // layered saw + noise drone
    const osc = c.createOscillator();
    osc.type = 'sawtooth';
    osc.frequency.value = 55;
    const filt = c.createBiquadFilter();
    filt.type = 'lowpass';
    filt.frequency.value = 280;
    filt.Q.value = 2;
    this.engineGain = c.createGain();
    this.engineGain.gain.value = 0.12;
    osc.connect(filt);
    filt.connect(this.engineGain);
    this.engineGain.connect(this.master);
    osc.start();
    this.engine = { osc, filt };

    // subtle high hum
    const o2 = c.createOscillator();
    o2.type = 'sine';
    o2.frequency.value = 180;
    const g2 = c.createGain();
    g2.gain.value = 0.04;
    o2.connect(g2); g2.connect(this.master);
    o2.start();
    this.engine.hum = o2;
  }
  setEngine(speedNorm, boosting) {
    if (!this.engine) return;
    const f = 50 + speedNorm * 90 + (boosting ? 40 : 0);
    this.engine.osc.frequency.setTargetAtTime(f, this.ctx.currentTime, 0.08);
    this.engine.filt.frequency.setTargetAtTime(220 + speedNorm * 600 + (boosting ? 400 : 0), this.ctx.currentTime, 0.1);
    this.engineGain.gain.setTargetAtTime(0.08 + speedNorm * 0.14, this.ctx.currentTime, 0.1);
  }
  ping() {
    if (!this.ctx) return;
    const t = this.ctx.currentTime;
    const o = this.ctx.createOscillator();
    const g = this.ctx.createGain();
    o.type = 'sine';
    o.frequency.setValueAtTime(880, t);
    o.frequency.exponentialRampToValueAtTime(1760, t + 0.08);
    g.gain.setValueAtTime(0.22, t);
    g.gain.exponentialRampToValueAtTime(0.001, t + 0.25);
    o.connect(g); g.connect(this.master);
    o.start(t); o.stop(t + 0.26);
  }
  whoosh() {
    if (!this.ctx) return;
    const t = this.ctx.currentTime;
    const buf = this.ctx.createBuffer(1, this.ctx.sampleRate * 0.25, this.ctx.sampleRate);
    const d = buf.getChannelData(0);
    for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 1.5);
    const src = this.ctx.createBufferSource();
    src.buffer = buf;
    const f = this.ctx.createBiquadFilter();
    f.type = 'bandpass';
    f.frequency.setValueAtTime(400, t);
    f.frequency.exponentialRampToValueAtTime(2400, t + 0.18);
    f.Q.value = 0.8;
    const g = this.ctx.createGain();
    g.gain.setValueAtTime(0.28, t);
    g.gain.exponentialRampToValueAtTime(0.001, t + 0.25);
    src.connect(f); f.connect(g); g.connect(this.master);
    src.start(t);
  }
  crash() {
    if (!this.ctx) return;
    const t = this.ctx.currentTime;
    const buf = this.ctx.createBuffer(1, this.ctx.sampleRate * 0.5, this.ctx.sampleRate);
    const d = buf.getChannelData(0);
    for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 0.7);
    const src = this.ctx.createBufferSource();
    src.buffer = buf;
    const f = this.ctx.createBiquadFilter();
    f.type = 'lowpass';
    f.frequency.setValueAtTime(900, t);
    f.frequency.exponentialRampToValueAtTime(80, t + 0.45);
    const g = this.ctx.createGain();
    g.gain.setValueAtTime(0.5, t);
    g.gain.exponentialRampToValueAtTime(0.001, t + 0.5);
    src.connect(f); f.connect(g); g.connect(this.master);
    src.start(t);
    // thump
    const o = this.ctx.createOscillator();
    o.type = 'sine';
    o.frequency.setValueAtTime(90, t);
    o.frequency.exponentialRampToValueAtTime(25, t + 0.3);
    const g2 = this.ctx.createGain();
    g2.gain.setValueAtTime(0.4, t);
    g2.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
    o.connect(g2); g2.connect(this.master);
    o.start(t); o.stop(t + 0.36);
  }
  boostStart() {
    if (!this.ctx) return;
    const t = this.ctx.currentTime;
    const o = this.ctx.createOscillator();
    o.type = 'sawtooth';
    o.frequency.setValueAtTime(100, t);
    o.frequency.exponentialRampToValueAtTime(400, t + 0.2);
    const g = this.ctx.createGain();
    g.gain.setValueAtTime(0.12, t);
    g.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
    const f = this.ctx.createBiquadFilter();
    f.type = 'highpass'; f.frequency.value = 200;
    o.connect(f); f.connect(g); g.connect(this.master);
    o.start(t); o.stop(t + 0.36);
  }
}
const SFX = new AudioSys();

// ═══════════════════════════════════════════════════════════
// SEEDED RNG for chunk consistency
// ═══════════════════════════════════════════════════════════
function hashSeed(n) {
  let x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
  return x - Math.floor(x);
}
function rng(seed) {
  let s = seed;
  return () => {
    s = (s * 16807 + 0.5) % 2147483647;
    return (s - 0.5) / 2147483646;
  };
}

// ═══════════════════════════════════════════════════════════
// THREE SETUP
// ═══════════════════════════════════════════════════════════
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.55;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

const scene = new THREE.Scene();
scene.background = new THREE.Color(PAL.skyTop);
scene.fog = new THREE.FogExp2(0x4a2038, 0.0016);

// Gradient sky via large dome
{
  const skyGeo = new THREE.SphereGeometry(900, 32, 16);
  const skyMat = new THREE.ShaderMaterial({
    side: THREE.BackSide,
    depthWrite: false,
    uniforms: {
      topColor: { value: new THREE.Color(PAL.skyTop) },
      botColor: { value: new THREE.Color(PAL.skyBot) },
      offset: { value: 0.1 },
      exponent: { value: 0.55 },
    },
    vertexShader: `
      varying vec3 vW;
      void main() {
        vec4 w = modelMatrix * vec4(position, 1.0);
        vW = w.xyz;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
      }`,
    fragmentShader: `
      uniform vec3 topColor, botColor;
      uniform float offset, exponent;
      varying vec3 vW;
      void main() {
        float h = normalize(vW + vec3(0.0, offset, 0.0)).y;
        float t = max(pow(max(h, 0.0), exponent), 0.0);
        // warm horizon band
        float band = smoothstep(0.0, 0.25, h) * (1.0 - smoothstep(0.15, 0.55, h));
        vec3 col = mix(botColor, topColor, t);
        col = mix(col, vec3(1.0, 0.45, 0.2), band * 0.55);
        gl_FragColor = vec4(col, 1.0);
      }`,
  });
  scene.add(new THREE.Mesh(skyGeo, skyMat));
}

// Lights — bright cyber-sunset (walls must read, not pure silhouette)
const ambient = new THREE.AmbientLight(0xc8a0b8, 0.95);
scene.add(ambient);

const sun = new THREE.DirectionalLight(PAL.sun, 2.4);
sun.position.set(-60, 90, -30);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 10;
sun.shadow.camera.far = 400;
sun.shadow.camera.left = -120;
sun.shadow.camera.right = 120;
sun.shadow.camera.top = 120;
sun.shadow.camera.bottom = -120;
sun.shadow.bias = -0.0005;
sun.shadow.intensity = 0.55;
scene.add(sun);

// Fill from the open canyon so unlit faces aren't black
const fill = new THREE.DirectionalLight(0xffd0a8, 1.15);
fill.position.set(50, 40, 80);
scene.add(fill);

const rim = new THREE.DirectionalLight(0xb48cff, 0.85);
rim.position.set(40, 30, 60);
scene.add(rim);

const hemi = new THREE.HemisphereLight(0xffb070, 0x4a3060, 0.95);
scene.add(hemi);

// Soft warm key near the craft so corridor always reads
const pathLight = new THREE.PointLight(0xffa060, 2.2, 90, 1.4);
pathLight.position.set(0, 22, 0);
scene.add(pathLight);

// Camera
const camera = new THREE.PerspectiveCamera(CFG.FOV_BASE, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 8, 18);

// Post-processing: bloom + radial speed blur
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(
  new THREE.Vector2(window.innerWidth, window.innerHeight),
  0.45, 0.35, 0.82
);
composer.addPass(bloom);

const SpeedBlurShader = {
  uniforms: {
    tDiffuse: { value: null },
    strength: { value: 0.0 },
  },
  vertexShader: /* glsl */`
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }`,
  fragmentShader: /* glsl */`
    uniform sampler2D tDiffuse;
    uniform float strength;
    varying vec2 vUv;
    void main() {
      vec2 center = vec2(0.5, 0.48);
      vec2 dir = vUv - center;
      float dist = length(dir);
      vec2 stepDir = dir * strength * 0.018;
      vec4 col = texture2D(tDiffuse, vUv);
      col += texture2D(tDiffuse, vUv - stepDir);
      col += texture2D(tDiffuse, vUv - stepDir * 2.0);
      col += texture2D(tDiffuse, vUv - stepDir * 3.0);
      col += texture2D(tDiffuse, vUv + stepDir);
      gl_FragColor = col / 5.0;
      // slight speed vignette toward edges
      gl_FragColor.rgb *= 1.0 - dist * strength * 0.18;
    }`,
};
const speedBlur = new ShaderPass(SpeedBlurShader);
composer.addPass(speedBlur);

// ═══════════════════════════════════════════════════════════
// PLAYER CRAFT
// ═══════════════════════════════════════════════════════════
function buildCraft() {
  const g = new THREE.Group();

  const bodyMat = new THREE.MeshStandardMaterial({
    color: PAL.craft, metalness: 0.75, roughness: 0.28, flatShading: true,
  });
  const accentMat = new THREE.MeshStandardMaterial({
    color: PAL.craftAccent, metalness: 0.5, roughness: 0.4, flatShading: true,
    emissive: PAL.craftAccent, emissiveIntensity: 0.25,
  });
  const glowMat = new THREE.MeshStandardMaterial({
    color: PAL.engine, emissive: PAL.engine, emissiveIntensity: 2.5,
    metalness: 0.2, roughness: 0.4, flatShading: true,
  });

  // Fuselage
  const body = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.45, 3.2), bodyMat);
  body.castShadow = true;
  g.add(body);

  // Nose
  const nose = new THREE.Mesh(new THREE.ConeGeometry(0.35, 1.4, 4), bodyMat);
  nose.rotation.x = -Math.PI / 2;
  nose.position.z = -2.1;
  nose.castShadow = true;
  g.add(nose);

  // Wings
  const wingGeo = new THREE.BoxGeometry(3.8, 0.08, 1.1);
  const wing = new THREE.Mesh(wingGeo, bodyMat);
  wing.position.set(0, 0, 0.3);
  wing.castShadow = true;
  g.add(wing);

  // Wing tips
  const tipL = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.5, 0.8), accentMat);
  tipL.position.set(-1.9, 0.15, 0.2);
  g.add(tipL);
  const tipR = tipL.clone();
  tipR.position.x = 1.9;
  g.add(tipR);

  // Cockpit
  const cock = new THREE.Mesh(
    new THREE.BoxGeometry(0.6, 0.35, 0.9),
    new THREE.MeshStandardMaterial({
      color: 0x1a1a2e, metalness: 0.9, roughness: 0.1,
      emissive: 0x4a6fff, emissiveIntensity: 0.3, flatShading: true,
    })
  );
  cock.position.set(0, 0.35, -0.4);
  g.add(cock);

  // Engine glow
  const eng = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.3, 0.4), glowMat);
  eng.position.set(0, 0, 1.7);
  g.add(eng);
  g.userData.engine = eng;
  g.userData.glowMat = glowMat;

  // Engine light
  const engLight = new THREE.PointLight(PAL.engine, 2.5, 18);
  engLight.position.set(0, 0, 2.2);
  g.add(engLight);
  g.userData.engLight = engLight;

  return g;
}

const craft = buildCraft();
scene.add(craft);

// ═══════════════════════════════════════════════════════════
// PARTICLES — trail + speed dust
// ═══════════════════════════════════════════════════════════
function makeParticles(count, size, color, opacity) {
  const geo = new THREE.BufferGeometry();
  const pos = new Float32Array(count * 3);
  const vel = new Float32Array(count * 3);
  const life = new Float32Array(count);
  for (let i = 0; i < count; i++) {
    pos[i * 3] = 0; pos[i * 3 + 1] = 0; pos[i * 3 + 2] = 0;
    life[i] = Math.random();
  }
  geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  const mat = new THREE.PointsMaterial({
    color, size, transparent: true, opacity,
    depthWrite: false, blending: THREE.AdditiveBlending, sizeAttenuation: true,
  });
  const pts = new THREE.Points(geo, mat);
  pts.frustumCulled = false;
  pts.userData = { vel, life, count };
  return pts;
}

const trail = makeParticles(120, 0.28, 0x7df9ff, 0.7);
scene.add(trail);
const dust = makeParticles(200, 0.15, 0xffb347, 0.45);
scene.add(dust);

function updateTrail(dt, boosting) {
  const pos = trail.geometry.attributes.position.array;
  const { vel, life, count } = trail.userData;
  const base = craft.position;
  for (let i = 0; i < count; i++) {
    life[i] -= dt * (boosting ? 2.5 : 1.6);
    if (life[i] <= 0) {
      life[i] = 1;
      pos[i * 3]     = base.x + (Math.random() - 0.5) * 0.6;
      pos[i * 3 + 1] = base.y + (Math.random() - 0.5) * 0.3;
      pos[i * 3 + 2] = base.z + 1.6 + Math.random() * 0.4;
      vel[i * 3] = (Math.random() - 0.5) * 2;
      vel[i * 3 + 1] = (Math.random() - 0.5) * 2;
      vel[i * 3 + 2] = 8 + Math.random() * 10 + (boosting ? 20 : 0);
    } else {
      pos[i * 3]     += vel[i * 3] * dt;
      pos[i * 3 + 1] += vel[i * 3 + 1] * dt;
      pos[i * 3 + 2] += vel[i * 3 + 2] * dt;
    }
  }
  trail.geometry.attributes.position.needsUpdate = true;
  trail.material.opacity = 0.4 + (boosting ? 0.5 : 0);
  trail.material.size = boosting ? 0.4 : 0.22;
}

function updateDust(dt, speed) {
  const pos = dust.geometry.attributes.position.array;
  const { vel, life, count } = dust.userData;
  const base = craft.position;
  const dens = Math.min(1, speed / 80);
  for (let i = 0; i < count; i++) {
    life[i] -= dt * 1.8;
    if (life[i] <= 0) {
      if (Math.random() > dens * 0.7) { life[i] = 0.01; continue; }
      life[i] = 0.5 + Math.random() * 0.5;
      pos[i * 3]     = base.x + (Math.random() - 0.5) * 50;
      pos[i * 3 + 1] = base.y + (Math.random() - 0.5) * 30;
      pos[i * 3 + 2] = base.z - 20 - Math.random() * 80;
      vel[i * 3] = 0;
      vel[i * 3 + 1] = 0;
      vel[i * 3 + 2] = speed * 1.2;
    } else {
      pos[i * 3 + 2] += vel[i * 3 + 2] * dt;
    }
  }
  dust.geometry.attributes.position.needsUpdate = true;
  dust.material.opacity = 0.15 + dens * 0.35;
}

// ═══════════════════════════════════════════════════════════
// PROCEDURAL CANYON
// ═══════════════════════════════════════════════════════════
const wallMat = new THREE.MeshStandardMaterial({
  color: PAL.wallA, metalness: 0.08, roughness: 0.72, flatShading: true,
  emissive: 0x3a1838, emissiveIntensity: 0.22,
});
const wallMatB = new THREE.MeshStandardMaterial({
  color: PAL.wallB, metalness: 0.08, roughness: 0.74, flatShading: true,
  emissive: 0x4a2040, emissiveIntensity: 0.2,
});
const wallMatC = new THREE.MeshStandardMaterial({
  color: PAL.wallC, metalness: 0.1, roughness: 0.7, flatShading: true,
  emissive: 0x5a2850, emissiveIntensity: 0.18,
});
const monoMat = new THREE.MeshStandardMaterial({
  color: PAL.mono, metalness: 0.25, roughness: 0.5, flatShading: true,
  emissive: 0x402858, emissiveIntensity: 0.28,
});
const groundMat = new THREE.MeshStandardMaterial({
  color: PAL.ground, metalness: 0.06, roughness: 0.9, flatShading: true,
  emissive: 0x201528, emissiveIntensity: 0.12,
});
const orbMat = new THREE.MeshStandardMaterial({
  color: PAL.orb, emissive: PAL.orb, emissiveIntensity: 2.2,
  metalness: 0.3, roughness: 0.3, flatShading: true,
});

const colliders = []; // { box: Box3, mesh, type: 'wall'|'mono'|'orb', center, radius }
const chunks = new Map(); // zIndex -> group

function makeRockColumn(w, h, d, mat) {
  const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat);
  mesh.castShadow = true;
  mesh.receiveShadow = true;
  // slight random tilt in build
  return mesh;
}

function generateChunk(zIndex, difficulty) {
  const group = new THREE.Group();
  group.userData.zIndex = zIndex;
  const z0 = zIndex * CFG.CHUNK_LEN;
  group.position.z = -z0;
  const rand = rng(hashSeed(zIndex * 97.13 + 13.7) * 1e6);
  const dens = Math.min(1, difficulty);
  // Wider, calmer opening; tighten only after the player is airborne
  const openEase = zIndex <= 1 ? 0 : Math.min(1, (zIndex - 1) / 3);
  const half = CFG.CANYON_HALF * (1.08 - dens * 0.2 - openEase * 0.06);
  const mats = [wallMat, wallMatB, wallMatC];
  const localColliders = [];

  // World-Z of chunk start is -z0; keep a clear tunnel at the very beginning
  const worldZStart = z0; // magnitude along track
  const avoidObstacles = worldZStart < CFG.SAFE_OPENING;

  // Floor ribbon
  const floor = new THREE.Mesh(
    new THREE.BoxGeometry(half * 2 + 40, 1.5, CFG.CHUNK_LEN + 2),
    groundMat
  );
  floor.position.set(0, -1.2, -CFG.CHUNK_LEN / 2);
  floor.receiveShadow = true;
  group.add(floor);

  // Meandering canyon center — start straight for honest spawn clearance
  const meander = zIndex === 0
    ? 0
    : Math.sin(zIndex * 0.7) * 8 + Math.cos(zIndex * 0.35) * 5;
  const wallSegments = 6;

  for (let s = 0; s < wallSegments; s++) {
    const frac = (s + 0.5) / wallSegments;
    const zLocal = -frac * CFG.CHUNK_LEN;
    // Deterministic jitter so visuals & colliders match (dampened in opening)
    const jitterScale = zIndex === 0 ? 0.25 : 1;
    const jL = (hashSeed(zIndex * 31.7 + s * 3.1) - 0.5) * 7 * jitterScale;
    const jR = (hashSeed(zIndex * 41.3 + s * 5.9) - 0.5) * 7 * jitterScale;
    // Enforce a minimum clear half-width around the flight corridor
    const minClear = zIndex === 0 ? 18 : 14;
    const leftInner = Math.min(-minClear, -half + meander + jL);
    const rightInner = Math.max(minClear, half + meander + jR);
    const hL = CFG.WALL_H * (0.6 + hashSeed(zIndex + s * 2.2) * 0.55);
    const hR = CFG.WALL_H * (0.6 + hashSeed(zIndex + s * 3.7) * 0.55);
    const thick = 10 + hashSeed(zIndex * 7 + s) * 16;
    const depth = CFG.CHUNK_LEN / wallSegments + 3;

    // Left wall block (inner face at leftInner)
    const left = makeRockColumn(thick, hL, depth, mats[s % 3]);
    left.position.set(leftInner - thick / 2, hL / 2 - 0.5, zLocal);
    left.rotation.z = (hashSeed(zIndex + s + 0.1) - 0.5) * 0.06;
    group.add(left);
    // Only colliders on dedicated slabs — big tilted wall AABBs were killing spawns

    // Right wall block (inner face at rightInner)
    const right = makeRockColumn(thick, hR, depth, mats[(s + 1) % 3]);
    right.position.set(rightInner + thick / 2, hR / 2 - 0.5, zLocal);
    right.rotation.z = (hashSeed(zIndex + s + 0.2) - 0.5) * 0.06;
    group.add(right);

    // Jagged ridge spikes (visual only — high up on wall crowns)
    if (hashSeed(zIndex * 9 + s) > 0.4) {
      const spike = makeRockColumn(thick * 0.45, hL * 0.32, depth * 0.35, mats[0]);
      spike.position.set(leftInner - thick * 0.4, hL + hL * 0.05, zLocal);
      group.add(spike);
    }
    if (hashSeed(zIndex * 11 + s) > 0.4) {
      const spike = makeRockColumn(thick * 0.45, hR * 0.32, depth * 0.35, mats[1]);
      spike.position.set(rightInner + thick * 0.4, hR + hR * 0.05, zLocal);
      group.add(spike);
    }

    // Inner-face slab colliders (axis-aligned, reliable near-miss + crash)
    const colH = Math.max(hL, hR) * 1.15;
    const colD = depth + 1;
    const leftCol = new THREE.Mesh(
      new THREE.BoxGeometry(2.4, colH, colD),
      new THREE.MeshBasicMaterial({ visible: false })
    );
    leftCol.position.set(leftInner - 1.2, colH / 2 - 0.5, zLocal);
    group.add(leftCol);
    localColliders.push({ mesh: leftCol, type: 'wall' });

    const rightCol = new THREE.Mesh(
      new THREE.BoxGeometry(2.4, colH, colD),
      new THREE.MeshBasicMaterial({ visible: false })
    );
    rightCol.position.set(rightInner + 1.2, colH / 2 - 0.5, zLocal);
    group.add(rightCol);
    localColliders.push({ mesh: rightCol, type: 'wall' });
  }

  // Floating monoliths — never in the opening stretch, and not on the flight line early on
  if (!avoidObstacles) {
    const monoCount = Math.floor(1 + dens * 3 + rand() * 2);
    for (let i = 0; i < monoCount; i++) {
      const mw = 2 + rand() * 4;
      const mh = 10 + rand() * 28;
      const md = 2 + rand() * 4;
      const mono = new THREE.Mesh(new THREE.BoxGeometry(mw, mh, md), monoMat);
      // Keep monoliths off the dead-center line a bit
      const sideBias = rand() > 0.5 ? 1 : -1;
      const mx = meander + sideBias * (6 + rand() * half * 0.55);
      const my = 5 + rand() * 16;
      // Bias obstacles deeper into the chunk (never flush with chunk near-edge)
      const mz = -(18 + rand() * (CFG.CHUNK_LEN - 28));
      mono.position.set(mx, my, mz);
      mono.rotation.y = rand() * Math.PI * 0.5;
      mono.castShadow = true;
      mono.receiveShadow = true;
      group.add(mono);
      localColliders.push({ mesh: mono, type: 'mono' });

      if (rand() > 0.65) {
        const s2 = new THREE.Mesh(
          new THREE.BoxGeometry(mw * 0.55, mh * 0.35, md * 0.55),
          mats[Math.floor(rand() * 3)]
        );
        s2.position.set(mx + sideBias * (2 + rand() * 3), my + mh * 0.35, mz + (rand() - 0.5) * 3);
        s2.castShadow = true;
        group.add(s2);
        localColliders.push({ mesh: s2, type: 'mono' });
      }
    }
  }

  // Tight squeezes — paired pillars with optional overhead beam
  if (!avoidObstacles && rand() < 0.35 + dens * 0.35) {
    const gap = Math.max(4.8, 7.0 - dens * 1.6);
    const cx = meander + (rand() - 0.5) * 5;
    const cz = -(CFG.CHUNK_LEN * (0.35 + rand() * 0.45));
    const ph = 16 + rand() * 18;
    for (const side of [-1, 1]) {
      const p = new THREE.Mesh(new THREE.BoxGeometry(3.2, ph, 3.2), wallMatC);
      p.position.set(cx + side * (gap / 2 + 1.6), ph / 2 + 1, cz);
      p.castShadow = true;
      group.add(p);
      localColliders.push({ mesh: p, type: 'mono' });
    }
    if (rand() > 0.4) {
      const beam = new THREE.Mesh(new THREE.BoxGeometry(gap + 7, 2.2, 2.8), monoMat);
      beam.position.set(cx, 11 + rand() * 6, cz);
      beam.castShadow = true;
      group.add(beam);
      localColliders.push({ mesh: beam, type: 'mono' });
    }
  }

  // Energy orbs — keep first cluster ahead of the craft, not in its face
  const orbCount = 2 + Math.floor(rand() * 3) + (dens > 0.5 ? 1 : 0);
  for (let i = 0; i < orbCount; i++) {
    const orb = new THREE.Mesh(new THREE.OctahedronGeometry(0.85, 0), orbMat.clone());
    const nearWall = rand() > 0.3;
    const minZ = (zIndex === 0) ? 25 : 8;
    const oz = -(minZ + rand() * (CFG.CHUNK_LEN - minZ - 4));
    const oy = 4 + rand() * 12;
    let ox;
    if (nearWall) {
      const side = rand() > 0.5 ? 1 : -1;
      ox = meander + side * (half * 0.55) + (rand() - 0.5) * 2.5;
    } else {
      ox = meander + (rand() - 0.5) * half * 0.55;
    }
    orb.position.set(ox, oy, oz);
    orb.userData.spin = 1.5 + rand() * 2;
    const ol = new THREE.PointLight(PAL.orb, 1.2, 14);
    orb.add(ol);
    group.add(orb);
    localColliders.push({ mesh: orb, type: 'orb', collected: false });
  }

  // Distant decorative ridges (no collision)
  for (let i = 0; i < 3; i++) {
    const ridge = new THREE.Mesh(
      new THREE.BoxGeometry(20 + rand() * 30, 8 + rand() * 20, 15 + rand() * 20),
      mats[Math.floor(rand() * 3)]
    );
    ridge.position.set(
      (rand() > 0.5 ? 1 : -1) * (half + 30 + rand() * 40),
      5 + rand() * 15,
      -rand() * CFG.CHUNK_LEN
    );
    ridge.rotation.y = rand() * 0.5;
    group.add(ridge);
  }

  group.userData.colliders = localColliders;
  group.userData.meander = meander;
  group.userData.half = half;
  return group;
}

function worldBox(mesh) {
  mesh.updateWorldMatrix(true, false);
  return new THREE.Box3().setFromObject(mesh);
}

function refreshColliders() {
  colliders.length = 0;
  for (const [, ch] of chunks) {
    for (const c of ch.userData.colliders) {
      if (c.type === 'orb' && c.collected) continue;
      const box = worldBox(c.mesh);
      const center = new THREE.Vector3();
      const size = new THREE.Vector3();
      box.getCenter(center);
      box.getSize(size);
      colliders.push({
        box, center, size,
        radius: Math.max(size.x, size.y, size.z) * 0.5,
        type: c.type,
        ref: c,
        mesh: c.mesh,
      });
    }
  }
}

function updateChunks(playerZ, score) {
  const idx = Math.floor(-playerZ / CFG.CHUNK_LEN);
  const difficulty = Math.min(1, score / 25000 + (-playerZ) / 8000);

  // spawn ahead
  for (let i = idx - CFG.CHUNK_BEHIND; i <= idx + CFG.CHUNK_AHEAD; i++) {
    if (i < 0) continue;
    if (!chunks.has(i)) {
      const ch = generateChunk(i, difficulty);
      scene.add(ch);
      chunks.set(i, ch);
    }
  }
  // cull far behind
  for (const [k, ch] of chunks) {
    if (k < idx - CFG.CHUNK_BEHIND - 1) {
      scene.remove(ch);
      ch.traverse(o => {
        if (o.geometry) o.geometry.dispose();
        // materials are shared mostly — skip dispose shared mats
      });
      chunks.delete(k);
    }
  }
  refreshColliders();
  return difficulty;
}

// ═══════════════════════════════════════════════════════════
// GAME STATE
// ═══════════════════════════════════════════════════════════
const keys = {};
const mouse = { x: 0, y: 0, active: false };

const state = {
  mode: 'title', // title | play | dead
  pos: new THREE.Vector3(0, 8, 0),
  vel: new THREE.Vector3(0, 0, 0), // lateral x, vertical y, forward is separate
  speed: CFG.BASE_SPEED,
  bank: 0,
  pitch: 0,
  energy: CFG.ENERGY_MAX,
  turbo: 0,
  boosting: false,
  score: 0,
  mult: 1,
  maxMult: 1,
  maxSpeed: 0,
  shake: 0,
  nearMissCD: 0,
  lastNear: 0,
  camOff: new THREE.Vector3(0, 5.5, 14),
  time: 0,
  difficulty: 0,
  grace: 0,
};

function disposeChunk(ch) {
  scene.remove(ch);
  ch.traverse(o => {
    if (o.geometry) o.geometry.dispose();
    // only dispose materials we cloned per-mesh (orbs)
    if (o.isMesh && o.material && o.material !== wallMat && o.material !== wallMatB &&
        o.material !== wallMatC && o.material !== monoMat && o.material !== groundMat &&
        o.material !== orbMat && o.material) {
      // orb uses orbMat.clone()
      if (o.material.emissive && o.material.color && o.material !== craft.userData.glowMat) {
        try { o.material.dispose(); } catch (_) {}
      }
    }
  });
}

function resetGame() {
  state.pos.set(0, 8, 0);
  state.vel.set(0, 0, 0);
  state.speed = CFG.BASE_SPEED;
  state.bank = 0;
  state.pitch = 0;
  state.energy = CFG.ENERGY_MAX;
  state.turbo = 40; // a little boost charge so launch feels alive
  state.boosting = false;
  state.score = 0;
  state.mult = 1;
  state.maxMult = 1;
  state.maxSpeed = 0;
  state.shake = 0;
  state.nearMissCD = 0;
  state.time = 0;
  state.difficulty = 0;
  state.grace = CFG.SPAWN_GRACE;
  craft.position.copy(state.pos);
  craft.rotation.set(0, 0, 0);

  // clear chunks completely so title-idle geometry can't linger
  for (const [, ch] of chunks) disposeChunk(ch);
  chunks.clear();
  colliders.length = 0;
  updateChunks(0, 0);
  // force matrix/world bound refresh after adding fresh chunks
  craft.updateMatrixWorld(true);
  refreshColliders();

  // snap camera so first frame isn't looking at old title path
  camera.position.set(0, 13, 16);
  camera.lookAt(0, 8, -18);
  camera.fov = CFG.FOV_BASE;
  camera.updateProjectionMatrix();
  updateHUD();
}

// ═══════════════════════════════════════════════════════════
// INPUT
// ═══════════════════════════════════════════════════════════
window.addEventListener('keydown', e => {
  keys[e.code] = true;
  if (e.code === 'Space') e.preventDefault();
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
window.addEventListener('mousemove', e => {
  mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
  mouse.active = true;
  const ret = document.getElementById('reticle');
  ret.style.left = e.clientX + 'px';
  ret.style.top = e.clientY + 'px';
});
window.addEventListener('mousedown', () => { if (state.mode === 'play') keys['Space'] = true; });
window.addEventListener('mouseup', () => { keys['Space'] = false; });

document.getElementById('start-btn').addEventListener('click', () => {
  SFX.init();
  document.getElementById('title-screen').classList.add('hidden');
  resetGame();
  state.mode = 'play';
});
document.getElementById('restart-btn').addEventListener('click', () => {
  document.getElementById('go-screen').classList.add('hidden');
  resetGame();
  state.mode = 'play';
  SFX.init();
});

// ═══════════════════════════════════════════════════════════
// PHYSICS + COLLISION
// ═══════════════════════════════════════════════════════════
const _playerBox = new THREE.Box3();
const _tmp = new THREE.Vector3();

function getInputAxes() {
  let ix = 0, iy = 0;
  if (keys['KeyA'] || keys['ArrowLeft'])  ix -= 1;
  if (keys['KeyD'] || keys['ArrowRight']) ix += 1;
  if (keys['KeyW'] || keys['ArrowUp'])    iy += 1;
  if (keys['KeyS'] || keys['ArrowDown'])  iy -= 1;
  // mouse assist (soft)
  if (mouse.active) {
    ix += mouse.x * 0.85;
    iy += mouse.y * 0.55;
  }
  ix = THREE.MathUtils.clamp(ix, -1, 1);
  iy = THREE.MathUtils.clamp(iy, -1, 1);
  return { ix, iy };
}

function physics(dt) {
  const { ix, iy } = getInputAxes();

  // Difficulty ramps base speed
  const targetBase = CFG.BASE_SPEED + state.difficulty * 55 + Math.min(40, state.score / 800);
  if (!state.boosting) {
    state.speed += (targetBase - state.speed) * (1 - Math.exp(-dt * 1.2));
  }

  // Turbo
  const wantBoost = keys['Space'] && state.turbo > 5;
  if (wantBoost && !state.boosting) {
    state.boosting = true;
    SFX.boostStart();
  }
  if (state.boosting) {
    if (!keys['Space'] || state.turbo <= 0) {
      state.boosting = false;
    } else {
      state.turbo = Math.max(0, state.turbo - CFG.TURBO_COST * dt);
      state.speed += (targetBase + CFG.TURBO_BOOST - state.speed) * (1 - Math.exp(-dt * 4));
    }
  } else {
    // charge turbo on speed / straight
    const straight = 1 - Math.min(1, Math.abs(ix) + Math.abs(iy) * 0.5);
    state.turbo = Math.min(CFG.TURBO_MAX, state.turbo + CFG.TURBO_GAIN * straight * (state.speed / 60) * dt);
  }

  // Lateral / vertical momentum
  state.vel.x += ix * CFG.LATERAL_ACCEL * dt;
  state.vel.y += iy * CFG.PITCH_ACCEL * dt;
  // drag
  state.vel.x *= Math.exp(-CFG.DRAG * dt);
  state.vel.y *= Math.exp(-CFG.DRAG * dt);
  state.vel.x = THREE.MathUtils.clamp(state.vel.x, -CFG.LATERAL_MAX, CFG.LATERAL_MAX);
  state.vel.y = THREE.MathUtils.clamp(state.vel.y, -CFG.PITCH_MAX, CFG.PITCH_MAX);

  // Pitch-based slight speed carry (dive -> faster)
  const pitchBonus = THREE.MathUtils.clamp(-state.vel.y * 0.35, -4, 12);
  const forward = state.speed + pitchBonus;

  // Integrate
  state.pos.x += state.vel.x * dt;
  state.pos.y += state.vel.y * dt;
  state.pos.z -= forward * dt;

  // Soft altitude clamp
  state.pos.y = THREE.MathUtils.clamp(state.pos.y, 1.5, 42);

  // Soft canyon confines (push inward before hard collide)
  // Approx using nearby wall colliders handled below

  // Visual bank / pitch
  const targetBank = -ix * CFG.BANK_MAX - state.vel.x * 0.012;
  const targetPitch = -state.vel.y * 0.025 + iy * 0.15;
  state.bank += (targetBank - state.bank) * (1 - Math.exp(-dt * 8));
  state.pitch += (targetPitch - state.pitch) * (1 - Math.exp(-dt * 7));

  craft.position.copy(state.pos);
  craft.rotation.z = state.bank;
  craft.rotation.x = state.pitch;
  craft.rotation.y = -state.vel.x * 0.004;

  // Engine glow intensity
  const gn = 1.5 + (state.speed / CFG.MAX_SPEED) * 2 + (state.boosting ? 2.5 : 0);
  craft.userData.glowMat.emissiveIntensity = gn;
  craft.userData.engLight.intensity = 1.5 + (state.boosting ? 4 : 0) + state.speed / 40;

  // Energy decay
  state.energy -= CFG.ENERGY_DECAY * (1 + state.difficulty * 0.4) * dt;
  if (state.boosting) state.energy -= CFG.ENERGY_DECAY * 0.5 * dt;

  // Score from distance + mult
  state.score += forward * state.mult * 0.35 * dt;
  state.maxSpeed = Math.max(state.maxSpeed, state.speed);

  // Mult decay toward 1
  if (state.nearMissCD <= 0) {
    state.mult = Math.max(1, state.mult - CFG.MULT_DECAY * dt);
  } else {
    state.nearMissCD -= dt;
  }
  state.maxMult = Math.max(state.maxMult, state.mult);

  // speed clamp visual
  state.speed = Math.min(state.speed, CFG.MAX_SPEED + 20);
}

function collisions(dt) {
  const pr = CFG.COLLIDE_R;
  const px = state.pos.x, py = state.pos.y, pz = state.pos.z;
  let closestWall = 999;
  let hit = false;
  const invuln = state.grace > 0;

  for (let i = colliders.length - 1; i >= 0; i--) {
    const c = colliders[i];
    // cheap reject on Z / X
    if (Math.abs(c.center.z - pz) > 30) continue;
    if (Math.abs(c.center.x - px) > 40) continue;

    if (c.type === 'orb') {
      const d = c.center.distanceTo(state.pos);
      if (d < 2.4 && !c.ref.collected) {
        c.ref.collected = true;
        c.mesh.visible = false;
        if (c.mesh.children) {
          c.mesh.children.forEach(ch => { if (ch.isLight) ch.intensity = 0; });
        }
        state.energy = Math.min(CFG.ENERGY_MAX, state.energy + CFG.ENERGY_ORB);
        state.turbo = Math.min(CFG.TURBO_MAX, state.turbo + 12);
        state.score += 50 * state.mult;
        SFX.ping();
      }
      continue;
    }

    // AABB vs sphere (craft)
    const bx = c.box;
    // Guard against inverted / empty boxes from unready matrices
    if (!isFinite(bx.min.x) || !isFinite(bx.max.x) || bx.isEmpty()) continue;

    const cx = THREE.MathUtils.clamp(px, bx.min.x, bx.max.x);
    const cy = THREE.MathUtils.clamp(py, bx.min.y, bx.max.y);
    const cz = THREE.MathUtils.clamp(pz, bx.min.z, bx.max.z);
    const dx = px - cx, dy = py - cy, dz = pz - cz;
    const distSq = dx * dx + dy * dy + dz * dz;

    if (distSq < pr * pr) {
      if (!invuln) {
        hit = true;
        break;
      }
      // during grace: gently nudge out instead of killing
      continue;
    }

    const dist = Math.sqrt(Math.max(distSq, 0));
    if (dist < closestWall) closestWall = dist;
  }

  // Near-miss (skip during grace so multiplier doesn't fake-stack in tunnel)
  if (!invuln && closestWall < CFG.NEAR_MISS_DIST && closestWall > pr) {
    const proximity = 1 - (closestWall - pr) / (CFG.NEAR_MISS_DIST - pr);
    const gain = CFG.NEAR_MISS_MULT_GAIN * proximity * (1 + state.difficulty) * dt * 18;
    state.mult = Math.min(CFG.MULT_MAX, state.mult + gain);
    state.shake = Math.max(state.shake, proximity * 0.35);
    state.turbo = Math.min(CFG.TURBO_MAX, state.turbo + proximity * 14 * dt);

    if (proximity > 0.55 && state.nearMissCD <= 0.05) {
      if (performance.now() - state.lastNear > 420) {
        state.lastNear = performance.now();
        const fx = document.getElementById('near-miss-fx');
        fx.textContent = proximity > 0.85 ? 'RAZOR EDGE' : 'NEAR MISS';
        fx.classList.remove('show');
        void fx.offsetWidth;
        fx.classList.add('show');
        SFX.whoosh();
        document.getElementById('mult-wrap').classList.remove('pulse');
        void document.getElementById('mult-wrap').offsetWidth;
        document.getElementById('mult-wrap').classList.add('pulse');
      }
      state.nearMissCD = 0.35;
    }
    document.getElementById('reticle').classList.toggle('danger', proximity > 0.4);
  } else {
    document.getElementById('reticle').classList.remove('danger');
  }

  return hit;
}

// ═══════════════════════════════════════════════════════════
// CAMERA
// ═══════════════════════════════════════════════════════════
function updateCamera(dt) {
  const boostT = state.boosting ? 1 : 0;
  const targetFov = THREE.MathUtils.lerp(CFG.FOV_BASE, CFG.FOV_BOOST, boostT * 0.85 + (state.speed - CFG.BASE_SPEED) / 200);
  camera.fov += (targetFov - camera.fov) * (1 - Math.exp(-dt * 3));
  camera.updateProjectionMatrix();

  // chase cam with lag
  const back = 12 + state.speed * 0.04;
  const up = 4.2 + Math.abs(state.bank) * 1.5;
  const ideal = _tmp.set(
    state.pos.x * 0.85 - state.vel.x * 0.08,
    state.pos.y + up,
    state.pos.z + back
  );

  camera.position.x += (ideal.x - camera.position.x) * (1 - Math.exp(-dt * 5));
  camera.position.y += (ideal.y - camera.position.y) * (1 - Math.exp(-dt * 4));
  camera.position.z += (ideal.z - camera.position.z) * (1 - Math.exp(-dt * 6));

  // look ahead
  const look = new THREE.Vector3(
    state.pos.x + state.vel.x * 0.12,
    state.pos.y + 0.5,
    state.pos.z - 18
  );
  camera.lookAt(look);

  // shake
  if (state.shake > 0.001) {
    camera.position.x += (Math.random() - 0.5) * state.shake;
    camera.position.y += (Math.random() - 0.5) * state.shake * 0.7;
    state.shake *= Math.exp(-dt * 8);
  }
  if (state.boosting) {
    state.shake = Math.max(state.shake, 0.12);
  }
}

// ═══════════════════════════════════════════════════════════
// HUD
// ═══════════════════════════════════════════════════════════
function updateHUD() {
  document.getElementById('score-val').textContent = Math.floor(state.score).toLocaleString();
  document.getElementById('mult-val').textContent = state.mult.toFixed(1);
  document.getElementById('speed-val').textContent = Math.floor(state.speed);

  const ePct = THREE.MathUtils.clamp(state.energy / CFG.ENERGY_MAX, 0, 1) * 100;
  const eBar = document.getElementById('energy-bar');
  eBar.style.width = ePct + '%';
  eBar.classList.toggle('low', state.energy < 28);

  const sPct = THREE.MathUtils.clamp((state.speed - 20) / (CFG.MAX_SPEED - 20), 0, 1) * 100;
  document.getElementById('speed-bar').style.width = sPct + '%';
  document.getElementById('turbo-bar').style.width = (state.turbo / CFG.TURBO_MAX * 22) + '%';

  document.getElementById('boost-fx').classList.toggle('active', state.boosting);
}

function gameOver() {
  state.mode = 'dead';
  SFX.crash();
  document.getElementById('go-score').textContent = Math.floor(state.score).toLocaleString();
  document.getElementById('go-maxspd').textContent = Math.floor(state.maxSpeed);
  document.getElementById('go-maxmult').textContent = state.maxMult.toFixed(1) + '×';
  document.getElementById('go-screen').classList.remove('hidden');
}

// ═══════════════════════════════════════════════════════════
// SPIN ORBS
// ═══════════════════════════════════════════════════════════
function animateOrbs(dt) {
  for (const [, ch] of chunks) {
    for (const c of ch.userData.colliders) {
      if (c.type === 'orb' && !c.collected && c.mesh.visible) {
        c.mesh.rotation.y += (c.mesh.userData.spin || 2) * dt;
        c.mesh.rotation.x += dt * 0.8;
        c.mesh.position.y += Math.sin(state.time * 3 + c.mesh.position.z) * 0.01;
      }
    }
  }
}

// ═══════════════════════════════════════════════════════════
// RESIZE
// ═══════════════════════════════════════════════════════════
window.addEventListener('resize', () => {
  const w = window.innerWidth, h = window.innerHeight;
  camera.aspect = w / h;
  camera.updateProjectionMatrix();
  renderer.setSize(w, h);
  composer.setSize(w, h);
  bloom.resolution.set(w, h);
});

// ═══════════════════════════════════════════════════════════
// MAIN LOOP
// ═══════════════════════════════════════════════════════════
let last = performance.now();
// bootstrap a couple chunks even on title
updateChunks(0, 0);

// idle drift on title
function titleIdle(dt) {
  state.time += dt;
  craft.position.set(Math.sin(state.time * 0.4) * 3, 8 + Math.sin(state.time * 0.7) * 1.2, -state.time * 12);
  craft.rotation.z = Math.sin(state.time * 0.5) * 0.2;
  craft.rotation.x = Math.sin(state.time * 0.3) * 0.08;
  camera.position.set(craft.position.x * 0.5, craft.position.y + 5, craft.position.z + 16);
  camera.lookAt(craft.position.x, craft.position.y, craft.position.z - 20);
  updateChunks(craft.position.z, 0);
  updateTrail(dt, false);
  updateDust(dt, 40);
  animateOrbs(dt);
}

function frame(now) {
  requestAnimationFrame(frame);
  let dt = (now - last) / 1000;
  last = now;
  dt = Math.min(dt, 0.05);
  state.time += dt;

  if (state.mode === 'title') {
    titleIdle(dt);
    composer.render();
    return;
  }

  if (state.mode === 'play') {
    if (state.grace > 0) state.grace = Math.max(0, state.grace - dt);
    state.difficulty = updateChunks(state.pos.z, state.score);
    physics(dt);
    // refresh colliders frequently — moved geometry needs fresh world boxes
    refreshColliders();

    const crashed = collisions(dt);
    // energy only kills after a frame of real play (never on the Launch frame)
    if (crashed || (state.energy <= 0 && state.grace <= 0 && state.time > 0.25)) {
      gameOver();
    }

    updateCamera(dt);
    updateTrail(dt, state.boosting);
    updateDust(dt, state.speed);
    animateOrbs(dt);
    updateHUD();

    const speedNorm = THREE.MathUtils.clamp(state.speed / CFG.MAX_SPEED, 0, 1);
    SFX.setEngine(speedNorm, state.boosting);

    // bloom + motion blur scale with speed / boost
    bloom.strength = 0.38 + (state.boosting ? 0.35 : 0) + state.mult * 0.015;
    const blurAmt = THREE.MathUtils.clamp(
      (state.speed - CFG.BASE_SPEED) / 90 + (state.boosting ? 0.55 : 0),
      0, 1.35
    );
    speedBlur.uniforms.strength.value = blurAmt;

    // sun + path light follow for readable lighting along the run
    sun.position.set(
      state.pos.x - 60,
      90,
      state.pos.z - 30
    );
    sun.target.position.copy(state.pos);
    sun.target.updateMatrixWorld();
    pathLight.position.set(state.pos.x, state.pos.y + 16, state.pos.z - 12);
  } else if (state.mode === 'dead') {
    // slow drift cam
    camera.position.y += dt * 0.5;
    updateTrail(dt, false);
  }

  composer.render();
}
requestAnimationFrame(frame);
</script>
</body>
</html>