← Canyon Run results

Canyon Run

Minimax m3 · full

◈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
3 / 5
Prompt spirit
4 / 5
Delight
4 / 5
imperfect but beautiful; inverted steering controls

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 57,676 bytes · SHA-256 ab41c569325c
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>CANYON RACER</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
  body { font-family: 'Helvetica Neue', 'Arial', sans-serif; color: #fff; user-select: none; }
  #game { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; }
  canvas { display: block; }

  #hud { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }

  .hud-corner { position: absolute; padding: 24px 32px; }
  .hud-label { font-size: 9px; letter-spacing: 5px; opacity: 0.45; font-weight: 300; margin-bottom: 6px; }

  #score-block { top: 0; left: 0; }
  #score-value { font-size: 38px; font-weight: 100; letter-spacing: 3px; line-height: 1; }
  #score-value::after { content: ''; display: inline-block; width: 0; }

  #energy-block { top: 0; right: 0; width: 260px; }
  .energy-bar-bg { width: 100%; height: 3px; background: rgba(255,255,255,0.08); position: relative; overflow: hidden; }
  .energy-bar { width: 100%; height: 100%; background: linear-gradient(90deg, #00e5ff, #b066ff); box-shadow: 0 0 14px #00e5ff, 0 0 4px #00e5ff; transform-origin: left; transition: transform 0.15s linear; }
  .energy-num { font-size: 11px; letter-spacing: 3px; opacity: 0.6; margin-top: 4px; text-align: right; }

  #multiplier-block { position: absolute; right: 32px; top: 50%; transform: translateY(-50%); text-align: right; }
  #multiplier { font-size: 84px; font-weight: 200; font-style: italic; letter-spacing: -2px; line-height: 1; text-shadow: 0 0 30px rgba(255,140,40,0.6); transition: transform 0.12s ease-out, text-shadow 0.2s; }
  #multiplier.pulse { transform: scale(1.25); text-shadow: 0 0 50px rgba(255,180,60,1), 0 0 20px rgba(255,100,20,0.8); }
  #multiplier-label { font-size: 9px; letter-spacing: 5px; opacity: 0.45; margin-top: 6px; }
  .mult-bar { width: 80px; height: 2px; background: rgba(255,255,255,0.1); margin-left: auto; margin-top: 8px; }
  .mult-bar-fill { width: 100%; height: 100%; background: linear-gradient(90deg, #ff7722, #ffcc44); box-shadow: 0 0 6px #ff7722; transform-origin: right; transition: transform 0.1s linear; }

  #speedo-block { position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); width: 520px; text-align: center; }
  .speedo-track { width: 100%; height: 1px; background: rgba(255,255,255,0.15); position: relative; margin-top: 8px; }
  .speedo-fill { position: absolute; left: 0; top: 0; height: 3px; background: linear-gradient(90deg, #ffaa22, #ffee66); box-shadow: 0 0 12px #ffaa22; width: 0%; transform: translateY(-1px); transition: width 0.1s linear; }
  .speedo-tick { position: absolute; top: 4px; width: 1px; height: 6px; background: rgba(255,255,255,0.2); }
  .speedo-tick.major { height: 10px; background: rgba(255,255,255,0.4); }
  .speedo-num { font-size: 11px; letter-spacing: 4px; opacity: 0.5; }
  .speedo-num .unit { opacity: 0.5; font-size: 9px; }

  #turbo-block { position: absolute; bottom: 70px; left: 50%; transform: translateX(-50%); width: 200px; text-align: center; opacity: 0; transition: opacity 0.3s; }
  #turbo-block.visible { opacity: 1; }
  .turbo-track { width: 100%; height: 2px; background: rgba(255,255,255,0.08); margin-top: 4px; }
  .turbo-fill { width: 0%; height: 100%; background: linear-gradient(90deg, #ff3300, #ffaa00); box-shadow: 0 0 10px #ff5500; transition: width 0.1s linear; }

  .near-msg { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 14px; letter-spacing: 6px; opacity: 0; font-weight: 300; color: #ffdd66; text-shadow: 0 0 20px #ff8800; pointer-events: none; }
  .near-msg.show { animation: nearMsg 0.6s ease-out; }
  @keyframes nearMsg { 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.8); } 30% { opacity: 1; transform: translate(-50%, -50%) scale(1.1); } 100% { opacity: 0; transform: translate(-50%, -60%) scale(1); } }

  #boost-flash { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; opacity: 0; background: radial-gradient(ellipse at center, transparent 40%, rgba(255,170,40,0.3) 100%); }
  #boost-flash.active { opacity: 1; }

  #damage-flash { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; opacity: 0; background: radial-gradient(ellipse at center, transparent 30%, rgba(255,0,40,0.5) 100%); }

  .overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: auto; z-index: 20; }
  #intro { background: radial-gradient(ellipse at center, rgba(20,5,40,0.85) 0%, rgba(0,0,0,0.95) 100%); }
  #intro h1 { font-size: 88px; font-weight: 100; letter-spacing: 16px; margin-bottom: 8px; background: linear-gradient(180deg, #ffdd88 0%, #ff6622 60%, #aa1144 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
  #intro .subtitle { font-size: 13px; letter-spacing: 8px; opacity: 0.5; margin-bottom: 50px; }
  .controls-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px 40px; margin-bottom: 50px; font-size: 11px; letter-spacing: 3px; }
  .control-item { display: flex; align-items: center; gap: 12px; opacity: 0.7; }
  .key { display: inline-block; padding: 6px 12px; border: 1px solid rgba(255,255,255,0.25); font-size: 10px; letter-spacing: 2px; min-width: 40px; text-align: center; }
  .btn { background: transparent; border: 1px solid rgba(255,170,60,0.6); color: #ffaa44; padding: 16px 48px; font-size: 12px; letter-spacing: 6px; cursor: pointer; transition: all 0.2s; font-family: inherit; }
  .btn:hover { background: #ffaa44; color: #000; box-shadow: 0 0 30px rgba(255,170,60,0.4); }

  #game-over { background: rgba(0,0,0,0.9); display: none; }
  #game-over.show { display: flex; }
  #game-over h1 { font-size: 72px; font-weight: 100; letter-spacing: 10px; margin-bottom: 8px; color: #ff4444; text-shadow: 0 0 30px rgba(255,40,40,0.4); }
  #game-over .subtitle { font-size: 11px; letter-spacing: 6px; opacity: 0.5; margin-bottom: 50px; }
  .stats { display: flex; gap: 80px; margin-bottom: 50px; }
  .stat { text-align: center; }
  .stat-label { font-size: 9px; letter-spacing: 5px; opacity: 0.4; margin-bottom: 10px; }
  .stat-value { font-size: 56px; font-weight: 200; color: #ffcc44; line-height: 1; }
  .stat-value .small { font-size: 24px; opacity: 0.6; }

  .scanline { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; background: repeating-linear-gradient(0deg, transparent 0px, transparent 2px, rgba(255,255,255,0.012) 2px, rgba(255,255,255,0.012) 4px); z-index: 5; }
</style>
</head>
<body>
<div id="game"></div>
<div id="boost-flash"></div>
<div id="damage-flash"></div>
<div class="scanline"></div>

<div id="hud">
  <div class="hud-corner" id="score-block">
    <div class="hud-label">SCORE</div>
    <div id="score-value">0</div>
  </div>

  <div class="hud-corner" id="energy-block">
    <div class="hud-label">ENERGY</div>
    <div class="energy-bar-bg"><div class="energy-bar" id="energy-bar"></div></div>
    <div class="energy-num" id="energy-num">100%</div>
  </div>

  <div id="multiplier-block">
    <div id="multiplier">1.0×</div>
    <div id="multiplier-label">RISK MULTIPLIER</div>
    <div class="mult-bar"><div class="mult-bar-fill" id="mult-bar-fill"></div></div>
  </div>

  <div id="turbo-block">
    <div class="hud-label" style="text-align:center;margin:0;">TURBO</div>
    <div class="turbo-track"><div class="turbo-fill" id="turbo-fill"></div></div>
  </div>

  <div id="speedo-block">
    <div class="speedo-num"><span id="speed-num">0</span> <span class="unit">M/S</span></div>
    <div class="speedo-track">
      <div class="speedo-fill" id="speedo-fill"></div>
    </div>
  </div>

  <div class="near-msg" id="near-msg">NEAR MISS</div>
</div>

<div class="overlay" id="intro">
  <h1>CANYON RACER</h1>
  <div class="subtitle">A HIGH-SPEED PROCEDURAL DESCENT</div>
  <div class="controls-grid">
    <div class="control-item"><span class="key">A</span><span class="key">D</span><span>BANK</span></div>
    <div class="control-item"><span class="key">W</span><span class="key">S</span><span>PITCH</span></div>
    <div class="control-item"><span class="key">SPACE</span><span>BOOST</span></div>
    <div class="control-item"><span class="key">SHIFT</span><span>BRAKE</span></div>
  </div>
  <button class="btn" id="start-btn">LAUNCH</button>
</div>

<div class="overlay" id="game-over">
  <h1>END OF RUN</h1>
  <div class="subtitle">THE CANYON CLAIMS ANOTHER</div>
  <div class="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 SPEED</div>
      <div class="stat-value"><span id="final-speed">0</span><span class="small"> M/S</span></div>
    </div>
    <div class="stat">
      <div class="stat-label">DISTANCE</div>
      <div class="stat-value"><span id="final-dist">0</span><span class="small"> M</span></div>
    </div>
  </div>
  <button class="btn" id="restart-btn">RESTART</button>
</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 { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

// ============================================================
// CONFIG
// ============================================================
const CFG = {
  CHUNK_SIZE: 70,
  NUM_CHUNKS: 14,
  POINTS_PER_CHUNK: 14,
  PLAYER_RADIUS: 1.1,
  WALL_HEIGHT: 32,
  CANYON_BASE_HALF: 22,
  NEAR_MISS_RANGE: 4.0,
  ORB_RADIUS: 0.9,
  SEED: 12345,
};

const $ = (id) => document.getElementById(id);

// ============================================================
// SEEDED RANDOM
// ============================================================
function mulberry32(a) {
  return function() {
    a |= 0; a = a + 0x6D2B79F5 | 0;
    let t = a;
    t = Math.imul(t ^ t >>> 15, t | 1);
    t ^= t + Math.imul(t ^ t >>> 7, t | 61);
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}
const rng = mulberry32(CFG.SEED);
function noise1D(x) {
  // smooth value noise
  const i = Math.floor(x);
  const f = x - i;
  const h = (n) => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); };
  const a = h(i), b = h(i + 1);
  const u = f * f * (3 - 2 * f);
  return a * (1 - u) + b * u;
}
function fbm(x) {
  let v = 0, a = 1, f = 1, max = 0;
  for (let i = 0; i < 4; i++) { v += noise1D(x * f) * a; max += a; a *= 0.5; f *= 2; }
  return v / max;
}

// ============================================================
// CANYON SHAPE FUNCTIONS
// ============================================================
function getCanyonAt(t) {
  // t = forward distance (positive going forward)
  // centerline offset
  const cx = Math.sin(t * 0.0042) * 9 + Math.sin(t * 0.0117) * 4 + Math.cos(t * 0.0023) * 5;
  // base width with smooth variation
  const baseHalf = CFG.CANYON_BASE_HALF + Math.sin(t * 0.008) * 4 - Math.cos(t * 0.006) * 2;
  // squeeze regions (deterministic periodic narrows)
  const squeezePhase = Math.sin(t * 0.0035 + 1.2);
  const squeeze = squeezePhase > 0.78 ? (squeezePhase - 0.78) * 90 : 0;
  const halfWidth = Math.max(8.5, baseHalf - squeeze);
  // ground height undulation
  const groundY = Math.sin(t * 0.007) * 0.6 + fbm(t * 0.02) * 0.4;
  return { centerX: cx, halfWidth, groundY };
}

// ============================================================
// AUDIO ENGINE (Web Audio API procedural SFX)
// ============================================================
class AudioEngine {
  constructor() { this.ctx = null; this.master = null; this.engineGain = null; this.engineOsc = null; this.engineOsc2 = null; }
  init() {
    if (this.ctx) return;
    this.ctx = new (window.AudioContext || window.webkitAudioContext)();
    this.master = this.ctx.createGain();
    this.master.gain.value = 0.5;
    this.master.connect(this.ctx.destination);
    // Continuous engine hum
    this.engineOsc = this.ctx.createOscillator();
    this.engineOsc.type = 'sawtooth';
    this.engineOsc.frequency.value = 55;
    this.engineOsc2 = this.ctx.createOscillator();
    this.engineOsc2.type = 'square';
    this.engineOsc2.frequency.value = 82;
    const engineFilter = this.ctx.createBiquadFilter();
    engineFilter.type = 'lowpass';
    engineFilter.frequency.value = 800;
    engineFilter.Q.value = 4;
    this.engineGain = this.ctx.createGain();
    this.engineGain.gain.value = 0;
    this.engineOsc.connect(engineFilter);
    this.engineOsc2.connect(engineFilter);
    engineFilter.connect(this.engineGain);
    this.engineGain.connect(this.master);
    this.engineOsc.start();
    this.engineOsc2.start();
  }
  setEngine(speed, boosting) {
    if (!this.ctx) return;
    const t = this.ctx.currentTime;
    this.engineOsc.frequency.setTargetAtTime(50 + speed * 0.6, t, 0.08);
    this.engineOsc2.frequency.setTargetAtTime(75 + speed * 0.9, t, 0.08);
    this.engineGain.gain.setTargetAtTime(boosting ? 0.18 : 0.07, t, 0.1);
  }
  ping(freq = 1) {
    if (!this.ctx) return;
    const t = this.ctx.currentTime;
    const osc = this.ctx.createOscillator();
    const g = this.ctx.createGain();
    osc.type = 'sine';
    const base = 700 + freq * 200;
    osc.frequency.setValueAtTime(base, t);
    osc.frequency.exponentialRampToValueAtTime(base * 2, t + 0.08);
    g.gain.setValueAtTime(0.25, t);
    g.gain.exponentialRampToValueAtTime(0.001, t + 0.18);
    osc.connect(g); g.connect(this.master);
    osc.start(t); osc.stop(t + 0.2);
    // Harmonic
    const osc2 = this.ctx.createOscillator();
    const g2 = this.ctx.createGain();
    osc2.type = 'triangle';
    osc2.frequency.setValueAtTime(base * 1.5, t);
    osc2.frequency.exponentialRampToValueAtTime(base * 3, t + 0.08);
    g2.gain.setValueAtTime(0.1, t);
    g2.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
    osc2.connect(g2); g2.connect(this.master);
    osc2.start(t); osc2.stop(t + 0.16);
  }
  whoosh(intensity = 1) {
    if (!this.ctx) return;
    const t = this.ctx.currentTime;
    const dur = 0.35;
    const bufSize = Math.floor(this.ctx.sampleRate * dur);
    const buf = this.ctx.createBuffer(1, bufSize, this.ctx.sampleRate);
    const data = buf.getChannelData(0);
    for (let i = 0; i < bufSize; i++) {
      data[i] = (Math.random() * 2 - 1) * (1 - i / bufSize);
    }
    const src = this.ctx.createBufferSource();
    src.buffer = buf;
    const filter = this.ctx.createBiquadFilter();
    filter.type = 'bandpass';
    filter.frequency.setValueAtTime(3500, t);
    filter.frequency.exponentialRampToValueAtTime(200, t + dur);
    filter.Q.value = 3;
    const g = this.ctx.createGain();
    g.gain.setValueAtTime(0.4 * intensity, t);
    g.gain.exponentialRampToValueAtTime(0.001, t + dur);
    src.connect(filter); filter.connect(g); g.connect(this.master);
    src.start(t);
  }
  boost() {
    if (!this.ctx) return;
    const t = this.ctx.currentTime;
    const osc = this.ctx.createOscillator();
    const g = this.ctx.createGain();
    osc.type = 'sawtooth';
    osc.frequency.setValueAtTime(80, t);
    osc.frequency.exponentialRampToValueAtTime(380, t + 0.35);
    g.gain.setValueAtTime(0.15, t);
    g.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
    osc.connect(g); g.connect(this.master);
    osc.start(t); osc.stop(t + 0.4);
  }
  crash() {
    if (!this.ctx) return;
    const t = this.ctx.currentTime;
    const dur = 0.9;
    const bufSize = Math.floor(this.ctx.sampleRate * dur);
    const buf = this.ctx.createBuffer(1, bufSize, this.ctx.sampleRate);
    const data = buf.getChannelData(0);
    for (let i = 0; i < bufSize; i++) {
      const env = Math.exp(-i / (bufSize * 0.25));
      data[i] = (Math.random() * 2 - 1) * env;
    }
    const src = this.ctx.createBufferSource();
    src.buffer = buf;
    const filter = this.ctx.createBiquadFilter();
    filter.type = 'lowpass';
    filter.frequency.setValueAtTime(2000, t);
    filter.frequency.exponentialRampToValueAtTime(200, t + dur);
    const g = this.ctx.createGain();
    g.gain.value = 0.5;
    src.connect(filter); filter.connect(g); g.connect(this.master);
    src.start(t);
    // Low boom
    const boom = this.ctx.createOscillator();
    const bg = this.ctx.createGain();
    boom.type = 'sine';
    boom.frequency.setValueAtTime(120, t);
    boom.frequency.exponentialRampToValueAtTime(30, t + 0.4);
    bg.gain.setValueAtTime(0.4, t);
    bg.gain.exponentialRampToValueAtTime(0.001, t + 0.5);
    boom.connect(bg); bg.connect(this.master);
    boom.start(t); boom.stop(t + 0.5);
  }
}
const audio = new AudioEngine();

// ============================================================
// GAME STATE
// ============================================================
const state = {
  started: false,
  gameOver: false,
  score: 0,
  multiplier: 1.0,
  multDecayTimer: 0,
  energy: 100,
  maxEnergy: 100,
  turbo: 0,
  maxTurbo: 100,
  speed: 55,
  maxSpeed: 90,
  baseSpeed: 55,
  time: 0,
  distance: 0,
  maxSpeedReached: 0,
  nearMissCooldown: 0,
  cameraShake: 0,
  prevMinDist: 999,
  boostActive: false,
  braking: false,
  multiplierPulse: 0,
  damageFlash: 0,
};

// ============================================================
// PLAYER
// ============================================================
const player = {
  group: new THREE.Group(),
  position: new THREE.Vector3(0, 5, 0),
  yaw: 0,
  pitch: 0,
  roll: 0,
  yawVel: 0,
  pitchVel: 0,
  forward: new THREE.Vector3(0, 0, -1),
  velocity: new THREE.Vector3(0, 0, -55),
};

function createCraft() {
  const g = new THREE.Group();

  // Body - stretched octahedron with faceted shading
  const bodyGeom = new THREE.OctahedronGeometry(1, 0);
  bodyGeom.scale(0.55, 0.22, 1.6);
  bodyGeom.translate(0, 0, 0.1);
  const bodyMat = new THREE.MeshStandardMaterial({
    color: 0xdde2ee, metalness: 0.85, roughness: 0.25, flatShading: true,
    emissive: 0x223344, emissiveIntensity: 0.25,
  });
  const body = new THREE.Mesh(bodyGeom, bodyMat);
  body.castShadow = true;
  g.add(body);

  // Top fin
  const finShape = new THREE.Shape();
  finShape.moveTo(0, 0);
  finShape.lineTo(0, 0.5);
  finShape.lineTo(1.0, -0.3);
  finShape.lineTo(0, 0);
  const finGeom = new THREE.ExtrudeGeometry(finShape, { depth: 0.08, bevelEnabled: false });
  finGeom.translate(-0.5, 0, -0.04);
  const finMat = new THREE.MeshStandardMaterial({
    color: 0x556688, metalness: 0.9, roughness: 0.3, flatShading: true,
    emissive: 0x112233, emissiveIntensity: 0.2,
  });
  const topFin = new THREE.Mesh(finGeom, finMat);
  topFin.rotation.y = Math.PI / 2;
  topFin.position.set(0, 0.15, 0.2);
  topFin.castShadow = true;
  g.add(topFin);

  // Wings
  const wingShape = new THREE.Shape();
  wingShape.moveTo(0, 0);
  wingShape.lineTo(2.2, -0.6);
  wingShape.lineTo(2.0, 0.1);
  wingShape.lineTo(0.4, 0.3);
  wingShape.lineTo(0, 0);
  const wingGeom = new THREE.ExtrudeGeometry(wingShape, { depth: 0.12, bevelEnabled: false });
  wingGeom.translate(0, 0, -0.06);
  const wingMat = new THREE.MeshStandardMaterial({
    color: 0x99a8c4, metalness: 0.8, roughness: 0.35, flatShading: true,
    emissive: 0x223355, emissiveIntensity: 0.15,
  });
  const lw = new THREE.Mesh(wingGeom, wingMat);
  lw.rotation.y = Math.PI / 2;
  lw.rotation.x = -0.15;
  lw.position.set(-0.4, -0.02, 0.3);
  lw.castShadow = true;
  g.add(lw);
  const rw = new THREE.Mesh(wingGeom, wingMat);
  rw.rotation.y = -Math.PI / 2;
  rw.scale.x = -1;
  rw.rotation.x = 0.15;
  rw.position.set(0.4, -0.02, 0.3);
  rw.castShadow = true;
  g.add(rw);

  // Cockpit - small dark glassy shape
  const cockGeom = new THREE.SphereGeometry(0.22, 8, 6, 0, Math.PI * 2, 0, Math.PI / 2);
  cockGeom.scale(0.8, 0.8, 1.6);
  cockGeom.translate(0, 0.12, -0.2);
  const cockMat = new THREE.MeshStandardMaterial({
    color: 0x112233, metalness: 0.9, roughness: 0.1, flatShading: true,
    emissive: 0x004488, emissiveIntensity: 0.3,
  });
  const cock = new THREE.Mesh(cockGeom, cockMat);
  g.add(cock);

  // Engine glow (emissive sphere at rear)
  const glowGeom = new THREE.SphereGeometry(0.28, 12, 8);
  const glowMat = new THREE.MeshBasicMaterial({ color: 0x66eeff });
  const glow = new THREE.Mesh(glowGeom, glowMat);
  glow.position.set(0, 0, 1.5);
  g.add(glow);
  // Inner bright core
  const coreGeom = new THREE.SphereGeometry(0.12, 8, 6);
  const coreMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
  const core = new THREE.Mesh(coreGeom, coreMat);
  core.position.set(0, 0, 1.5);
  g.add(core);

  return g;
}

// ============================================================
// CHUNK / CANYON GENERATION
// ============================================================
const wallMaterial = new THREE.MeshStandardMaterial({
  vertexColors: true,
  flatShading: true,
  roughness: 0.92,
  metalness: 0.05,
});
const monolithMaterial = new THREE.MeshStandardMaterial({
  color: 0x1a1830, metalness: 0.85, roughness: 0.3, flatShading: true,
  emissive: 0x441166, emissiveIntensity: 0.4,
});
const monolithMaterial2 = new THREE.MeshStandardMaterial({
  color: 0x2a1a30, metalness: 0.8, roughness: 0.35, flatShading: true,
  emissive: 0x884422, emissiveIntensity: 0.35,
});
const groundMaterial = new THREE.MeshStandardMaterial({
  color: 0x2a1408, roughness: 0.95, metalness: 0.0,
});

const chunks = new Map();
const allOrbs = []; // { pos, mesh, collected, phase }
const allMonoliths = []; // { pos, mesh, size, rotSpeed, axis }

function buildChunk(idx) {
  const group = new THREE.Group();
  group.userData.idx = idx;
  const startT = idx * CFG.CHUNK_SIZE;
  const endT = (idx + 1) * CFG.CHUNK_SIZE;

  // ---- Walls ----
  const positions = [];
  const colors = [];
  const indices = [];

  const baseCol = new THREE.Color(0x2a1208);
  const midCol = new THREE.Color(0x6a2810);
  const topCol = new THREE.Color(0xcc6633);
  const highlightCol = new THREE.Color(0xffaa55);

  for (let side = 0; side < 2; side++) {
    const sign = side === 0 ? -1 : 1; // -1 = left, +1 = right
    const sunFactor = side === 1 ? 1.0 : 0.55; // right wall is sunlit

    for (let i = 0; i <= CFG.POINTS_PER_CHUNK; i++) {
      const t = startT + (i / CFG.POINTS_PER_CHUNK) * CFG.CHUNK_SIZE;
      const z = -t;
      const canyon = getCanyonAt(t);
      const innerX = canyon.centerX + sign * canyon.halfWidth;
      const outerX = innerX + sign * 7;

      // Wall height with jagged variation
      const heightBase = CFG.WALL_HEIGHT;
      const heightVar = (fbm(t * 0.04) - 0.3) * 12;
      const peak = fbm(t * 0.08 + side * 13) > 0.72 ? 6 + fbm(t * 0.15) * 8 : 0;
      const height = heightBase + heightVar + peak;

      // Slight tilt in/out for organic look
      const outerShift = (fbm(t * 0.05 + side * 7) - 0.5) * 2.5;

      // Vertices: 0=BI, 1=TI, 2=BO, 3=TO
      positions.push(innerX, canyon.groundY, z);
      positions.push(innerX, canyon.groundY + height, z);
      positions.push(outerX + outerShift, canyon.groundY, z);
      positions.push(outerX + outerShift, canyon.groundY + height * 0.85, z);

      // Colors per vertex (bottom dark, top light, with sun factor)
      const cBot = baseCol.clone().multiplyScalar(sunFactor * 0.7);
      const cMid = midCol.clone().multiplyScalar(sunFactor);
      const cTop = topCol.clone().multiplyScalar(sunFactor);
      const cTopO = highlightCol.clone().multiplyScalar(sunFactor * 0.8);
      // BI dark, TI mid-top, BO darkest, TO mid
      colors.push(cBot.r, cBot.g, cBot.b);
      colors.push(cTop.r * 0.8, cTop.g * 0.8, cTop.b * 0.8);
      colors.push(cBot.r * 0.5, cBot.g * 0.5, cBot.b * 0.5);
      colors.push(cMid.r, cMid.g, cMid.b);
    }
  }

  const N = CFG.POINTS_PER_CHUNK + 1;
  for (let side = 0; side < 2; side++) {
    for (let i = 0; i < CFG.POINTS_PER_CHUNK; i++) {
      const a = side * N * 4 + i * 4;
      const b = side * N * 4 + (i + 1) * 4;
      // Inner face
      indices.push(a, a + 1, b + 1, a, b + 1, b);
      // Top face
      indices.push(a + 1, a + 3, b + 3, a + 1, b + 3, b + 1);
      // Outer face (reverse winding)
      indices.push(a + 3, a + 2, b + 2, a + 3, b + 2, b + 3);
    }
  }

  const wallGeom = new THREE.BufferGeometry();
  wallGeom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
  wallGeom.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
  wallGeom.setIndex(indices);
  wallGeom.computeVertexNormals();

  const wallMesh = new THREE.Mesh(wallGeom, wallMaterial);
  wallMesh.castShadow = true;
  wallMesh.receiveShadow = true;
  group.add(wallMesh);

  // ---- Ground (canyon floor) ----
  // Build a ground strip that follows canyon center
  const gPositions = [];
  const gColors = [];
  const gIndices = [];
  const gSegs = CFG.POINTS_PER_CHUNK;
  for (let i = 0; i <= gSegs; i++) {
    const t = startT + (i / gSegs) * CFG.CHUNK_SIZE;
    const z = -t;
    const canyon = getCanyonAt(t);
    const halfW = canyon.halfWidth - 0.5;
    // two vertices across, two in y
    gPositions.push(canyon.centerX - halfW, canyon.groundY, z);
    gPositions.push(canyon.centerX + halfW, canyon.groundY, z);
    const tint = 0.8 + fbm(t * 0.1) * 0.4;
    gColors.push(0.18 * tint, 0.08 * tint, 0.04 * tint);
    gColors.push(0.18 * tint, 0.08 * tint, 0.04 * tint);
  }
  for (let i = 0; i < gSegs; i++) {
    const a = i * 2, b = (i + 1) * 2;
    gIndices.push(a, a + 1, b + 1, a, b + 1, b);
  }
  const gGeom = new THREE.BufferGeometry();
  gGeom.setAttribute('position', new THREE.Float32BufferAttribute(gPositions, 3));
  gGeom.setAttribute('color', new THREE.Float32BufferAttribute(gColors, 3));
  gGeom.setIndex(gIndices);
  gGeom.computeVertexNormals();
  const gMesh = new THREE.Mesh(gGeom, groundMaterial);
  gMesh.receiveShadow = true;
  group.add(gMesh);

  // ---- Orbs ----
  const orbCount = 3 + Math.floor(rng() * 3);
  for (let i = 0; i < orbCount; i++) {
    const t = startT + (i + 0.3) / orbCount * CFG.CHUNK_SIZE + (rng() - 0.5) * 8;
    const z = -t;
    const canyon = getCanyonAt(t);
    // offset: mostly center, sometimes to one side
    let offX = 0;
    const r = rng();
    if (r < 0.45) offX = 0;
    else if (r < 0.75) offX = (canyon.halfWidth - 3.5) * (rng() < 0.5 ? -1 : 1) * (0.4 + rng() * 0.6);
    else offX = (canyon.halfWidth - 2.5) * (rng() < 0.5 ? -1 : 1);
    const x = canyon.centerX + offX;
    const y = 3.5 + rng() * 4;
    const orb = createOrb();
    orb.position.set(x, y, z);
    group.add(orb);
    allOrbs.push({ pos: orb.position, mesh: orb, collected: false, phase: rng() * Math.PI * 2 });
  }

  // ---- Monoliths ----
  // Difficulty scales with distance
  const diff = Math.min(1, startT / 4000);
  const monoChance = 0.35 + diff * 0.3;
  if (rng() < monoChance) {
    const t = startT + CFG.CHUNK_SIZE * (0.3 + rng() * 0.4);
    const z = -t;
    const canyon = getCanyonAt(t);
    const side = rng() < 0.5 ? -1 : 1;
    const xOffset = canyon.halfWidth * (0.35 + rng() * 0.25);
    const x = canyon.centerX + side * xOffset;
    const y = (10 + rng() * 8) * (0.7 + diff * 0.5);
    const type = rng() < 0.5 ? 0 : 1;
    const mono = createMonolith(type);
    mono.position.set(x, y, z);
    mono.rotation.y = rng() * Math.PI * 2;
    group.add(mono);
    allMonoliths.push({ pos: mono.position, mesh: mono, type, rotSpeed: (rng() - 0.5) * 0.3, axis: rng() < 0.5 ? 'y' : 'x', size: 1 });
  }

  // ---- Floating obstacles (tight squeezes have floating blocks) ----
  if (rng() < 0.2 + diff * 0.15) {
    const t = startT + CFG.CHUNK_SIZE * (0.4 + rng() * 0.3);
    const z = -t;
    const canyon = getCanyonAt(t);
    if (canyon.halfWidth < 16) {
      // place floating block in center requiring bank
      const x = canyon.centerX + (rng() - 0.5) * (canyon.halfWidth - 5);
      const y = 4 + rng() * 5;
      const block = createFloatingBlock();
      block.position.set(x, y, z);
      group.add(block);
      allMonoliths.push({ pos: block.position, mesh: block, type: 2, rotSpeed: 0.2, axis: 'y', size: 1.5 });
    }
  }

  return group;
}

function createOrb() {
  const group = new THREE.Group();
  // outer
  const outer = new THREE.Mesh(
    new THREE.IcosahedronGeometry(0.45, 1),
    new THREE.MeshStandardMaterial({
      color: 0xaaeeff, emissive: 0x00ddff, emissiveIntensity: 2.5,
      flatShading: true, metalness: 0.2, roughness: 0.3,
    })
  );
  group.add(outer);
  // inner core
  const core = new THREE.Mesh(
    new THREE.IcosahedronGeometry(0.2, 0),
    new THREE.MeshBasicMaterial({ color: 0xffffff })
  );
  group.add(core);
  // glow ring
  const ring = new THREE.Mesh(
    new THREE.TorusGeometry(0.7, 0.04, 6, 16),
    new THREE.MeshBasicMaterial({ color: 0x66ddff, transparent: true, opacity: 0.5 })
  );
  ring.rotation.x = Math.PI / 2;
  group.add(ring);
  return group;
}

function createMonolith(type) {
  const group = new THREE.Group();
  if (type === 0) {
    // Tall hex prism
    const h = 18;
    const geom = new THREE.CylinderGeometry(0.8, 1.1, h, 6, 1);
    const mesh = new THREE.Mesh(geom, monolithMaterial);
    mesh.castShadow = true;
    group.add(mesh);
    // Top accent
    const top = new THREE.Mesh(
      new THREE.CylinderGeometry(0.3, 0.3, 1.5, 6),
      new THREE.MeshBasicMaterial({ color: 0xff66cc })
    );
    top.position.y = h / 2 + 0.7;
    group.add(top);
    // Bottom accent
    const bot = new THREE.Mesh(
      new THREE.CylinderGeometry(0.3, 0.3, 1.5, 6),
      new THREE.MeshBasicMaterial({ color: 0xff66cc })
    );
    bot.position.y = -h / 2 - 0.7;
    group.add(bot);
  } else {
    // Wide floating block
    const geom = new THREE.BoxGeometry(3, 4, 3);
    const mesh = new THREE.Mesh(geom, monolithMaterial2);
    mesh.castShadow = true;
    group.add(mesh);
    // Edge glow
    const edges = new THREE.EdgesGeometry(geom);
    const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xff8844 }));
    group.add(line);
  }
  return group;
}

function createFloatingBlock() {
  const group = new THREE.Group();
  const size = 2.5;
  const geom = new THREE.BoxGeometry(size, size, size);
  const mat = new THREE.MeshStandardMaterial({
    color: 0x331a2a, metalness: 0.7, roughness: 0.4, flatShading: true,
    emissive: 0xaa2200, emissiveIntensity: 0.3,
  });
  const mesh = new THREE.Mesh(geom, mat);
  mesh.castShadow = true;
  group.add(mesh);
  const edges = new THREE.EdgesGeometry(geom);
  const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xffaa44 }));
  group.add(line);
  return group;
}

function updateChunks() {
  const playerT = -player.position.z;
  const currentIdx = Math.floor(playerT / CFG.CHUNK_SIZE);
  const keepStart = currentIdx - 1;
  const keepEnd = currentIdx + CFG.NUM_CHUNKS;

  // Generate new chunks ahead
  for (let i = keepStart; i <= keepEnd; i++) {
    if (i < 0) continue;
    if (!chunks.has(i)) {
      const c = buildChunk(i);
      scene.add(c);
      chunks.set(i, c);
    }
  }
  // Remove old chunks behind
  for (const [idx, chunk] of chunks) {
    if (idx < keepStart - 1) {
      scene.remove(chunk);
      chunk.traverse(obj => {
        if (obj.geometry) obj.geometry.dispose();
        if (obj.material) {
          if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose());
          else obj.material.dispose();
        }
      });
      // Remove orbs and monoliths from global lists
      chunk.children.forEach(child => {
        const orbIdx = allOrbs.findIndex(o => o.mesh === child);
        if (orbIdx >= 0) allOrbs.splice(orbIdx, 1);
        const monoIdx = allMonoliths.findIndex(m => m.mesh === child);
        if (monoIdx >= 0) allMonoliths.splice(monoIdx, 1);
      });
      chunks.delete(idx);
    }
  }
}

// ============================================================
// PARTICLE SYSTEMS
// ============================================================
const TRAIL_MAX = 80;
const trailPositions = new Float32Array(TRAIL_MAX * 3);
const trailAlphas = new Float32Array(TRAIL_MAX);
const trailSizes = new Float32Array(TRAIL_MAX);
let trailHead = 0;
let trailGeom, trailPoints;

function initTrail() {
  for (let i = 0; i < TRAIL_MAX; i++) {
    trailPositions[i * 3] = 0;
    trailPositions[i * 3 + 1] = -1000;
    trailPositions[i * 3 + 2] = 0;
    trailAlphas[i] = 0;
    trailSizes[i] = 0;
  }
  trailGeom = new THREE.BufferGeometry();
  trailGeom.setAttribute('position', new THREE.BufferAttribute(trailPositions, 3));
  trailGeom.setAttribute('alpha', new THREE.BufferAttribute(trailAlphas, 1));
  trailGeom.setAttribute('size', new THREE.BufferAttribute(trailSizes, 1));

  // Custom shader for points
  const trailMat = new THREE.ShaderMaterial({
    uniforms: { color: { value: new THREE.Color(0x44ddff) } },
    vertexShader: `
      attribute float alpha;
      attribute float size;
      varying float vAlpha;
      void main() {
        vAlpha = alpha;
        vec4 mv = modelViewMatrix * vec4(position, 1.0);
        gl_PointSize = size * (300.0 / -mv.z);
        gl_Position = projectionMatrix * mv;
      }
    `,
    fragmentShader: `
      uniform vec3 color;
      varying float vAlpha;
      void main() {
        vec2 c = gl_PointCoord - 0.5;
        float d = length(c);
        if (d > 0.5) discard;
        float a = smoothstep(0.5, 0.0, d) * vAlpha;
        gl_FragColor = vec4(color, a);
      }
    `,
    transparent: true,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
  });
  trailPoints = new THREE.Points(trailGeom, trailMat);
  scene.add(trailPoints);
}

function emitTrail(dt) {
  const intensity = state.boostActive ? 3 : 1;
  for (let i = 0; i < intensity; i++) {
    const i3 = trailHead * 3;
    trailPositions[i3] = player.position.x + (Math.random() - 0.5) * 0.3;
    trailPositions[i3 + 1] = player.position.y + (Math.random() - 0.5) * 0.2;
    trailPositions[i3 + 2] = player.position.z + 1.5;
    trailAlphas[trailHead] = state.boostActive ? 1.2 : 0.9;
    trailSizes[trailHead] = state.boostActive ? 12 : 7;
    trailHead = (trailHead + 1) % TRAIL_MAX;
  }
  // Decay
  for (let i = 0; i < TRAIL_MAX; i++) {
    trailAlphas[i] = Math.max(0, trailAlphas[i] - dt * 2.5);
  }
  trailGeom.attributes.position.needsUpdate = true;
  trailGeom.attributes.alpha.needsUpdate = true;
  trailGeom.attributes.size.needsUpdate = true;
}

// Dust particles
const DUST_MAX = 140;
const dustPositions = new Float32Array(DUST_MAX * 3);
const dustSizes = new Float32Array(DUST_MAX);
const dustColors = new Float32Array(DUST_MAX * 3);
let dustGeom, dustPoints;

function initDust() {
  for (let i = 0; i < DUST_MAX; i++) {
    resetDust(i, true);
  }
  dustGeom = new THREE.BufferGeometry();
  dustGeom.setAttribute('position', new THREE.BufferAttribute(dustPositions, 3));
  dustGeom.setAttribute('size', new THREE.BufferAttribute(dustSizes, 1));
  dustGeom.setAttribute('color', new THREE.BufferAttribute(dustColors, 3));

  const dustMat = new THREE.ShaderMaterial({
    vertexShader: `
      attribute float size;
      attribute vec3 color;
      varying vec3 vColor;
      void main() {
        vColor = color;
        vec4 mv = modelViewMatrix * vec4(position, 1.0);
        gl_PointSize = size * (250.0 / -mv.z);
        gl_Position = projectionMatrix * mv;
      }
    `,
    fragmentShader: `
      varying vec3 vColor;
      void main() {
        vec2 c = gl_PointCoord - 0.5;
        float d = length(c);
        if (d > 0.5) discard;
        float a = smoothstep(0.5, 0.1, d) * 0.6;
        gl_FragColor = vec4(vColor, a);
      }
    `,
    transparent: true,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
  });
  dustPoints = new THREE.Points(dustGeom, dustMat);
  scene.add(dustPoints);
}

function resetDust(i, initial) {
  const t = -player.position.z + (initial ? Math.random() * 200 : 0);
  dustPositions[i * 3] = player.position.x + (Math.random() - 0.5) * 70;
  dustPositions[i * 3 + 1] = Math.random() * 20 - 2;
  dustPositions[i * 3 + 2] = player.position.z - 30 - Math.random() * 180;
  dustSizes[i] = 1 + Math.random() * 2.5;
  const tone = Math.random();
  if (tone < 0.5) { dustColors[i*3] = 1.0; dustColors[i*3+1] = 0.5; dustColors[i*3+2] = 0.2; }
  else if (tone < 0.85) { dustColors[i*3] = 0.9; dustColors[i*3+1] = 0.6; dustColors[i*3+2] = 0.3; }
  else { dustColors[i*3] = 0.7; dustColors[i*3+1] = 0.5; dustColors[i*3+2] = 0.9; }
}

function updateDust() {
  for (let i = 0; i < DUST_MAX; i++) {
    if (dustPositions[i * 3 + 2] > player.position.z + 15) {
      resetDust(i, false);
    }
  }
  dustGeom.attributes.position.needsUpdate = true;
  dustGeom.attributes.size.needsUpdate = true;
  dustGeom.attributes.color.needsUpdate = true;
}

// Spark burst (for orb collection)
const bursts = []; // {pos, time, max, mesh}
function createBurst(pos, color) {
  const count = 16;
  const geom = new THREE.BufferGeometry();
  const positions = new Float32Array(count * 3);
  const velocities = [];
  for (let i = 0; i < count; i++) {
    positions[i * 3] = pos.x;
    positions[i * 3 + 1] = pos.y;
    positions[i * 3 + 2] = pos.z;
    velocities.push(new THREE.Vector3(
      (Math.random() - 0.5) * 8,
      (Math.random() - 0.5) * 8,
      (Math.random() - 0.5) * 8
    ));
  }
  geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  const mat = new THREE.PointsMaterial({
    color, size: 0.4, transparent: true, opacity: 1,
    blending: THREE.AdditiveBlending, depthWrite: false,
  });
  const pts = new THREE.Points(geom, mat);
  scene.add(pts);
  bursts.push({ pts, velocities, time: 0, max: 0.6 });
}
function updateBursts(dt) {
  for (let i = bursts.length - 1; i >= 0; i--) {
    const b = bursts[i];
    b.time += dt;
    const positions = b.pts.geometry.attributes.position.array;
    for (let j = 0; j < b.velocities.length; j++) {
      positions[j * 3] += b.velocities[j].x * dt;
      positions[j * 3 + 1] += b.velocities[j].y * dt;
      positions[j * 3 + 2] += b.velocities[j].z * dt;
      b.velocities[j].multiplyScalar(0.92);
    }
    b.pts.geometry.attributes.position.needsUpdate = true;
    b.pts.material.opacity = Math.max(0, 1 - b.time / b.max);
    if (b.time >= b.max) {
      scene.remove(b.pts);
      b.pts.geometry.dispose();
      b.pts.material.dispose();
      bursts.splice(i, 1);
    }
  }
}

// ============================================================
// SCENE SETUP
// ============================================================
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0xff7744, 60, 220);

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.5, 600);
camera.position.set(0, 8, 14);

