← Canyon Run results

Canyon Run

Minimax m3 · middle

◈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

Human review · Jul 17, 2026 · rubric 1

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

Visual design
4 / 5
UX / feel
3 / 5
Works
2 / 5
Prompt spirit
2 / 5
Delight
4 / 5
Wow factor because of visuals and ship design, but basically unplayable

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 55,415 bytes · SHA-256 453b4dee7264
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SUNRIDGE // Canyon Racer</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Rajdhani:wght@300;500;700&display=swap" rel="stylesheet">
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0210; font-family: 'Orbitron', sans-serif; color: #fff; }
  canvas { display: block; }

  #hud {
    position: fixed; top: 0; left: 0; width: 100%; height: 100%;
    pointer-events: none; z-index: 10;
    text-shadow: 0 0 8px rgba(255,107,53,0.6);
  }

  /* Top-left score */
  #score-panel { position: absolute; top: 22px; left: 26px; }
  #score-label { font-size: 10px; letter-spacing: 4px; opacity: 0.6; font-weight: 400; }
  #score-value { font-size: 32px; font-weight: 900; letter-spacing: 2px; color: #ffe4b5;
    text-shadow: 0 0 12px #ff6b35, 0 0 24px rgba(255,107,53,0.4); }

  /* Top-right altitude/speed info */
  #info-panel { position: absolute; top: 22px; right: 26px; text-align: right; }
  .info-row { font-size: 11px; letter-spacing: 2px; opacity: 0.7; margin-bottom: 4px; font-weight: 500; }
  .info-val { font-size: 18px; font-weight: 700; color: #b0e0ff; text-shadow: 0 0 8px #00aaff; }

  /* Center multiplier */
  #multiplier-wrap { position: absolute; top: 60px; left: 50%; transform: translateX(-50%); text-align: center; }
  #multiplier { font-size: 56px; font-weight: 900; line-height: 1;
    background: linear-gradient(180deg, #fff 0%, #ff6b35 100%);
    -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
    filter: drop-shadow(0 0 12px rgba(255,107,53,0.8));
    transition: transform 0.08s ease-out; display: inline-block; }
  #multiplier.pulse { transform: scale(1.4); }
  #multiplier-label { font-size: 9px; letter-spacing: 5px; opacity: 0.5; margin-top: 2px; }

  /* Bottom energy bar */
  #energy-container {
    position: absolute; bottom: 36px; left: 50%; transform: translateX(-50%);
    width: 340px; height: 6px;
    background: rgba(255,255,255,0.06);
    border: 1px solid rgba(255,107,53,0.35);
    clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%, 4px 50%);
  }
  #energy-bar {
    height: 100%; width: 100%;
    background: linear-gradient(90deg, #ff2266 0%, #ff6b35 40%, #ffcc00 100%);
    box-shadow: 0 0 12px #ff6b35, inset 0 0 6px rgba(255,255,255,0.3);
    transition: width 0.15s linear;
  }
  #energy-label { position: absolute; bottom: 48px; left: 50%; transform: translateX(-50%);
    font-size: 9px; letter-spacing: 4px; opacity: 0.6; }

  /* Bottom-right speedometer */
  #speedo-wrap { position: absolute; bottom: 50px; right: 26px; }
  #speedo-label { font-size: 9px; letter-spacing: 3px; opacity: 0.5; margin-bottom: 4px; text-align: right; }
  #speedo-bar { width: 220px; height: 2px; background: rgba(255,255,255,0.12); position: relative; }
  #speedo-fill { position: absolute; left: 0; top: 0; height: 100%;
    background: linear-gradient(90deg, #00ddff, #00ffff);
    box-shadow: 0 0 8px #00ffff; transition: width 0.1s linear; }
  #speedo-tick { position: absolute; top: -3px; width: 1px; height: 8px; background: rgba(255,107,53,0.5); }

  /* Turbo meter */
  #turbo-wrap { position: absolute; bottom: 30px; right: 26px; }
  #turbo-label { font-size: 9px; letter-spacing: 3px; opacity: 0.5; margin-bottom: 4px; text-align: right; color: #ff5599; }
  #turbo-bar { width: 220px; height: 3px; background: rgba(255,255,255,0.08); position: relative; overflow: hidden; }
  #turbo-fill { position: absolute; left: 0; top: 0; height: 100%; width: 0%;
    background: linear-gradient(90deg, #ff2266, #ff66cc);
    box-shadow: 0 0 10px #ff2266; transition: width 0.1s linear; }
  #turbo-ready { font-size: 10px; color: #ff66cc; opacity: 0; letter-spacing: 2px; margin-top: 3px; text-align: right;
    text-shadow: 0 0 8px #ff2266; animation: none; }
  #turbo-ready.show { opacity: 1; animation: blink 0.8s infinite; }
  @keyframes blink { 0%,50% { opacity: 1; } 51%,100% { opacity: 0.3; } }

  /* Near-miss popup */
  #nearmiss-popup { position: absolute; left: 50%; top: 45%; transform: translate(-50%, -50%);
    font-size: 28px; font-weight: 900; letter-spacing: 3px; color: #ffee00;
    text-shadow: 0 0 16px #ff8800, 0 0 32px #ff4400;
    opacity: 0; pointer-events: none; transition: opacity 0.3s, transform 0.5s; }
  #nearmiss-popup.show { opacity: 1; transform: translate(-50%, -80%); }

  /* Overlays */
  .overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%;
    display: flex; align-items: center; justify-content: center; z-index: 20;
    background: radial-gradient(ellipse at center, rgba(20,5,40,0.85) 0%, rgba(5,0,15,0.95) 100%);
    backdrop-filter: blur(8px); }
  .overlay.hidden { display: none; }

  .panel { text-align: center; max-width: 600px; padding: 40px; }
  .title { font-size: 64px; font-weight: 900; letter-spacing: 8px; margin-bottom: 8px;
    background: linear-gradient(180deg, #ffeeaa 0%, #ff6b35 50%, #aa1144 100%);
    -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
    filter: drop-shadow(0 0 20px rgba(255,107,53,0.6)); }
  .subtitle { font-family: 'Rajdhani', sans-serif; font-size: 16px; letter-spacing: 6px; opacity: 0.6; margin-bottom: 40px; }
  .controls { font-family: 'Rajdhani', sans-serif; font-size: 15px; line-height: 2; opacity: 0.8; margin-bottom: 40px; }
  .controls span { color: #00ddff; font-weight: 700; }
  .btn { font-family: 'Orbitron', sans-serif; font-size: 16px; font-weight: 700; letter-spacing: 4px;
    padding: 16px 40px; background: transparent; color: #ff6b35; border: 1px solid #ff6b35;
    cursor: pointer; pointer-events: auto; transition: all 0.2s; text-transform: uppercase; }
  .btn:hover { background: #ff6b35; color: #0a0210; box-shadow: 0 0 24px #ff6b35; }

  .gameover-stats { display: flex; gap: 40px; justify-content: center; margin: 30px 0 40px; }
  .stat { text-align: center; }
  .stat-label { font-size: 10px; letter-spacing: 3px; opacity: 0.5; margin-bottom: 8px; }
  .stat-value { font-size: 36px; font-weight: 900; color: #ffe4b5; text-shadow: 0 0 12px #ff6b35; }

  /* Damage flash */
  #damage-flash { position: fixed; top: 0; left: 0; width: 100%; height: 100%;
    background: radial-gradient(ellipse at center, transparent 40%, rgba(255,0,50,0.4) 100%);
    opacity: 0; pointer-events: none; z-index: 9; transition: opacity 0.3s; }
  #damage-flash.show { opacity: 1; }

  /* Vignette */
  #vignette { position: fixed; top: 0; left: 0; width: 100%; height: 100%;
    background: radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,0.6) 100%);
    pointer-events: none; z-index: 8; }
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="vignette"></div>
<div id="damage-flash"></div>

