← Canyon Run results

Canyon Run

ornith 1.0 9b · 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.

The reviewer marked this result as broken. It remains in the archive.

Visual design
Unscored
UX / feel
Unscored
Works
Unscored
Prompt spirit
Unscored
Delight
Unscored

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,520 bytes · SHA-256 4ffd65d7b8a5
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Canyon Racer</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
/* ── css: game-layout ── */
html, body { margin: 0; padding: 0; overflow: hidden; background: #0a0a1a; }
canvas#game-canvas { display: block; width: 100vw; height: 100vh; }

#ui-layer { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; }
#hud-top { position: absolute; top: 24px; left: 32px; display: flex; gap: 16px; align-items: flex-start; }

#score-display { font-family: 'Courier New', monospace; font-size: 20px; color: #ffdd57; text-shadow: 0 0 8px rgba(255,200,80,0.6); letter-spacing: 2px; }
#multiplier-display { font-family: 'Courier New', monospace; font-size: 18px; color: #ff4488; text-shadow: 0 0 8px rgba(255,70,136,0.6); letter-spacing: 2px; transition: transform 0.1s ease; }
#multiplier-display.pulse { transform: scale(1.2); }

#hud-speedometer { position: absolute; bottom: 32px; left: 50%; transform: translateX(-50%); pointer-events: none; opacity: 0.85; }

#energy-bar-container { position: absolute; bottom: 64px; right: 32px; width: 120px; height: 8px; pointer-events: none; }
.energy-track { background: rgba(255,255,255,0.08); border-radius: 4px; overflow: hidden; border: 1px solid rgba(255,255,255,0.15); }
#energy-fill { width: 100%; height: 100%; background: linear-gradient(90deg, #ff6600, #ffdd44); border-radius: 4px; box-shadow: 0 0 8px rgba(255,180,50,0.5); transition: width 0.3s ease; }
#energy-fill.low { background: linear-gradient(90deg, #ff2200, #ff6600); box-shadow: 0 0 12px rgba(255,50,40,0.7); }

#game-over-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 10; pointer-events: auto; }
#go-title { font-size: 48px; color: #ffdd44; text-shadow: 0 0 20px rgba(255,200,68,0.7); margin-bottom: 16px; letter-spacing: 4px; }
#go-score { font-size: 24px; color: #aaa; margin: 8px 0; }
#go-speed { font-size: 24px; color: #aaa; margin: 8px 0; }
#restart-btn { margin-top: 32px; padding: 12px 48px; background: rgba(255,220,60,0.15); border: 2px solid #ffdd44; color: #ffdd44; font-family: 'Courier New', monospace; font-size: 16px; letter-spacing: 3px; cursor: pointer; transition: all 0.2s; text-transform: uppercase; }
#restart-btn:hover { background: rgba(255,220,60,0.3); box-shadow: 0 0 24px rgba(255,200,68,0.4); transform: scale(1.05); }

#start-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 10; pointer-events: auto; }
#start-overlay h1 { font-size: 64px; color: #ffdd44; text-shadow: 0 0 30px rgba(255,200,68,0.7); letter-spacing: 8px; margin-bottom: 24px; }
.sub, .sub2 { font-family: 'Courier New', monospace; color: #aaa; font-size: 14px; text-align: center; line-height: 1.8; }

.hidden { display: none !important; }
</style>
</head>
<body>
<div id="game-container">
  <canvas id="game-canvas" width="1280" height="720"></canvas>
  <div id="ui-layer">
    <div id="hud-top" class="hidden">
      <div id="score-display"><span id="score-val">0</span></div>
      <div id="multiplier-display"><span id="mult-val">1.0x</span></div>
    </div>
    <div id="hud-speedometer" class="hidden">
      <svg viewBox="0 0 200 80" xmlns="http://www.w3.org/2000/svg">
        <path d="M15,60 Q100,5 185,60" fill="none" stroke="#ff4444" stroke-width="2" opacity="0.5"></path>
        <circle id="speed-marker" cx="100" cy="78" r="6" fill="#fff"></circle>
      </svg>
    </div>
    <div id="hud-energy-bar" class="hidden">
      <div class="energy-track">
        <div id="energy-fill"></div>
      </div>
    </div>
  </div>
  <div id="game-over-overlay" class="hidden">
    <h1 id="go-title">GAME OVER</h1>
    <p id="go-score">Final Score: 0</p>
    <p id="go-speed">Max Speed: 0</p>
    <button id="restart-btn">RESTART</button>
  </div>
  <div id="start-overlay" class="hidden">
    <h1>RACER</h1>
    <p class="sub">MOUSE to steer | SCROLL/Wheel for speed | SHIFT boost</p>
    <p class="sub2">ESC to pause</p>
  </div>
</div>
<script>
'use strict';

// ── const: CONFIG ──
const CONFIG = {
  WORLD: { width: 200, chunkLength: 120, horizonDist: 400, renderDist: 350 },
  PLAYER: { mass: 8, maxSpeed: 3.2, boostMaxSpeed: 6.5, accel: 0.08, friction: 0.96, turnRate: 0.12, bankSensitivity: 4, pitchSens: 0.06 },
  ENERGY: { decayRate: 0.035, orbValue: 25, boostCostPerFrame: 0.008 },
  SCORING: { baseScorePerUnit: 1, nearMissBonus: 5, maxMultiplier: 12, minNearDist: 6, farNearDist: 14 },
  GENERATION: { chunkCount: 3, obstacleDensityBase: 0.25, difficultyScale: 0.0008, wallSegmentsPerChunk: 4 },
  VISUAL: { trailLength: 60, dustRate: 0.3, boostFOVZoom: 0.35, boostFOVBase: 55, boostFOVMax: 90, shakeNearMissIntensity: 8, shakeBoostIntensity: 4 },
};

// ── class: SoundSynth ──
class SoundSynth {
  constructor() {
    this.ctx = null;
    this.masterGain = null;
    this.engineOsc = null;
    this._running = false;
  }
  
  init() {
    if (!this.ctx) {
      this.ctx = new (window.AudioContext || window.webkitAudioContext)();
      this.masterGain = this.ctx.createGain();
      this.masterGain.gain.value = 0.25;
      this.masterGain.connect(this.ctx.destination);
    }
    if (this.ctx.state === 'suspended') {
      this.ctx.resume();
    }
  }

  _engage() {
    if (!this._running && this.ctx) {
      this._running = true;
      const baseFreq = 45 + Math.sin(Date.now() * 0.002) * 3;
      try {
        this.engineOsc = this.ctx.createOscillator();
        this.engineOsc.type = 'sawtooth';
        this.engineOsc.frequency.value = baseFreq;
        const env = this.ctx.createGain();
        env.gain.setValueAtTime(0.3, this.ctx.currentTime);
        this.engineOsc.connect(env);
        env.connect(this.masterGain);
        this.engineOsc.start();
      } catch(e) {}
    }
  }

  _dissolve() {
    if (this.engineOsc && this._running) {
      try { this.engineOsc.stop(); } catch(e) {}
      this._running = false;
    }
  }

  engine(speedRatio, boostLevel) {
    if (!this.ctx || !this.masterGain) return;
    this._engage();
    const freq = 45 + speedRatio * 80;
    try {
      if (this.engineOsc) {
        this.engineOsc.frequency.setTargetAtTime(freq, this.ctx.currentTime, 0.1);
      }
    } catch(e) {}
  }

  ping() {
    if (!this.ctx || !this.masterGain) return;
    try {
      const osc = this.ctx.createOscillator();
      const gain = this.ctx.createGain();
      osc.type = 'sine';
      osc.frequency.setValueAtTime(1200, this.ctx.currentTime);
      osc.frequency.exponentialRampToValueAtTime(800, this.ctx.currentTime + 0.15);
      gain.gain.setValueAtTime(0.4, this.ctx.currentTime);
      gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.2);
      osc.connect(gain);
      gain.connect(this.masterGain);
      osc.start();
      osc.stop(this.ctx.currentTime + 0.25);
    } catch(e) {}
  }

  whoosh() {
    if (!this.ctx || !this.masterGain) return;
    try {
      const bufferSize = this.ctx.sampleRate * 0.15;
      const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
      const data = buffer.getChannelData(0);
      for (let i = 0; i < bufferSize; i++) {
        data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (this.ctx.sampleRate * 0.05));
      }
      const src = this.ctx.createBufferSource();
      src.buffer = buffer;
      const gain = this.ctx.createGain();
      gain.gain.setValueAtTime(0.2, this.ctx.currentTime);
      gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.15);
      src.connect(gain);
      gain.connect(this.masterGain);
      src.start();
    } catch(e) {}
  }

  collect() {
    if (!this.ctx || !this.masterGain) return;
    try {
      const osc = this.ctx.createOscillator();
      const gain = this.ctx.createGain();
      osc.type = 'sine';
      osc.frequency.setValueAtTime(800, this.ctx.currentTime);
      osc.frequency.exponentialRampToValueAtTime(1600, this.ctx.currentTime + 0.12);
      gain.gain.setValueAtTime(0.35, this.ctx.currentTime);
      gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.2);
      osc.connect(gain);
      gain.connect(this.masterGain);
      osc.start();
      osc.stop(this.ctx.currentTime + 0.25);
    } catch(e) {}
  }

  crash() {
    if (!this.ctx || !this.masterGain) return;
    try {
      const bufferSize = this.ctx.sampleRate * 0.3;
      const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
      const data = buffer.getChannelData(0);
      for (let i = 0; i < bufferSize; i++) {
        data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (this.ctx.sampleRate * 0.03));
      }
      const src = this.ctx.createBufferSource();
      src.buffer = buffer;
      const gain = this.ctx.createGain();
      gain.gain.setValueAtTime(0.4, this.ctx.currentTime);
      gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.35);
      src.connect(gain);
      gain.connect(this.masterGain);
      src.start();
    } catch(e) {}
  }

  turbo() {
    if (!this.ctx || !this.masterGain) return;
    try {
      const osc = this.ctx.createOscillator();
      const gain = this.ctx.createGain();
      osc.type = 'square';
      osc.frequency.value = 80 + Math.random() * 40;
      gain.gain.setValueAtTime(0.15, this.ctx.currentTime);
      gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.2);
      osc.connect(gain);
      gain.connect(this.masterGain);
      osc.start();
      osc.stop(this.ctx.currentTime + 0.25);
    } catch(e) {}
  }

  stop() { this._dissolve(); }
}