// Sky
const skyGeom = new THREE.SphereGeometry(400, 32, 16);
const skyMat = new THREE.ShaderMaterial({
  side: THREE.BackSide,
  uniforms: {
    topColor: { value: new THREE.Color(0x0a0220) },
    midColor: { value: new THREE.Color(0x4a1144) },
    horizonColor: { value: new THREE.Color(0xff5522) },
    sunColor: { value: new THREE.Color(0xffcc88) },
    sunDir: { value: new THREE.Vector3(0.35, 0.08, -1).normalize() },
  },
  vertexShader: `
    varying vec3 vWorld;
    void main() {
      vec4 wp = modelMatrix * vec4(position, 1.0);
      vWorld = wp.xyz;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform vec3 topColor, midColor, horizonColor, sunColor, sunDir;
    varying vec3 vWorld;
    void main() {
      vec3 dir = normalize(vWorld);
      float h = dir.y;
      vec3 col;
      if (h > 0.0) {
        col = mix(horizonColor, midColor, smoothstep(0.0, 0.4, h));
        col = mix(col, topColor, smoothstep(0.3, 1.0, h));
      } else {
        col = mix(horizonColor, vec3(0.15, 0.05, 0.02), smoothstep(0.0, -0.3, h));
      }
      // Sun
      float sunDot = max(0.0, dot(dir, sunDir));
      col += sunColor * pow(sunDot, 64.0) * 1.5;
      col += sunColor * pow(sunDot, 8.0) * 0.4;
      col += vec3(1.0, 0.4, 0.1) * pow(sunDot, 2.0) * 0.15;
      gl_FragColor = vec4(col, 1.0);
    }
  `,
});
const sky = new THREE.Mesh(skyGeom, skyMat);
sky.frustumCulled = false;
scene.add(sky);