<div id="hud">
  <div id="score-panel">
    <div id="score-label">SCORE</div>
    <div id="score-value">0</div>
  </div>

  <div id="info-panel">
    <div class="info-row">ALT <span class="info-val" id="alt-val">0</span></div>
    <div class="info-row">VEL <span class="info-val" id="vel-val">0</span></div>
  </div>

  <div id="multiplier-wrap">
    <div id="multiplier">×1.0</div>
    <div id="multiplier-label">PROXIMITY MULTIPLIER</div>
  </div>

  <div id="energy-label">FUSION CORE</div>
  <div id="energy-container"><div id="energy-bar"></div></div>

  <div id="speedo-wrap">
    <div id="speedo-label">VELOCITY</div>
    <div id="speedo-bar"><div id="speedo-fill"></div><div id="speedo-tick" style="left:75%"></div></div>
  </div>

  <div id="turbo-wrap">
    <div id="turbo-label">TURBO</div>
    <div id="turbo-bar"><div id="turbo-fill"></div></div>
    <div id="turbo-ready">▲ BOOST READY [SPACE] ▲</div>
  </div>

  <div id="nearmiss-popup">NEAR MISS</div>
</div>

<div id="start-screen" class="overlay">
  <div class="panel">
    <div class="title">SUNRIDGE</div>
    <div class="subtitle">CANYON RACER</div>
    <div class="controls">
      <span>A</span> / <span>D</span> &nbsp; BANK LEFT / RIGHT<br>
      <span>W</span> / <span>S</span> &nbsp; PITCH UP / DOWN<br>
      <span>SPACE</span> &nbsp; ACTIVATE TURBO BOOST<br>
      <span>R</span> &nbsp; RESTART
    </div>
    <div style="font-family:'Rajdhani'; font-size:13px; opacity:0.5; margin-bottom:30px; letter-spacing:2px;">
      SKIM THE CLIFFS • CHASE THE LIGHT • STAY FUELED
    </div>
    <button class="btn" id="start-btn">INITIATE</button>
  </div>
</div>

<div id="gameover-screen" class="overlay hidden">
  <div class="panel">
    <div class="title" style="font-size:48px;">SYSTEM FAILURE</div>
    <div class="subtitle">CRAFT DESTROYED</div>
    <div class="gameover-stats">
      <div class="stat"><div class="stat-label">FINAL SCORE</div><div class="stat-value" id="final-score">0</div></div>
      <div class="stat"><div class="stat-label">MAX VELOCITY</div><div class="stat-value" id="max-speed">0</div></div>
      <div class="stat"><div class="stat-label">DISTANCE</div><div class="stat-value" id="distance">0</div></div>
    </div>
    <button class="btn" id="restart-btn">REINITIATE</button>
  </div>
</div>

<script type="importmap">
{
  "imports": {
    "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
    "three/addons/": "https://unpkg.com/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 { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

// ============================================================
// CONSTANTS
// ============================================================
const CHUNK_LENGTH = 220;
const CHUNK_COUNT = 5;
const CHUNK_AHEAD = 3;     // how many chunks ahead of player to keep
const SEGMENTS_PER_CHUNK = 44;
const HEIGHT_SEGS = 8;
const WALL_BOTTOM = -45;
const WALL_TOP = 60;
const BASE_HALF_WIDTH = 32;

const PLAYER_RADIUS = 1.8;
const COLLISION_MARGIN = 0.8;
const NEAR_MISS_DIST = 12;
const NEAR_MISS_NEAR = 4;
const BASE_SPEED = 95;
const MAX_SPEED_BOOST = 1.65;
const LATERAL_ACCEL = 70;
const VERTICAL_ACCEL = 55;
const LATERAL_DAMP = 2.5;
const VERTICAL_DAMP = 2.0;
const ROLL_LERP = 6;
const PITCH_LERP = 5;

const ENERGY_DECAY = 4.2;  // per second
const ORB_ENERGY = 28;
const ORB_SCORE = 75;
const COLLISION_ENERGY_LOSS = 35;

const TURBO_MAX = 100;
const TURBO_GAIN_PER_SEC = 12;
const TURBO_COST_PER_SEC = 55;

// ============================================================
// GAME STATE
// ============================================================
const state = {
  started: false,
  gameOver: false,
  score: 0,
  multiplier: 1,
  multiplierTimer: 0,
  energy: 100,
  turbo: 35,
  boosting: false,
  wasBoosting: false,
  baseSpeed: BASE_SPEED,
  currentSpeed: BASE_SPEED,
  maxSpeed: 0,
  distance: 0,
  elapsed: 0,
  cameraShake: 0,
  shakeDecay: 0,
  lastNearMissTime: 0,
  nearMissFlash: 0,
  nearMissText: 0,
  speedLinesIntensity: 0,
};

// Player physics state
const player = {
  x: 0, y: 18, z: 0,
  vx: 0, vy: 0,
  roll: 0, pitch: 0,
  targetRoll: 0, targetPitch: 0,
};
// Will be set to pathX(0) on first frame in startGame

// Input
const keys = {};
let mouseDown = false;

// Three.js objects
let scene, camera, renderer, composer, bloomPass;
let playerGroup, playerMesh, cockpitMesh, engineGlow;
let voidPlane;
let sunMesh;
let sunLight;

// Chunks
const chunks = [];

// Game objects
const orbs = [];
const monoliths = [];
const speedParticles = [];
const trailParticles = [];
const burstParticles = [];

// Audio
let audioCtx = null;
let engineOsc = null, engineOsc2 = null, engineFilter = null, engineGain = null, engineGain2 = null;
let engineFilter2 = null;

// Seeded random
function mulberry32(seed) {
  return function() {
    let t = seed += 0x6D2B79F5;
    t = Math.imul(t ^ t >>> 15, t | 1);
    t ^= t + Math.imul(t ^ t >>> 7, t | 61);
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}

// ============================================================
// PATH FUNCTIONS
// ============================================================
function pathX(z) {
  return Math.sin(z * 0.0072) * 32
       + Math.sin(z * 0.0183 + 1.3) * 14
       + Math.cos(z * 0.0411 + 2.1) * 6
       + Math.sin(z * 0.0031) * 8;
}
function pathHalfWidth(z) {
  // Range roughly 16 to 50
  const base = BASE_HALF_WIDTH;
  const wide = Math.sin(z * 0.0041) * 10;
  const narrow = -Math.abs(Math.sin(z * 0.0193 + 0.7)) * 16;
  const squeeze = -Math.pow(Math.max(0, Math.sin(z * 0.0067 + 1.8)), 4) * 14;
  return Math.max(14, base + wide + narrow + squeeze);
}
function pathTangent(z) {
  const dx = (pathX(z + 1) - pathX(z - 1)) * 0.5;
  return Math.atan2(dx, 1);
}

// ============================================================
// AUDIO
// ============================================================
function initAudio() {
  if (audioCtx) return;
  try {
    audioCtx = new (window.AudioContext || window.webkitAudioContext)();

    // Engine - dual oscillator with filter
    engineOsc = audioCtx.createOscillator();
    engineOsc.type = 'sawtooth';
    engineOsc.frequency.value = 55;

    engineOsc2 = audioCtx.createOscillator();
    engineOsc2.type = 'sine';
    engineOsc2.frequency.value = 82;

    engineFilter = audioCtx.createBiquadFilter();
    engineFilter.type = 'lowpass';
    engineFilter.frequency.value = 400;
    engineFilter.Q.value = 3;

    engineFilter2 = audioCtx.createBiquadFilter();
    engineFilter2.type = 'lowpass';
    engineFilter2.frequency.value = 250;

    engineGain = audioCtx.createGain();
    engineGain.gain.value = 0.0;

    engineGain2 = audioCtx.createGain();
    engineGain2.gain.value = 0.0;

    const masterGain = audioCtx.createGain();
    masterGain.gain.value = 0.35;

    engineOsc.connect(engineFilter);
    engineOsc2.connect(engineFilter2);
    engineFilter.connect(engineGain);
    engineFilter2.connect(engineGain2);
    engineGain.connect(masterGain);
    engineGain2.connect(masterGain);
    masterGain.connect(audioCtx.destination);

    engineOsc.start();
    engineOsc2.start();
  } catch(e) { console.warn('Audio init failed', e); }
}

function updateEngineSound() {
  if (!audioCtx || !engineOsc) return;
  const speedNorm = Math.max(0, Math.min(1, (state.currentSpeed - BASE_SPEED) / (BASE_SPEED * (MAX_SPEED_BOOST - 1))));
  const baseFreq = 50 + speedNorm * 80;
  engineOsc.frequency.setTargetAtTime(baseFreq, audioCtx.currentTime, 0.05);
  engineOsc2.frequency.setTargetAtTime(baseFreq * 1.5, audioCtx.currentTime, 0.05);
  engineFilter.frequency.setTargetAtTime(300 + speedNorm * 1200, audioCtx.currentTime, 0.05);
  engineFilter2.frequency.setTargetAtTime(200 + speedNorm * 800, audioCtx.currentTime, 0.05);
  const active = state.started && !state.gameOver;
  const vol = active ? (0.10 + speedNorm * 0.07) : 0;
  const vol2 = active ? (0.05 + speedNorm * 0.04) : 0;
  engineGain.gain.setTargetAtTime(vol, audioCtx.currentTime, 0.1);
  engineGain2.gain.setTargetAtTime(vol2, audioCtx.currentTime, 0.1);
}

function playCollect() {
  if (!audioCtx) return;
  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();
  osc.type = 'sine';
  osc.frequency.setValueAtTime(880, audioCtx.currentTime);
  osc.frequency.exponentialRampToValueAtTime(1760, audioCtx.currentTime + 0.08);
  gain.gain.setValueAtTime(0.25, audioCtx.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.25);
  osc.connect(gain); gain.connect(audioCtx.destination);
  osc.start(); osc.stop(audioCtx.currentTime + 0.3);

  // Harmonic
  const osc2 = audioCtx.createOscillator();
  const gain2 = audioCtx.createGain();
  osc2.type = 'triangle';
  osc2.frequency.setValueAtTime(1760, audioCtx.currentTime);
  osc2.frequency.exponentialRampToValueAtTime(3520, audioCtx.currentTime + 0.08);
  gain2.gain.setValueAtTime(0.12, audioCtx.currentTime);
  gain2.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.2);
  osc2.connect(gain2); gain2.connect(audioCtx.destination);
  osc2.start(); osc2.stop(audioCtx.currentTime + 0.25);
}

function playNearMiss() {
  if (!audioCtx) return;
  const dur = 0.4;
  const bufferSize = Math.floor(audioCtx.sampleRate * dur);
  const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
  const data = buffer.getChannelData(0);
  for (let i = 0; i < bufferSize; i++) {
    data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize);
  }
  const noise = audioCtx.createBufferSource();
  noise.buffer = buffer;
  const filter = audioCtx.createBiquadFilter();
  filter.type = 'bandpass';
  filter.frequency.setValueAtTime(3000, audioCtx.currentTime);
  filter.frequency.exponentialRampToValueAtTime(400, audioCtx.currentTime + dur);
  filter.Q.value = 4;
  const gain = audioCtx.createGain();
  gain.gain.setValueAtTime(0.35, audioCtx.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + dur);
  noise.connect(filter); filter.connect(gain); gain.connect(audioCtx.destination);
  noise.start();
}

function playBoost() {
  if (!audioCtx) return;
  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();
  osc.type = 'sawtooth';
  osc.frequency.setValueAtTime(150, audioCtx.currentTime);
  osc.frequency.exponentialRampToValueAtTime(600, audioCtx.currentTime + 0.35);
  const filter = audioCtx.createBiquadFilter();
  filter.type = 'lowpass';
  filter.frequency.setValueAtTime(800, audioCtx.currentTime);
  filter.frequency.exponentialRampToValueAtTime(2000, audioCtx.currentTime + 0.35);
  gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.45);
  osc.connect(filter); filter.connect(gain); gain.connect(audioCtx.destination);
  osc.start(); osc.stop(audioCtx.currentTime + 0.5);
}