// ── class: CanyonChunk ──
class CanyonChunk {
  constructor(zStart, zEnd) {
    this.zStart = zStart;
    this.zEnd = zEnd;
    this.centerZ = (zStart + zEnd) / 2;
    this.walls = [];
    this.obstacles = [];
    this.orbs = [];
    this._meshGroup = null;
    this.generate();
  }

  generate() {
    const width = CONFIG.WORLD.width;
    const segCount = Math.max(4, Math.floor(CONFIG.GENERATION.wallSegmentsPerChunk + (this.zStart / 1000) * 2));
    
    for (let i = 0; i < segCount; i++) {
      this.walls.push({
        z: this.centerZ + (i - segCount/2) * (width / segCount),
        leftX: -width/2 + Math.sin(i * 1.3 + this.zStart * 0.015) * width * 0.4,
        rightX: width/2 + Math.cos(i * 1.7 + this.centerZ * 0.012) * width * 0.4,
        height: 30 + Math.random() * 80,
      });
    }

    const density = CONFIG.GENERATION.obstacleDensityBase + (this.zStart / 500) * 0.1;
    
    for (let i = 0; i < Math.floor(density * width); i++) {
      this._placeObstacle(i / density, width);
    }

    // Orbs scattered throughout the chunk
    const orbCount = Math.max(2, Math.min(8, Math.floor(width / CONFIG.WORLD.chunkLength)));
    for (let i = 0; i < orbCount; i++) {
      this.orbs.push({
        z: this.centerZ + (i - orbCount/2) * (width / orbCount),
        x: (Math.random() - 0.5) * width * 0.8,
        y: -10 + Math.random() * 35,
      });
    }

    // Extra orbs for longer chunks
    if (this.zEnd - this.zStart > CONFIG.WORLD.chunkLength * 0.6) {
      for (let i = 0; i < 4; i++) {
        this.orbs.push({
          z: this.centerZ + (Math.random() - 0.5) * width,
          x: (Math.random() - 0.5) * width * 0.7,
          y: -8 + Math.random() * 45,
        });
      }
    }
  }

  _placeObstacle(offset, width) {
    if (offset < 0.1 || offset > 0.95) return;
    
    const typeRand = Math.random();
    let type, x, w, h;
    
    if (typeRand < 0.35) {
      // Floating Monolith
      type = 'monolith';
      x = (Math.random() - 0.5) * width * 1.2;
      const tiltZ = Math.sin(offset * 2.7 + this.centerZ * 0.01);
      w = 12 + Math.random() * 10;
      h = 22 + Math.random() * 30;
    } else if (typeRand < 0.68) {
      // Tight Squeeze
      type = 'squeeze';
      x = (Math.random() - 0.5) * width * 1.4;
      w = 2 + Math.random() * 3;
      h = 38 + Math.random() * 60;
    } else {
      // Wall Jutting Out
      type = 'wall';
      x = (Math.random() - 0.5) * width * 1.3;
      w = 5 + Math.random() * 12;
      h = 18 + Math.random() * 45;
    }

    this.obstacles.push({ type, z: this.centerZ + x, y: -12 + Math.random() * 30, width: w, height: h });
  }

  rebuild(scene) {
    if (this._meshGroup) scene.remove(this._meshGroup);

    const group = new THREE.Group();
    this._meshGroup = group;
    
    // Walls
    for (const w of this.walls) {
      const geo = new THREE.BoxGeometry(0.8, w.height * 1.2, 0.6);
      const mat = this._getWallMaterial(w.z, w.leftX, w.rightX);
      const mesh = new THREE.Mesh(geo, mat);
      mesh.position.set((w.leftX + w.rightX)/2 - this.centerZ, -5 + Math.random()*3, 0);
      mesh.castShadow = true;
      group.add(mesh);

      // Top edge highlight
      const lineGeo = new THREE.BoxGeometry(w.height * 1.05, 0.1, 0.62);
      const lineMat = new THREE.MeshLambertMaterial({ color: 0xffaa33 });
      const lineMesh = new THREE.Mesh(lineGeo, lineMat);
      lineMesh.position.set((w.leftX + w.rightX)/2 - this.centerZ, w.height * 1.25 - 5 + Math.random()*3, 0);
      group.add(lineMesh);
    }

    // Obstacles
    for (const obs of this.obstacles) {
      const geo = new THREE.BoxGeometry(obs.width, obs.height * 1.2, 0.8);
      let mat;
      if (obs.type === 'monolith') mat = new THREE.MeshLambertMaterial({ color: 0x4a7f6a });
      else if (obs.type === 'squeeze') mat = new THREE.MeshLambertMaterial({ color: 0x5a5a8a });
      else mat = new THREE.MeshLambertMaterial({ color: 0x3a4a6a });
      const mesh = new THREE.Mesh(geo, mat);
      mesh.position.set(obs.width/2 - this.centerZ, obs.y - 5 + Math.random()*3, 0);
      mesh.castShadow = true;
      group.add(mesh);
    }

    // Orbs (created as separate groups)
    for (let i = 0; i < this.orbs.length; i++) {
      const orbMesh = createOrb(this.orbs[i].z, this.orbs[i].x - this.centerZ, this.orbs[i].y);
      group.add(orbMesh);
    }

    scene.add(group);
  }