// Sun (visual)
const sunGeom = new THREE.SphereGeometry(8, 16, 12);
const sunMat = new THREE.MeshBasicMaterial({ color: 0xffeecc });
const sunMesh = new THREE.Mesh(sunGeom, sunMat);
const sunPos = new THREE.Vector3(70, 8, -200);
sunMesh.position.copy(sunPos);
scene.add(sunMesh);

// Distant mountains
const mountainGroup = new THREE.Group();
for (let i = 0; i < 40; i++) {
  const angle = (i / 40) * Math.PI * 2 + Math.random() * 0.1;
  const dist = 280 + Math.random() * 60;
  const h = 40 + Math.random() * 90;
  const w = 30 + Math.random() * 30;
  const mGeom = new THREE.ConeGeometry(w, h, 4 + Math.floor(Math.random() * 3));
  const mMat = new THREE.MeshStandardMaterial({
    color: new THREE.Color().setHSL(0.85 + Math.random() * 0.1, 0.5, 0.12 + Math.random() * 0.05),
    flatShading: true, roughness: 1.0, metalness: 0,
  });
  const m = new THREE.Mesh(mGeom, mMat);
  m.position.set(Math.cos(angle) * dist, h / 2 - 25, Math.sin(angle) * dist);
  m.rotation.y = Math.random() * Math.PI;
  mountainGroup.add(m);
}
scene.add(mountainGroup);