function playCrash() {
  if (!audioCtx) return;
  const dur = 1.2;
  const bufferSize = Math.floor(audioCtx.sampleRate * dur);
  const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
  const data = buffer.getChannelData(0);
  for (let i = 0; i < bufferSize; i++) {
    data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (audioCtx.sampleRate * 0.35));
  }
  const noise = audioCtx.createBufferSource();
  noise.buffer = buffer;
  const filter = audioCtx.createBiquadFilter();
  filter.type = 'lowpass';
  filter.frequency.setValueAtTime(1500, audioCtx.currentTime);
  filter.frequency.exponentialRampToValueAtTime(100, audioCtx.currentTime + dur);
  const gain = audioCtx.createGain();
  gain.gain.value = 0.5;
  noise.connect(filter); filter.connect(gain); gain.connect(audioCtx.destination);
  noise.start();

  // Sub boom
  const osc = audioCtx.createOscillator();
  const g2 = audioCtx.createGain();
  osc.type = 'sine';
  osc.frequency.setValueAtTime(80, audioCtx.currentTime);
  osc.frequency.exponentialRampToValueAtTime(20, audioCtx.currentTime + 0.6);
  g2.gain.setValueAtTime(0.5, audioCtx.currentTime);
  g2.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.7);
  osc.connect(g2); g2.connect(audioCtx.destination);
  osc.start(); osc.stop(audioCtx.currentTime + 0.8);
}

// ============================================================
// THREE.JS SETUP
// ============================================================
function initThree() {
  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x1a0833);
  scene.fog = new THREE.Fog(0xff7755, 90, 320);

  camera = new THREE.PerspectiveCamera(72, window.innerWidth / window.innerHeight, 0.5, 800);
  camera.position.set(0, 20, -10);
  camera.lookAt(0, 18, 10);

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

  // Post-processing
  composer = new EffectComposer(renderer);
  composer.addPass(new RenderPass(scene, camera));
  bloomPass = new UnrealBloomPass(
    new THREE.Vector2(window.innerWidth, window.innerHeight),
    0.85, 0.55, 0.55
  );
  composer.addPass(bloomPass);
  composer.addPass(new OutputPass());

  // Lights
  const ambient = new THREE.AmbientLight(0x4a2060, 0.5);
  scene.add(ambient);

  sunLight = new THREE.DirectionalLight(0xff8855, 1.8);
  sunLight.position.set(80, 40, 100);
  sunLight.castShadow = true;
  sunLight.shadow.mapSize.set(2048, 2048);
  sunLight.shadow.camera.near = 10;
  sunLight.shadow.camera.far = 400;
  sunLight.shadow.camera.left = -100;
  sunLight.shadow.camera.right = 100;
  sunLight.shadow.camera.top = 100;
  sunLight.shadow.camera.bottom = -100;
  sunLight.shadow.bias = -0.0005;
  sunLight.target.position.set(0, 0, 0);
  scene.add(sunLight);
  scene.add(sunLight.target);

  const fillLight = new THREE.DirectionalLight(0x6066aa, 0.4);
  fillLight.position.set(-60, 20, 50);
  scene.add(fillLight);

  const rimLight = new THREE.DirectionalLight(0xff3388, 0.6);
  rimLight.position.set(0, 30, -100);
  scene.add(rimLight);

  // Sky dome with gradient
  createSkyDome();

  // Sun disc
  createSun();

  // Void plane (far below)
  const voidGeom = new THREE.PlaneGeometry(2500, 2500);
  const voidMat = new THREE.MeshPhongMaterial({ color: 0x080218, shininess: 0 });
  voidPlane = new THREE.Mesh(voidGeom, voidMat);
  voidPlane.rotation.x = -Math.PI / 2;
  voidPlane.position.y = -80;
  voidPlane.receiveShadow = true;
  scene.add(voidPlane);

  // Distant mountains
  for (let i = 0; i < 30; i++) {
    const angle = (i / 30) * Math.PI * 2;
    const dist = 350 + Math.random() * 100;
    const h = 50 + Math.random() * 80;
    const w = 30 + Math.random() * 40;
    const geom = new THREE.ConeGeometry(w, h, 4);
    const mat = new THREE.MeshBasicMaterial({ color: 0x1a0830 });
    const m = new THREE.Mesh(geom, mat);
    m.position.set(Math.cos(angle) * dist, h/2 - 40, Math.sin(angle) * dist);
    scene.add(m);
  }

  // Player
  createPlayer();
}