  _getWallMaterial(z, leftX, rightX) {
    const hue = ((z / CONFIG.WORLD.horizonDist + leftX / (CONFIG.WORLD.width * 2)) % 1 + 1) % 1;
    return new THREE.MeshLambertMaterial({ 
      color: new THREE.Color().setHSL(hue, 0.4, 0.18),
      flatShading: true
    });
  }

  // Collision detection helpers
  checkCollision(playerX, playerY) {
    const halfR = 0.5;
    
    for (const w of this.walls) {
      if (Math.abs(playerZ - this.centerZ) > CONFIG.WORLD.chunkLength * 0.4) continue;
      // Check if player is between wall segment left and right edges
      if (playerX >= w.leftX && playerX <= w.rightX) return true;
    }

    for (const obs of this.obstacles) {
      const dx = Math.abs(playerX - obs.width/2);
      const dy = Math.abs((playerY + 10) - obs.height * 0.3);
      if (dx < halfR && dy < halfR) return true;
    }

    // Near-miss distance calculation for scoring
    let minDist = Infinity;
    
    for (const w of this.walls) {
      const dz = Math.abs(playerZ - this.centerZ);
      if (dz > CONFIG.WORLD.chunkLength * 0.45) continue;
      const dx1 = Math.abs(playerX - w.leftX);
      const dx2 = Math.abs(playerX - w.rightX);
      minDist = Math.min(minDist, dx1, dx2);
    }

    for (const obs of this.obstacles) {
      const dz = Math.abs(playerZ - obs.z);
      if (dz > CONFIG.WORLD.chunkLength * 0.45) continue;
      const dx = Math.abs(playerX - obs.width/2);
      minDist = Math.min(minDist, dx);
    }

    return this._nearMissDistance(playerZ, playerX, playerY);
  }

  _nearMissDistance(pz, px, py) {
    let minDist = Infinity;
    
    for (const w of this.walls) {
      if (Math.abs(pz - this.centerZ) > CONFIG.WORLD.chunkLength * 0.4) continue;
      const dx1 = Math.abs(px - w.leftX);
      const dx2 = Math.abs(px - w.rightX);
      minDist = Math.min(minDist, dx1, dx2);
    }

    for (const obs of this.obstacles) {
      if (Math.abs(pz - obs.z) > CONFIG.WORLD.chunkLength * 0.4) continue;
      const dx = Math.abs(px - obs.width/2);
      minDist = Math.min(minDist, dx);
    }

    return Math.max(1, minDist);
  }
}

// ── class: CanyonManager ──
class CanyonManager {
  constructor(scene) {
    this.scene = scene;
    this.chunks = [];
    this.activeChunk = null; // The chunk centered on player's Z
    this.spawnZ = -CONFIG.WORLD.horizonDist;
    this._recycleZ = CONFIG.WORLD.renderDist + 200;
  }

  update(playerZ, score) {
    const halfLen = CONFIG.WORLD.chunkLength / 2;
    const targetChunkZ = playerZ - (this.spawnZ % CONFIG.WORLD.chunkLength);
    
    // Find or create the right chunk
    let found = false;
    for (let c of this.chunks) {
      if (!c.isOverlapping(playerZ)) continue;
      if (Math.abs(c.centerZ - targetChunkZ) < halfLen) {
        this.activeChunk = c;
        found = true;
        break;
      }
    }

    if (!found && !this.chunks.length) {
      const z0 = Math.floor(this.spawnZ / CONFIG.WORLD.chunkLength) * CONFIG.WORLD.chunkLength - halfLen;
      this.chunks.push(new CanyonChunk(z0, z0 + CONFIG.WORLD.chunkLength));
    }

    // Spawn new chunks ahead
    while (this.spawnZ < targetChunkZ - halfLen) {
      const nextZ = Math.floor(this.spawnZ / CONFIG.WORLD.chunkLength) * CONFIG.WORLD.chunkLength;
      this.chunks.push(new CanyonChunk(nextZ, nextZ + CONFIG.WORLD.chunkLength));
      this.spawnZ += CONFIG.WORLD.chunkLength;
    }

    // Remove old chunks
    const removeZ = playerZ - CONFIG.WORLD.renderDist * 1.5;
    for (let i = this.chunks.length - 1; i >= 0; i--) {
      if (this.chunks[i].zEnd < removeZ) {
        this.scene.remove(this.chunks[i]._meshGroup);
        this._disposeObject(this.chunks[i]);
        this.chunks.splice(i, 1);
      }
    }

    // Cleanup orphaned meshes from removed chunks
    const remainingChunkSet = new Set(this.chunks.map(c => c));
    for (let i = this.scene.children.length - 1; i >= 0; i--) {
      if (this.scene.children[i] === undefined) continue;
      const child = this.scene.children[i];
      // Check if it's an orphaned mesh group from a removed chunk
      if (child.userData && child.userData.parentChunk && !remainingChunkSet.has(child.userData.parentChunk)) {
        scene.remove(child);
      }
    }

    return this.activeChunk;
  }

  _disposeObject(chunk) {
    for (const wall of chunk.walls) {
      if (wall.mesh) scene.remove(wall.mesh);
      wall.mesh = null;
    }
    for (const obs of chunk.obstacles) {
      if (obs.mesh) scene.remove(obs.mesh);
      obs.mesh = null;
    }
    for (const orb of chunk.orbs) {
      if (orb.mesh) scene.remove(orb.mesh);
      orb.mesh = null;
    }
  }

  rebuildChunk(chunk) {
    this._disposeObject(chunk);
    
    // Rebuild walls
    const wallGeo = new THREE.BoxGeometry(0.8, 1, 0.5);
    for (let i = 0; i < chunk.walls.length; i++) {
      const w = chunk.walls[i];
      const mesh = new THREE.Mesh(wallGeo, this._getWallMaterial(i));
      mesh.position.set(w.leftX + w.rightX)/2, -10 + Math.random()*3, w.z - playerZ;
      // Actually we store relative to chunk center - let me use a different approach
    }
  }

  _getWallMaterial(index) {
    const h = CONFIG.WORLD.chunkLength / (index === this.walls.length-1 ? this.walls.length : 1);
    return new THREE.MeshLambertMaterial({ color: 0x6a3d2a });
  }
}

// ── function: createOrb ──
function createOrb(z, x, y) {
  // Create a glowing orb with inner glow using emissive material
  const group = new THREE.Group();
  
  const coreGeo = new THREE.OctahedronGeometry(0.5, 0);
  const coreMat = new THREE.MeshBasicMaterial({ 
    color: 0xffdd44,
    transparent: true,
    opacity: 0.9
  });
  const core = new THREE.Mesh(coreGeo, coreMat);
  group.add(core);

  const glowGeo = new THREE.OctahedronGeometry(1.2, 0);
  const glowMat = new THREE.MeshBasicMaterial({ 
    color: 0xffaa33,
    transparent: true,
    opacity: 0.35
  });
  const glow = new THREE.Mesh(glowGeo, glowMat);
  group.add(glow);

  // Outer halo ring
  const ringGeo = new THREE.RingGeometry(1.8, 2.2, 16);
  const ringMat = new THREE.MeshBasicMaterial({ 
    color: 0xff44aa, 
    transparent: true, 
    opacity: 0.25,
    side: THREE.DoubleSide
  });
  const ring = new THREE.Mesh(ringGeo, ringMat);
  ring.rotation.x = Math.PI / 2;
  group.add(ring);

  group.userData.orbZ = z + CONFIG.WORLD.chunkLength / 2; // offset for relative positioning
  group.position.set(x, y, -CONFIG.WORLD.chunkLength/2);
  
  return group;
}