// Lights
const sunLight = new THREE.DirectionalLight(0xffaa66, 1.6);
sunLight.position.set(70, 30, 100);
sunLight.target.position.set(0, 0, -50);
sunLight.castShadow = true;
sunLight.shadow.mapSize.width = 2048;
sunLight.shadow.mapSize.height = 2048;
sunLight.shadow.camera.near = 1;
sunLight.shadow.camera.far = 250;
sunLight.shadow.camera.left = -80;
sunLight.shadow.camera.right = 80;
sunLight.shadow.camera.top = 80;
sunLight.shadow.camera.bottom = -80;
sunLight.shadow.bias = -0.0005;
sunLight.shadow.normalBias = 0.02;
scene.add(sunLight);
scene.add(sunLight.target);

const fillLight = new THREE.DirectionalLight(0x6644aa, 0.5);
fillLight.position.set(-50, 20, 50);
scene.add(fillLight);

const hemi = new THREE.HemisphereLight(0xff8855, 0x220a22, 0.4);
scene.add(hemi);

const ambient = new THREE.AmbientLight(0x442255, 0.3);
scene.add(ambient);

// Player-following light for path visibility
const playerLight = new THREE.PointLight(0xffaa66, 1.2, 35, 1.5);
playerLight.position.set(0, 8, 5);
scene.add(playerLight);