function createSkyDome() {
  const skyGeom = new THREE.SphereGeometry(500, 32, 16);
  const skyMat = new THREE.ShaderMaterial({
    uniforms: {
      topColor: { value: new THREE.Color(0x0a0220) },
      midColor: { value: new THREE.Color(0x6b1a4a) },
      bottomColor: { value: new THREE.Color(0xff6b35) },
      sunColor: { value: new THREE.Color(0xffcc66) },
      sunDir: { value: new THREE.Vector3(0.3, 0.15, 1).normalize() }
    },
    vertexShader: `
      varying vec3 vWorldPos;
      void main() {
        vec4 wp = modelMatrix * vec4(position, 1.0);
        vWorldPos = wp.xyz;
        gl_Position = projectionMatrix * viewMatrix * wp;
      }
    `,
    fragmentShader: `
      uniform vec3 topColor, midColor, bottomColor, sunColor, sunDir;
      varying vec3 vWorldPos;
      void main() {
        vec3 dir = normalize(vWorldPos);
        float h = dir.y;
        vec3 col;
        if (h > 0.0) {
          float t = pow(h, 0.6);
          col = mix(midColor, topColor, t);
        } else {
          col = mix(midColor, bottomColor, pow(-h, 0.4) * 0.7);
        }
        // Sun glow
        float sunDot = max(0.0, dot(dir, sunDir));
        col += sunColor * pow(sunDot, 12.0) * 0.6;
        col += sunColor * pow(sunDot, 4.0) * 0.15;
        // Horizon glow
        float horizon = 1.0 - abs(h);
        col += vec3(1.0, 0.5, 0.2) * pow(horizon, 6.0) * 0.3;
        gl_FragColor = vec4(col, 1.0);
      }
    `,
    side: THREE.BackSide,
    depthWrite: false,
    fog: false
  });
  const sky = new THREE.Mesh(skyGeom, skyMat);
  scene.add(sky);
}

function createSun() {
  const sunGeom = new THREE.CircleGeometry(40, 32);
  const sunMat = new THREE.MeshBasicMaterial({
    color: 0xffeeaa,
    transparent: true,
    opacity: 0.95,
    fog: false
  });
  sunMesh = new THREE.Mesh(sunGeom, sunMat);
  sunMesh.position.set(150, 50, 400);
  scene.add(sunMesh);

  // Sun glow ring
  const glowGeom = new THREE.CircleGeometry(80, 32);
  const glowMat = new THREE.ShaderMaterial({
    uniforms: { color: { value: new THREE.Color(0xff7733) } },
    vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
    fragmentShader: `uniform vec3 color; varying vec2 vUv; void main() { float d = length(vUv - 0.5) * 2.0; gl_FragColor = vec4(color, pow(1.0 - d, 3.0) * 0.6); }`,
    transparent: true,
    depthWrite: false,
    fog: false
  });
  const glow = new THREE.Mesh(glowGeom, glowMat);
  glow.position.copy(sunMesh.position);
  glow.position.z -= 0.1;
  scene.add(glow);
}

function createPlayer() {
  playerGroup = new THREE.Group();

  // Main body - low poly wedge using octahedron stretched
  const bodyGeom = new THREE.OctahedronGeometry(1, 0);
  bodyGeom.scale(0.7, 0.28, 1.7);
  const bodyMat = new THREE.MeshPhongMaterial({
    color: 0xc8d4e0,
    flatShading: true,
    shininess: 120,
    specular: 0x6688aa
  });
  playerMesh = new THREE.Mesh(bodyGeom, bodyMat);
  playerMesh.castShadow = true;
  playerGroup.add(playerMesh);

  // Wings
  const wingMat = new THREE.MeshPhongMaterial({
    color: 0x9aa8b8,
    flatShading: true,
    shininess: 80
  });

  // Right wing
  const wgR = new THREE.BoxGeometry(2.2, 0.08, 1.2);
  wgR.translate(1.2, -0.1, -0.5);
  const wR = new THREE.Mesh(wgR, wingMat);
  wR.castShadow = true;
  playerGroup.add(wR);

  // Left wing
  const wgL = new THREE.BoxGeometry(2.2, 0.08, 1.2);
  wgL.translate(-1.2, -0.1, -0.5);
  const wL = new THREE.Mesh(wgL, wingMat);
  wL.castShadow = true;
  playerGroup.add(wL);

  // Wing tips (angled fins)
  const finGeomR = new THREE.BoxGeometry(0.08, 0.5, 0.8);
  finGeomR.translate(2.2, 0.1, -0.4);
  const finR = new THREE.Mesh(finGeomR, wingMat);
  finR.castShadow = true;
  playerGroup.add(finR);
  const finGeomL = new THREE.BoxGeometry(0.08, 0.5, 0.8);
  finGeomL.translate(-2.2, 0.1, -0.4);
  const finL = new THREE.Mesh(finGeomL, wingMat);
  finL.castShadow = true;
  playerGroup.add(finL);

  // Cockpit
  const cockpitGeom = new THREE.SphereGeometry(0.3, 6, 4, 0, Math.PI * 2, 0, Math.PI / 2);
  cockpitGeom.translate(0, 0.25, 0.3);
  const cockpitMat = new THREE.MeshPhongMaterial({
    color: 0x00ddff,
    emissive: 0x00aadd,
    emissiveIntensity: 0.7,
    flatShading: true,
    shininess: 100
  });
  cockpitMesh = new THREE.Mesh(cockpitGeom, cockpitMat);
  playerGroup.add(cockpitMesh);

  // Underside accent
  const accentGeom = new THREE.BoxGeometry(0.4, 0.05, 2.5);
  accentGeom.translate(0, -0.22, -0.2);
  const accentMat = new THREE.MeshBasicMaterial({ color: 0x00ffff });
  const accent = new THREE.Mesh(accentGeom, accentMat);
  playerGroup.add(accent);

  // Engine glow (two planes for stereo effect)
  const eg1 = new THREE.PlaneGeometry(0.4, 0.25);
  const em1 = new THREE.MeshBasicMaterial({ color: 0x00ffff, transparent: true, opacity: 0.95 });
  engineGlow = new THREE.Mesh(eg1, em1);
  engineGlow.position.set(0, 0, -1.7);
  playerGroup.add(engineGlow);

  const eg2 = new THREE.PlaneGeometry(0.6, 0.4);
  const em2 = new THREE.MeshBasicMaterial({ color: 0x66ffff, transparent: true, opacity: 0.5 });
  const engineGlow2 = new THREE.Mesh(eg2, em2);
  engineGlow2.position.set(0, 0, -1.8);
  playerGroup.add(engineGlow2);

  playerGroup.position.set(0, 18, 0);
  scene.add(playerGroup);
}

