← Canyon Run results

Canyon Run

Claude haiku 5.5 · full

Initial view of Canyon Run

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Task text captured before this run. Effective runtime system instructions were not captured.

Generation prompt · 175415d5e1
Role: You are an expert Three.js and WebGL game developer specializing in high-performance, minimalist arcade racers.

Objective: Create an HTML5 game (HTML/CSS/JS) that is a "High-Speed Procedural Canyon Racer." The gameplay must mimic the "flow" and "tension" of Race the Sun.

1. Core Gameplay Mechanics (The "Feel")
Movement Physics: Implement a "flight-based" physics model rather than direct axis control. The player (a sleek, low-poly geometric craft) should have momentum, banking (rolling) during turns, and pitch-based acceleration. Movement should feel "weighty" but highly responsive.
The Risk/Reward System (Proximity Scoring): Implement a "Near-Miss" mechanic. The closer the player passes to canyon walls or obstacles without colliding, the higher the score multiplier becomes.
Energy/Fuel Mechanic: The player has a constant "energy decay." Collecting glowing geometric orbs (recharges) is essential to stay alive. This forces the player to take risky paths through narrow gaps to stay fueled.
Speed & Acceleration: Implement a "Boost" system. Collecting items or maintaining high-speed straightaways builds a "Turbo" meter that allows for temporary extreme velocity and visual FOV widening.
2. Procedural Environment Generation
Infinite Generation: Use a chunk-based procedural generation system. As the player moves forward, generate new canyon segments and destroy old ones to ensure the game runs infinitely without memory leaks.
Terrain Variety: Generate three types of obstacles:
Canyon Walls: Massive, sweeping geological formations.
Floating Monoliths: Large, geometric pillars that require banking to avoid.
Tight Squeezes: Narrow gaps that require precise pitch/roll control.
Difficulty Scaling: As the score increases, increase the speed of the player and the density/tightness of the procedural obstacles.
3. Visual Direction (Aesthetic & Atmosphere)
Art Style: "Minimalist Low-Poly." Use a highly stylized, geometric aesthetic. No textures; use flat shading or simple vertex colors.
Lighting & Color Palette: Use a "Golden Hour" or "Cyber-Sunset" palette (deep oranges, purples, and silvers). Implement dramatic, long shadows cast by the canyon walls.
Post-Processing Effects (via Three.js):
Bloom: To make the energy orbs and the craft’s engine glow.
Motion Blur: To simulate extreme speed.
Camera Shake: Subtle shake when passing very close to walls or during high-speed boosts.
Particle Systems: Add a "trail" effect behind the player craft and "dust" or "debris" particles that fly past the player to emphasize speed.
4. HUD & User Interface
Minimalist HUD: The HUD should be non-intrusive.
Speedometer: A sleek, thin line representing current velocity.
Multiplier Counter: A large, stylized number that pulses when a "Near-Miss" is detected.
Energy Bar: A glowing bar (possibly circular around the player or at the bottom) that depletes and refills.
Game Over Screen: A clean, high-contrast overlay showing "Final Score," "Max Speed," and a "Restart" button.
5. Technical Requirements
Frameworks: Use Three.js for 3D rendering (include Three.js via CDN).
Performance: Use requestAnimationFrame. Optimize geometry to ensure a smooth 60fps on most hardware.
Collision Detection: Implement efficient bounding-box or sphere-based collision detection between the player and the procedural geometry.
Sound Synthesis: Use the Web Audio API to generate procedural sound effects (a low hum for the engine, a "ping" for collectibles, and a synthesized "whoosh" for near-misses).
Inspect original source 41,171 bytes · SHA-256 df86f2c826e9
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>Ember Canyon - Procedural Canyon Racer</title>
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#120a1c;font-family:"Segoe UI",Helvetica,Arial,sans-serif;color:#f3e6d8;user-select:none;-webkit-user-select:none;touch-action:none}
canvas#c{position:fixed;inset:0;width:100%;height:100%;display:block}
.hud{position:fixed;pointer-events:none}
#score{top:22px;left:28px;font-size:11px;letter-spacing:4px;color:#e9d5c0;opacity:.85}
#scoreVal{display:block;font-size:30px;font-weight:300;letter-spacing:3px;color:#fff3e0;margin-top:4px;font-variant-numeric:tabular-nums}
#speedWrap{top:26px;right:28px;width:200px;text-align:right;font-size:11px;letter-spacing:3px;color:#e9d5c0;opacity:.85}
#speedTrack{margin:8px 0 6px;height:2px;background:rgba(255,255,255,.12);position:relative}
#speedBar{position:absolute;left:0;top:0;bottom:0;width:0;background:linear-gradient(90deg,#ffd08a,#ff5e8a);box-shadow:0 0 10px #ff8a4c}
#speedNum{font-size:12px;font-variant-numeric:tabular-nums;letter-spacing:2px}
#mult{top:16%;left:50%;transform:translateX(-50%);font-size:64px;font-weight:200;color:#ffe2b8;text-shadow:0 0 18px rgba(255,150,80,.55);white-space:nowrap;opacity:.92;text-align:center}
#mult small{display:block;font-size:11px;letter-spacing:5px;color:#c8a8d8;margin-top:-6px}
#mult.pulse{animation:multPulse .38s ease-out}
@keyframes multPulse{0%{transform:translateX(-50%) scale(1.25);color:#ffffff}100%{transform:translateX(-50%) scale(1);color:#ffe2b8}}
#nm{top:25%;left:50%;transform:translateX(-50%);font-size:13px;letter-spacing:5px;color:#fff;opacity:0;white-space:nowrap;text-shadow:0 0 12px #ff9d5c}
#nm.show{animation:nmAnim .9s ease-out}
@keyframes nmAnim{0%{opacity:0;transform:translate(-50%,8px)}15%{opacity:1;transform:translate(-50%,0)}100%{opacity:0;transform:translate(-50%,-16px)}}
#energyWrap{bottom:34px;left:50%;transform:translateX(-50%);width:360px}
#energyLabel{font-size:10px;letter-spacing:4px;color:#e9d5c0;opacity:.8;margin-bottom:8px;display:flex;justify-content:space-between}
#energyTrack{height:6px;background:rgba(255,255,255,.1);border-radius:3px;overflow:hidden;box-shadow:0 0 0 1px rgba(255,220,180,.08)}
#energyBar{height:100%;width:100%;background:linear-gradient(90deg,#ff4f6d,#ffb45c 60%,#ffe39a);box-shadow:0 0 14px rgba(255,170,90,.8)}
#energyBar.low{animation:lowFlash .5s infinite alternate;background:linear-gradient(90deg,#ff2e4d,#ff6a6a)}
@keyframes lowFlash{from{opacity:1}to{opacity:.35}}
#turboTrack{margin-top:7px;height:2px;background:rgba(255,255,255,.1)}
#turboBar{height:100%;width:0;background:linear-gradient(90deg,#8e5bff,#d19bff);box-shadow:0 0 10px #b07bff}
#turboBar.ready{box-shadow:0 0 14px #e0b0ff}
.overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:radial-gradient(ellipse at center,rgba(20,6,30,.45),rgba(6,2,12,.94));z-index:10}
.overlay.hidden{display:none}
.card{max-width:580px;padding:34px 46px;text-align:center}
h1{margin:0;font-weight:200;font-size:44px;letter-spacing:12px;color:#fff3e0;text-shadow:0 0 24px rgba(255,150,80,.5)}
h1 span{display:block;font-size:12px;letter-spacing:6px;color:#f0a878;margin-top:12px}
.sub{font-size:13px;line-height:1.9;color:#d9c6dc;margin:26px 0 0;letter-spacing:1px}
.sub b{color:#ffd9a8;font-weight:500}
.stats{display:grid;grid-template-columns:1fr 1fr;gap:18px 24px;margin:26px 0 4px}
.stat{border-top:1px solid rgba(255,200,150,.25);padding-top:10px}
.stat label{display:block;font-size:10px;letter-spacing:4px;color:#c9a3c8}
.stat div{font-size:26px;font-weight:300;color:#fff;font-variant-numeric:tabular-nums}
.stat.big{grid-column:1/3}
.stat.big div{font-size:46px;color:#ffe2b8}
.reason{font-size:11px;letter-spacing:3px;color:#ff8a8a;margin-top:8px;min-height:14px}
button{margin-top:24px;background:transparent;border:1px solid #ffd9a8;color:#ffe9cf;padding:13px 38px;font-size:13px;letter-spacing:5px;cursor:pointer;box-shadow:0 0 18px rgba(255,160,90,.3);font-family:inherit;transition:all .2s}
button:hover{background:#ffd9a8;color:#1a0a22;box-shadow:0 0 28px rgba(255,200,130,.7)}
.hint{font-size:10px;letter-spacing:3px;color:#9a7fa0;margin-top:16px}
</style>
</head>
<body>
<canvas id="c"></canvas>

<div class="hud" id="score"><span>SCORE</span><span id="scoreVal">000000</span></div>
<div class="hud" id="speedWrap"><div>VELOCITY</div><div id="speedTrack"><div id="speedBar"></div></div><div id="speedNum">0 KPH</div></div>
<div class="hud" id="mult"><span id="multVal">1.0×</span><small>MULTIPLIER</small></div>
<div class="hud" id="nm">NEAR MISS</div>
<div class="hud" id="energyWrap">
  <div id="energyLabel"><span>ENERGY</span><span>TURBO</span></div>
  <div id="energyTrack"><div id="energyBar"></div></div>
  <div id="turboTrack"><div id="turboBar"></div></div>
</div>

<div class="overlay" id="start">
  <div class="card">
    <h1>EMBER CANYON<span>PROCEDURAL CANYON RACER</span></h1>
    <p class="sub">
      <b>A / D</b> bank and strafe &nbsp;·&nbsp; <b>W / S</b> pitch up to climb, dive to accelerate<br>
      <b>SPACE / SHIFT</b> turbo &nbsp;·&nbsp; graze walls and gates for <b>near-miss</b> multipliers<br>
      Collect the gold orbs to refill <b>energy</b> — it drains constantly.
    </p>
    <button id="startBtn">LAUNCH</button>
    <div class="hint">ENTER / SPACE TO START · TOUCH: HOLD TO BOOST, DRAG TO STEER</div>
  </div>
</div>

<div class="overlay hidden" id="over">
  <div class="card">
    <h1>CANYON LOST<span>SIGNAL TERMINATED</span></h1>
    <div class="stats">
      <div class="stat big"><label>FINAL SCORE</label><div id="fScore">0</div></div>
      <div class="stat"><label>MAX SPEED</label><div id="fSpeed">0 KPH</div></div>
      <div class="stat"><label>MAX MULTIPLIER</label><div id="fMult">1.0×</div></div>
      <div class="stat"><label>NEAR MISSES</label><div id="fNear">0</div></div>
      <div class="stat"><label>DISTANCE</label><div id="fDist">0 KM</div></div>
    </div>
    <div class="reason" id="fReason"></div>
    <button id="restartBtn">RESTART</button>
  </div>
</div>

<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/AfterimageShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/Pass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/MaskPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/AfterimagePass.js"></script>
<script>
'use strict';
if (typeof THREE === 'undefined') {
  document.body.innerHTML = '<div style="padding:40px;font-family:sans-serif;color:#fff">Three.js failed to load from CDN.</div>';
  throw new Error('Three.js missing');
}

// ---------- constants & helpers ----------
const CL = 60;             // chunk length
const AHEAD = 340, BEHIND = 70;
const PR = 0.6;            // craft collision radius
const FLOOR = -6, CEIL = 16, CEIL_PLAYER = 9;
const NEAR = 1.1;          // near-miss clearance
const MAXM = 12;
const clamp = (v, a, b) => (v < a ? a : (v > b ? b : v));
const rnd = (a, b) => a + Math.random() * (b - a);

// ---------- state ----------
const S = {
  alive: false, started: false, score: 0, energy: 100, turbo: 20, mult: 1, multTimer: 0, nmCool: 0, wallCool: 0,
  maxV: 0, maxMult: 1, nears: 0, dist: 0, shake: 0, boosting: false, t: 0, overTimer: 0, reason: ''
};
const P = { x: 0, y: 1, z: 0, vx: 0, vy: 0, v: 52, roll: 0, pitch: 0 };
const keys = {};
const touch = { on: false, boost: false, x: 0, y: 0 };
const difficulty = () => clamp(S.score / 7000, 0, 1);

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

const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0xc0643f, 60, 260);
const camera = new THREE.PerspectiveCamera(66, innerWidth / innerHeight, 0.1, 1000);
const camSmooth = new THREE.Vector3(0, 3, 8);

// gradient sky dome (vertex colored, no textures)
(function () {
  const g = new THREE.SphereGeometry(600, 24, 14);
  const pos = g.attributes.position;
  const cols = [];
  const top = new THREE.Color(0x1c0a33), mid = new THREE.Color(0x6d2f56);
  const hor = new THREE.Color(0xf08a4b), low = new THREE.Color(0x140a1f);
  const c = new THREE.Color();
  for (let i = 0; i < pos.count; i++) {
    const t = pos.getY(i) / 600;
    if (t >= 0) {
      if (t < 0.22) c.copy(hor).lerp(mid, t / 0.22);
      else c.copy(mid).lerp(top, Math.min(1, (t - 0.22) / 0.78));
    } else {
      c.copy(hor).lerp(low, Math.min(1, -t * 3));
    }
    cols.push(c.r, c.g, c.b);
  }
  g.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3));
  const m = new THREE.Mesh(g, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.BackSide, fog: false, depthWrite: false }));
  m.renderOrder = -10;
  scene.add(m);
})();

function makeHaloTexture() {
  const cv = document.createElement('canvas');
  cv.width = cv.height = 128;
  const x = cv.getContext('2d');
  const g = x.createRadialGradient(64, 64, 0, 64, 64, 64);
  g.addColorStop(0, 'rgba(255,255,255,1)');
  g.addColorStop(0.2, 'rgba(255,255,255,.5)');
  g.addColorStop(1, 'rgba(255,255,255,0)');
  x.fillStyle = g;
  x.fillRect(0, 0, 128, 128);
  return new THREE.CanvasTexture(cv);
}
const haloTex = makeHaloTexture();

// sun disc + halo (placed far ahead along a fixed direction)
const SUN_DIR = new THREE.Vector3(0.25, 0.3, -0.92).normalize();
const sunMesh = new THREE.Mesh(new THREE.IcosahedronGeometry(26, 1), new THREE.MeshBasicMaterial({ color: 0xffd9a0, fog: false }));
scene.add(sunMesh);
const sunHalo = new THREE.Sprite(new THREE.SpriteMaterial({ map: haloTex, color: 0xff9a4a, blending: THREE.AdditiveBlending, transparent: true, depthWrite: false, fog: false }));
sunHalo.scale.set(340, 340, 1);
scene.add(sunHalo);

// lights: warm low sun with long shadows, purple hemisphere fill
scene.add(new THREE.HemisphereLight(0xffb37a, 0x2a1438, 0.85));
const sunLight = new THREE.DirectionalLight(0xffa35c, 1.9);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(2048, 2048);
{
  const sc = sunLight.shadow.camera;
  sc.left = -45; sc.right = 45; sc.top = 45; sc.bottom = -45; sc.near = 1; sc.far = 260;
  sc.updateProjectionMatrix();
  sunLight.shadow.bias = -0.0008;
  sunLight.shadow.normalBias = 0.03;
}
scene.add(sunLight);
scene.add(sunLight.target);

// floor (receives long canyon shadows); follows player along z
const floor = new THREE.Mesh(
  new THREE.PlaneGeometry(900, 900),
  new THREE.MeshStandardMaterial({ color: 0x3a2347, roughness: 1, metalness: 0, flatShading: true })
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = FLOOR;
floor.receiveShadow = true;
scene.add(floor);

// shared materials & geometries (only wall geometry is per-chunk)
const M = {
  gate: new THREE.MeshStandardMaterial({ color: 0xa04a3c, roughness: 0.8, flatShading: true, emissive: 0x2a0a06 }),
  orb: new THREE.MeshStandardMaterial({ color: 0xffe08a, emissive: 0xffa53a, emissiveIntensity: 2.6, roughness: 0.2, flatShading: true }),
  craftBody: new THREE.MeshStandardMaterial({ color: 0xe6e1f2, roughness: 0.3, metalness: 0.7, flatShading: true }),
  craftDark: new THREE.MeshStandardMaterial({ color: 0x2e2140, roughness: 0.5, metalness: 0.4, flatShading: true }),
  canopy: new THREE.MeshStandardMaterial({ color: 0x6fe3ff, emissive: 0x1a6a88, roughness: 0.2, flatShading: true }),
  engine: new THREE.MeshBasicMaterial({ color: 0xffc27a }),
  monolith: new THREE.MeshStandardMaterial({ color: 0xb9b0cf, roughness: 0.4, metalness: 0.35, flatShading: true })
};
const boxGeo = new THREE.BoxGeometry(1, 1, 1);
const orbGeo = new THREE.IcosahedronGeometry(0.42, 0);
const engineGeo = new THREE.SphereGeometry(0.22, 8, 6);
const orbHaloMat = new THREE.SpriteMaterial({ map: haloTex, color: 0xffa040, blending: THREE.AdditiveBlending, transparent: true, depthWrite: false });
const wallMat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.92, metalness: 0, flatShading: true, side: THREE.DoubleSide });

// ---------- player craft (low-poly) ----------
const craft = new THREE.Group();
craft.rotation.order = 'YXZ';
scene.add(craft);
{
  const body = new THREE.Mesh(new THREE.ConeGeometry(0.36, 2.2, 4), M.craftBody);
  body.rotation.x = -Math.PI / 2; // tip points -Z (forward)
  body.castShadow = true;
  craft.add(body);
  const wing = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.06, 0.8), M.craftDark);
  wing.position.set(0, 0, 0.3);
  wing.castShadow = true;
  craft.add(wing);
  const fin = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.7, 0.7), M.craftBody);
  fin.position.set(0, 0.36, 0.55);
  craft.add(fin);
  const canopy = new THREE.Mesh(new THREE.OctahedronGeometry(0.28, 0), M.canopy);
  canopy.position.set(0, 0.24, -0.3);
  canopy.scale.set(1, 0.7, 1.6);
  craft.add(canopy);
  const eng = new THREE.Mesh(engineGeo, M.engine);
  eng.position.set(0, 0, 1.15);
  craft.add(eng);
}
const engineHalo = new THREE.Sprite(new THREE.SpriteMaterial({ map: haloTex, color: 0xff9040, blending: THREE.AdditiveBlending, transparent: true, depthWrite: false }));
engineHalo.position.set(0, 0, 1.3);
engineHalo.scale.set(1.6, 1.6, 1);
craft.add(engineHalo);

// ---------- particle systems ----------
// engine trail: ring buffer of recent nozzle positions, fading by index
const TR = 48;
const trPos = new Float32Array(TR * 3);
const trCol = new Float32Array(TR * 3);
for (let i = 0; i < TR; i++) {
  const f = Math.pow(1 - i / TR, 1.5);
  trCol[i * 3] = f; trCol[i * 3 + 1] = f * 0.5; trCol[i * 3 + 2] = f * 0.22;
}
const trGeo = new THREE.BufferGeometry();
trGeo.setAttribute('position', new THREE.BufferAttribute(trPos, 3));
trGeo.setAttribute('color', new THREE.BufferAttribute(trCol, 3));
const trail = new THREE.Points(trGeo, new THREE.PointsMaterial({ size: 0.55, vertexColors: true, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false }));
trail.frustumCulled = false;
scene.add(trail);

// dust / debris: fixed pool that streams past the player (wraps, no allocation)
const DU = 320;
const duPos = new Float32Array(DU * 3);
const duSpd = new Float32Array(DU);
for (let i = 0; i < DU; i++) {
  duPos[i * 3] = rnd(-18, 18);
  duPos[i * 3 + 1] = rnd(-5, 14);
  duPos[i * 3 + 2] = rnd(-330, 20);
  duSpd[i] = rnd(0.7, 1.3);
}
const duGeo = new THREE.BufferGeometry();
duGeo.setAttribute('position', new THREE.BufferAttribute(duPos, 3));
const dustMat = new THREE.PointsMaterial({ size: 0.24, color: 0xffd7a8, transparent: true, opacity: 0.6, blending: THREE.AdditiveBlending, depthWrite: false });
const dust = new THREE.Points(duGeo, dustMat);
dust.frustumCulled = false;
scene.add(dust);

// crash sparks (fixed pool)
const SP_N = 90;
const spPos = new Float32Array(SP_N * 3);
const spVel = new Float32Array(SP_N * 3);
const spGeo = new THREE.BufferGeometry();
spGeo.setAttribute('position', new THREE.BufferAttribute(spPos, 3));
const spMat = new THREE.PointsMaterial({ color: 0xffb050, size: 0.4, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false });
const sparks = new THREE.Points(spGeo, spMat);
sparks.frustumCulled = false;
scene.add(sparks);
let sparkLife = 0;
function burst(x, y, z) {
  for (let i = 0; i < SP_N; i++) {
    spPos[i * 3] = x; spPos[i * 3 + 1] = y; spPos[i * 3 + 2] = z;
    const a = Math.random() * Math.PI * 2, s = rnd(8, 36);
    spVel[i * 3] = Math.cos(a) * s;
    spVel[i * 3 + 1] = Math.sin(a) * s * 0.7 + rnd(0, 8);
    spVel[i * 3 + 2] = rnd(-10, 10);
  }
  sparkLife = 1.6;
}
function updateSparks(dt) {
  if (sparkLife <= 0) { spMat.opacity = 0; return; }
  sparkLife -= dt;
  const damp = Math.exp(-dt * 2.5);
  for (let i = 0; i < SP_N * 3; i++) {
    spVel[i] *= damp;
    spPos[i] += spVel[i] * dt;
  }
  spMat.opacity = clamp(sparkLife, 0, 1);
  spGeo.attributes.position.needsUpdate = true;
}

// ---------- post-processing: bloom + speed-driven motion blur ----------
const composer = new THREE.EffectComposer(renderer);
composer.addPass(new THREE.RenderPass(scene, camera));
const bloom = new THREE.UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.9, 0.5, 0.55);
composer.addPass(bloom);
const afterimage = new THREE.AfterimagePass(0.0);
afterimage.renderToScreen = true;
composer.addPass(afterimage);

// ---------- procedural audio (Web Audio, no files) ----------
let AC = null, sfxBus = null, noiseBuf = null, eng = {};
function initAudio() {
  if (AC) { if (AC.state === 'suspended') AC.resume(); return; }
  AC = new (window.AudioContext || window.webkitAudioContext)();
  sfxBus = AC.createGain();
  sfxBus.gain.value = 0.5;
  sfxBus.connect(AC.destination);

  noiseBuf = AC.createBuffer(1, AC.sampleRate * 2, AC.sampleRate);
  const nd = noiseBuf.getChannelData(0);
  for (let i = 0; i < nd.length; i++) nd[i] = Math.random() * 2 - 1;

  // engine hum: two oscillators through a lowpass
  const o1 = AC.createOscillator(); o1.type = 'sawtooth'; o1.frequency.value = 48;
  const o2 = AC.createOscillator(); o2.type = 'triangle'; o2.frequency.value = 72;
  const lp = AC.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 300; lp.Q.value = 3;
  const eg = AC.createGain(); eg.gain.value = 0;
  o1.connect(lp); o2.connect(lp); lp.connect(eg); eg.connect(sfxBus);
  o1.start(); o2.start();

  // wind: looping band-passed noise tied to speed
  const ns = AC.createBufferSource(); ns.buffer = noiseBuf; ns.loop = true;
  const bp = AC.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 800; bp.Q.value = 0.7;
  const wg = AC.createGain(); wg.gain.value = 0;
  ns.connect(bp); bp.connect(wg); wg.connect(sfxBus);
  ns.start();
  eng = { o1, o2, lp, eg, wg };
}
function tone(fA, fB, dur, type, vol) {
  if (!AC) return;
  const t = AC.currentTime;
  const o = AC.createOscillator(), g = AC.createGain();
  o.type = type;
  o.frequency.setValueAtTime(fA, t);
  o.frequency.exponentialRampToValueAtTime(fB, t + dur);
  g.gain.setValueAtTime(vol, t);
  g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
  o.connect(g); g.connect(sfxBus);
  o.start(t); o.stop(t + dur + 0.05);
}
function noiseFx(dur, f0, f1, vol, type) {
  if (!AC) return;
  const t = AC.currentTime;
  const s = AC.createBufferSource(); s.buffer = noiseBuf;
  const f = AC.createBiquadFilter(); f.type = type || 'bandpass'; f.Q.value = 1.2;
  f.frequency.setValueAtTime(f0, t);
  f.frequency.exponentialRampToValueAtTime(f1, t + dur);
  const g = AC.createGain();
  g.gain.setValueAtTime(0.0001, t);
  g.gain.exponentialRampToValueAtTime(vol, t + dur * 0.25);
  g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
  s.connect(f); f.connect(g); g.connect(sfxBus);
  s.start(t, Math.random() * 1.0); s.stop(t + dur + 0.05);
}
const sfx = {
  ping() { tone(1100, 2300, 0.22, 'sine', 0.22); tone(1650, 3400, 0.3, 'triangle', 0.07); },
  whoosh(s) { noiseFx(0.5, 380 + s * 300, 2600, 0.3 * (0.6 + s * 0.4)); },
  crash() { noiseFx(1.3, 1400, 70, 0.8, 'lowpass'); tone(170, 26, 1.0, 'sawtooth', 0.35); }
};
function updateAudio() {
  if (!AC || !eng.o1) return;
  const t = AC.currentTime, v = P.v;
  const f = 44 + v * 0.55 + (S.boosting ? 20 : 0);
  eng.o1.frequency.setTargetAtTime(f, t, 0.08);
  eng.o2.frequency.setTargetAtTime(f * 1.5, t, 0.08);
  eng.lp.frequency.setTargetAtTime(220 + v * 5, t, 0.1);
  eng.eg.gain.setTargetAtTime(S.alive ? 0.09 + (S.boosting ? 0.05 : 0) : 0, t, 0.2);
  eng.wg.gain.setTargetAtTime(S.alive ? clamp((v - 55) / 120, 0, 1) * 0.18 : 0, t, 0.2);
}

// ---------- world generation (chunked; analytic canyon so walls are seam-free) ----------
const chunks = [];
const chunkByK = new Map();
let nextK = 0;
let activeOrbs = [];
const KNOT = 6;                 // z spacing of wall profile knots (CL divisible by KNOT)
const NK = CL / KNOT + 1;
const PN = 5;                   // profile points per wall cross-section

function hash(x) { const s = Math.sin(x * 12.9898) * 43758.5453; return s - Math.floor(s); }
function canyonBase(z) { return 9.5 + 2.4 * Math.sin(z * 0.021) + 1.4 * Math.sin(z * 0.0573 + 1.7); }
function wallAt(z, c) {
  const t = clamp((c.zS - z) / CL, 0, 1);
  return canyonBase(z) - c.pinch * Math.sin(Math.PI * t);
}
function canyonW(z) {
  const c = chunkByK.get(Math.floor(-z / CL));
  return c ? wallAt(z, c) : canyonBase(z);
}

const WALL_BASE = [new THREE.Color(0x2b1538), new THREE.Color(0x7e3a58), new THREE.Color(0xe0804a), new THREE.Color(0x3b1f4f), new THREE.Color(0x2a1538)];
function buildWallGeo(c) {
  const count = 2 * NK * PN;
  const pos = new Float32Array(count * 3), col = new Float32Array(count * 3);
  const idx = [];
  for (let si = 0; si < 2; si++) {
    const s = si === 0 ? -1 : 1;
    for (let n = 0; n < NK; n++) {
      const z = c.zS - n * KNOT;
      const W = wallAt(z, c);
      const offs = [1.1 + 0.5 * Math.sin(z * 0.37 + si), 0, 0.9 + 0.5 * Math.sin(z * 0.53 + si * 2), 38, 38];
      const ys = [FLOOR - 1.2, FLOOR + 8 + 2.2 * Math.sin(z * 0.13 + si), CEIL + 0.5 * Math.sin(z * 0.2), CEIL + 0.5, FLOOR - 1.2];
      for (let p = 0; p < PN; p++) {
        const i = (si * NK + n) * PN + p;
        pos[i * 3] = s * (W + offs[p]);
        pos[i * 3 + 1] = ys[p];
        pos[i * 3 + 2] = z;
        const j = 0.85 + 0.3 * hash(n * 7 + p * 13 + si * 31 + c.k * 17);
        col[i * 3] = WALL_BASE[p].r * j;
        col[i * 3 + 1] = WALL_BASE[p].g * j;
        col[i * 3 + 2] = WALL_BASE[p].b * j;
      }
    }
    for (let n = 0; n < NK - 1; n++) for (let p = 0; p < PN - 1; p++) {
      const a = (si * NK + n) * PN + p, b = a + 1;
      const d = (si * NK + n + 1) * PN + p, e = d + 1;
      idx.push(a, b, d, b, e, d);
    }
  }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  g.setAttribute('color', new THREE.BufferAttribute(col, 3));
  g.setIndex(idx);
  g.computeVertexNormals();
  return g;
}

function makeBoxMesh(b, mat) {
  const m = new THREE.Mesh(boxGeo, mat);
  m.scale.set(b.x1 - b.x0, b.y1 - b.y0, b.z1 - b.z0);
  m.position.set((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, (b.z0 + b.z1) / 2);
  m.castShadow = true;
  m.receiveShadow = true;
  return m;
}

function insideAnyBox(x, y, z, c, pad) {
  for (const b of c.boxes) {
    if (x > b.x0 - pad && x < b.x1 + pad && y > b.y0 - pad && y < b.y1 + pad && z > b.z0 - pad && z < b.z1 + pad) return true;
  }
  return false;
}

function genChunk(k) {
  const zS = -k * CL, zE = zS - CL;
  const d = difficulty();
  const c = { k, zS, zE, group: new THREE.Group(), boxes: [], orbs: [], pinch: 0, wall: null };
  chunkByK.set(k, c);

  // pattern selection; pinch = sculpted canyon narrowing (never below ~4.5 half-width)
  let pattern = 'open';
  if (k >= 2) {
    const r = Math.random();
    pattern = r < 0.22 ? 'pinch' : (r < 0.5 ? 'mono' : (r < 0.75 ? 'gate' : 'mix'));
    if (pattern === 'pinch') {
      let minBase = Infinity;
      for (let i = 0; i <= 6; i++) minBase = Math.min(minBase, canyonBase(zS - CL * i / 6));
      c.pinch = clamp(rnd(2.2, 3.0) + d * 0.6, 0, Math.max(0, minBase - 4.5));
    }
  }

  c.wall = new THREE.Mesh(buildWallGeo(c), wallMat);
  c.wall.castShadow = true;
  c.wall.receiveShadow = true;
  c.group.add(c.wall);

  let n = 0;
  if (k >= 2) n = 1 + (Math.random() < 0.35 + d * 0.5 ? 1 : 0) + (d > 0.5 && Math.random() < 0.5 ? 1 : 0);

  const gates = [];
  for (let i = 0; i < n; i++) {
    const zc = zS - CL * (i + 1) / (n + 1) + rnd(-3, 3);
    const W = wallAt(zc, c);
    let wantGate;
    if (pattern === 'gate') wantGate = Math.random() < 0.8;
    else if (pattern === 'pinch') wantGate = Math.random() < 0.7;
    else if (pattern === 'mix') wantGate = Math.random() < 0.5;
    else wantGate = Math.random() < 0.25 + d * 0.3;

    if (wantGate) {
      // tight squeeze: frame spanning the canyon with a rectangular hole
      const gw = Math.max(2.5, 3.5 - d * 1.0);
      const gr = Math.max(0, W - gw / 2 - 1.5);
      const gx = rnd(-gr, gr);
      const gy = rnd(-2.4, 2.8);
      const gd = 1.6;
      const z0 = zc - gd / 2, z1 = zc + gd / 2;
      const L = -W - 2, R = W + 2, TOP = CEIL + 4, BOT = FLOOR - 2;
      const pieces = [
        { x0: L, x1: gx - gw / 2, y0: BOT, y1: TOP },
        { x0: gx + gw / 2, x1: R, y0: BOT, y1: TOP },
        { x0: gx - gw / 2, x1: gx + gw / 2, y0: gy + gw / 2, y1: TOP },
        { x0: gx - gw / 2, x1: gx + gw / 2, y0: BOT, y1: gy - gw / 2 }
      ];
      const owner = { nm: false, kind: 'gate' };
      for (const p of pieces) {
        if (p.x1 - p.x0 < 0.05 || p.y1 - p.y0 < 0.05) continue;
        const b = { x0: p.x0, x1: p.x1, y0: p.y0, y1: p.y1, z0, z1, owner };
        c.boxes.push(b);
        c.group.add(makeBoxMesh(b, M.gate));
      }
      gates.push({ gx, gy, zc });
    } else {
      // floating monolith: must bank around it
      const mw = rnd(2.8, 3.6), md = 2.4, mh = rnd(6, 9);
      const yb = rnd(-3.8, 0.8);
      const range = Math.max(0, W - mw / 2 - 1.4);
      const mx = rnd(-range, range);
      const b = { x0: mx - mw / 2, x1: mx + mw / 2, y0: yb, y1: Math.min(CEIL, yb + mh), z0: zc - md / 2, z1: zc + md / 2, owner: { nm: false, kind: 'mono' } };
      c.boxes.push(b);
      c.group.add(makeBoxMesh(b, M.monolith));
    }
  }

  // energy orbs (some sit inside gate holes = risky reward)
  let oc = (Math.random() < 0.5 + d * 0.1 ? 1 : 0) + (Math.random() < 0.2 ? 1 : 0);
  if (k < 2) oc = 1;
  for (let j = 0; j < oc; j++) {
    let x, y, z;
    if (gates.length && Math.random() < 0.5) {
      const g = gates[0];
      x = g.gx; y = g.gy; z = g.zc;
    } else {
      const W = wallAt(zS - CL / 2, c);
      x = rnd(-W * 0.6, W * 0.6);
      y = rnd(-2.5, 3);
      z = rnd(zE + 8, zS - 8);
      if (insideAnyBox(x, y, z, c, 0.9)) continue;
    }
    const mesh = new THREE.Mesh(orbGeo, M.orb);
    mesh.position.set(x, y, z);
    const h = new THREE.Sprite(orbHaloMat);
    h.scale.set(3.2, 3.2, 1);
    mesh.add(h);
    c.group.add(mesh);
    const o = { mesh, x, y, z, chunk: c, taken: false };
    c.orbs.push(o);
    activeOrbs.push(o);
  }

  scene.add(c.group);
  chunks.push(c);
}

function removeChunk(c) {
  scene.remove(c.group);
  c.wall.geometry.dispose();          // per-chunk GPU buffer; boxes & materials are shared
  for (const o of c.orbs) o.taken = true;
  activeOrbs = activeOrbs.filter(o => o.chunk !== c);
  chunkByK.delete(c.k);
}

function ensureChunks() {
  while (-(nextK + 1) * CL > P.z - AHEAD) { genChunk(nextK); nextK++; }
  for (let i = chunks.length - 1; i >= 0; i--) {
    const c = chunks[i];
    if (c.zE > P.z + BEHIND) { removeChunk(c); chunks.splice(i, 1); }
  }
}
function resetWorld() {
  for (const c of chunks) { scene.remove(c.group); c.wall.geometry.dispose(); }
  chunks.length = 0;
  chunkByK.clear();
  activeOrbs = [];
  nextK = 0;
  ensureChunks();
}

// ---------- gameplay logic ----------
function nearMiss(clear, kind) {
  S.nears++;
  const closeness = 1 - clamp(clear / NEAR, 0, 1);
  S.mult = Math.min(MAXM, S.mult + 0.25 + 0.6 * closeness);
  S.maxMult = Math.max(S.maxMult, S.mult);
  S.multTimer = 3.2;
  S.score += Math.round(120 * S.mult);
  S.turbo = Math.min(100, S.turbo + 6);
  S.shake = Math.max(S.shake, 0.35);
  S.nmCool = 0.35;
  sfx.whoosh(closeness);
  const el = document.getElementById('mult');
  el.classList.remove('pulse'); void el.offsetWidth; el.classList.add('pulse');
  const nm = document.getElementById('nm');
  nm.textContent = closeness > 0.6 ? 'SKIN GRAZE' : (kind === 'gate' ? 'TIGHT SQUEEZE' : 'NEAR MISS');
  nm.classList.remove('show'); void nm.offsetWidth; nm.classList.add('show');
}

function crash(reason) {
  if (!S.alive) return;
  S.alive = false;
  S.reason = reason;
  S.overTimer = 1.6;
  S.boosting = false;
  sfx.crash();
  burst(P.x, P.y, P.z);
  S.shake = 1.2;
  craft.visible = false;
}

function checkWorld(dt) {
  const W = canyonW(P.z);
  const wallClear = Math.min(P.x + W, W - P.x) - PR;
  if (wallClear < 0) { crash('WALL IMPACT'); return; }
  if (P.y - PR < FLOOR) { crash('GROUND IMPACT'); return; }

  // obstacle boxes: sphere vs AABB. Track the closest graze this frame.
  let best = Infinity, bestOwner = null, bestKind = '';
  for (const c of chunks) {
    for (const b of c.boxes) {
      if (P.z < b.z0 - 3 || P.z > b.z1 + 3) continue;
      const cx = clamp(P.x, b.x0, b.x1), cy = clamp(P.y, b.y0, b.y1), cz = clamp(P.z, b.z0, b.z1);
      const dx = P.x - cx, dy = P.y - cy, dz = P.z - cz;
      const clear = Math.sqrt(dx * dx + dy * dy + dz * dz) - PR;
      if (clear < 0) { crash(b.owner.kind === 'gate' ? 'SQUEEZED THROUGH FRAME' : 'MONOLITH IMPACT'); return; }
      if (clear < NEAR && !b.owner.nm && clear < best) { best = clear; bestOwner = b.owner; bestKind = b.owner.kind; }
    }
  }
  if (bestOwner && S.nmCool <= 0) {
    bestOwner.nm = true;
    nearMiss(best, bestKind);
  }

  // canyon wall graze (rate limited)
  S.wallCool = Math.max(0, S.wallCool - dt);
  if (wallClear < NEAR && S.wallCool <= 0 && S.nmCool <= 0) {
    S.wallCool = 0.9;
    nearMiss(wallClear, 'wall');
  }
  if (wallClear < 0.5) S.shake = Math.max(S.shake, 0.25);

  // orbs: refill energy + turbo
  for (let i = activeOrbs.length - 1; i >= 0; i--) {
    const o = activeOrbs[i];
    const dx = P.x - o.x, dy = P.y - o.y, dz = P.z - o.z;
    if (dx * dx + dy * dy + dz * dz < (PR + 0.55) * (PR + 0.55)) {
      o.taken = true;
      o.chunk.group.remove(o.mesh);
      activeOrbs.splice(i, 1);
      S.energy = Math.min(100, S.energy + 22);
      S.turbo = Math.min(100, S.turbo + 8);
      S.score += Math.round(60 * S.mult);
      burst_orb();
    }
  }
}

// orb pickup: flash the orb's halo briefly (no per-pickup allocation)
function burst_orb() {
  sfx.ping();
}

function update(dt) {
  S.t += dt;
  const left = keys.KeyA || keys.ArrowLeft, right = keys.KeyD || keys.ArrowRight;
  const up = keys.KeyW || keys.ArrowUp, down = keys.KeyS || keys.ArrowDown;
  let ix = (right ? 1 : 0) - (left ? 1 : 0);
  let iy = (up ? 1 : 0) - (down ? 1 : 0);
  let wantBoost = !!(keys.Space || keys.ShiftLeft || keys.ShiftRight);
  if (touch.on) { ix = touch.x; iy = touch.y * 0.8; wantBoost = touch.boost; }
  ix = clamp(ix, -1, 1); iy = clamp(iy, -1, 1);

  if (S.alive && S.started) {
    const boosting = wantBoost && S.turbo > 0.5;
    S.boosting = boosting;
    const d = difficulty();

    // flight model: lateral/vertical velocity lags input (weight)
    P.vx += (ix * 22 - P.vx) * (1 - Math.exp(-dt * 3.0));
    P.x += P.vx * dt;
    P.roll += (ix * 0.8 - P.roll) * (1 - Math.exp(-dt * 5));
    P.pitch += (iy * 0.42 - P.pitch) * (1 - Math.exp(-dt * 4));
    const vyT = Math.sin(P.pitch) * P.v * 0.55;
    P.vy += (vyT - P.vy) * (1 - Math.exp(-dt * 3.5));
    P.y += P.vy * dt;
    if (P.y > CEIL_PLAYER) { P.y = CEIL_PLAYER; P.vy = Math.min(P.vy, 0); }

    // pitch-based acceleration: diving speeds up, climbing bleeds speed
    let vBase = 50 + d * 36 - Math.abs(ix) * 5;
    if (boosting) vBase += 42;
    vBase = Math.min(vBase, 140);
    P.v += (vBase - P.v) * (1 - Math.exp(-dt * 0.9));
    P.v += (Math.max(0, -P.pitch) * 34 - Math.max(0, P.pitch) * 22) * dt;
    P.v = clamp(P.v, 25, 150);
    P.z -= P.v * dt;
    S.dist += P.v * dt;
    S.maxV = Math.max(S.maxV, P.v);

    // turbo meter: spend while boosting, build on sustained straights
    if (boosting) S.turbo = Math.max(0, S.turbo - 26 * dt);
    else if (P.v > 85) S.turbo = Math.min(100, S.turbo + (Math.abs(ix) < 0.25 ? 5 : 2) * dt);

    // constant energy decay (rises with difficulty and boosting)
    S.energy -= (2.8 + d * 2.2 + (boosting ? 2.5 : 0)) * dt;
    if (S.energy <= 0) { S.energy = 0; crash('OUT OF ENERGY'); }

    // multiplier decay + score
    if (S.multTimer > 0) S.multTimer -= dt;
    else if (S.mult > 1) S.mult = Math.max(1, S.mult - dt * 0.5);
    S.score += P.v * 0.5 * dt * S.mult;
    S.nmCool = Math.max(0, S.nmCool - dt);

    ensureChunks();
    if (S.alive) checkWorld(dt);
  } else {
    S.boosting = false;
    if (!S.alive && S.started && S.overTimer > 0) {
      S.overTimer -= dt;
      if (S.overTimer <= 0) showOver();
    }
    ensureChunks();
  }

  for (const c of chunks) for (const o of c.orbs) if (!o.taken) o.mesh.rotation.y += dt * 2.2;

  updateSparks(dt);
  visuals(dt);
  hud();
  updateAudio();
}

// ---------- visuals ----------
const _tmpV = new THREE.Vector3();
const _lookV = new THREE.Vector3();
let sunPulse = 0;
function visuals(dt) {
  // craft transform: pitch from input, banking from lateral input, yaw toward travel
  craft.position.set(P.x, P.y, P.z);
  craft.rotation.set(P.pitch * 0.7, -P.vx * 0.018, -P.roll * 1.0);
  craft.visible = S.alive;
  const boostK = S.boosting ? 1 : 0;
  engineHalo.scale.setScalar(1.6 + boostK * 1.2 + Math.sin(S.t * 40) * 0.1);
  engineHalo.material.opacity = 0.8 + boostK * 0.2;

  // engine trail: shift ring history, insert current nozzle point
  for (let i = TR - 1; i > 0; i--) {
    trPos[i * 3] = trPos[(i - 1) * 3];
    trPos[i * 3 + 1] = trPos[(i - 1) * 3 + 1];
    trPos[i * 3 + 2] = trPos[(i - 1) * 3 + 2];
  }
  trPos[0] = P.x; trPos[1] = P.y; trPos[2] = P.z + 1.3;
  if (!S.alive) { for (let i = 0; i < TR * 3; i++) trPos[i] = 0; }
  trGeo.attributes.position.needsUpdate = true;
  trail.material.size = 0.45 + (S.boosting ? 0.35 : 0);

  // dust: static in world, wrapped around player to create streaming motion
  const span = 350;
  for (let i = 0; i < DU; i++) {
    let z = duPos[i * 3 + 2];
    if (z > P.z + 22) z -= span;
    else if (z < P.z - 330) z += span;
    duPos[i * 3 + 2] = z;
  }
  duGeo.attributes.position.needsUpdate = true;
  dustMat.size = 0.22 + clamp((P.v - 50) / 100, 0, 1) * 0.25;
  dustMat.opacity = 0.35 + clamp((P.v - 50) / 90, 0, 1) * 0.5;

  // sun & light follow the view / player
  sunMesh.position.copy(camera.position).addScaledVector(SUN_DIR, 500);
  sunHalo.position.copy(sunMesh.position);
  sunLight.position.set(P.x + SUN_DIR.x * 120, P.y + SUN_DIR.y * 120, P.z + SUN_DIR.z * 120);
  sunLight.target.position.set(P.x, FLOOR, P.z - 40);
  sunLight.target.updateMatrixWorld();
  floor.position.z = P.z;
  floor.position.x = P.x;

  // sky dome follows camera
  scene.children.forEach(ch => { if (ch.geometry && ch.geometry.type === 'SphereGeometry') ch.position.copy(camera.position); });

  // camera chase rig
  const speedK = clamp((P.v - 50) / 90, 0, 1);
  const boostK2 = S.boosting ? 1 : 0;
  _tmpV.set(P.x * 0.7 - P.roll * 1.5, P.y + 2.3 + speedK * 0.2, P.z + 9.2 - speedK * 0.4);
  camSmooth.lerp(_tmpV, 1 - Math.exp(-dt * 7));
  // camera shake: walls close, boost, crashes
  S.shake = Math.max(0, S.shake - dt * 2.4);
  const sh = S.shake * S.shake;
  camera.position.set(
    camSmooth.x + (Math.random() - 0.5) * sh * 1.8,
    camSmooth.y + (Math.random() - 0.5) * sh * 1.8 + boostK2 * (Math.random() - 0.5) * 0.12,
    camSmooth.z
  );
  _lookV.set(P.x * 0.8, P.y * 0.85 + 0.3, P.z - 16);
  camera.lookAt(_lookV);

  // FOV widens with turbo / boost
  const targetFov = 66 + boostK2 * 14 + speedK * 4 + (S.alive ? 0 : -6);
  camera.fov += (targetFov - camera.fov) * (1 - Math.exp(-dt * 4));
  camera.updateProjectionMatrix();

  // motion blur (afterimage damp) and bloom scale with speed
  afterimage.uniforms['damp'].value = S.alive ? clamp((P.v - 60) / 120, 0, 1) * 0.5 : 0;
  bloom.strength = 0.85 + boostK2 * 0.35 + (S.mult > 3 ? 0.1 : 0);

  // orb hue pulse
  sunPulse += dt;
  orbHaloMat.opacity = 0.7 + 0.25 * Math.sin(sunPulse * 3);

  // fog tint: darker in deep canyon/high difficulty
  scene.fog.far = 260 - difficulty() * 60;
}

// ---------- HUD ----------
const el = {
  score: document.getElementById('scoreVal'), speedBar: document.getElementById('speedBar'), speedNum: document.getElementById('speedNum'),
  mult: document.getElementById('multVal'), energyBar: document.getElementById('energyBar'), turboBar: document.getElementById('turboBar')
};
let lastScoreTxt = '';
function hud() {
  const sTxt = String(Math.floor(S.score)).padStart(6, '0');
  if (sTxt !== lastScoreTxt) { el.score.textContent = sTxt; lastScoreTxt = sTxt; }
  el.speedBar.style.width = (clamp((P.v - 25) / 125, 0, 1) * 100).toFixed(1) + '%';
  el.speedNum.textContent = Math.round(P.v * 3.2) + ' KPH';
  el.mult.textContent = S.mult.toFixed(1) + '×';
  el.energyBar.style.width = clamp(S.energy, 0, 100).toFixed(1) + '%';
  el.energyBar.classList.toggle('low', S.energy < 25 && S.alive);
  el.turboBar.style.width = clamp(S.turbo, 0, 100).toFixed(1) + '%';
  el.turboBar.classList.toggle('ready', S.turbo >= 100);
}

function showOver() {
  document.getElementById('fScore').textContent = Math.floor(S.score).toLocaleString();
  document.getElementById('fSpeed').textContent = Math.round(S.maxV * 3.2) + ' KPH';
  document.getElementById('fMult').textContent = S.maxMult.toFixed(1) + '×';
  document.getElementById('fNear').textContent = S.nears;
  document.getElementById('fDist').textContent = (S.dist / 1000 * 1.0).toFixed(2) + ' KM';
  document.getElementById('fReason').textContent = S.reason;
  document.getElementById('over').classList.remove('hidden');
}

// ---------- flow: start / restart ----------
function resetGame() {
  Object.assign(S, { alive: true, started: true, score: 0, energy: 100, turbo: 20, mult: 1, multTimer: 0, nmCool: 0, wallCool: 0,
    maxV: 0, maxMult: 1, nears: 0, dist: 0, shake: 0, boosting: false, overTimer: 0, reason: '' });
  Object.assign(P, { x: 0, y: 1, z: 0, vx: 0, vy: 0, v: 52, roll: 0, pitch: 0 });
  camSmooth.set(0, 3.3, 9.2);
  craft.visible = true;
  sparkLife = 0; spMat.opacity = 0;
  resetWorld();
  document.getElementById('start').classList.add('hidden');
  document.getElementById('over').classList.add('hidden');
  initAudio();
}
document.getElementById('startBtn').addEventListener('click', () => { if (!S.alive && !S.started) resetGame(); });
document.getElementById('restartBtn').addEventListener('click', () => resetGame());

// ---------- input ----------
const STOP = new Set(['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ShiftLeft', 'ShiftRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD']);
addEventListener('keydown', e => {
  if (STOP.has(e.code)) e.preventDefault();
  keys[e.code] = true;
  if ((e.code === 'Enter' || e.code === 'Space') && !e.repeat) {
    if (!S.started) resetGame();
    else if (!S.alive && S.overTimer <= 0 && !document.getElementById('over').classList.contains('hidden')) resetGame();
  }
});
addEventListener('keyup', e => { keys[e.code] = false; });
addEventListener('blur', () => { for (const k in keys) keys[k] = false; });

// touch: drag to steer (relative to touch-down), hold to boost
let tStart = null;
canvas.addEventListener('pointerdown', e => {
  if (e.pointerType !== 'touch') return;
  touch.on = true; touch.boost = true; tStart = { x: e.clientX, y: e.clientY };
  if (!S.started) resetGame();
});
canvas.addEventListener('pointermove', e => {
  if (!touch.on || !tStart) return;
  touch.x = clamp((e.clientX - tStart.x) / 90, -1, 1);
  touch.y = clamp(-(e.clientY - tStart.y) / 90, -1, 1);
});
const endTouch = () => { touch.on = false; touch.boost = false; touch.x = 0; touch.y = 0; tStart = null; };
canvas.addEventListener('pointerup', endTouch);
canvas.addEventListener('pointercancel', endTouch);

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

// ---------- main loop ----------
let last = performance.now();
function frame(now) {
  requestAnimationFrame(frame);
  const dt = Math.min(0.033, (now - last) / 1000);
  last = now;
  update(dt);
  composer.render();
}
// initial world so the backdrop isn't empty behind the title card
ensureChunks();
camSmooth.set(0, 3.3, 9.2);
visuals(0.016);
requestAnimationFrame(frame);
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"e3f2dc09-fd7a-4dfd-a3dd-ac4476a9ccfc","tokensIn":917620,"tokensOut":431883,"tokensTotal":1349503,"cost":0.23504799999999998,"turns":18,"toolCalls":23,"failedToolCalls":1,"timestamp":"2026-10-08T02:31:09.453Z"} -->