// Player craft
const craft = createCraft();
scene.add(craft);
craft.position.copy(player.position);

// Ground plane (extends infinitely with chunks)
const bigGround = new THREE.Mesh(
  new THREE.PlaneGeometry(500, 500),
  new THREE.MeshStandardMaterial({ color: 0x0a0402, roughness: 1.0, metalness: 0 })
);
bigGround.rotation.x = -Math.PI / 2;
bigGround.position.y = -3;
bigGround.receiveShadow = true;
scene.add(bigGround);

// ============================================================
// POST-PROCESSING
// ============================================================
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
renderer.outputColorSpace = THREE.SRGBColorSpace;
$('game').appendChild(renderer.domElement);

const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.7, 0.4, 0.85);
composer.addPass(bloom);

// Vignette + chromatic aberration shader
const vignettePass = new ShaderPass({
  uniforms: {
    tDiffuse: { value: null },
    offset: { value: 1.1 },
    darkness: { value: 0.65 },
    aberration: { value: 0.0012 },
  },
  vertexShader: `
    varying vec2 vUv;
    void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
  `,
  fragmentShader: `
    uniform sampler2D tDiffuse;
    uniform float offset;
    uniform float darkness;
    uniform float aberration;
    varying vec2 vUv;
    void main() {
      vec2 uv = vUv;
      vec2 dir = uv - 0.5;
      float dist = length(dir);
      // Chromatic aberration
      vec2 ca = dir * aberration;
      float r = texture2D(tDiffuse, uv - ca).r;
      float g = texture2D(tDiffuse, uv).g;
      float b = texture2D(tDiffuse, uv + ca).b;
      vec3 col = vec3(r, g, b);
      // Vignette
      float vig = smoothstep(offset, offset - 0.7, dist);
      col *= mix(1.0 - darkness, 1.0, vig);
      // Grain
      float grain = fract(sin(dot(uv * 1000.0, vec2(12.9898, 78.233))) * 43758.5453);
      col += (grain - 0.5) * 0.03;
      gl_FragColor = vec4(col, 1.0);
    }
  `,
});
composer.addPass(vignettePass);
composer.addPass(new OutputPass());