// ============================================================
// CHUNK GENERATION
// ============================================================
function createChunkGeometry(zStart, zEnd) {
  const segments = SEGMENTS_PER_CHUNK;
  const heightSegs = HEIGHT_SEGS;
  const positionsL = [], colorsL = [], indicesL = [];
  const positionsR = [], colorsR = [], indicesR = [];

  for (let i = 0; i <= segments; i++) {
    const t = i / segments;
    const z = zStart + t * (zEnd - zStart);
    const cx = pathX(z);
    const hw = pathHalfWidth(z);

    for (let j = 0; j <= heightSegs; j++) {
      const ht = j / heightSegs;
      const y = WALL_BOTTOM + ht * (WALL_TOP - WALL_BOTTOM);

      // Left wall
      const lNoise1 = Math.sin(z * 0.4 + j * 0.7) * 1.2;
      const lNoise2 = Math.sin(z * 0.13 + j * 0.3 + 1.2) * 2.0;
      const lx = cx - hw - lNoise1 - lNoise2 * 0.5;
      positionsL.push(lx, y, z);

      // Right wall
      const rNoise1 = Math.cos(z * 0.35 + j * 0.5 + 2.1) * 1.2;
      const rNoise2 = Math.sin(z * 0.11 + j * 0.4 + 0.8) * 2.0;
      const rx = cx + hw + rNoise1 + rNoise2 * 0.5;
      positionsR.push(rx, y, z);

      // Color: dark base, orange highlight at top, with some variation
      const colorBase = new THREE.Color(0x0a0218);
      const colorMid = new THREE.Color(0x3a1240);
      const colorHigh = new THREE.Color(0xff6b35);
      const colorPeak = new THREE.Color(0xffcc88);

      let c;
      if (ht < 0.4) {
        c = colorBase.clone().lerp(colorMid, ht / 0.4);
      } else if (ht < 0.75) {
        c = colorMid.clone().lerp(colorHigh, (ht - 0.4) / 0.35);
      } else {
        c = colorHigh.clone().lerp(colorPeak, (ht - 0.75) / 0.25);
      }

      // Random brightness variation
      const v = 0.85 + Math.random() * 0.3;
      c.multiplyScalar(v);

      // Cracks/strata - horizontal bands
      const strata = Math.sin(y * 0.5 + z * 0.02) * 0.5 + 0.5;
      c.lerp(new THREE.Color(0xff5533), strata * 0.1 * (1 - ht));

      colorsL.push(c.r, c.g, c.b);
      colorsR.push(c.r * 0.95, c.g * 0.9, c.b);
    }
  }

  // Build indices for quads
  for (let i = 0; i < segments; i++) {
    for (let j = 0; j < heightSegs; j++) {
      const a = i * (heightSegs + 1) + j;
      const b = a + 1;
      const c = a + (heightSegs + 1);
      const d = c + 1;

      // Left wall: normal points right (+X), so CCW from right
      indicesL.push(a, c, b);
      indicesL.push(b, c, d);

      // Right wall: normal points left (-X), so CCW from left
      indicesR.push(a, b, c);
      indicesR.push(b, d, c);
    }
  }

  const geomL = new THREE.BufferGeometry();
  geomL.setAttribute('position', new THREE.Float32BufferAttribute(positionsL, 3));
  geomL.setAttribute('color', new THREE.Float32BufferAttribute(colorsL, 3));
  geomL.setIndex(indicesL);
  geomL.computeVertexNormals();

  const geomR = new THREE.BufferGeometry();
  geomR.setAttribute('position', new THREE.Float32BufferAttribute(positionsR, 3));
  geomR.setAttribute('color', new THREE.Float32BufferAttribute(colorsR, 3));
  geomR.setIndex(indicesR);
  geomR.computeVertexNormals();

  return { left: geomL, right: geomR };
}

function createChunk(index) {
  const zStart = index * CHUNK_LENGTH;
  const zEnd = zStart + CHUNK_LENGTH;
  const group = new THREE.Group();
  group.position.z = zStart;

  const wallMat = new THREE.MeshPhongMaterial({
    vertexColors: true,
    flatShading: true,
    shininess: 15
  });

  const geoms = createChunkGeometry(zStart, zEnd);
  const leftMesh = new THREE.Mesh(geoms.left, wallMat);
  leftMesh.receiveShadow = true;
  leftMesh.castShadow = true;
  group.add(leftMesh);

  const rightMesh = new THREE.Mesh(geoms.right, wallMat);
  rightMesh.receiveShadow = true;
  rightMesh.castShadow = true;
  group.add(rightMesh);

  // Seeded random for chunk content
  const seed = Math.floor((index + 1) * 9999);
  const rng = mulberry32(seed);

  // Add monoliths (obstacles) - not in first 2 chunks
  const chunkMonoliths = [];
  if (index >= 1) {
    const numMonoliths = 2 + Math.floor(rng() * 4);
    for (let i = 0; i < numMonoliths; i++) {
      const mz = zStart + 20 + rng() * (CHUNK_LENGTH - 40);
      const mx = pathX(mz) + (rng() - 0.5) * (pathHalfWidth(mz) * 1.4);
      const my = -10 + rng() * 25;
      const mh = 15 + rng() * 35;
      const mw = 2 + rng() * 4;
      const md = 2 + rng() * 4;
      const isTall = rng() > 0.5;

      const mGeom = isTall
        ? new THREE.BoxGeometry(mw, mh, md)
        : new THREE.OctahedronGeometry(mw, 0);

      const mMat = new THREE.MeshPhongMaterial({
        color: 0x1a0825,
        emissive: 0x441122,
        emissiveIntensity: 0.4,
        flatShading: true,
        shininess: 40
      });
      const mMesh = new THREE.Mesh(mGeom, mMat);
      mMesh.position.set(mx, my, mz);
      mMesh.castShadow = true;
      mMesh.receiveShadow = true;
      group.add(mMesh);

      // Glowing edge
      if (rng() > 0.5) {
        const edgeGeom = new THREE.BoxGeometry(mw * 1.02, mh * 1.02, md * 1.02);
        const edgeMat = new THREE.MeshBasicMaterial({
          color: 0xff3366,
          wireframe: true,
          transparent: true,
          opacity: 0.6
        });
        const edge = new THREE.Mesh(edgeGeom, edgeMat);
        edge.position.copy(mMesh.position);
        group.add(edge);
      }

      chunkMonoliths.push({
        mesh: mMesh,
        x: mx, y: my, z: mz,
        radius: Math.max(mw, md) * 0.7 + PLAYER_RADIUS,
        triggered: false
      });
    }
  }
  monoliths.push(...chunkMonoliths);

  // Add orbs
  const chunkOrbs = [];
  const numOrbs = 3 + Math.floor(rng() * 5);
  for (let i = 0; i < numOrbs; i++) {
    const oz = zStart + 15 + (i + rng()) * (CHUNK_LENGTH / numOrbs);
    const hw = pathHalfWidth(oz);
    const ox = pathX(oz) + (rng() - 0.5) * hw * 0.7;
    const oy = 5 + rng() * 30;
    const orb = createOrb(ox, oy, oz);
    group.add(orb.mesh);
    chunkOrbs.push(orb);
  }
  orbs.push(...chunkOrbs);

  scene.add(group);

  return { index, zStart, zEnd, group, leftMesh, rightMesh };
}