// ── class: Player ──
class Player {
  constructor(scene) {
    this.scene = scene;
    
    // Physics state
    this.pos = new THREE.Vector3(0, -15, -CONFIG.WORLD.chunkLength * 2);
    this.vel = new THREE.Vector3(0.8, 0, 0);
    this.acc = CONFIG.PLAYER.accel;
    this.friction = CONFIG.PLAYER.friction;
    this.maxSpeed = CONFIG.PLAYER.maxSpeed;
    this.boostMaxSpeed = CONFIG.PLAYER.boostMaxSpeed;
    this.turnRate = CONFIG.PLAYER.turnRate;
    
    // Angular state (banking/pitching)
    this.roll = 0, this.rollTarget = 0;
    this.pitch = 0, this.pitchTarget = 0;
    this.angularVel = new THREE.Vector2(0, 0);
    
    // Energy & boost
    this.energy = CONFIG.ENERGY.orbValue * 4;
    this.maxEnergy = CONFIG.ENERGY.orbValue * 4;
    this.boostActive = false;
    this.boostLevel = 0;
    this.turboTimer = 0;
    
    // Scoring
    this.score = 0;
    this.multiplier = 1.0;
    this.nearMissDist = Infinity;
    this.lastScoreZ = this.pos.z;
    this.totalDistance = 0;
    
    // Visual state
    this.mesh = null;
    this.engineTrail = [];
    
    this._createMesh();
  }

  _createMesh() {
    const group = new THREE.Group();
    
    // Main craft body - sleek angular shape
    const bodyGeo = new THREE.ConeGeometry(0.8, 3, 4);
    bodyGeo.rotateX(Math.PI / 2);
    bodyGeo.translate(0, 1.5, 0);
    const bodyMat = new THREE.MeshLambertMaterial({ 
      color: 0xaaaaaa, flatShading: true, transparent: true, opacity: 0.9 
    });
    this.meshBody = new THREE.Mesh(bodyGeo, bodyMat);
    group.add(this.meshBody);
    
    // Wing fins (left and right)
    const wingGeo = new THREE.BoxGeometry(2.5, 0.1, 1.2);
    const wingLeft = new THREE.Mesh(wingGeo, bodyMat.clone());
    wingLeft.position.set(-1.8, 0, 0.4);
    group.add(wingLeft);
    
    const wingRight = new THREE.Mesh(wingGeo, bodyMat.clone());
    wingRight.position.set(1.8, 0, 0.4);
    group.add(wingRight);
    
    // Nose cone - brighter
    const noseGeo = new THREE.ConeGeometry(0.5, 1.2, 6);
    noseGeo.rotateX(Math.PI / 2);
    noseGeo.translate(0, 1.8, 0);
    const noseMat = new THREE.MeshBasicMaterial({ 
      color: 0xffdd44, transparent: true, opacity: 0.7 
    });
    this.meshNose = new THREE.Mesh(noseGeo, noseMat);
    group.add(this.meshNose);
    
    // Engine glow at rear
    const engineGeo = new THREE.SphereGeometry(0.6, 8, 8);
    const engineMat = new THREE.MeshBasicMaterial({ 
      color: 0xff2244, transparent: true, opacity: 0.8
    });
    this.meshEngine = new THREE.Mesh(engineGeo, engineMat);
    this.meshEngine.position.z = -1.5;
    group.add(this.meshEngine);

    // Engine glow particles container
    this._engineGlowParticles = [];
    for (let i = 0; i < 8; i++) {
      const pGeo = new THREE.SphereGeometry(0.2 + Math.random()*0.3, 6, 6);
      const pMat = new THREE.MeshBasicMaterial({ 
        color: new THREE.Color().setHSL(0.1 + Math.random()*0.05, 0.9, 0.4),
        transparent: true, opacity: 0.6
      });
      const pMesh = new THREE.Mesh(pGeo, pMat);
      this._engineGlowParticles.push({ mesh: pMesh, offset: i / 8 });
      group.add(pMesh);
    }

    // Wing lights (cyan accents)
    const lightGeo = new THREE.BoxGeometry(0.2, 0.2, 1.5);
    const lightMat = new THREE.MeshBasicMaterial({ color: 0x44ffaa });
    
    for (let side of [-1, 1]) {
      const l1 = new THREE.Mesh(lightGeo, lightMat.clone());
      l1.position.set(side * 2.2, 0.6, 0.8);
      group.add(l1);
      
      const l2 = new THREE.Mesh(lightGeo, lightMat.clone());
      l2.position.set(side * 2.2, -0.6, 0.8);
      group.add(l2);
    }

    // Wireframe outline for style
    const wireGeo = new THREE.OctahedronGeometry(1.5, 0);
    wireGeo.rotateX(Math.PI/2).translate(0, 2, 0);
    const wireMat = new THREE.MeshBasicMaterial({ 
      color: 0xff44aa, wireframe: true, transparent: true, opacity: 0.3 
    });
    this.meshWireframe = new THREE.Mesh(wireGeo, wireMat);
    group.add(this.meshWireframe);

    // Position at player location (offset by y since we're below ground)
    const offsetZ = -CONFIG.WORLD.chunkLength / 2;
    group.position.set(0, 0, offsetZ);
    
    this.mesh = group;
    scene.add(group);
  }