// ============================================================
// INPUT
// ============================================================
const keys = {};
window.addEventListener('keydown', e => {
  keys[e.code] = true;
  if (e.code === 'Space' || ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) e.preventDefault();
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
  composer.setSize(window.innerWidth, window.innerHeight);
});

// ============================================================
// GAME FUNCTIONS
// ============================================================
function startGame() {
  audio.init();
  $('intro').style.display = 'none';
  state.started = true;
  state.gameOver = false;
  state.score = 0;
  state.multiplier = 1.0;
  state.energy = 100;
  state.turbo = 0;
  state.speed = state.baseSpeed;
  state.maxSpeed = 90;
  state.maxSpeedReached = 0;
  state.distance = 0;
  state.time = 0;
  state.nearMissCooldown = 0;
  state.prevMinDist = 999;
  player.position.set(0, 5, 0);
  player.yaw = 0;
  player.pitch = 0;
  player.roll = 0;
  player.yawVel = 0;
  player.pitchVel = 0;
  player.forward.set(0, 0, -1);
  // Clear chunks and regenerate
  for (const [idx, chunk] of chunks) {
    scene.remove(chunk);
    chunk.traverse(obj => {
      if (obj.geometry) obj.geometry.dispose();
      if (obj.material) {
        if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose());
        else obj.material.dispose();
      }
    });
  }
  chunks.clear();
  allOrbs.length = 0;
  allMonoliths.length = 0;
  updateChunks();
}