function createOrb(x, y, z) {
  const geom = new THREE.IcosahedronGeometry(0.8, 0);
  const mat = new THREE.MeshBasicMaterial({
    color: 0xffee44
  });
  const mesh = new THREE.Mesh(geom, mat);

  // Glow sprite
  const glowGeom = new THREE.PlaneGeometry(4, 4);
  const glowMat = new THREE.ShaderMaterial({
    uniforms: { color: { value: new THREE.Color(0xffcc00) } },
    vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
    fragmentShader: `uniform vec3 color; varying vec2 vUv; void main() { float d = length(vUv - 0.5) * 2.0; gl_FragColor = vec4(color, pow(1.0 - d, 2.5) * 0.8); }`,
    transparent: true,
    depthWrite: false
  });
  const glow = new THREE.Mesh(glowGeom, glowMat);
  mesh.add(glow);

  mesh.position.set(x, y, z);

  return {
    mesh,
    x, y, z,
    baseY: y,
    phase: Math.random() * Math.PI * 2,
    collected: false
  };
}

function initChunks() {
  for (let i = 0; i < CHUNK_COUNT; i++) {
    chunks.push(createChunk(i));
  }
}

function recycleChunks() {
  // Find chunks behind the player and move them ahead
  for (let chunk of chunks) {
    if (player.z > chunk.zEnd + CHUNK_LENGTH) {
      // Remove old content
      // Remove monoliths and orbs from this chunk
      for (let i = monoliths.length - 1; i >= 0; i--) {
        if (monoliths[i].z >= chunk.zStart && monoliths[i].z < chunk.zEnd) {
          monoliths.splice(i, 1);
        }
      }
      for (let i = orbs.length - 1; i >= 0; i--) {
        if (orbs[i].z >= chunk.zStart && orbs[i].z < chunk.zEnd && !orbs[i].collected) {
          scene.remove(orbs[i].mesh);
          orbs.splice(i, 1);
        }
      }
      // Dispose geometries
      chunk.leftMesh.geometry.dispose();
      chunk.rightMesh.geometry.dispose();
      scene.remove(chunk.group);

      // Find max chunk index
      const maxIndex = Math.max(...chunks.map(c => c.index)) + 1;

      // Recreate
      const newChunk = createChunk(maxIndex);
      Object.assign(chunk, newChunk);
    }
  }
}

// ============================================================
// PARTICLE SYSTEMS
// ============================================================
function initParticles() {
  // Speed lines - dust/debris flying past
  for (let i = 0; i < 80; i++) {
    const geom = new THREE.PlaneGeometry(0.08, 1.5 + Math.random() * 2);
    const mat = new THREE.MeshBasicMaterial({
      color: new THREE.Color().setHSL(0.08 + Math.random() * 0.05, 0.8, 0.6),
      transparent: true,
      opacity: 0.0,
      side: THREE.DoubleSide,
      depthWrite: false
    });
    const mesh = new THREE.Mesh(geom, mat);
    scene.add(mesh);
    speedParticles.push({
      mesh,
      x: 0, y: 0, z: 0,
      speed: 1 + Math.random() * 0.5,
      reset() {
        this.x = player.x + (Math.random() - 0.5) * 120;
        this.y = Math.random() * 50 - 10;
        this.z = player.z + 100 + Math.random() * 200;
        this.mesh.position.set(this.x, this.y, this.z);
        this.mesh.material.opacity = 0;
        this.mesh.scale.y = 0.5 + Math.random() * 1.5;
      }
    });
    speedParticles[i].reset();
  }

  // Trail particles
  for (let i = 0; i < 40; i++) {
    const geom = new THREE.PlaneGeometry(0.6, 0.6);
    const mat = new THREE.MeshBasicMaterial({
      color: 0x00ffff,
      transparent: true,
      opacity: 0,
      depthWrite: false
    });
    const mesh = new THREE.Mesh(geom, mat);
    scene.add(mesh);
    trailParticles.push({
      mesh,
      life: 0, maxLife: 0.6,
      active: false
    });
  }
  let trailIdx = 0;
  window._emitTrail = function() {
    const p = trailParticles[trailIdx];
    p.mesh.position.set(
      player.x + (Math.random() - 0.5) * 0.5,
      player.y - 0.1,
      player.z - 1.8
    );
    p.mesh.material.opacity = 0.8;
    p.life = p.maxLife;
    p.active = true;
    p.mesh.scale.setScalar(0.8 + Math.random() * 0.4);
    trailIdx = (trailIdx + 1) % trailParticles.length;
  };

  // Burst particles (for collection, collision)
  for (let i = 0; i < 60; i++) {
    const geom = new THREE.PlaneGeometry(0.3, 0.3);
    const mat = new THREE.MeshBasicMaterial({
      color: 0xffaa00,
      transparent: true,
      opacity: 0,
      depthWrite: false
    });
    const mesh = new THREE.Mesh(geom, mat);
    scene.add(mesh);
    burstParticles.push({
      mesh,
      vx: 0, vy: 0, vz: 0,
      life: 0, maxLife: 0.8,
      active: false
    });
  }
  let burstIdx = 0;
  window._emitBurst = function(x, y, z, color, count) {
    for (let i = 0; i < count; i++) {
      const p = burstParticles[burstIdx];
      p.mesh.position.set(x, y, z);
      p.mesh.material.color.set(color);
      p.vx = (Math.random() - 0.5) * 20;
      p.vy = (Math.random() - 0.5) * 20;
      p.vz = (Math.random() - 0.5) * 20;
      p.life = p.maxLife;
      p.maxLife = 0.4 + Math.random() * 0.6;
      p.active = true;
      p.mesh.material.opacity = 1;
      p.mesh.scale.setScalar(0.5 + Math.random() * 0.8);
      burstIdx = (burstIdx + 1) % burstParticles.length;
    }
  };
}

function updateParticles(dt) {
  // Speed lines
  const speedNorm = Math.max(0, Math.min(1, (state.currentSpeed - BASE_SPEED) / (BASE_SPEED * (MAX_SPEED_BOOST - 1))));
  const intensity = Math.max(0.15, Math.min(1, speedNorm * 1.5));
  for (let p of speedParticles) {
    const distTraveled = state.currentSpeed * dt * p.speed;
    p.z -= distTraveled;
    if (p.z < player.z - 30) {
      p.reset();
    }
    // Fade in/out
    const dz = p.z - player.z;
    let op = 0;
    if (dz > 0 && dz < 200) {
      op = Math.min(1, dz / 50) * intensity;
    } else if (dz < 0 && dz > -30) {
      op = (1 + dz / 30) * intensity;
    }
    p.mesh.material.opacity = op * 0.6;
    // Stretch with speed
    p.mesh.scale.y = 1 + speedNorm * 4;
  }

  // Trail
  for (let p of trailParticles) {
    if (!p.active) continue;
    p.life -= dt;
    if (p.life <= 0) {
      p.active = false;
      p.mesh.material.opacity = 0;
      continue;
    }
    p.mesh.material.opacity = (p.life / p.maxLife) * 0.7;
    p.mesh.scale.setScalar(p.life / p.maxLife * 1.2);
    // Particles stay in place while player moves forward (creates trail effect)
  }

  // Emit trail
  if (state.started && !state.gameOver) {
    if (Math.random() < 0.8) window._emitTrail();
  }

  // Burst
  for (let p of burstParticles) {
    if (!p.active) continue;
    p.life -= dt;
    if (p.life <= 0) {
      p.active = false;
      p.mesh.material.opacity = 0;
      continue;
    }
    p.mesh.position.x += p.vx * dt;
    p.mesh.position.y += p.vy * dt;
    p.mesh.position.z += p.vz * dt;
    p.mesh.material.opacity = p.life / p.maxLife;
    p.mesh.scale.setScalar(0.3 + p.life / p.maxLife * 0.7);
  }
}

// ============================================================
// INPUT
// ============================================================
function initInput() {
  window.addEventListener('keydown', e => {
    keys[e.code] = true;
    if (e.code === 'KeyR' && state.gameOver) restart();
    if (e.code === 'Space') e.preventDefault();
  });
  window.addEventListener('keyup', e => { keys[e.code] = false; });
  window.addEventListener('mousedown', () => { mouseDown = true; });
  window.addEventListener('mouseup', () => { mouseDown = false; });
  window.addEventListener('resize', onResize);
}