  update(inputState, dt) {
    // Pitch input (vertical control via mouse scroll or space/shift)
    if (inputState.pitch > 0) {
      this.vel.z += CONFIG.PLAYER.pitchSens * inputState.pitch * this.maxSpeed;
    } else if (inputState.pitch < 0) {
      this.vel.z -= Math.abs(CONFIG.PLAYER.pitchSens) * Math.min(inputState.pitch, -1);
    }
    
    // Horizontal steering target based on input
    const steerTarget = inputState.steer || 0;
    const currentSteerDir = (this.vel.x > 0 ? 1 : this.vel.x < 0 ? -1 : 0);
    const desiredVelX = steerTarget * this.maxSpeed * currentSteerDir;
    
    // Apply momentum with damping
    this.vel.x *= Math.max(0.9, 1 - dt * CONFIG.PLAYER.turnRate * Math.abs(this.vel.z));
    this.vel.y *= this.friction;
    
    // Banking during turns
    const steerAmount = desiredVelX / this.maxSpeed;
    if (steerAmount !== 0) {
      this.rollTarget -= steerAmount * CONFIG.PLAYER.bankSensitivity * this.turnRate;
      this.pitchTarget += Math.abs(steerAmount) * 0.3;
    } else {
      this.rollTarget = THREE.MathUtils.lerp(this.rollTarget, 0, dt * 2);
      if (this.meshPitch !== undefined) {
        this.meshPitch = THREE.MathUtils.lerp(this.meshPitch, 0, dt * 2);
      }
    }
    
    // Energy decay + boost mechanics
    const energyDecay = CONFIG.ENERGY.decayRate + Math.abs(this.vel.z / this.maxSpeed) * 0.01;
    if (this.boostActive && this.energy > 0) {
      this.turboTimer -= dt;
      const costPerFrame = CONFIG.ENERGY.boostCostPerFrame;
      this.energy -= costPerFrame;
      
      // Turbo boosts horizontal velocity dramatically
      this.vel.x *= 1.15;
    } else if (this.vel.x > 0 || this.vel.x < 0) {
      // Natural deceleration on x-axis when not boosting
      this.vel.x = Math.max(-this.friction, Math.min(this.friction, this.vel.x));
    }
    
    // Apply energy decay to base
    this.energy = THREE.MathUtils.clamp(this.energy - energyDecay * dt, 0, this.maxEnergy);
    
    // Boost activation
    if (inputState.boost && this.energy >= CONFIG.ENERGY.orbValue) {
      this.boostActive = true;
      this.boostLevel = Math.min(1, this.energy / CONFIG.ENERGY.orbValue * 3);
      this.turboTimer = 0.4;
    } else if (this.turboTimer > 0) {
      // Boost fading out
      const fadeAmount = 1 - this.turboTimer / 0.4;
      if (fadeAmount < 1) {
        this.boostLevel *= fadeAmount;
        if (this.boostLevel <= 0.05) {
          this.boostActive = false;
          this.boostLevel = 0;
        }
      }
    } else {
      // Slow down under boost
      const slowFactor = (1 - this.boostLevel) * 0.96;
      if (this.vel.x > 2 || this.vel.x < -2) {
        this.vel.x *= Math.max(0.5, slowFactor);
      }
    }

    // Cap speed
    const targetSpeed = this.boostActive ? this.boostMaxSpeed * this.boostLevel : this.maxSpeed;
    if (this.vel.length() > targetSpeed) {
      const scale = targetSpeed / this.vel.length();
      this.vel.multiplyScalar(scale);
    }
    
    // Move player forward
    this.pos.x += this.vel.x * dt;
    this.pos.y += this.vel.y * dt;
    this.pos.z += this.vel.z * dt;
    
    // Track score and distance
    const distMoved = Math.abs(this.pos.z - this.lastScoreZ);
    if (distMoved > 0.1) {
      const speedBonus = Math.min(2, CONFIG.PLAYER.maxSpeed / distMoved * 2);
      this.score += distMoved * CONFIG.SCORING.baseScorePerUnit + speedBonus;
      this.totalDistance += distMoved;
      this.lastScoreZ = this.pos.z;
    }

    // Update angular state (interpolate to target)
    this.roll = THREE.MathUtils.lerp(this.roll, this.rollTarget, dt * 3);
    if (this.meshPitch !== undefined) {
      this.meshPitch = THREE.MathUtils.lerp(this.meshPitch, this.pitchTarget, dt * 2.5);
    }

    // Update mesh position relative to player pos
    const worldPos = new THREE.Vector3(0, -15 + this.pos.y, this.pos.z);
    if (this.mesh.position) {
      this.mesh.position.copy(worldPos);
    }
    
    // Update engine glow particles
    for (const p of this._engineGlowParticles) {
      const speed = Math.abs(this.vel.z);
      p.mesh.position.z -= (speed + 0.5) * dt;
      if (p.mesh.position.z < -4) {
        p.mesh.visible = false;
      } else {
        p.mesh.visible = true;
        // Flash on boost
        p.mesh.material.opacity = 0.6 + Math.sin(Date.now() * 0.015 * this.boostLevel) * 0.3;
        if (this.boostActive && this.boostLevel > 0.8) {
          p.mesh.material.color.setHSL(0.05, 1, 0.7); // white-hot on turbo
        } else {
          p.mesh.material.color.setHSL(0.05 + Math.random()*0.03, 0.9, 0.4);
        }
      }
    }

    // Engine trail particles
    if (Math.abs(this.vel.z) > 1) {
      const newTrail = new TrailParticle();
      newTrail.life = 1;
      newTrail.size = 0.3 + Math.random() * 0.5;
      newTrail.color = this.boostActive && this.boostLevel > 0.6 
        ? new THREE.Color(0xff4422) 
        : new THREE.Color().setHSL(0.1, 0.8, 0.3);
      
      // Position behind player
      const offset = (-speed * dt) + (Math.random() - 0.5) * 0.5;
      newTrail.mesh.position.set(
        this.pos.x + (Math.random() - 0.5) * 2,
        this.pos.y - 10 + Math.random() * 8,
        this.pos.z + offset
      );
      
      particleSystem.add(newTrail);
    }

    // Update wireframe to match bank/pitch
    if (this.meshWireframe && this.meshBody) {
      const bankRot = new THREE.Euler(this.roll, 0, this.meshPitch, 'XYZ');
      this.meshBody.rotation.set(
        Math.sin(this.pitch * 0.3) * 0.15,
        -bankRot.x, 
        bankRot.z
      );
    }

    // Near-miss detection (handled by CanyonManager)
    this.nearMissDist = Infinity; // Reset, will be set by CanyonManager
    
    return { scoreDelta: distMoved * CONFIG.SCORING.baseScorePerUnit };
  }

  collectOrb() {
    if (!this.boostActive || this.energy >= this.maxEnergy) return false;
    const cost = Math.min(CONFIG.ENERGY.orbValue, (1 - this.energy / this.maxEnergy) * CONFIG.ENERGY.orbValue);
    this.energy += cost;
    return true;
  }

  die() {
    soundSynth.crash();
    if (this.mesh) scene.remove(this.mesh);
  }

  restart() {
    this.score = 0;
    this.multiplier = 1.0;
    this.totalDistance = 0;
    this.nearMissDist = Infinity;
    
    // Reset physics
    this.pos.set(0, -15, -CONFIG.WORLD.chunkLength * 2);
    this.vel.set(0.8, 0, 0);
    this.roll = 0;
    this.pitchTarget = 0;
    this.energy = CONFIG.ENERGY.orbValue * 4;
    this.boostActive = false;
    this.turboTimer = 0;
    this.boostLevel = 0;

    // Reset engine glow
    for (const p of this._engineGlowParticles) {
      p.mesh.position.z = -1.5;
      p.mesh.material.opacity = 0.6;
      p.mesh.visible = true;
    }

    if (this.mesh) scene.remove(this.mesh);
    this._createMesh();
    scene.add(this.mesh);
  }
}

// ── class: TrailParticle ──
class TrailParticle {
  constructor() {
    this.life = 1;
    this.maxLife = 1;
    this.size = 0.3 + Math.random() * 0.5;
    this.color = new THREE.Color(0xff44aa);
    const geo = new THREE.SphereGeometry(this.size, 6, 6);
    const mat = new THREE.MeshBasicMaterial({ 
      color: this.color, transparent: true, opacity: 1 
    });
    this.mesh = new THREE.Mesh(geo, mat);
  }

  update(dt) {
    this.life -= dt * 2;
    if (this.life <= 0) return false; // expired
    const t = this.life / this.maxLife;
    this.mesh.material.opacity = t * 0.6;
    this.size *= (1 - t);
    return true; // still alive
  }

  dispose() {
    if (this.mesh) scene.remove(this.mesh);
  }
}

// ── class: ParticleSystem ──
class ParticleSystem {
  constructor(scene) {
    this.scene = scene;
    this.particles = [];
    this._geoPool = null;
    
    // Pre-allocate geometry buffer for performance
    const geom = new THREE.BufferGeometry();
    const positions = new Float32Array(3 * CONFIG.VISUAL.trailLength);
    geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    this._geoPool = geom;
    
    const mat = new THREE.PointsMaterial({
      color: 0xffdd44,
      size: 2,
      transparent: true,
      opacity: 0.6,
      blending: THREE.AdditiveBlending,
      depthWrite: false
    });
    this._systemGeom = geom.clone();
    this._systemMat = new THREE.PointsMaterial({
      color: new THREE.Color(0xffaa33),
      size: 1.5,
      transparent: true,
      opacity: 0.7,
      blending: THREE.AdditiveBlending,
      depthWrite: false
    });

    this._system = new THREE.Points(this._systemGeom, this._systemMat);
    scene.add(this._system);
  }

  add(debris) {
    const count = CONFIG.VISUAL.dustRate;
    for (let i = 0; i < count; i++) {
      this.particles.push({
        mesh: null, // We'll use a simple approach - just track positions
        life: 1,
        maxLife: 0.5 + Math.random() * 0.5,
        size: 0.3 + Math.random() * 0.8,
        vx: (Math.random() - 0.5) * 4,
        vy: (Math.random() - 1) * 2,
        vz: 2 + Math.random() * 6,
        x: debris.x + (Math.random()-0.5)*3,
        y: debris.y + (Math.random()-0.5)*2,
        z: debris.z + (Math.random()-1)*8,
      });
    }
    
    // Add to buffer if we have enough
    this.particles.push({
      mesh: null,
      life: 1,
      maxLife: 0.3,
      size: 0.5 + Math.random() * 1.0,
      vx: (Math.random()-0.5)*2,
      vy: -1 + Math.random()*4,
      vz: 1 + Math.random()*4,
      x: debris.x + (Math.random()-0.5),
      y: debris.y + Math.random()*8,
      z: debris.z + Math.random()*6,
    });

    // If particles exceed buffer size, reset oldest ones
    if (this.particles.length > CONFIG.VISUAL.trailLength) {
      const toRemove = this.particles.splice(0, this.particles.length - CONFIG.VISUAL.trailLength);
      for (const p of toRemove) {
        // Could dispose meshes here but we're using buffer approach
      }
    }
  }