function gameOver() {
  if (state.gameOver) return;
  state.gameOver = true;
  state.started = false;
  audio.crash();
  $('damage-flash').style.opacity = '1';
  $('damage-flash').style.transition = 'opacity 0.1s';
  setTimeout(() => {
    $('damage-flash').style.transition = 'opacity 1s';
    $('damage-flash').style.opacity = '0';
  }, 100);
  $('final-score').textContent = Math.floor(state.score);
  $('final-speed').textContent = Math.floor(state.maxSpeedReached);
  $('final-dist').textContent = Math.floor(state.distance);
  $('game-over').classList.add('show');
}

function restartGame() {
  $('game-over').classList.remove('show');
  startGame();
}

$('start-btn').addEventListener('click', startGame);
$('restart-btn').addEventListener('click', restartGame);

function showNearMsg(text) {
  const el = $('near-msg');
  el.textContent = text;
  el.classList.remove('show');
  void el.offsetWidth;
  el.classList.add('show');
}

// ============================================================
// UPDATE LOOP
// ============================================================
let lastTime = performance.now();
let prevPlayerZ = 0;

function update(dt) {
  if (!state.started) return;
  state.time += dt;
  state.distance += state.speed * dt;

  // ---- Player input → rotation ----
  const yawInput = ((keys['ArrowRight'] || keys['KeyD']) ? 1 : 0) - ((keys['ArrowLeft'] || keys['KeyA']) ? 1 : 0);
  const pitchInput = ((keys['ArrowUp'] || keys['KeyW']) ? 1 : 0) - ((keys['ArrowDown'] || keys['KeyS']) ? 1 : 0);
  const boostInput = !!keys['Space'];
  const brakeInput = !!keys['ShiftLeft'] || !!keys['ShiftRight'];

  state.boostActive = boostInput && state.turbo > 0 && !brakeInput;
  state.braking = brakeInput;

  // Yaw and pitch with inertia
  player.yawVel += yawInput * 2.4 * dt;
  player.yawVel *= Math.pow(0.06, dt); // damping
  player.yaw += player.yawVel * dt;

  player.pitchVel += pitchInput * 1.8 * dt;
  player.pitchVel *= Math.pow(0.06, dt);
  player.pitch += player.pitchVel * dt;
  player.pitch = Math.max(-0.55, Math.min(0.55, player.pitch));

  // Roll (banking) follows yaw velocity
  const targetRoll = -player.yawVel * 0.5;
  player.roll += (targetRoll - player.roll) * Math.min(1, 8 * dt);

  // ---- Speed ----
  // Difficulty scaling
  state.maxSpeed = 90 + Math.min(100, state.score * 0.015);
  const targetSpeed = state.boostActive ? state.maxSpeed * 1.55 : (state.braking ? state.baseSpeed * 0.7 : state.maxSpeed);
  const speedLerp = (targetSpeed - state.speed) * 2.0 * dt;
  state.speed += speedLerp;
  state.maxSpeedReached = Math.max(state.maxSpeedReached, state.speed);

  // Turbo
  if (state.boostActive) {
    state.turbo = Math.max(0, state.turbo - 35 * dt);
  } else {
    state.turbo = Math.min(state.maxTurbo, state.turbo + (8 + state.speed * 0.08) * dt);
  }

  // ---- Apply movement ----
  player.forward.set(0, 0, -1).applyEuler(new THREE.Euler(player.pitch, player.yaw, 0, 'YXZ'));
  player.position.addScaledVector(player.forward, state.speed * dt);

  // Constrain position
  player.position.y = Math.max(1.5, Math.min(20, player.position.y));
  const t = -player.position.z;
  const canyon = getCanyonAt(t);
  const maxX = canyon.centerX + canyon.halfWidth - CFG.PLAYER_RADIUS;
  const minX = canyon.centerX - canyon.halfWidth + CFG.PLAYER_RADIUS;
  if (player.position.x > maxX) player.position.x = maxX;
  if (player.position.x < minX) player.position.x = minX;

  // Update craft transform
  craft.position.copy(player.position);
  craft.rotation.set(player.pitch, player.yaw, player.roll, 'YXZ');

  // ---- Energy decay ----
  const energyDecay = (3.2 + state.maxSpeed * 0.025) * dt;
  state.energy -= energyDecay;
  if (state.energy <= 0) {
    state.energy = 0;
    gameOver();
    return;
  }

  // ---- Multiplier decay ----
  state.multiplier = Math.max(1.0, state.multiplier - 0.25 * dt);
  state.multDecayTimer = Math.max(0, state.multDecayTimer - dt);

  // ---- Score (base from distance) ----
  state.score += state.speed * dt * 0.5 * state.multiplier;

  // ---- Collisions ----
  // Wall collision (X bounds)
  const distToLeft = player.position.x - (canyon.centerX - canyon.halfWidth);
  const distToRight = (canyon.centerX + canyon.halfWidth) - player.position.x;
  const minDist = Math.min(distToLeft, distToRight);

  if (minDist < CFG.PLAYER_RADIUS) {
    gameOver();
    return;
  }

  // Near-miss
  state.nearMissCooldown = Math.max(0, state.nearMissCooldown - dt);
  if (minDist < CFG.PLAYER_RADIUS + CFG.NEAR_MISS_RANGE) {
    if (state.prevMinDist > minDist && state.nearMissCooldown <= 0) {
      const intensity = 1 - (minDist - CFG.PLAYER_RADIUS) / CFG.NEAR_MISS_RANGE;
      const multGain = 0.4 + intensity * 0.8;
      state.multiplier = Math.min(12, state.multiplier + multGain);
      state.cameraShake = Math.max(state.cameraShake, 0.15 + intensity * 0.4);
      state.multiplierPulse = 0.2;
      audio.whoosh(intensity);
      showNearMsg(intensity > 0.7 ? 'CLOSE!' : (intensity > 0.4 ? 'NEAR MISS' : 'CLOSE CALL'));
      state.nearMissCooldown = 0.25;
    }
  }
  state.prevMinDist = minDist;

  // Orb collection
  for (const orb of allOrbs) {
    if (orb.collected) continue;
    const dx = player.position.x - orb.pos.x;
    const dy = player.position.y - orb.pos.y;
    const dz = player.position.z - orb.pos.z;
    const d2 = dx * dx + dy * dy + dz * dz;
    if (d2 < 2.5 * 2.5) {
      orb.collected = true;
      orb.mesh.visible = false;
      state.energy = Math.min(state.maxEnergy, state.energy + 28);
      state.turbo = Math.min(state.maxTurbo, state.turbo + 18);
      state.score += 100 * state.multiplier;
      state.cameraShake = Math.max(state.cameraShake, 0.1);
      audio.ping(Math.random());
      createBurst(orb.pos, 0x66eeff);
    }
  }

  // Monolith collisions
  for (const m of allMonoliths) {
    const dx = player.position.x - m.pos.x;
    const dy = player.position.y - m.pos.y;
    const dz = player.position.z - m.pos.z;
    const d2 = dx * dx + dy * dy + dz * dz;
    const r = CFG.PLAYER_RADIUS + (m.size || 1) * 1.2;
    if (d2 < r * r) {
      gameOver();
      return;
    }
  }

  // ---- Camera ----
  const idealOffset = new THREE.Vector3(0, 3, 12);
  idealOffset.applyEuler(new THREE.Euler(0, player.yaw, player.roll * 0.5));
  const idealPos = player.position.clone().add(idealOffset);
  camera.position.lerp(idealPos, Math.min(1, 4 * dt));

  const lookTarget = player.position.clone().addScaledVector(player.forward, 18);
  camera.lookAt(lookTarget);

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

  // FOV
  const targetFov = 75 + (state.boostActive ? 18 : 0) + Math.min(8, state.speed * 0.03);
  camera.fov += (targetFov - camera.fov) * Math.min(1, 3 * dt);
  camera.updateProjectionMatrix();

  // Sun light follows player
  sunLight.position.set(player.position.x + 70, 30, player.position.z + 100);
  sunLight.target.position.set(player.position.x, 0, player.position.z - 50);
  sunLight.target.updateMatrixWorld();

  // Player light follows player (illuminates immediate area)
  playerLight.position.set(player.position.x, player.position.y + 3, player.position.z + 2);

  // Mountains follow
  mountainGroup.position.x = player.position.x;
  mountainGroup.position.z = player.position.z;
  bigGround.position.x = player.position.x;
  bigGround.position.z = player.position.z;

  // Sky follows
  sky.position.x = player.position.x;
  sky.position.z = player.position.z;
  sunMesh.position.set(player.position.x + 70, 8, player.position.z - 200);

  // ---- Update particles ----
  emitTrail(dt);
  updateDust();
  updateBursts(dt);

  // ---- Animate orbs and monoliths ----
  const time = state.time;
  for (const orb of allOrbs) {
    if (orb.collected) continue;
    orb.mesh.rotation.y = time * 1.5 + orb.phase;
    orb.mesh.rotation.x = time * 0.8;
    orb.mesh.position.y = orb.pos.y + Math.sin(time * 2 + orb.phase) * 0.3;
    orb.mesh.children[2].rotation.z = time * 2;
  }
  for (const m of allMonoliths) {
    if (m.axis === 'y') m.mesh.rotation.y += m.rotSpeed * dt;
    else m.mesh.rotation.x += m.rotSpeed * dt;
  }

  // ---- Update chunks ----
  updateChunks();

  // ---- Audio ----
  audio.setEngine(state.speed, state.boostActive);
  if (state.boostActive && Math.random() < 0.02) audio.boost();

  // ---- HUD ----
  updateHUD();
}