function onResize() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
  composer.setSize(window.innerWidth, window.innerHeight);
  bloomPass.setSize(window.innerWidth, window.innerHeight);
}

// ============================================================
// PLAYER UPDATE
// ============================================================
function updatePlayer(dt) {
  if (!state.started || state.gameOver) return;

  // Lateral control
  if (keys['KeyA'] || keys['ArrowLeft']) {
    player.targetRoll = 0.85;
    player.vx -= LATERAL_ACCEL * dt;
  } else if (keys['KeyD'] || keys['ArrowRight']) {
    player.targetRoll = -0.85;
    player.vx += LATERAL_ACCEL * dt;
  } else {
    player.targetRoll = 0;
  }

  // Vertical control
  if (keys['KeyW'] || keys['ArrowUp']) {
    player.targetPitch = -0.45;
    player.vy += VERTICAL_ACCEL * dt;
  } else if (keys['KeyS'] || keys['ArrowDown']) {
    player.targetPitch = 0.45;
    player.vy -= VERTICAL_ACCEL * dt;
  } else {
    player.targetPitch = 0;
  }

  // Boost
  state.boosting = (keys['Space'] || mouseDown) && state.turbo > 0;
  if (state.boosting) {
    state.turbo = Math.max(0, state.turbo - TURBO_COST_PER_SEC * dt);
    if (state.turbo === 0) state.boosting = false;
  } else {
    state.turbo = Math.min(TURBO_MAX, state.turbo + TURBO_GAIN_PER_SEC * dt);
  }
  // Play boost sound on rising edge
  if (state.boosting && !state.wasBoosting) {
    playBoost();
  }
  state.wasBoosting = state.boosting;

  // Damping
  player.vx *= Math.pow(0.5, dt * LATERAL_DAMP);
  player.vy *= Math.pow(0.5, dt * VERTICAL_DAMP);

  // Speed
  const difficultyMult = 1 + Math.min(state.score / 8000, 0.8);
  state.baseSpeed = BASE_SPEED * difficultyMult;
  state.currentSpeed = state.boosting ? state.baseSpeed * MAX_SPEED_BOOST : state.baseSpeed;
  state.maxSpeed = Math.max(state.maxSpeed, state.currentSpeed);

  // Update position
  player.x += player.vx * dt;
  player.y += player.vy * dt;
  player.z += state.currentSpeed * dt;

  // Clamp vertical
  player.y = Math.max(-15, Math.min(50, player.y));

  // Smooth roll/pitch
  player.roll += (player.targetRoll - player.roll) * Math.min(1, dt * ROLL_LERP);
  player.pitch += (player.targetPitch - player.pitch) * Math.min(1, dt * PITCH_LERP);

  // Yaw follows path tangent (slight lean into turns)
  const targetYaw = -pathTangent(player.z) * 0.35;
  playerGroup.rotation.y += (targetYaw - playerGroup.rotation.y) * Math.min(1, dt * 3);

  // Apply visual transforms
  playerGroup.position.set(player.x, player.y, player.z);
  playerGroup.rotation.z = player.roll;
  playerGroup.rotation.x = player.pitch;

  // Engine glow scaling with speed
  const speedRatio = state.currentSpeed / state.baseSpeed;
  engineGlow.scale.set(1 + speedRatio * 0.5, 1 + speedRatio * 0.8, 1);
  engineGlow.material.opacity = 0.7 + speedRatio * 0.3;
  if (state.boosting) {
    engineGlow.material.color.setHex(0xff66ff);
  } else {
    engineGlow.material.color.setHex(0x00ffff);
  }
}

// ============================================================
// COLLISIONS & NEAR MISS
// ============================================================
function checkCollisions() {
  if (!state.started || state.gameOver) return;

  // Wall collision - check left and right wall at player Z
  const cx = pathX(player.z);
  const hw = pathHalfWidth(player.z);
  const leftWallX = cx - hw;
  const rightWallX = cx + hw;

  // Approximate surface noise effect (walls can protrude inward up to ~2.2 units)
  const surfaceAdjust = 2.5;
  const distToLeft = (player.x - PLAYER_RADIUS) - (leftWallX + surfaceAdjust);
  const distToRight = (rightWallX - surfaceAdjust) - (player.x + PLAYER_RADIUS);
  const minWallDist = Math.min(distToLeft, distToRight);

  // Collision
  if (minWallDist < -COLLISION_MARGIN) {
    onCollision('wall');
    return;
  }

  // Near miss
  if (minWallDist > 0 && minWallDist < NEAR_MISS_DIST) {
    if (state.elapsed - state.lastNearMissTime > 0.3) {
      onNearMiss(minWallDist, 'wall');
    }
  }

  // Monolith collisions
  for (let m of monoliths) {
    const dz = m.z - player.z;
    if (Math.abs(dz) > 30) continue;
    const dx = m.x - player.x;
    const dy = m.y - player.y;
    const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);
    if (dist < m.radius) {
      onCollision('monolith');
      return;
    } else if (dist < m.radius + NEAR_MISS_DIST && !m.triggered) {
      onNearMiss(dist - m.radius, 'monolith');
      m.triggered = true;
    }
    // Reset triggered flag when passed
    if (dz < -15) m.triggered = false;
  }

  // Orb collection
  for (let i = orbs.length - 1; i >= 0; i--) {
    const orb = orbs[i];
    if (orb.collected) continue;
    const dz = orb.z - player.z;
    if (Math.abs(dz) > 10) continue;
    const dx = orb.x - player.x;
    const dy = orb.y - player.y;
    const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);
    if (dist < 3) {
      collectOrb(orb, i);
    }
  }
}

function onNearMiss(distance, type) {
  state.lastNearMissTime = state.elapsed;
  const closeness = Math.max(0, NEAR_MISS_DIST - distance) / NEAR_MISS_DIST;
  const bonus = Math.floor(50 + closeness * 250);
  state.score += bonus * state.multiplier;
  state.multiplier = Math.min(5, state.multiplier + 0.15 + closeness * 0.3);
  state.multiplierTimer = 1.5;

  // Visual feedback
  state.nearMissFlash = 0.4;
  state.nearMissText = 1.0;
  state.cameraShake = Math.max(state.cameraShake, 0.15 + closeness * 0.3);

  // Audio
  playNearMiss();

  // Multiplier pulse
  const multEl = document.getElementById('multiplier');
  multEl.classList.add('pulse');
  setTimeout(() => multEl.classList.remove('pulse'), 100);

  // Show popup
  const popup = document.getElementById('nearmiss-popup');
  popup.classList.add('show');
  setTimeout(() => popup.classList.remove('show'), 400);
}

function collectOrb(orb, index) {
  orb.collected = true;
  state.energy = Math.min(100, state.energy + ORB_ENERGY);
  state.score += ORB_SCORE * state.multiplier;
  state.turbo = Math.min(TURBO_MAX, state.turbo + 8);

  // Visual
  window._emitBurst(orb.x, orb.y, orb.z, 0xffee44, 12);

  // Audio
  playCollect();

  // Remove
  scene.remove(orb.mesh);
  orb.mesh.geometry.dispose();
  orb.mesh.material.dispose();
  orbs.splice(index, 1);
}

function onCollision(type) {
  if (state.gameOver) return;
  state.energy -= COLLISION_ENERGY_LOSS;
  state.cameraShake = 0.8;
  state.turbo = Math.max(0, state.turbo - 30);

  // Visual
  document.getElementById('damage-flash').classList.add('show');
  setTimeout(() => document.getElementById('damage-flash').classList.remove('show'), 300);

  // Burst
  window._emitBurst(player.x, player.y, player.z, 0xff3366, 25);
  playCrash();

  if (state.energy <= 0) {
    gameOver();
  }
}