  update(dt) {
    const positions = this._systemGeom.attributes.position.array;
    let idx = 0;
    
    for (let i = 0; i < CONFIG.VISUAL.trailLength && idx < positions.length; i++) {
      if (i >= this.particles.length) break;
      const p = this.particles[i];
      
      if (!p || p.life <= 0) continue;
      
      // Update position based on velocity
      p.x += p.vx * dt;
      p.y += p.vy * dt;
      p.z += p.vz * dt;
      
      // Decrease life (moves toward horizon = dies faster as it goes further back)
      const fadeSpeed = 2 + Math.abs(p.vz / 10);
      p.life -= dt * fadeSpeed;
      
      if (p.life <= 0) continue;
      
      positions[idx++] = p.x;
      positions[idx++] = p.y;
      positions[idx++] = p.z;
    }

    // Fill remaining with zeroed positions (invisible)
    while (idx < positions.length) {
      positions[idx++] = 0;
      positions[idx++] = 0;
      positions[idx++] = 0;
    }

    this._systemGeom.attributes.position.needsUpdate = true;
  }

  dispose() {
    if (this._system) scene.remove(this._system);
  }
}

// ── class: HUDController ──
class HUDController {
  constructor() {
    this.scoreEl = document.getElementById('score-val');
    this.multEl = document.getElementById('mult-val');
    this.speedMarker = document.getElementById('speed-marker');
    this.energyFill = document.getElementById('energy-fill');
    this.multiplierPulse = false;
    
    // Speed calibration: 0-100% arc for speed range
    this.maxSpeedDisplay = CONFIG.PLAYER.boostMaxSpeed * 2.5; // Scale up for visual effect
    this.baseSpeed = CONFIG.PLAYER.maxSpeed;
    this.boostRatio = this.maxSpeedDisplay / (this.baseSpeed + CONFIG.PLAYER.boostMaxSpeed);
    
    // Initial state
    this.update({ score: 0, multiplier: 1, speed: 0, energy: 1, boostActive: false });
  }

  update(state) {
    if (!state || !this.scoreEl) return;
    
    const s = state;
    
    // Score display with pulsing effect on multiplier change
    this.scoreEl.textContent = Math.floor(s.score).toLocaleString();
    if (s.multiplier > 1 && s.lastMultiplier !== undefined && s.lastMultiplier < 2) {
      this._triggerPulse();
    }
    s.lastMultiplier = s.multiplier;
    
    // Multiplier display with pulse animation class
    this.multEl.textContent = s.multiplier.toFixed(1) + 'x';
    if (s.nearMiss && !this.multiplierPulse) {
      this._triggerPulse();
    }
    
    // Speedometer arc - position marker along the curve
    const speedRatio = Math.min(s.speed / this.maxSpeedDisplay, 0.95);
    const totalArcLen = 280; // matches dasharray
    const offset = totalArcLen * (1 - speedRatio) * 0.6; // scaled range
    if (this.speedMarker) {
      this.speedMarker.setAttribute('stroke-dashoffset', offset.toFixed(1));
    }
    
    // Color the marker based on speed zone
    if (s.boostActive && s.boostLevel > 0.7) {
      this.speedMarker.style.fill = '#fff';
    } else if (speedRatio > 0.85) {
      this.speedMarker.style.fill = '#ff4422';
    } else if (speedRatio > 0.6) {
      this.speedMarker.style.fill = '#ffaa00';
    } else {
      this.speedMarker.style.fill = '#00ffaa';
    }

    // Energy bar
    const energyPct = s.energy / s.maxEnergy;
    this.energyFill.style.width = (energyPct * 100).toFixed(1) + '%';
    
    if (energyPct < 0.3) {
      this.energyFill.classList.add('low');
    } else {
      this.energyFill.classList.remove('low');
    }

    // Update visibility
    const isPlaying = s && s.totalDistance > 1;
    document.getElementById('hud-speedometer').classList.toggle('hidden', !isPlaying);
    document.getElementById('hud-energy-bar').classList.toggle('hidden', !isPlaying);
    document.getElementById('multiplier-display').classList.toggle('hidden', !isPlaying);
    
    // Near-miss pulse class
    this.multiplierPulse = s.nearMiss || false;
    if (this.multEl) {
      this.multEl.classList.toggle('pulse', this.multiplierPulse);
    }
  }

  _triggerPulse() {
    if (!this.multEl) return;
    
    // Use a quick CSS transition via JS manipulation
    const pulseCount = Math.max(1, Math.min(3, Math.floor(this.score / 50)));
    let i = 0;
    this._pulseInterval = setInterval(() => {
      if (this.multEl) this.multEl.classList.toggle('pulse');
      i++;
      if (i >= pulseCount * 4) clearInterval(this._pulseInterval);
    }, 150);
  }

  gameOver(score, maxSpeed) {
    document.getElementById('go-score').textContent = `Final Score: ${Math.floor(score).toLocaleString()}`;
    document.getElementById('go-speed').textContent = `Max Speed: ${(maxSpeed / CONFIG.PLAYER.boostMaxSpeed * 100).toFixed(0)}%`;
    document.getElementById('game-over-overlay').classList.remove('hidden');
  }
}

// ── class: CameraController ──
class CameraController {
  constructor(scene) {
    this.scene = scene;
    this.camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, CONFIG.WORLD.horizonDist * 2);
    
    // Camera state
    this.targetPos = new THREE.Vector3();
    this.position = new THREE.Vector3();
    this.targetFOV = CONFIG.PLAYER.boostFOVBase;
    this.currentFOV = CONFIG.PLAYER.boostFOVBase;
    this.shakeIntensity = 0;
    this.shakeAccum = 0;
    this.fovZoomFactor = CONFIG.VISUAL.boostFOVZoom;

    scene.add(this.camera);
    
    // Add ambient and directional lighting
    const ambLight = new THREE.AmbientLight(0x4a3520, 0.6);
    scene.add(ambLight);
    
    this.dirLight = new THREE.DirectionalLight(0xffaa33, 1.2);
    this.dirLight.position.set(50, 80, -30);
    this.dirLight.castShadow = true;
    this.dirLight.shadow.camera.left = -100;
    this.dirLight.shadow.camera.right = 100;
    this.dirLight.shadow.camera.top = 100;
    this.dirLight.shadow.camera.bottom = -100;
    this.dirLight.shadow.camera.near = 1;
    this.dirLight.shadow.camera.far = CONFIG.WORLD.horizonDist * 2;
    this.dirLight.shadow.mapSize.width = 2048;
    this.dirLight.shadow.mapSize.height = 2048;
    scene.add(this.dirLight);

    // Secondary warm light from below (underground feel)
    const underLight = new THREE.PointLight(0xff6633, 0.5, 100);
    underLight.position.set(0, -20, 0);
    scene.add(underLight);

