← Race the Sun results

Race the Sun

Claude haiku 5.5 · full

Initial view of Race the Sun

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 · ed8f7d7215
# Task: Build an HTML Game Create an endless-runner game (vanilla JS, no external assets, no CDN, no libraries) in the spirit of *Race the Sun*. The top priority is **fun, tight, juicy gameplay** — every mechanic should feel responsive and satisfying. ## Core Fantasy Pilot a sleek solar craft endlessly forward through a procedural geometric world. The world revolves around a distant sun on the horizon. Stay in the light, dodge obstacles, grab energy, and survive as long as possible as speed ramps up. ## Gameplay Specification **Movement** - Auto-forward motion; speed gradually accelerates over time - Player controls: Left/Right strafe, Up/Down pitch (WASD or arrow keys) - Bank/roll the craft when strafing (visual tilt + slight forward lean on pitch) - Snappy controls: short easing on input (≈80–120ms), but with a tiny inertia tail so it feels weighty, not floaty - Optional: small "boost" on tap (space) limited by an energy bar **World & Camera** - Endless procedural course seeded by a per-run seed - Three lanes or a wide 2D plane; obstacles spawn in waves and ramps - Camera fixed behind/above the craft, with subtle screen-shake on near-misses and bigger shake on collisions - Speed lines / chromatic-aberration flourish when above a velocity threshold **Sun & Energy** - The sun sits on the horizon and slowly sinks during a run; if it sets, game over - Craft has an energy meter; drains slowly, drains faster when in shadow (behind tall obstacles or off-sun side) - Pickups: ⚡ energy cells, ✦ score tokens, and chain-multiplier orbs - Crashing = game over; near-misses = score bonus + screen-shake **Scoring & Loop** - Score = distance × multiplier from collected tokens - Show distance, score, multiplier, energy, and current speed on HUD - Local high score saved via `localStorage` - Title screen → play → game-over screen with restart (R or click) ## Visual & Audio Style - **Look:** minimalist low-poly geometry, bold silhouettes, vibrant gradient sky (sunset palette), strong contrast between sunlit and shadowed zones - **FX:** particles for thrust, pickups, and crashes; brief flash on damage; speed-line overlay at high speed - **Sound:** procedurally generated via WebAudio (short synth blips for pickup, low thud for crash, wind/drone loop tied to speed) — keep it simple but punchy ## Technical Requirements - HTML5 Canvas, requestAnimationFrame, target 60 FPS - Procedural generation with a deterministic seed per run - Simple geometric collision (AABB or circle vs. AABB) - Clear section comments (`// === SETUP ===`, etc.) - Tunable constants block at the top (speeds, gravity, spawn rates, colors) for easy balancing - Aim for 600–1500 lines of focused, readable code ## Quality Bar (must hit all) - Controls feel *immediately* responsive — no input lag, no sticky movement - Difficulty curve is fair: easy to learn, tense by 30s, frantic by 90s - Every interaction has feedback: particles, shake, sound, color - It must be fun to fail — restart-to-play should be one keystroke - Runs smoothly in a modern Chrome/Firefox tab at 1080p
Inspect original source 38,773 bytes · SHA-256 ed91005e32c9
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SOLAR DASH</title>
<style>
  html,body{margin:0;height:100%;background:#0b0415;overflow:hidden;font-family:"Trebuchet MS","Segoe UI",Verdana,sans-serif;user-select:none}
  canvas{position:fixed;left:0;top:0;width:100vw;height:100vh;display:block}
  #ov{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none}
  .panel{display:none;text-align:center;color:#fff8ec;background:rgba(14,5,30,.62);padding:26px 42px;border-radius:16px;border:1px solid rgba(255,200,140,.28);box-shadow:0 0 60px rgba(255,120,60,.25);backdrop-filter:blur(4px);pointer-events:auto;cursor:pointer;min-width:360px}
  .panel.show{display:block;animation:pin .35s ease-out}
  @keyframes pin{from{transform:scale(.94);opacity:0}to{transform:none;opacity:1}}
  h1{font-size:68px;letter-spacing:10px;margin:0 0 4px;font-weight:900;background:linear-gradient(#fff6d0,#ffa050 55%,#ff2e7a);-webkit-background-clip:text;background-clip:text;color:transparent}
  .sub{letter-spacing:4px;font-size:13px;color:#ffc89a;opacity:.85;margin-bottom:18px}
  .keys{display:grid;grid-template-columns:auto 1fr;gap:6px 16px;text-align:left;font-size:14px;margin:14px auto 18px;max-width:340px}
  .keys b{color:#ffd27a;font-weight:700}
  .big{font-size:20px;font-weight:700;letter-spacing:2px;color:#ffe6b8;margin-top:8px}
  .stat{font-size:15px;line-height:1.8;color:#ffe9cf}
  .stat span{color:#ffd27a;font-weight:700}
  .blink{animation:bl 1.1s steps(2,start) infinite}
  @keyframes bl{to{visibility:hidden}}
  .tip{font-size:12px;opacity:.6;margin-top:12px}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="ov">
  <div id="title" class="panel show">
    <h1>SOLAR DASH</h1>
    <div class="sub">RIDE THE LIGHT · OUTRUN THE SUNSET</div>
    <div class="keys">
      <b>A / D · ← →</b><span>strafe &amp; bank</span>
      <b>W / S · ↑ ↓</b><span>pitch altitude</span>
      <b>SPACE / SHIFT</b><span>hold to boost (burns energy)</span>
      <b>M</b><span>mute</span>
    </div>
    <div class="big blink">PRESS ENTER OR SPACE</div>
    <div class="tip">Stay in the sunlight. Collect ⚡ cells, ✦ tokens and ◎ orbs. Don't set.</div>
  </div>
  <div id="over" class="panel">
    <h1 style="font-size:46px;letter-spacing:6px">SUNSET</h1>
    <div class="stat" id="ostat"></div>
    <div class="big blink" style="font-size:16px">R OR CLICK TO RUN AGAIN</div>
  </div>
</div>
<script>
// === CONFIG (tunable) ===
const CFG = {
  BASE_SPEED: 24,          // units/s at start
  SPEED_RAMP: 0.33,        // extra units/s gained per second survived
  MAX_SPEED: 62,
  BOOST_MULT: 1.35,        // speed multiplier while boosting
  BOOST_DRAIN: 9,          // energy/s while boosting
  BOOST_MIN: 8,            // energy needed to start a boost
  SPEED_EASE: 1.4,         // forward speed response (1/s)
  MAX_VX: 9,               // lateral units/s
  MAX_VY: 6,               // vertical units/s
  EASE_IN: 18,             // input response (~55ms time constant)
  EASE_OUT: 7,             // release rate (gives a small inertia tail)
  PX_MAX: 3.4,
  PY_MIN: 0.6, PY_MAX: 4.6,
  LANES: [-2, 0, 2],
  SUN_SINK: 0.0065,        // sun level lost per second (1 -> 0 in ~150s)
  SUN_ORB: 0.05,           // sun restored by an orb
  SUN_X_SHADOW: 2.7,       // |x| beyond this = off-sun side (shadow)
  ENERGY_MAX: 100,
  DRAIN_LIT: 1.1,          // energy/s in sunlight
  DRAIN_SHADOW: 3.2,       // energy/s in shadow
  CELL_GAIN: 22,
  NEAR_MISS: 0.42,         // gap (units) that counts as a near miss
  SPAWN_AHEAD: 300,        // generate world this far ahead
  DRAW_FAR: 230,           // draw distance
  CAM_DIST: 9,             // camera behind craft
  CAM_H: 3.2,              // camera height
  FOCAL: 0.85,             // focal length = H * FOCAL
  HORIZON: 0.5,            // horizon line as fraction of screen height
  MAX_PARTS: 500,
  COL: {
    skyTop: '#0e0724', skyMid: '#3a1152', skyLow: '#9b2f5e', skyHorizon: '#ff6a3d', skyGlow: '#ffb35c',
    groundTop: '#3a1838', groundBot: '#07030c',
    sunCore: '#fffbe0', sunMid: '#ffd36b', sunEdge: '#ff7a3d',
    blockTop: '#ffb36b', blockFront: '#2a1842', blockSide: '#4a2358',
    archFront: '#3a1a3c', archSide: '#6a2a5c', edge: 'rgba(255,190,120,0.28)',
    token: '127,246,255', cell: '255,225,77', orb: '255,90,220',
    craftA: '#fff4da', craftB: '#ff8a4e', craftShadow: '#b89cff', canopy: '#27e0ff'
  }
};

// === SETUP ===
const cv = document.getElementById('c');
const ctx = cv.getContext('2d');
let W = 0, H = 0, DPR = 1, F = 600, HZ = 0;
function resize() {
  DPR = Math.min(window.devicePixelRatio || 1, 1.5);
  W = innerWidth; H = innerHeight;
  cv.width = Math.floor(W * DPR); cv.height = Math.floor(H * DPR);
  F = H * CFG.FOCAL; HZ = H * CFG.HORIZON;
}
addEventListener('resize', resize); resize();

const $title = document.getElementById('title');
const $over = document.getElementById('over');
const $ostat = document.getElementById('ostat');
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const lerp = (a, b, t) => a + (b - a) * t;
const HI_KEY = 'solar-dash-hi';
let hiScore = 0;
try { hiScore = +localStorage.getItem(HI_KEY) || 0; } catch (e) {}

// Seeded RNG (mulberry32) — one stream per run drives all procedural content
function mulberry32(a) {
  return function () {
    a |= 0; a = a + 0x6D2B79F5 | 0;
    let t = Math.imul(a ^ a >>> 15, 1 | a);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}

// Global run state
const S = {
  mode: 'title',           // title | play | dying | over
  t: 0,                    // run time (s)
  time: 0,                 // wall-ish time for animation
  seed: 0, rnd: Math.random, count: 0,
  pz: 0, px: 0, py: 2.2, vx: 0, vy: 0, pitch: 0, roll: 0, speed: 0,
  score: 0, chain: 0, mult: 1, energy: CFG.ENERGY_MAX, sun: 1,
  boost: false, shadowed: false, shake: 0, flash: 0, flashC: '255,255,255',
  cause: '', deadT: 0, overT: 0, cursorZ: 0,
  obs: [], pick: [], parts: [], pops: []
};
let camX = 0, camY = CFG.CAM_H, camZ = 0;
// Projection: world (x,y,z) -> screen [sx, sy, depth]
function proj(x, y, z) {
  const d = z - camZ, k = F / d;
  return [W * 0.5 + (x - camX) * k, HZ + (camY - y) * k, d];
}

// === AUDIO (procedural WebAudio) ===
const AU = { ctx: null, master: null, windG: null, windF: null, drone: null, noise: null, muted: false };
function audioInit() {
  if (AU.ctx) { if (AU.ctx.state === 'suspended') AU.ctx.resume(); return; }
  const AC = window.AudioContext || window.webkitAudioContext;
  if (!AC) return;
  const c = new AC(); AU.ctx = c;
  AU.master = c.createGain(); AU.master.gain.value = AU.muted ? 0 : 0.55; AU.master.connect(c.destination);
  const nb = c.createBuffer(1, c.sampleRate * 2, c.sampleRate), nd = nb.getChannelData(0);
  for (let i = 0; i < nd.length; i++) nd[i] = Math.random() * 2 - 1;
  AU.noise = nb;
  // wind: looped noise through a bandpass whose cutoff rides the speed
  const src = c.createBufferSource(); src.buffer = nb; src.loop = true;
  const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 400; bp.Q.value = 0.7;
  AU.windG = c.createGain(); AU.windG.gain.value = 0;
  src.connect(bp); bp.connect(AU.windG); AU.windG.connect(AU.master); AU.windF = bp; src.start();
  // drone: low sawtooth through lowpass
  const o = c.createOscillator(); o.type = 'sawtooth'; o.frequency.value = 55;
  const lp = c.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 240; lp.Q.value = 3;
  const dg = c.createGain(); dg.gain.value = 0;
  o.connect(lp); lp.connect(dg); dg.connect(AU.master); o.start();
  AU.drone = { o, lp, g: dg };
}
function tone(f0, f1, dur, type, vol, delay = 0) {
  if (!AU.ctx) return;
  const c = AU.ctx, t = c.currentTime + delay;
  const o = c.createOscillator(), g = c.createGain();
  o.type = type;
  o.frequency.setValueAtTime(f0, t);
  o.frequency.exponentialRampToValueAtTime(f1, t + dur);
  g.gain.setValueAtTime(vol, t);
  g.gain.exponentialRampToValueAtTime(0.0008, t + dur);
  o.connect(g); g.connect(AU.master);
  o.start(t); o.stop(t + dur + 0.03);
}
function thud() {
  if (!AU.ctx) return;
  const c = AU.ctx, t = c.currentTime;
  const o = c.createOscillator(), g = c.createGain();
  o.type = 'sine';
  o.frequency.setValueAtTime(110, t); o.frequency.exponentialRampToValueAtTime(28, t + 0.5);
  g.gain.setValueAtTime(0.9, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.55);
  o.connect(g); g.connect(AU.master); o.start(t); o.stop(t + 0.6);
  const n = c.createBufferSource(); n.buffer = AU.noise;
  const lp = c.createBiquadFilter(); lp.type = 'lowpass';
  lp.frequency.setValueAtTime(900, t); lp.frequency.exponentialRampToValueAtTime(120, t + 0.4);
  const ng = c.createGain(); ng.gain.setValueAtTime(0.6, t); ng.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
  n.connect(lp); lp.connect(ng); ng.connect(AU.master);
  n.start(t, Math.random() * 1.5); n.stop(t + 0.5);
}
function audioFrame(speed, boosting, live) {
  if (!AU.ctx) return;
  const c = AU.ctx, t = c.currentTime;
  const k = clamp((speed - 18) / 50, 0, 1);
  AU.windG.gain.setTargetAtTime(live ? 0.05 + k * 0.28 + (boosting ? 0.08 : 0) : 0, t, 0.08);
  AU.windF.frequency.setTargetAtTime(280 + k * 900 + (boosting ? 300 : 0), t, 0.1);
  AU.drone.o.frequency.setTargetAtTime(48 + k * 40, t, 0.2);
  AU.drone.lp.frequency.setTargetAtTime(160 + k * 500, t, 0.2);
  AU.drone.g.gain.setTargetAtTime(live ? 0.05 + k * 0.1 : 0, t, 0.15);
}

// === INPUT ===
const keys = { left: false, right: false, up: false, down: false, boost: false };
const KMAP = {
  ArrowLeft: 'left', KeyA: 'left', ArrowRight: 'right', KeyD: 'right',
  ArrowUp: 'up', KeyW: 'up', ArrowDown: 'down', KeyS: 'down',
  Space: 'boost', ShiftLeft: 'boost', ShiftRight: 'boost'
};
addEventListener('keydown', e => {
  audioInit();
  const k = KMAP[e.code];
  if (k) { keys[k] = true; e.preventDefault(); }
  if (e.repeat) return;
  if (e.code === 'KeyM') { AU.muted = !AU.muted; if (AU.master) AU.master.gain.value = AU.muted ? 0 : 0.55; }
  if (e.code === 'Enter' || e.code === 'Space' || e.code === 'KeyR') onAction(e.code);
});
addEventListener('keyup', e => { const k = KMAP[e.code]; if (k) keys[k] = false; });
addEventListener('blur', () => { for (const k in keys) keys[k] = false; });
addEventListener('pointerdown', () => { audioInit(); onAction('click'); });

// === WORLD GENERATION (seeded, deterministic per run) ===
const PW = 0.4, PH = 0.22, PD = 0.5;     // craft half-extents for collision
const SEG = {};
function rnd() { return S.rnd(); }
function pick(arr) { return arr[Math.floor(rnd() * arr.length)]; }
function addBlock(x, z, hw, hh, hd = 1.2) {
  S.obs.push({ k: 'block', x, y: 0, z, hw, hh, hd, minSep: 99, live: false });
}
function addBeam(x0, x1, z, y) {
  // two overhead segments with a gap between x0..x1 handled by caller
  S.obs.push({ k: 'beam', x: (x0 + x1) / 2, y, z, hw: (x1 - x0) / 2, hh: 0.6, hd: 1.0, minSep: 99, live: false });
}
function addPickup(type, x, y, z) {
  S.pick.push({ type, x, y, z, ph: rnd() * 6.28, taken: false });
}
// Pattern: wall of blocks with one open lane
SEG.wall = (z, d) => {
  const gap = Math.floor(rnd() * 3);
  for (let i = 0; i < 3; i++) {
    if (i === gap) continue;
    const hh = 1.3 + rnd() * (1.4 + d * 1.4);
    addBlock(CFG.LANES[i], z, 0.85, hh);
  }
  const gx = CFG.LANES[gap];
  for (let j = 0; j < 4; j++) addPickup(rnd() < 0.6 ? 'token' : 'cell', gx, 1.4 + rnd() * 1.6, z - 6 + j * 4);
};
// Pattern: overhead beam with a gap; must pitch above/below or use the gap
SEG.beam = (z, d) => {
  const gap = Math.floor(rnd() * 3);
  const y = 1.7 + rnd() * (1.1 + d * 0.8);
  const gx = CFG.LANES[gap];
  addBeam(-3.6, gx - 1.1, z, y);
  addBeam(gx + 1.1, 3.6, z, y);
  for (let j = 0; j < 5; j++) addPickup('token', gx, 1.6 + j * 0.25, z - 8 + j * 4);
};
// Pattern: zig-zag slalom of blocks
SEG.slalom = (z, d) => {
  let a = Math.floor(rnd() * 3);
  for (let j = 0; j < 3; j++) {
    const lane = (j % 2 === 0) ? (a === 0 ? 2 : 0) : (a === 2 ? -2 : 0);
    const x = j % 2 === 0 ? (rnd() < 0.5 ? -2 : 2) : 0;
    addBlock(x, z + j * 9, 0.85, 2.2 + rnd() * 1.2 + d);
    addPickup('cell', x * 0.35 + (rnd() - 0.5) * 0.4, 1.8, z + j * 9 + 4.5);
  }
};
// Pattern: stripe of cells / tokens to reward line-riding
SEG.line = (z, d) => {
  const lane = pick(CFG.LANES);
  const y0 = 1.0 + rnd() * 2.4;
  for (let j = 0; j < 8; j++) {
    const y = y0 + Math.sin(j * 0.7) * 0.8;
    addPickup(j % 3 === 2 ? 'cell' : 'token', lane + Math.sin(j * 0.4) * 0.5, y, z + j * 3.2);
  }
};
// Pattern: low ground blocks (cheap to hop over)
SEG.low = (z, d) => {
  const gap = Math.floor(rnd() * 3);
  for (let i = 0; i < 3; i++) {
    if (i !== gap) addBlock(CFG.LANES[i], z, 0.85, 0.7 + rnd() * 0.6);
  }
  addPickup('token', CFG.LANES[gap], 1.2, z - 3);
  addPickup('token', CFG.LANES[gap], 1.3, z + 3);
};
// Pattern: lone orb (multiplier + sun restore)
SEG.orb = (z) => {
  addPickup('orb', pick(CFG.LANES), 2.0 + rnd() * 1.2, z);
};

function spawnSegment(z) {
  const d = clamp(S.t / 90, 0, 1);
  const r = rnd();
  if (S.t < 6) SEG.line(z, d);
  else if (r < 0.28 - d * 0.05) SEG.wall(z, d);
  else if (r < 0.50) SEG.beam(z, d);
  else if (r < 0.70 + d * 0.1) SEG.slalom(z, d);
  else if (r < 0.88) SEG.line(z, d);
  else if (r < 0.95) SEG.low(z, d);
  else SEG.orb(z);
  if (rnd() < 0.25 + d * 0.15) SEG.line(z + 10, d);
}
function nextGap() {
  const d = clamp(S.t / 90, 0, 1);
  return lerp(34, 15, d) + rnd() * 8;
}
function ensureWorld() {
  while (S.nextZ < S.pz + CFG.SPAWN_AHEAD) {
    spawnSegment(S.nextZ);
    S.nextZ += nextGap();
  }
}

// === STATE RESET ===
function newRun() {
  const urlSeed = new URLSearchParams(location.search).get('seed');
  S.seed = urlSeed ? (parseInt(urlSeed, 36) || 1) : ((Date.now() ^ (Math.random() * 1e9)) >>> 0);
  S.rnd = mulberry32(S.seed);
  S.mode = 'play'; S.t = 0; S.dist = 0; S.time = 0;
  S.pz = 0; S.px = 0; S.py = 2.2; S.vx = 0; S.vy = 0; S.pitch = 0; S.roll = 0; S.speed = CFG.BASE_SPEED;
  S.score = 0; S.chain = 0; S.chainT = 0; S.mult = 1; S.energy = CFG.ENERGY_MAX; S.sun = 1;
  S.boost = false; S.shadowed = false; S.shake = 0; S.flash = 0; S.flashC = '255,255,255';
  S.cause = ''; S.deadT = 0; S.nextZ = 40; S.dist = 0; S.nearMisses = 0; S.tokens = 0; S.stall = false;
  S.obs = []; S.pick = []; S.parts = []; S.pops = [];
  camX = 0; camY = CFG.CAM_H; camZ = -CFG.CAM_DIST;
  S.sunWarn = 0; S.rotor = 0;
  ensureWorld();
  $title.classList.remove('show'); $over.classList.remove('show');
}

// Particle + pop helpers (world-space)
function burst(x, y, z, n, col, spd, life, size, grav = 0) {
  for (let i = 0; i < n; i++) {
    if (S.parts.length >= CFG.MAX_PARTS) return;
    const a = rnd() * 6.283, b = (rnd() - 0.5) * 2, s = spd * (0.3 + rnd() * 0.7);
    S.parts.push({ x, y, z, vx: Math.cos(a) * s * Math.sqrt(1 - b * b), vy: b * s, vz: Math.sin(a) * s * Math.sqrt(1 - b * b) * 0.5,
      life, max: life, col, size, grav });
  }
}
function pop(text, x, y, z, col) { S.pops.push({ text, x, y, z, life: 1, col }); }
function addShake(v) { S.shake = Math.min(1.6, S.shake + v); }
function flash(c, v) { S.flash = Math.max(S.flash, v); S.flashC = c; }

// === UPDATE ===
function nearMiss(o) {
  S.nearMisses++;
  const bonus = Math.round(60 * S.mult);
  S.score += bonus;
  addShake(0.28);
  burst(S.px, S.py, S.pz + 0.5, 10, '255,255,255', 6, 0.45, 0.07);
  pop('NEAR +' + bonus, S.px, S.py + 0.8, S.pz, '255,240,160');
  tone(1400, 2200, 0.09, 'triangle', 0.14);
  tone(900, 1500, 0.12, 'sine', 0.1, 0.04);
}
function collectPickup(p) {
  p.taken = true;
  if (p.type === 'token') {
    S.tokens++;
    S.chain++; S.chainT = 3.5;
    S.mult = Math.min(8, 1 + Math.floor(S.chain / 6));
    const add = Math.round(30 * S.mult); S.score += add;
    burst(p.x, p.y, p.z, 10, CFG.COL.token, 5, 0.5, 0.09);
    pop('+' + add, p.x, p.y + 0.4, p.z, '150,250,255');
    tone(880 + S.chain * 25, 1760 + S.chain * 40, 0.12, 'square', 0.09);
    flash('127,246,255', 0.05);
  } else if (p.type === 'cell') {
    S.energy = Math.min(CFG.ENERGY_MAX, S.energy + CFG.CELL_GAIN);
    const add = Math.round(15 * S.mult); S.score += add;
    burst(p.x, p.y, p.z, 14, CFG.COL.cell, 6, 0.6, 0.11);
    pop('+ENERGY', p.x, p.y + 0.4, p.z, '255,225,77');
    tone(523, 1046, 0.22, 'triangle', 0.16);
    tone(784, 1568, 0.2, 'sine', 0.1, 0.06);
  } else if (p.type === 'orb') {
    S.chain += 6; S.chainT = 3.5;
    S.mult = Math.min(8, 1 + Math.floor(S.chain / 6));
    S.sun = Math.min(1, S.sun + CFG.SUN_ORB);
    S.score += 150 * S.mult;
    burst(p.x, p.y, p.z, 24, CFG.COL.orb, 9, 0.8, 0.13);
    pop('ORB x' + S.mult, p.x, p.y + 0.5, p.z, '255,120,230');
    tone(440, 1320, 0.35, 'sawtooth', 0.09);
    tone(660, 1980, 0.3, 'sine', 0.12, 0.08);
    addShake(0.12);
  }
}
function crash(cause) {
  if (S.mode !== 'play') return;
  S.mode = 'dying'; S.cause = cause; S.deadT = 0;
  addShake(1.4); flash('255,80,60', 0.7);
  burst(S.px, S.py, S.pz, 60, '255,160,90', 14, 1.1, 0.2, 2);
  burst(S.px, S.py, S.pz, 30, '180,140,255', 8, 1.4, 0.14, 1);
  thud();
  S.best = Math.max(hiScore, Math.floor(S.score));
}

function update(dt) {
  const K = keys;
  const ix = (K.right ? 1 : 0) - (K.left ? 1 : 0);
  const iy = (K.up ? 1 : 0) - (K.down ? 1 : 0);
  const dying = S.mode === 'dying';
  S.time += dt;

  // Speed: ramps with time; boost multiplies; empty energy stalls the craft
  let target = Math.min(CFG.MAX_SPEED, CFG.BASE_SPEED + CFG.SPEED_RAMP * S.t);
  S.boost = !dying && K.boost && S.energy > 0.5;
  if (S.boost) target *= CFG.BOOST_MULT;
  if (S.energy <= 0) { target *= 0.55; S.stall = true; } else S.stall = false;
  if (dying) target = 0;
  const kS = S.boost ? 3.2 : 1.1;
  S.speed += (target - S.speed) * (1 - Math.exp(-kS * dt));
  if (dying) S.speed = Math.max(0, S.speed - 6 * dt);
  const sd = S.speed * dt;
  S.pz += sd; S.dist += sd;

  // Lateral + vertical easing: snappy on press, short inertia tail on release
  if (!dying) {
    const tvx = ix * CFG.MAX_VX, tvy = iy * CFG.MAX_VY;
    const rx = ix !== 0 ? CFG.EASE_IN : CFG.EASE_OUT;
    const ry = iy !== 0 ? CFG.EASE_IN : CFG.EASE_OUT;
    S.vx += (tvx - S.vx) * (1 - Math.exp(-rx * dt));
    S.vy += (tvy - S.vy) * (1 - Math.exp(-ry * dt));
    S.px += S.vx * dt; S.py += S.vy * dt;
    if (S.px < -CFG.PX_MAX) { S.px = -CFG.PX_MAX; S.vx = 0; }
    if (S.px > CFG.PX_MAX) { S.px = CFG.PX_MAX; S.vx = 0; }
    if (S.py < CFG.PY_MIN) { S.py = CFG.PY_MIN; S.vy = 0; }
    if (S.py > CFG.PY_MAX) { S.py = CFG.PY_MAX; S.vy = 0; }
  } else {
    S.vx *= Math.exp(-3 * dt); S.vy *= Math.exp(-3 * dt);
  }
  // visual bank/pitch
  const rollT = -(S.vx / CFG.MAX_VX) * 0.7;
  S.roll += (rollT - S.roll) * (1 - Math.exp(-12 * dt));
  const pitchT = (S.vy / CFG.MAX_VY) * 0.35 + (S.boost ? 0.12 : 0);
  S.pitch += (pitchT - S.pitch) * (1 - Math.exp(-10 * dt));
  S.rotor += dt * (10 + S.speed * 0.4);

  if (!dying) {
    // Shadow check: off the sun's cone, or directly behind a block
    let sh = Math.abs(S.px) > CFG.SUN_X_SHADOW;
    for (const o of S.obs) {
      if (o.k !== 'block') continue;
      const behind = S.pz - (o.z + o.hd);
      if (behind > 0 && behind < 9 && Math.abs(S.px - o.x) < o.hw + 0.35) { sh = true; break; }
    }
    S.shadowed = sh;

    // Energy
    let drain = (sh ? CFG.DRAIN_SHADOW : CFG.DRAIN_LIT);
    if (S.boost) drain += CFG.BOOST_DRAIN;
    S.energy = Math.max(0, S.energy - drain * dt);

    // Sun sinks; hits zero => sunset game over
    S.sun = Math.max(0, S.sun - CFG.SUN_SINK * dt);
    S.sunWarn = S.sun < 0.18 ? S.sunWarn + dt : 0;

    // Chain decay
    if (S.chain > 0) { S.chainT -= dt; if (S.chainT <= 0) { S.chain = 0; S.mult = 1; } }

    // Distance score
    S.score += S.speed * dt * 0.5 * S.mult;
  }

  // World obstacles: collision + near-miss tracking
  if (!dying) {
    for (const o of S.obs) {
      const dz = Math.abs(S.pz - o.z);
      if (dz < o.hd + PD) {
        const gx = Math.abs(S.px - o.x) - (o.hw + PW);
        const gy1 = S.py - PH - (o.y + o.hh), gy2 = o.y - (S.py + PH);
        const gy = Math.max(gy1, gy2);
        const sep = Math.max(gx, gy);
        o.minSep = Math.min(o.minSep, sep);
        if (sep < 0) { crash(o.k === 'beam' ? 'BEAM' : 'IMPACT'); break; }
        o.live = true;
      } else if (o.live && S.pz > o.z) {
        o.live = false;
        if (o.minSep >= 0 && o.minSep < CFG.NEAR_MISS) nearMiss(o);
        o.minSep = 99;
        o.done = true;
      }
    }
    // Pickups
    for (const p of S.pick) {
      if (p.taken) continue;
      if (Math.abs(p.z - S.pz) < 1.0 && Math.abs(p.x - S.px) < 0.9 && Math.abs(p.y - S.py) < 0.9) collectPickup(p);
    }
  }

  // Thrust trail + flicker particles
  if (S.mode === 'play' || dying) {
    const rate = dying ? 0 : (S.boost ? 3 : 1);
    const n = Math.random() < (0.5 + S.speed / 60) * rate ? 1 : 0;
    if (n) S.parts.push({ x: S.px + (rnd() - 0.5) * 0.3, y: S.py - 0.05, z: S.pz - 0.6, vx: (rnd() - 0.5) * 0.6, vy: (rnd() - 0.5) * 0.4, vz: -S.speed * 0.2,
      life: 0.35, max: 0.35, col: S.boost ? '255,220,120' : '255,140,80', size: 0.12, grav: 0 });
    if (S.boost && Math.random() < 0.5) S.parts.push({ x: S.px, y: S.py, z: S.pz - 0.8, vx: (rnd() - 0.5), vy: (rnd() - 0.5), vz: -S.speed * 0.4, life: 0.3, max: 0.3, col: '255,255,255', size: 0.1, grav: 0 });
  }

  // Particle physics
  for (let i = S.parts.length - 1; i >= 0; i--) {
    const q = S.parts[i];
    q.life -= dt;
    if (q.life <= 0) { S.parts[i] = S.parts[S.parts.length - 1]; S.parts.pop(); continue; }
    q.x += q.vx * dt; q.y += q.vy * dt; q.z += q.vz * dt;
    q.vy -= q.grav * dt * 9; q.vx *= Math.exp(-2 * dt); q.vz *= Math.exp(-1 * dt);
  }
  for (let i = S.pops.length - 1; i >= 0; i--) {
    S.pops[i].life -= dt * 0.9;
    if (S.pops[i].life <= 0) S.pops.splice(i, 1);
  }

  // World streaming
  ensureWorld();
  if (S.obs.length > 200 || S.pz % 10 < dt) {
    S.obs = S.obs.filter(o => o.z > S.pz - 40);
    S.pick = S.pick.filter(p => p.z > S.pz - 10 && !p.taken);
  }

  // Camera (behind + above, lags the craft slightly)
  const kc = 1 - Math.exp(-6 * dt);
  camX += (S.px * 0.45 - camX) * kc;
  camY += (CFG.CAM_H + (S.py - 2.2) * 0.3 - camY) * kc;
  camZ = S.pz - CFG.CAM_DIST;

  S.shake *= Math.exp(-7 * dt);
  S.flash *= Math.exp(-6 * dt);

  // Dying timeline -> game over
  if (dying) {
    S.deadT += dt;
    if (S.deadT > 1.3) {
      S.mode = 'over'; S.overT = 0;
      const sc = Math.floor(S.score);
      if (sc > hiScore) { hiScore = sc; try { localStorage.setItem(HI_KEY, String(hiScore)); } catch (e) {} }
      showOver(sc);
    }
  }
}
function showOver(sc) {
  const reason = { SUNSET: 'THE SUN SANK', IMPACT: 'CRASHED INTO A BLOCK', BEAM: 'CLIPPED AN ARCH' }[S.cause] || S.cause;
  $ostat.innerHTML =
    `${reason}<br>Score <span>${sc}</span> &nbsp; Best <span>${hiScore}</span><br>` +
    `Distance <span>${Math.floor(S.dist)} m</span> &nbsp; Tokens <span>${S.tokens}</span><br>` +
    `Near misses <span>${S.nearMisses}</span> &nbsp; Max chain x<span>${S.mult}</span><br>` +
    `<span style="font-size:11px;opacity:.6">seed ${S.seed.toString(36)}</span>`;
  $over.classList.add('show');
}

function pruneWorld() {
  S.obs = S.obs.filter(o => o.z > S.pz - 40);
  S.pick = S.pick.filter(p => p.z > S.pz - 10 && !p.taken);
}
function attract(dt) {
  S.time += dt;
  S.pz += dt * 20; S.px = Math.sin(S.time * 0.6) * 1.6; S.py = 2.2 + Math.sin(S.time * 0.45) * 0.4;
  S.roll = 0; S.pitch = 0; S.speed = 20; S.rotor += dt * 14;
  ensureWorld(); pruneWorld();
  const kc = 1 - Math.exp(-3 * dt);
  camX += (S.px * 0.45 - camX) * kc; camY += (CFG.CAM_H - camY) * kc; camZ = S.pz - CFG.CAM_DIST;
}

// === RENDER ===
const AUX = document.createElement('canvas'), aux = AUX.getContext('2d');
const FONT = '"Trebuchet MS","Segoe UI",Verdana,sans-serif';
function poly(pts, fill) {
  ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
  for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
  ctx.closePath(); ctx.fillStyle = fill; ctx.fill();
}
function halo(x, y, r, col, a) {
  const g = ctx.createRadialGradient(x, y, 0, x, y, r);
  g.addColorStop(0, `rgba(${col},${a})`); g.addColorStop(1, `rgba(${col},0)`);
  ctx.fillStyle = g; ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function starPath(x, y, R, r) {
  ctx.beginPath();
  for (let i = 0; i < 8; i++) {
    const a = i * Math.PI / 4 - Math.PI / 2, rr = i % 2 ? r : R;
    const px = x + Math.cos(a) * rr, py = y + Math.sin(a) * rr;
    if (i) ctx.lineTo(px, py); else ctx.moveTo(px, py);
  }
  ctx.closePath();
}
function sunScreen() {
  const r = H * 0.11;
  return { x: W * 0.5 - camX * 40, y: HZ - r * 0.55 + (1 - S.sun) * r * 1.6, r };
}
function drawSky() {
  const C = CFG.COL;
  const g = ctx.createLinearGradient(0, 0, 0, HZ);
  g.addColorStop(0, C.skyTop); g.addColorStop(0.45, C.skyMid); g.addColorStop(0.82, C.skyLow); g.addColorStop(1, C.skyHorizon);
  ctx.fillStyle = g; ctx.fillRect(0, 0, W, HZ + 2);
  const s = sunScreen();
  const glow = ctx.createRadialGradient(s.x, s.y, s.r * 0.5, s.x, s.y, s.r * 5);
  glow.addColorStop(0, 'rgba(255,200,110,0.55)'); glow.addColorStop(0.4, 'rgba(255,110,60,0.18)'); glow.addColorStop(1, 'rgba(255,80,60,0)');
  ctx.fillStyle = glow; ctx.fillRect(0, 0, W, HZ + 2);
  const sg = ctx.createRadialGradient(s.x, s.y - s.r * 0.2, s.r * 0.1, s.x, s.y, s.r);
  sg.addColorStop(0, C.sunCore); sg.addColorStop(0.55, C.sunMid); sg.addColorStop(1, C.sunEdge);
  ctx.fillStyle = sg; ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, 6.283); ctx.fill();
  // retro banding across the lower disc
  ctx.save(); ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, 6.283); ctx.clip();
  ctx.fillStyle = 'rgba(70,18,60,0.45)';
  for (let i = 0; i < 4; i++) ctx.fillRect(s.x - s.r, s.y + s.r * (0.12 + i * 0.12), s.r * 2, s.r * (0.03 + i * 0.012));
  ctx.restore();
}
function drawGround() {
  const C = CFG.COL;
  const g = ctx.createLinearGradient(0, HZ, 0, H);
  g.addColorStop(0, C.groundTop); g.addColorStop(1, C.groundBot);
  ctx.fillStyle = g; ctx.fillRect(0, HZ, W, H - HZ);
  // sun reflection smear on ground
  const s = sunScreen();
  const rg = ctx.createRadialGradient(s.x, HZ, 0, s.x, HZ, W * 0.35);
  rg.addColorStop(0, 'rgba(255,170,90,0.35)'); rg.addColorStop(1, 'rgba(255,120,60,0)');
  ctx.save(); ctx.beginPath(); ctx.rect(0, HZ, W, H - HZ); ctx.clip();
  ctx.translate(s.x, HZ); ctx.scale(1, 0.25); ctx.translate(-s.x, -HZ);
  ctx.fillStyle = rg; ctx.fillRect(0, HZ, W, H * 4); ctx.restore();
  // grid
  ctx.lineWidth = 1;
  const z0 = Math.ceil((camZ + 1.5) / 4) * 4, z1 = camZ + CFG.DRAW_FAR;
  for (let z = z0; z < z1; z += 4) {
    const a = clamp(1 - (z - camZ) / CFG.DRAW_FAR, 0, 1) * 0.24;
    const l = proj(-40, 0, z), r = proj(40, 0, z);
    ctx.strokeStyle = `rgba(255,160,120,${a})`;
    ctx.beginPath(); ctx.moveTo(l[0], l[1]); ctx.lineTo(r[0], r[1]); ctx.stroke();
  }
  ctx.strokeStyle = 'rgba(255,160,120,0.14)';
  for (let x = -40; x <= 40; x += 4) {
    const n = proj(x, 0, camZ + 1.5), f = proj(x, 0, camZ + CFG.DRAW_FAR);
    ctx.beginPath(); ctx.moveTo(n[0], n[1]); ctx.lineTo(f[0], f[1]); ctx.stroke();
  }
}
// Long shadows cast toward the camera by blocks (sun sits ahead)
function drawBlockShadows() {
  for (const o of S.obs) {
    if (o.k !== 'block') continue;
    const zb = o.z - o.hd, zs = zb - 9;
    if (zb - camZ < 1 || zb - camZ > CFG.DRAW_FAR) continue;
    const x0 = o.x - o.hw, x1 = o.x + o.hw;
    poly([proj(x0, 0, zb), proj(x1, 0, zb), proj(x1, 0, zs), proj(x0, 0, zs)], 'rgba(8,2,16,0.5)');
  }
}
function drawBox(x, y, z, hw, hh, hd, kind) {
  const C = CFG.COL;
  const y0 = y - hh, y1 = y + hh, zn = z - hd, zf = z + hd;
  const topC = kind === 'arch' ? C.archSide : C.blockTop;
  const frontC = kind === 'arch' ? C.archFront : C.blockFront;
  const sideC = kind === 'arch' ? C.archSide : C.blockSide;
  if (camX < x - hw) poly([proj(x - hw, y0, zn), proj(x - hw, y0, zf), proj(x - hw, y1, zf), proj(x - hw, y1, zn)], sideC);
  else if (camX > x + hw) poly([proj(x + hw, y0, zn), proj(x + hw, y0, zf), proj(x + hw, y1, zf), proj(x + hw, y1, zn)], sideC);
  if (camY > y1) poly([proj(x - hw, y1, zn), proj(x + hw, y1, zn), proj(x + hw, y1, zf), proj(x - hw, y1, zf)], topC);
  const a = proj(x - hw, y0, zn), b = proj(x + hw, y0, zn), c = proj(x + hw, y1, zn), d = proj(x - hw, y1, zn);
  poly([a, b, c, d], frontC);
  // sunlit rim
  ctx.strokeStyle = C.edge; ctx.lineWidth = 1.5;
  ctx.beginPath(); ctx.moveTo(d[0], d[1]); ctx.lineTo(c[0], c[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
function drawPickup(p) {
  const bob = Math.sin(S.time * 3 + p.ph) * 0.12;
  const q = proj(p.x, p.y + bob, p.z), s = F / q[2];
  const x = q[0], y = q[1];
  const pulse = 1 + 0.12 * Math.sin(S.time * 8 + p.ph);
  const r = Math.max(4, 0.32 * s) * pulse;
  if (p.type === 'token') {
    halo(x, y, r * 2.4, CFG.COL.token, 0.35);
    ctx.fillStyle = '#e6fdff'; starPath(x, y, r * 1.2, r * 0.36); ctx.fill();
  } else if (p.type === 'cell') {
    halo(x, y, r * 2.2, CFG.COL.cell, 0.4);
    ctx.fillStyle = '#ffe14d'; ctx.beginPath(); ctx.arc(x, y, r * 0.85, 0, 6.283); ctx.fill();
    ctx.fillStyle = '#2a1a00'; ctx.beginPath();
    ctx.moveTo(x + r * 0.15, y - r * 0.6); ctx.lineTo(x - r * 0.3, y + r * 0.1); ctx.lineTo(x, y + r * 0.1);
    ctx.lineTo(x - r * 0.15, y + r * 0.6); ctx.lineTo(x + r * 0.3, y - r * 0.1); ctx.lineTo(x, y - r * 0.1);
    ctx.closePath(); ctx.fill();
  } else {
    halo(x, y, r * 2.8, CFG.COL.orb, 0.45);
    ctx.strokeStyle = `rgb(${CFG.COL.orb})`; ctx.lineWidth = Math.max(2, r * 0.28);
    ctx.beginPath(); ctx.arc(x, y, r * 0.9, 0, 6.283); ctx.stroke();
    ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(x, y, r * 0.3, 0, 6.283); ctx.fill();
  }
}
function drawWorld() {
  const list = [];
  for (const o of S.obs) { const d = o.z - camZ; if (d >= 1 && d <= CFG.DRAW_FAR) list.push(o); }
  list.sort((a, b) => b.z - a.z);
  for (const o of list) drawBox(o.x, o.y, o.z, o.hw, o.hh, o.hd, o.k === 'block' ? 'block' : 'arch');
  for (const p of S.pick) {
    if (p.taken) continue;
    const d = p.z - camZ;
    if (d >= 1 && d <= CFG.DRAW_FAR) drawPickup(p);
  }
}
function drawParticles() {
  for (const q of S.parts) {
    const p = proj(q.x, q.y, q.z);
    if (p[2] < 0.6) continue;
    const a = q.life / q.max;
    ctx.fillStyle = `rgba(${q.col},${(a * 0.9).toFixed(3)})`;
    ctx.beginPath(); ctx.arc(p[0], p[1], Math.max(0.8, q.size * F / p[2] * a), 0, 6.283); ctx.fill();
  }
}
function drawPops() {
  ctx.save(); ctx.textAlign = 'center'; ctx.font = `900 18px ${FONT}`;
  for (const p of S.pops) {
    const q = proj(p.x, p.y + (1 - p.life) * 1.2, p.z);
    if (q[2] < 1) continue;
    ctx.globalAlpha = Math.min(1, p.life * 2);
    ctx.fillStyle = `rgb(${p.col})`;
    ctx.fillText(p.text, q[0], q[1]);
  }
  ctx.restore();
}
// Craft: low-poly triangles, transformed by pitch/roll, projected in 3D
const CRAFT_V = [[0, 0, 1.1], [-0.85, 0, -0.55], [0.85, 0, -0.55], [0, 0.38, -0.35], [0, -0.16, -0.35], [0, 0.22, 0.35]];
const CRAFT_F = [[0, 1, 3, 0], [0, 3, 2, 0], [0, 2, 4, 1], [0, 4, 1, 1], [1, 4, 3, 2], [2, 3, 4, 2], [0, 5, 3, 3], [0, 2, 5, 3]];
function craftPt(v, roll, pitch) {
  const cp = Math.cos(pitch), sp = Math.sin(pitch), cr = Math.cos(roll), sr = Math.sin(roll);
  const y1 = v[1] * cp + v[2] * sp, z1 = -v[1] * sp + v[2] * cp;
  const x2 = v[0] * cr - y1 * sr, y2 = v[0] * sr + y1 * cr;
  return proj(S.px + x2 * 0.8, S.py + y2 * 0.8, S.pz + z1 * 0.8);
}
function drawCraft() {
  const dying = S.mode === 'dying';
  const roll = S.roll + (dying ? S.deadT * 7 : 0);
  const pitch = S.pitch - (dying ? S.deadT * 2.2 : 0);
  // ground shadow
  const g = proj(S.px, 0, S.pz), rx = 0.8 * F / g[2];
  const hgt = clamp(1 - (S.py - 0.5) / 5, 0.15, 1);
  ctx.fillStyle = `rgba(0,0,0,${0.4 * hgt})`;
  ctx.beginPath(); ctx.ellipse(g[0], g[1], rx, rx * 0.28, 0, 0, 6.283); ctx.fill();
  if (S.mode === 'over') return;
  const pts = CRAFT_V.map(v => craftPt(v, roll, pitch));
  const cols = S.shadowed ? ['#8a7aa8', '#5c3a6a', '#40305a', '#2a9ab0'] : ['#fff4da', '#ff9a5a', '#7d5fb0', '#27e0ff'];
  const order = CRAFT_F.slice().sort((a, b) => (pts[b[0]][2] + pts[b[1]][2] + pts[b[2]][2]) - (pts[a[0]][2] + pts[a[1]][2] + pts[a[2]][2]));
  for (const f of order) poly([pts[f[0]], pts[f[1]], pts[f[2]]], cols[f[3]]);
  // thruster glow
  const e = craftPt([0, 0, -0.9], roll, pitch);
  const boostK = S.boost ? 1 : 0.5;
  halo(e[0], e[1], F / e[2] * (0.5 + S.speed / 80) * boostK + 6, S.boost ? '255,220,120' : '255,120,60', 0.6);
}
function drawHaze() {
  const top = HZ - H * 0.08;
  const g = ctx.createLinearGradient(0, top, 0, HZ + H * 0.02);
  g.addColorStop(0, 'rgba(255,140,80,0)'); g.addColorStop(0.7, 'rgba(255,150,90,0.22)'); g.addColorStop(1, 'rgba(255,150,90,0)');
  ctx.fillStyle = g; ctx.fillRect(0, top, W, H * 0.1);
}
function drawSpeedLines(k) {
  if (k <= 0.01) return;
  const cx = W * 0.5, cy = HZ;
  ctx.strokeStyle = `rgba(255,225,190,${0.45 * k})`; ctx.lineWidth = 1.4;
  ctx.beginPath();
  for (let i = 0; i < 28; i++) {
    const a = i * 2.399, p = (S.time * (1.2 + k * 1.8) + i * 0.0371) % 1;
    const r0 = lerp(0.15, 0.85, p), len = 0.15 * p * k + 0.02;
    const x0 = cx + Math.cos(a) * r0 * W * 0.5, y0 = cy + Math.sin(a) * r0 * H * 0.55;
    ctx.moveTo(x0, y0); ctx.lineTo(x0 + Math.cos(a) * len * W * 0.5, y0 + Math.sin(a) * len * H * 0.55);
  }
  ctx.stroke();
}
function label(t, x, y) { ctx.fillStyle = 'rgba(255,205,150,0.8)'; ctx.font = `800 12px ${FONT}`; ctx.letterSpacing = '2px'; ctx.fillText(t, x, y); ctx.letterSpacing = '0px'; }
function value(t, x, y, size) { ctx.fillStyle = '#fff6e6'; ctx.font = `900 ${size}px ${FONT}`; ctx.fillText(t, x, y); }
function drawHUD() {
  const pad = 24;
  ctx.save(); ctx.textBaseline = 'top'; ctx.textAlign = 'left';
  ctx.shadowColor = 'rgba(0,0,0,0.6)'; ctx.shadowBlur = 6;
  label('DISTANCE', pad, pad); value(Math.floor(S.dist) + ' m', pad, pad + 14, 30);
  label('SCORE', pad, pad + 66); value(String(Math.floor(S.score)), pad, pad + 80, 26);
  ctx.fillStyle = S.mult > 1 ? '#ffd27a' : '#ffe9cf'; ctx.font = `900 22px ${FONT}`;
  ctx.fillText('×' + S.mult, pad, pad + 116);
  const frac = S.mult >= 8 ? 1 : (S.chain % 6) / 6;
  ctx.fillStyle = 'rgba(255,255,255,0.15)'; ctx.fillRect(pad + 58, pad + 124, 120, 6);
  ctx.fillStyle = '#7ff6ff'; ctx.fillRect(pad + 58, pad + 124, 120 * frac, 6);
  // right column
  ctx.textAlign = 'right';
  label('SPEED', W - pad, pad); value(Math.round(S.speed * 3.6) + ' km/h', W - pad, pad + 14, 26);
  label('BEST', W - pad, pad + 66); value(String(Math.max(hiScore, Math.floor(S.score))), W - pad, pad + 80, 22);
  // sun meter (top center)
  const bw = 260, bx = W * 0.5 - bw / 2, by = pad;
  ctx.textAlign = 'center';
  const flick = S.sunWarn > 0 && Math.floor(S.time * 6) % 2 === 0;
  label('SUN', W * 0.5, by);
  ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(bx, by + 18, bw, 8);
  ctx.fillStyle = flick ? '#ff3b3b' : (S.sun < 0.25 ? '#ff6a3d' : '#ffc45c');
  ctx.fillRect(bx, by + 18, bw * S.sun, 8);
  // energy (bottom center)
  const ew = 320, ex = W * 0.5 - ew / 2, ey = H - 52;
  const en = S.energy / CFG.ENERGY_MAX;
  let status = '';
  if (S.shadowed) status = 'IN SHADOW · DRAINING';
  if (S.boost) status = 'BOOST';
  if (S.energy <= 0) status = 'DEAD BATTERY · STALLING';
  label('ENERGY', W * 0.5, ey - 22);
  ctx.fillStyle = 'rgba(0,0,0,0.4)'; ctx.fillRect(ex, ey, ew, 12);
  const eg = ctx.createLinearGradient(ex, 0, ex + ew, 0);
  if (en < 0.2 && Math.floor(S.time * 8) % 2) eg.addColorStop(0, '#ff3b3b'), eg.addColorStop(1, '#ff3b3b');
  else eg.addColorStop(0, '#ffd24a'), eg.addColorStop(1, '#7ff6ff');
  ctx.fillStyle = eg; ctx.fillRect(ex, ey, ew * en, 12);
  ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.strokeRect(ex, ey, ew, 12);
  ctx.fillStyle = S.shadowed ? '#9fb8ff' : (S.boost ? '#ffe9a0' : '#ffb07a');
  ctx.font = `800 13px ${FONT}`; ctx.fillText(status, W * 0.5, ey + 20);
  ctx.restore();
}
function render() {
  const sp = clamp((S.speed - 36) / 22, 0, 1);
  const boostK = S.boost ? 0.6 : 0;
  const k = Math.max(sp, boostK);
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  ctx.fillStyle = '#0b0415'; ctx.fillRect(0, 0, W, H);
  const sh = S.shake * S.shake * 9;
  ctx.save();
  if (sh > 0.05) ctx.translate((Math.random() - 0.5) * sh * 2, (Math.random() - 0.5) * sh * 2);
  drawSky(); drawGround(); drawBlockShadows(); drawWorld(); drawParticles();
  if (S.mode !== 'title') drawCraft();
  drawPops(); drawHaze();
  drawSpeedLines(k);
  ctx.restore();
  // screen-space flourishes
  if (S.flash > 0.01) { ctx.fillStyle = `rgba(${S.flashC},${Math.min(0.6, S.flash * 0.6)})`; ctx.fillRect(0, 0, W, H); }
  if (S.shadowed && S.mode === 'play') {
    const vg = ctx.createRadialGradient(W / 2, H / 2, H * 0.3, W / 2, H / 2, H * 0.85);
    vg.addColorStop(0, 'rgba(40,40,120,0)'); vg.addColorStop(1, 'rgba(40,40,120,0.45)');
    ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H);
  }
  // chromatic aberration at high speed: ghost copies offset horizontally
  if (k > 0.5) {
    aux.canvas.width = cv.width; aux.canvas.height = cv.height;
    aux.drawImage(cv, 0, 0);
    ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = 0.12 * k;
    const o = 3 * DPR * k;
    ctx.drawImage(AUX, -o, 0); ctx.drawImage(AUX, o, 0);
    ctx.restore();
  }
  if (S.mode === 'play' || S.mode === 'dying') drawHUD();
  if (S.mode === 'over') { ctx.fillStyle = 'rgba(8,2,16,0.45)'; ctx.fillRect(0, 0, W, H); }
}

// === MAIN LOOP ===
let lastT = performance.now();
function frame(now) {
  const dt = Math.min(0.05, Math.max(0, (now - lastT) / 1000));
  lastT = now;
  const steps = Math.max(1, Math.ceil(dt / (1 / 120)));
  const h = dt / steps;
  for (let i = 0; i < steps; i++) {
    if (S.mode === 'play' || S.mode === 'dying') update(h);
    else if (S.mode === 'title') attract(h);
    else if (S.mode === 'over') { S.overT += h; S.time += h; }
  }
  audioFrame(S.speed, S.boost, S.mode === 'play' || S.mode === 'dying');
  render();
  requestAnimationFrame(frame);
}

// === INIT ===
function onAction(code) {
  if (S.mode === 'title') { newRun(); return; }
  if (S.mode === 'over' && S.overT > 0.6) newRun();
}
newRun();
S.mode = 'title';
$title.classList.add('show');
requestAnimationFrame(t => { lastT = t; requestAnimationFrame(frame); });
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"b2d00bd5-2cda-4b6e-991a-a60d06c23ad7","tokensIn":134664,"tokensOut":106013,"tokensTotal":240677,"cost":0.05821899000000001,"turns":6,"toolCalls":8,"failedToolCalls":0,"timestamp":"2026-10-08T05:57:49.159Z"} -->