function updateHUD() {
  $('score-value').textContent = Math.floor(state.score).toLocaleString();
  $('energy-bar').style.transform = `scaleX(${state.energy / state.maxEnergy})`;
  $('energy-num').textContent = Math.ceil(state.energy) + '%';
  $('speed-num').textContent = Math.floor(state.speed);
  const speedPct = Math.min(100, (state.speed / 200) * 100);
  $('speedo-fill').style.width = speedPct + '%';
  $('turbo-fill').style.width = Math.min(100, state.turbo) + '%';
  $('turbo-block').classList.toggle('visible', state.turbo > 5 || state.boostActive);

  const multEl = $('multiplier');
  multEl.textContent = state.multiplier.toFixed(1) + '×';
  if (state.multiplierPulse > 0) {
    multEl.classList.add('pulse');
    state.multiplierPulse -= 1/60;
  } else {
    multEl.classList.remove('pulse');
  }
  const multBarPct = ((state.multiplier - 1) / 11) * 100;
  $('mult-bar-fill').style.transform = `scaleX(${multBarPct / 100})`;

  $('boost-flash').classList.toggle('active', state.boostActive);
}

function animate() {
  requestAnimationFrame(animate);
  const now = performance.now();
  let dt = (now - lastTime) / 1000;
  lastTime = now;
  if (dt > 0.1) dt = 0.1; // clamp

  update(dt);

  // Always update trail decay even when not started (subtle)
  if (!state.started) {
    for (let i = 0; i < TRAIL_MAX; i++) trailAlphas[i] = Math.max(0, trailAlphas[i] - dt * 2.5);
    trailGeom.attributes.alpha.needsUpdate = true;
    updateBursts(dt);
  }

  composer.render();
}

// ============================================================
// INIT
// ============================================================
function init() {
  initTrail();
  initDust();
  // Generate initial chunks
  for (let i = 0; i < CFG.NUM_CHUNKS; i++) {
    const c = buildChunk(i);
    scene.add(c);
    chunks.set(i, c);
  }
  // First frame
  composer.render();
  animate();
}

init();
</script>
</body>
</html>
<!-- agent-meta {"model":"minimax/minimax-m3","provider":"openrouter","persona":"full","sessionId":"7f8917e6-279e-45a7-9acb-11115df25b42","tokensIn":1845749,"tokensOut":61022,"tokensTotal":1906771,"turns":31,"toolCalls":30,"failedToolCalls":0,"timestamp":"2026-07-13T21:57:47.840Z"} -->