    // Purple accent light
    const purpleLight = new THREE.PointLight(0x8844ff, 0.3, 80);
    purpleLight.position.set(-60, 10, 20);
    scene.add(purpleLight);
  }

  update(playerPos, state) {
    // Target position: offset behind and below player
    const offset = new THREE.Vector3(
      -playerPos.x * 0.3,
      -18 + playerPos.y * 0.5,
      -65
    );
    
    this.targetPos.set(playerPos.x + offset.x, playerPos.y + offset.y, playerPos.z + offset.z);
    // Add some forward lean based on pitch
    const pitchLean = (state?.pitch || 0) * 2;
    this.targetPos.y += pitchLean;

    // Smooth camera position following target
    this.position.x += (this.targetPos.x - this.position.x) * 0.15;
    this.position.y += (this.targetPos.y - this.position.y) * 0.12;
    this.position.z += (this.targetPos.z + this.shakeAccum * 0.3 - this.position.z) * 0.12;

    // Apply shake to Z position
    if (this.shakeIntensity > 0 && Math.random() < 0.5) {
      const shakeScale = this.shakeIntensity / CONFIG.VISUAL.shakeNearMissIntensity;
      this.shakeAccum += (Math.random() - 0.5) * 2 * shakeScale;
    } else if (this.shakeAccum > 0) {
      this.shakeAccum *= 0.85;
    }

    // FOV zoom on boost
    const speedRatio = state?.speed / CONFIG.PLAYER.boostMaxSpeed || 0;
    this.targetFOV = CONFIG.PLAYER.boostFOVBase + speedRatio * CONFIG.VISUAL.boostFOVZoom;
    
    this.currentFOV += (this.targetFOV - this.currentFOV) * 0.15;

    // Apply camera
    if (state?.nearMiss || this.shakeIntensity > 0) {
      const shakeScale = state?.nearMiss ? CONFIG.VISUAL.shakeNearMissIntensity : this.shakeIntensity;
      
      // Camera shake vector
      if (this.position.x === undefined) {
        // First frame
        this.position.set(this.targetPos.clone());
      }
      
      this.camera.position.copy(this.position);
    } else {
      this.camera.position.copy(this.position);
    }

    // Look slightly ahead of player position
    const lookTarget = new THREE.Vector3(
      this.targetPos.x + state?.steer || 0,
      this.targetPos.y - (state?.pitch || 0) * 1.5,
      this.targetPos.z + 40
    );
    
    // Add FOV zoom offset to look target
    const fovZoomOffset = state?.boostLevel ? speedRatio * CONFIG.VISUAL.boostFOVZoom : 0;
    lookTarget.y += fovZoomOffset;

    this.camera.lookAt(lookTarget);
    this.camera.fov = this.currentFOV;
    this.camera.updateProjectionMatrix();
  }

  setShake(intensity) {
    this.shakeIntensity = intensity || 0;
  }
}

// ── class: PostProcessor ──
class PostProcessor {
  constructor(scene, renderer) {
    this.scene = scene;
    this.renderer = renderer;
    this.composer = null;
    
    if (THREE.EffectComposer && THREE.UnrealBloomPass) {
      // Three.js includes these in the main build for r128+
      
      // Create a copy of camera with bloom pass target resolution
      const bloomCamera = new THREE.Camera();
      bloomCamera.copy(renderer.camera);
      bloomCamera.position.set(0, 0, 0);
      
      this.composer = new THREE.EffectComposer(renderer);
      this.composer.addPass(new THREE.RenderPass(scene, renderer.camera));
      
      // Bloom pass
      const bloomRes = Math.max(128, renderer.domElement.width * 0.5),
            bloomHeight = Math.max(64, renderer.domElement.height * 0.5);
      this.composer.addPass(new THREE.UnrealBloomPass(
        new THREE.Vector2(bloomRes, bloomHeight),
        1.2,   // strength - makes glowing elements bright
        0.4,   // radius - blur spread
        0.8    // threshold - minimum brightness to bloom
      ));

      // Chromatic aberration for speed feel
      this.composer.addPass(new THREE.ChromaticAberrationPass(
        new THREE.Vector2(renderer.domElement.width, renderer.domElement.height),
        0.002 + (renderer.camera.fov / 90) * 0.0015
      ));

      // Vignette pass for depth
      this.composer.addPass(new THREE.VignettePass(
        new THREE.Vector2(renderer.domElement.width, renderer.domElement.height),
        0.6,   // darkness
        0.8,   // offset  
        1.5    // roundness
      ));

      // Color grading - warm golden hour tint
      this.composer.addPass(new THREE.ShaderPass(THREE.UniformsLib[null] || {}, 'vertexShader', 'fragmentShader'), {
        resolution: new THREE.Vector2(renderer.domElement.width, renderer.domElement.height),
        exposure: 1.3,
        contrast: 0.95,
        saturation: 1.15,
      });

    } else {
      // Fallback - use simple post-processing with tone mapping
      this.composer = null;
    }
  }

  render(scene, camera) {
    if (this.composer) {
      this.composer.render(scene, camera);
    } else {
      this.renderer.render(scene, camera);
    }
  }
}

// ── class: GameLoop ──
class GameLoop {
  constructor() {
    this.scene = new THREE.Scene();
    this.renderer = new THREE.WebGLRenderer({ 
      antialias: true, alpha: false, powerPreference: 'high-performance' 
    });
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.renderer.outputEncoding = THREE.sRGBEncoding;

    // Post processor (Three.js r128 bundles EffectComposer + bloom)
    if (THREE.EffectComposer && THREE.UnrealBloomPass) {
      const bloomCamera = new THREE.Camera();
      bloomCamera.copy(this.camera || this.renderer.domElement);
      
      this.postProcessor = new PostProcessor(this.scene, this.renderer);
    } else {
      this.postProcessor = null;
    }

    // Sound synth instance - created here to ensure availability
    this.soundSynth = new SoundSynth();

    this.state = { running: false };
    this.inputState = { steer: 0, pitch: 0, boost: false };
    this._keyBoost = false;
    this._keyPitch = 0;
    this._keyState = {};

    // Game objects (initialized in main)
    this.player = null;
    this.canyonManager = null;
    this.particleSystem = null;
    this.hud = new HUDController();
    this._gameTime = 0;

    window.addEventListener('resize', () => this.onResize());
    document.addEventListener('keydown', e => this._onKeyDown(e));
    document.addEventListener('keyup', e => this._onKeyUp(e));
    
    this.renderer.domElement.style.cursor = 'none';
  }

  start() {
    if (this.state.running) return;
    
    // Init audio on user interaction
    if (!this.soundSynth.ctx || this.soundSynth.ctx.state === 'suspended') {
      this.soundSynth.init();
    }
    
    this.player = new Player(this.scene);
    this.canyonManager = new CanyonManager(this.scene);
    this.particleSystem = new ParticleSystem(this.scene);

    // Generate initial canyon chunks
    const halfChunk = CONFIG.WORLD.chunkLength / 2;
    for (let i = -1; i < 4; i++) {
      const zStart = Math.floor(i * CONFIG.WORLD.chunkLength) - halfChunk;
      this.canyonManager.chunks.push(new CanyonChunk(zStart, zStart + CONFIG.WORLD.chunkLength));
    }

    // Set initial player position matching first chunk  
    const firstZ = Math.floor(-CONFIG.WORLD.chunkLength * 2);
    this.player.pos.set(0, -15, firstZ);

    document.getElementById('start-overlay').classList.add('hidden');
    this.state.running = true;
    
    // Kick off game loop
    const loop = () => {
      if (!this.state.running) return;
      requestAnimationFrame(loop);
      this._tick();
    };
    loop();
  }

  _getKeySteer() {
    let dir = 0;
    for (const k of ['ArrowLeft', 'a', 'A']) { if (this._keyState[k]) dir--; }
    for (const k of ['ArrowRight', 'd', 'D']) { if (this._keyState[k]) dir++; }
    return dir; // -1, 0, or 1
  }