// ============================================================
// CAMERA
// ============================================================
function updateCamera(dt) {
  if (!state.started) {
    // Intro camera - slowly orbit
    const t = state.elapsed * 0.2;
    camera.position.set(
      Math.cos(t) * 25,
      25 + Math.sin(t * 0.5) * 5,
      player.z - 15
    );
    camera.lookAt(player.x * 0.5, player.y, player.z + 20);
    return;
  }

  // Follow camera
  const targetX = player.x;
  const targetY = player.y + 2.5;
  const targetZ = player.z - 9;

  const lerp = Math.min(1, dt * 5);
  camera.position.x += (targetX - camera.position.x) * lerp;
  camera.position.y += (targetY - camera.position.y) * lerp;
  camera.position.z += (targetZ - camera.position.z) * Math.min(1, dt * 8);

  // Look ahead and slightly toward velocity
  const lookX = player.x + player.vx * 0.08;
  const lookY = player.y + 0.5;
  const lookZ = player.z + 15;
  camera.lookAt(lookX, lookY, lookZ);

  // Bank camera slightly with roll
  camera.rotation.z = player.roll * 0.3;

  // FOV widens with speed
  const speedNorm = (state.currentSpeed - state.baseSpeed) / (state.baseSpeed * (MAX_SPEED_BOOST - 1));
  const targetFOV = 72 + speedNorm * 28 + (state.boosting ? 5 : 0);
  camera.fov += (targetFOV - camera.fov) * Math.min(1, dt * 2);
  camera.updateProjectionMatrix();

  // Camera shake
  if (state.cameraShake > 0.01) {
    const s = state.cameraShake;
    camera.position.x += (Math.random() - 0.5) * s;
    camera.position.y += (Math.random() - 0.5) * s;
    camera.position.z += (Math.random() - 0.5) * s * 0.5;
    state.cameraShake *= Math.pow(0.05, dt);
  }
}

// ============================================================
// HUD UPDATE
// ============================================================
function updateHUD() {
  document.getElementById('score-value').textContent = Math.floor(state.score).toLocaleString();
  document.getElementById('alt-val').textContent = Math.floor(player.y + 50);
  document.getElementById('vel-val').textContent = Math.floor(state.currentSpeed);

  // Multiplier
  document.getElementById('multiplier').textContent = '×' + state.multiplier.toFixed(1);

  // Energy
  document.getElementById('energy-bar').style.width = Math.max(0, state.energy) + '%';

  // Speedometer
  const speedPct = Math.min(100, (state.currentSpeed / (BASE_SPEED * MAX_SPEED_BOOST)) * 100);
  document.getElementById('speedo-fill').style.width = speedPct + '%';

  // Turbo
  const turboPct = (state.turbo / TURBO_MAX) * 100;
  document.getElementById('turbo-fill').style.width = turboPct + '%';
  const turboReady = document.getElementById('turbo-ready');
  if (turboPct > 50) {
    turboReady.classList.add('show');
  } else {
    turboReady.classList.remove('show');
  }
}

// ============================================================
// GAME FLOW
// ============================================================
function startGame() {
  initAudio();
  if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();

  state.started = true;
  state.gameOver = false;
  state.score = 0;
  state.multiplier = 1;
  state.multiplierTimer = 0;
  state.energy = 100;
  state.turbo = 35;
  state.boosting = false;
  state.wasBoosting = false;
  state.maxSpeed = 0;
  state.elapsed = 0;

  player.x = pathX(0); player.y = 18; player.z = 0;
  player.vx = 0; player.vy = 0;
  player.roll = 0; player.pitch = 0;
  player.targetRoll = 0; player.targetPitch = 0;

  document.getElementById('start-screen').classList.add('hidden');
  document.getElementById('gameover-screen').classList.add('hidden');
}

function gameOver() {
  state.gameOver = true;
  state.boosting = false;
  document.getElementById('final-score').textContent = Math.floor(state.score).toLocaleString();
  document.getElementById('max-speed').textContent = Math.floor(state.maxSpeed);
  document.getElementById('distance').textContent = Math.floor(player.z) + 'm';
  setTimeout(() => {
    document.getElementById('gameover-screen').classList.remove('hidden');
  }, 800);
}

function restart() {
  // Clean up chunks and objects
  for (let chunk of chunks) {
    chunk.leftMesh.geometry.dispose();
    chunk.rightMesh.geometry.dispose();
    scene.remove(chunk.group);
  }
  chunks.length = 0;
  for (let orb of orbs) {
    scene.remove(orb.mesh);
  }
  orbs.length = 0;
  monoliths.length = 0;

  initChunks();
  startGame();
}

// ============================================================
// MAIN LOOP
// ============================================================
let lastTime = performance.now();
function animate() {
  requestAnimationFrame(animate);
  const now = performance.now();
  const dt = Math.min(0.05, (now - lastTime) / 1000);
  lastTime = now;

  if (state.started && !state.gameOver) {
    state.elapsed += dt;

    // Difficulty scaling
    const difficultyMult = 1 + Math.min(state.score / 8000, 0.8);

    // Energy decay
    state.energy -= ENERGY_DECAY * dt * difficultyMult;
    if (state.energy <= 0) {
      gameOver();
    }

    // Score (passive)
    state.score += dt * 15 * state.multiplier;

    // Multiplier decay
    state.multiplierTimer -= dt;
    if (state.multiplierTimer <= 0) {
      state.multiplier = Math.max(1, state.multiplier - dt * 0.6);
    }

    // Update player
    updatePlayer(dt);

    // Check collisions
    checkCollisions();

    // Recycle chunks
    recycleChunks();
  } else if (!state.started) {
    state.elapsed += dt;
  }

  // Always update camera (for intro orbit)
  updateCamera(dt);

  // Update orbs (rotation/animation)
  for (let orb of orbs) {
    if (!orb.collected) {
      orb.mesh.rotation.y += dt * 3;
      orb.mesh.rotation.x += dt * 1.5;
      orb.mesh.position.y = orb.baseY + Math.sin(state.elapsed * 2 + orb.phase) * 0.8;
    }
  }

  // Update void plane position
  voidPlane.position.z = player.z;
  voidPlane.position.x = player.x;

  // Update sun light to follow player (for shadow camera)
  if (sunLight) {
    sunLight.position.set(player.x + 80, 40, player.z + 100);
    sunLight.target.position.set(player.x, 0, player.z + 20);
    sunLight.target.updateMatrixWorld();
  }

  // Update sun position (distant, follows)
  if (sunMesh) {
    sunMesh.position.z = player.z + 350;
    sunMesh.position.x = player.x + 120;
    sunMesh.lookAt(camera.position);
  }

  // Update particles
  updateParticles(dt);

  // Update engine sound
  updateEngineSound();

  // Update HUD
  updateHUD();

  // Render
  composer.render();
}

// ============================================================
// INIT
// ============================================================
initThree();
initChunks();
initParticles();
initInput();

document.getElementById('start-btn').addEventListener('click', startGame);
document.getElementById('restart-btn').addEventListener('click', restart);

// Auto-pause audio when not focused
window.addEventListener('blur', () => {
  if (engineGain) engineGain.gain.setTargetAtTime(0, audioCtx.currentTime, 0.1);
});
window.addEventListener('focus', () => {
  if (engineGain && state.started && !state.gameOver) {
    // Engine sound will resume naturally on next frame
  }
});

animate();
</script>
</body>
</html>
<!-- agent-meta {"model":"minimax/minimax-m3","provider":"openrouter","persona":"middle","sessionId":"7e9f1761-aaf4-4904-aab2-6d809194e089","tokensIn":1595147,"tokensOut":57307,"tokensTotal":1652454,"turns":29,"toolCalls":28,"failedToolCalls":1,"timestamp":"2026-07-13T22:07:05.189Z"} -->