  _onKeyDown(e) {
    const key = e.key.toLowerCase();
    
    switch(key) {
      case 'arrowleft': case 'a': this.inputState.steer -= 1; break;
      case 'arrowright': case 'd': this.inputState.steer += 1; break;
      case 'w': case 'arrowup': 
        if (!this._keyPitch) this._keyPitch = 0;
        this._keyPitch = Math.max(this._keyPitch || 0, 2); break;
      case 's': case 'arrowdown': 
        this._keyPitch -= 1.5; break;
      case 'shift': case ' ':
        if (this.state.running) {
          this._keyBoost = true;
          this.inputState.boost = true;
        } else if (!this.soundSynth.ctx) {
          this.soundSynth.init();
        } break;
      case 'escape':
        if (!this.state.running) {
          this.start();
        } else {
          document.exitPointerLock?.();
          this.soundSynth.stop();
        } break;
    }

    if (key !== 'escape') {
      this.inputState.steer = Math.max(-1, Math.min(1, this.inputState.steer));
      if (!['shift',' ','w','s','arrowup','arrowdown'].includes(key)) {
        this._keyPitch *= 0.95;
      }
    }
  }

  _onKeyUp(e) {
    const key = e.key.toLowerCase();
    if (key === 'shift' || key === ' ') {
      this._keyBoost = false;
      this.inputState.boost = false;
    } else if (['arrowleft','a','arrowright','d'].includes(key)) {
      // Steer decays naturally via player physics friction  
    }
  }

  onResize() {
    const w = window.innerWidth, h = window.innerHeight;
    this.renderer.setSize(w, h);
    
    if (this.camera) {
      this.camera.aspect = w / h;
      this.camera.updateProjectionMatrix();
    }
  }

  gameOver() {
    this.state.running = false;
    this.soundSynth.stop();
    document.exitPointerLock?.();
    
    const maxSpeed = Math.max(
      CONFIG.PLAYER.boostMaxSpeed * 2.5,
      1 + this.player.score * 0.01);
    this.hud.gameOver(this.player.score, maxSpeed);
  }

  restart() {
    // Clean up all scene objects except lights and camera
    const toRemove = [];
    for (const child of this.scene.children) {
      if (child !== this.camera && !['DirectionalLight', 'PointLight'].includes(child.constructor.name)) {
        toRemove.push(child);
      }
    }
    for (let i = toRemove.length - 1; i >= 0; i--) {
      this.scene.remove(toRemove[i]);
    }

    // Rebuild canyon chunks with fresh instances  
    const halfChunk = CONFIG.WORLD.chunkLength / 2;
    this.canyonManager.chunks = [];
    for (let i = -1; i < 4; i++) {
      const zStart = Math.floor(i * CONFIG.WORLD.chunkLength) - halfChunk;
      this.canyonManager.chunks.push(new CanyonChunk(zStart, zStart + CONFIG.WORLD.chunkLength));
    }

    // Reset player
    this.player.restart();
    
    document.getElementById('game-over-overlay').classList.add('hidden');
    this.hud.update({ score: 0, multiplier: 1, speed: 0, energy: 1, boostActive: false });
    this.state.running = true;
  }

  dispose() {
    if (this.particleSystem) this.particleSystem.dispose();
    if (this.postProcessor?.composer) this.postProcessor.composer.dispose();
    this.soundSynth.stop();
  }
}

// ── const: main ──
// Entry point - creates GameLoop and wires up UI/events
const main = function() {
  const game = new GameLoop();
  window._gameInstance = game;
  
  // Mouse controls for steering (active when pointer locked)
  document.addEventListener('mousemove', function(e) {
    if (!document.pointerLockElement || !window._gameInstance?.state.running) return;
    
    const steer = e.movementX * 0.025;
    game.inputState.steer += steer;
    game.inputState.steer = Math.max(-1, Math.min(1, game.inputState.steer));

    if (e.movementY && game.inputState.pitch === 0) {
      game.inputState.pitch += e.movementY * 0.05;
    } else if (e.movementY) {
      game.inputState.pitch = Math.max(-3, Math.min(3, 
        (game.inputState.pitch || 0) + e.movementY * 0.02));
    }
    
    lastMouseX = e.clientX;
  });

  document.addEventListener('wheel', function(e) {
    if (!document.pointerLockElement || !window._gameInstance?.state.running) return;
    const delta = Math.sign(e.deltaY); // -1 forward, +1 backward  
    game.inputState.pitch += delta * 2;
    game.inputState.pitch = Math.max(-3, Math.min(3, game.inputState.pitch));
    e.preventDefault();
  }, { passive: false });

  const canvas = document.getElementById('game-canvas');
  
  canvas.addEventListener('click', function(e) {
    e.preventDefault();
    if (!document.pointerLockElement) {
      canvas.requestPointerLock();
    }
  });

  // Pointer lock change handler - manages start/stop state
  document.addEventListener('pointerlockchange', function() {
    const g = window._gameInstance;
    if (g && !document.pointerLockElement && g.state.running) {
      g.state.running = false;
      g.soundSynth.stop();
    } else if (!document.pointerLockElement) {
      // Not locked - show start overlay if needed
      const startOverlay = document.getElementById('start-overlay');
      if (startOverlay && !startOverlay.classList.contains('hidden')) return;
      
      if (!g?.soundSynth.ctx) g.soundSynth.init();
      startOverlay?.classList.remove('hidden');
    }
  });

  // Keyboard controls (fallback when mouse isn't available)
  document.addEventListener('keydown', function(e) {
    const key = e.key.toLowerCase();
    
    switch(key) {
      case 'arrowleft': case 'a': game.inputState.steer -= 1; break;
      case 'arrowright': case 'd': game.inputState.steer += 1; break;
      case 'w': case 'arrowup': 
        if (!game.inputState.pitch) game.inputState.pitch = 0;
        game.inputState.pitch = Math.max(game.inputState.pitch || 0, 2); break;
      case 's': case 'arrowdown': 
        game.inputState.pitch -= 1.5; break;
      case 'shift': case ' ':
        if (game.state.running) {
          game._keyBoost = true;
          game.inputState.boost = true;
        } else if (!window._gameInstance?.state.running) {
          game.soundSynth.init();
        } break;
      case 'escape':
        if (!window._gameInstance?.state.running) {
          game.start();
        } else if (window._gameInstance?.state.running) {
          document.exitPointerLock();
          game.soundSynth.stop();
        } break;
    }

    // Clamp steer from keyboard
    if (key !== 'escape' && window._gameInstance) {
      game.inputState.steer = Math.max(-1, Math.min(1, game.inputState.steer));
      
      // Reset pitch toward zero with decay when no keys held
      if (!['shift',' ','w','s','arrowup','arrowdown'].includes(key)) {
        game.inputState.pitch *= 0.95;
      }
    }
  });

  document.addEventListener('keyup', function(e) {
    const key = e.key.toLowerCase();
    if (key === 'shift' || key === ' ') {
      game._keyBoost = false;
      game.inputState.boost = false;
    } else if (['arrowleft','a','arrowright','d'].includes(key)) {
      // Steer decays naturally via player physics friction
    }
  });

  // Hide HUD until game starts  
  const hudEls = document.querySelectorAll('#hud-top, #hud-speedometer, #hud-energy-bar');
  for (const el of hudEls) el.classList.add('hidden');

  // Start the game on load after a brief delay to show start screen  
  setTimeout(function() {
    if (!window._gameInstance) return;
    
    const canvas = document.getElementById('game-canvas');
    canvas.addEventListener('click', function(e) {
      e.preventDefault();
      
      // If not locked, lock it
      if (!document.pointerLockElement) {
        canvas.requestPointerLock();
      } else {
        // Already locked - just start the game
      }
      
      window._gameInstance.start();
      
      for (const el of hudEls) el.classList.remove('hidden');
      document.getElementById('start-overlay').classList.add('hidden');
    }, true);
  }, 500);

  return game;
};

// ── const: soundSynth ──
// Global instance - created before main runs
const soundSynth = new SoundSynth();

// ── main ──
// Main entry point - called automatically after all units are defined
document.addEventListener('DOMContentLoaded', function() { main(); });
</script>
</body>
</html>