← Race the Sun results

Race the Sun

Grok 4.5 · legacy

Initial view of Race the Sun

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

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 48,769 bytes · SHA-256 1a8ff736209e
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Solar Dash — Endless Runner</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body {
    width: 100%; height: 100%;
    overflow: hidden;
    background: #0a0612;
    font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  }
  canvas {
    display: block;
    width: 100%;
    height: 100%;
    cursor: none;
  }
  #hint {
    position: fixed;
    bottom: 14px;
    left: 50%;
    transform: translateX(-50%);
    color: rgba(255,220,180,0.45);
    font-size: 12px;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    pointer-events: none;
    user-select: none;
  }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hint">WASD / Arrows · Space boost · R restart</div>
<script>
// =============================================================================
// === TUNABLE CONSTANTS =======================================================
// =============================================================================
const C = {
  // World & physics
  BASE_SPEED: 42,
  MAX_SPEED: 140,
  ACCEL: 0.65,            // units/s²
  LANE_HALF: 18,          // half-width of playable plane
  STRAFE_SPEED: 38,
  STRAFE_EASE: 0.18,      // lerp factor per frame-ish (scaled by dt)
  PITCH_SPEED: 16,
  PITCH_RANGE: 5.5,
  BOOST_MULT: 1.55,
  BOOST_DRAIN: 28,        // energy/s while boosting
  INERTIA: 0.88,

  // Energy / sun
  ENERGY_MAX: 100,
  ENERGY_DRAIN: 3.2,      // base drain /s
  ENERGY_SHADOW: 11,      // extra drain in shadow
  ENERGY_PICKUP: 22,
  SUN_START: 1.0,         // 1 = high, 0 = set
  SUN_SINK_RATE: 0.0065,  // per second (tunes ~150s theoretical max)
  SUN_SINK_SPEED: 0.0008, // extra sink scaled by speed

  // Spawning
  SPAWN_DIST_BASE: 28,
  SPAWN_DIST_MIN: 11,
  OBSTACLE_CHANCE: 0.55,
  PICKUP_CHANCE: 0.38,
  RAMP_CHANCE: 0.12,
  WAVE_SIZE: [2, 5],

  // Scoring
  DIST_SCORE_RATE: 1.2,
  TOKEN_SCORE: 50,
  NEAR_MISS_SCORE: 35,
  NEAR_MISS_DIST: 2.4,

  // Visual
  FOV: 1.15,
  CAM_DIST: 14,
  CAM_HEIGHT: 6.5,
  CAM_LOOK: 28,
  SHAKE_DECAY: 0.88,
  PARTICLE_MAX: 420,

  // Colors (sunset palette)
  SKY_TOP: [28, 12, 48],
  SKY_MID: [210, 70, 60],
  SKY_HORIZON: [255, 170, 80],
  SUN_CORE: [255, 250, 220],
  SUN_GLOW: [255, 160, 40],
  GROUND: [18, 10, 28],
  GROUND_LIT: [55, 30, 45],
  CRAFT: [255, 230, 120],
  CRAFT_ACCENT: [80, 220, 255],
  OBSTACLE: [40, 20, 55],
  OBSTACLE_LIT: [120, 50, 80],
  SHADOW: [8, 4, 16],
  ENERGY_COL: [80, 255, 200],
  TOKEN_COL: [255, 220, 80],
  MULTI_COL: [255, 100, 220],
};

// =============================================================================
// === SETUP ===================================================================
// =============================================================================
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let W = 0, H = 0, DPR = 1;

function resize() {
  DPR = Math.min(window.devicePixelRatio || 1, 2);
  W = window.innerWidth;
  H = window.innerHeight;
  canvas.width = (W * DPR) | 0;
  canvas.height = (H * DPR) | 0;
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
window.addEventListener('resize', resize);
resize();

// RNG (mulberry32) — deterministic per run
function makeRng(seed) {
  let s = seed >>> 0;
  return function () {
    s = (s + 0x6D2B79F5) >>> 0;
    let t = Math.imul(s ^ (s >>> 15), 1 | s);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
function randRange(rng, a, b) { return a + rng() * (b - a); }
function randInt(rng, a, b) { return (a + rng() * (b - a + 1)) | 0; }
function pick(rng, arr) { return arr[(rng() * arr.length) | 0]; }
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
function lerp(a, b, t) { return a + (b - a) * t; }
function smoothstep(t) { return t * t * (3 - 2 * t); }
function dist2(ax, az, bx, bz) { const dx = ax - bx, dz = az - bz; return dx * dx + dz * dz; }

// =============================================================================
// === INPUT ===================================================================
// =============================================================================
const keys = Object.create(null);
const JUST = Object.create(null);

window.addEventListener('keydown', e => {
  const k = e.key.toLowerCase();
  if (!keys[k]) JUST[k] = true;
  keys[k] = true;
  if (['arrowup','arrowdown','arrowleft','arrowright',' ','w','a','s','d'].includes(k) || e.code === 'Space') {
    e.preventDefault();
  }
  if (k === 'r' && (state.mode === 'playing' || state.mode === 'gameover')) {
    startRun();
  }
  if ((k === ' ' || k === 'enter') && state.mode === 'title') {
    startRun();
  }
  if ((k === ' ' || k === 'enter' || k === 'r') && state.mode === 'gameover') {
    startRun();
  }
});
window.addEventListener('keyup', e => {
  keys[e.key.toLowerCase()] = false;
});
canvas.addEventListener('click', () => {
  if (state.mode === 'title' || state.mode === 'gameover') startRun();
  tryUnlockAudio();
});
window.addEventListener('mousedown', tryUnlockAudio);
window.addEventListener('keydown', tryUnlockAudio, { once: false });

function inputAxis() {
  let x = 0, y = 0;
  if (keys['a'] || keys['arrowleft']) x -= 1;
  if (keys['d'] || keys['arrowright']) x += 1;
  if (keys['w'] || keys['arrowup']) y += 1;
  if (keys['s'] || keys['arrowdown']) y -= 1;
  return { x, y };
}
function consumeJust(k) {
  if (JUST[k]) { JUST[k] = false; return true; }
  return false;
}
function clearJust() {
  for (const k in JUST) JUST[k] = false;
}

// =============================================================================
// === AUDIO (WebAudio procedural) =============================================
// =============================================================================
let audioCtx = null;
let masterGain = null;
let droneOsc = null, droneGain = null, windFilter = null;

function tryUnlockAudio() {
  if (audioCtx) return;
  try {
    audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    masterGain = audioCtx.createGain();
    masterGain.gain.value = 0.35;
    masterGain.connect(audioCtx.destination);
    setupDrone();
  } catch (_) {}
}

function setupDrone() {
  if (!audioCtx) return;
  droneOsc = audioCtx.createOscillator();
  droneOsc.type = 'sawtooth';
  droneOsc.frequency.value = 55;
  windFilter = audioCtx.createBiquadFilter();
  windFilter.type = 'lowpass';
  windFilter.frequency.value = 200;
  windFilter.Q.value = 0.7;
  droneGain = audioCtx.createGain();
  droneGain.gain.value = 0;
  droneOsc.connect(windFilter);
  windFilter.connect(droneGain);
  droneGain.connect(masterGain);
  droneOsc.start();
}

function setDrone(speedNorm, playing) {
  if (!droneGain || !audioCtx) return;
  const t = audioCtx.currentTime;
  const target = playing ? 0.04 + speedNorm * 0.12 : 0;
  droneGain.gain.setTargetAtTime(target, t, 0.15);
  if (droneOsc) droneOsc.frequency.setTargetAtTime(48 + speedNorm * 80, t, 0.2);
  if (windFilter) windFilter.frequency.setTargetAtTime(180 + speedNorm * 1200, t, 0.2);
}

function beep(freq, dur, type, vol, slide) {
  if (!audioCtx || !masterGain) return;
  const t0 = audioCtx.currentTime;
  const o = audioCtx.createOscillator();
  const g = audioCtx.createGain();
  o.type = type || 'square';
  o.frequency.setValueAtTime(freq, t0);
  if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, freq * slide), t0 + dur);
  g.gain.setValueAtTime(vol || 0.15, t0);
  g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
  o.connect(g); g.connect(masterGain);
  o.start(t0); o.stop(t0 + dur + 0.02);
}

function noiseBurst(dur, vol, hp) {
  if (!audioCtx || !masterGain) return;
  const n = (audioCtx.sampleRate * dur) | 0;
  const buf = audioCtx.createBuffer(1, n, audioCtx.sampleRate);
  const d = buf.getChannelData(0);
  for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / n);
  const src = audioCtx.createBufferSource();
  src.buffer = buf;
  const f = audioCtx.createBiquadFilter();
  f.type = hp ? 'highpass' : 'lowpass';
  f.frequency.value = hp || 400;
  const g = audioCtx.createGain();
  g.gain.value = vol || 0.2;
  src.connect(f); f.connect(g); g.connect(masterGain);
  src.start();
}

function sfxPickup(kind) {
  if (kind === 'energy') { beep(520, 0.08, 'sine', 0.18, 1.6); beep(780, 0.1, 'sine', 0.12, 1.3); }
  else if (kind === 'token') { beep(660, 0.06, 'triangle', 0.16, 1.4); beep(990, 0.09, 'triangle', 0.1); }
  else { beep(440, 0.05, 'square', 0.12); beep(660, 0.05, 'square', 0.1); beep(880, 0.12, 'square', 0.14, 1.5); }
}
function sfxNearMiss() { beep(180, 0.05, 'sawtooth', 0.08, 0.5); noiseBurst(0.06, 0.08, 800); }
function sfxCrash() { noiseBurst(0.35, 0.45, 120); beep(90, 0.4, 'sawtooth', 0.25, 0.3); beep(60, 0.5, 'sine', 0.2, 0.4); }
function sfxBoost() { beep(220, 0.15, 'sawtooth', 0.1, 2.2); }
function sfxUI() { beep(400, 0.05, 'sine', 0.1); beep(600, 0.08, 'sine', 0.08); }

// =============================================================================
// === PARTICLES ===============================================================
// =============================================================================
const particles = [];

function spawnParticle(x, y, z, opts) {
  if (particles.length >= C.PARTICLE_MAX) particles.splice(0, 40);
  particles.push({
    x, y, z,
    vx: opts.vx || 0,
    vy: opts.vy || 0,
    vz: opts.vz || 0,
    life: opts.life || 0.6,
    maxLife: opts.life || 0.6,
    size: opts.size || 0.3,
    color: opts.color || [255, 255, 255],
    drag: opts.drag != null ? opts.drag : 0.98,
    gravity: opts.gravity || 0,
    glow: !!opts.glow,
  });
}

function burst(x, y, z, n, color, speed, life) {
  for (let i = 0; i < n; i++) {
    const a = Math.random() * Math.PI * 2;
    const elev = (Math.random() - 0.3) * Math.PI * 0.6;
    const sp = speed * (0.4 + Math.random() * 0.8);
    spawnParticle(x, y, z, {
      vx: Math.cos(a) * Math.cos(elev) * sp,
      vy: Math.sin(elev) * sp + 2,
      vz: Math.sin(a) * Math.cos(elev) * sp,
      life: life * (0.5 + Math.random() * 0.6),
      size: 0.15 + Math.random() * 0.45,
      color,
      gravity: -8,
      glow: true,
    });
  }
}

function updateParticles(dt) {
  for (let i = particles.length - 1; i >= 0; i--) {
    const p = particles[i];
    p.life -= dt;
    if (p.life <= 0) { particles.splice(i, 1); continue; }
    p.vx *= p.drag; p.vy *= p.drag; p.vz *= p.drag;
    p.vy += p.gravity * dt;
    p.x += p.vx * dt;
    p.y += p.vy * dt;
    p.z += p.vz * dt;
  }
}

// =============================================================================
// === PROCEDURAL WORLD ========================================================
// =============================================================================
// Stream of segments along +Z (forward). Player starts at z=0 heading +Z? 
// Convention: craft moves +Z, camera looks +Z, sun is far +Z on horizon-ish
// Actually classic race-the-sun: forward is into the distance toward the sun.
// We'll use +Z as forward (increasing z = further ahead into the world).

function createWorld(seed) {
  const rng = makeRng(seed);
  return {
    seed,
    rng,
    nextZ: 20,
    entities: [],       // obstacles, pickups, ramps
    decor: [],          // distant pyramids / pillars for depth
    groundStripes: [],  // minor ground detail
    shadowRects: [],    // dynamic: tall obstacle shadows (approx other entities)
  };
}

function spawnWave(world, playerZ, speed) {
  // Tighten spacing with speed
  const speedT = clamp((speed - C.BASE_SPEED) / (C.MAX_SPEED - C.BASE_SPEED), 0, 1);
  const gap = lerp(C.SPAWN_DIST_BASE, C.SPAWN_DIST_MIN, speedT);
  const count = randInt(world.rng, C.WAVE_SIZE[0], C.WAVE_SIZE[1]);
  const baseZ = world.nextZ;
  world.nextZ += gap + count * 3;

  // Occasionally leave a rest zone early on
  if (playerZ < 80 && world.rng() < 0.15) {
    world.nextZ += 15;
    return;
  }

  for (let i = 0; i < count; i++) {
    const z = baseZ + i * randRange(world.rng, 2.5, 5.5);
    const roll = world.rng();

    if (roll < C.OBSTACLE_CHANCE) {
      spawnObstacle(world, z, speedT);
    } else if (roll < C.OBSTACLE_CHANCE + C.PICKUP_CHANCE) {
      spawnPickup(world, z);
    } else if (roll < C.OBSTACLE_CHANCE + C.PICKUP_CHANCE + C.RAMP_CHANCE) {
      spawnRamp(world, z);
    } else {
      spawnObstacle(world, z, speedT);
      if (world.rng() < 0.4) spawnPickup(world, z + 2);
    }
  }

  // Occasional wall formation / gate
  if (world.rng() < 0.18 + speedT * 0.15) {
    spawnGate(world, baseZ + gap * 0.5, speedT);
  }

  // Decor pillars far off sides
  if (world.rng() < 0.5) {
    for (let k = 0; k < 3; k++) {
      world.decor.push({
        x: (world.rng() < 0.5 ? -1 : 1) * randRange(world.rng, C.LANE_HALF + 4, C.LANE_HALF + 40),
        z: baseZ + randRange(world.rng, 0, gap),
        h: randRange(world.rng, 4, 22),
        w: randRange(world.rng, 1.5, 4),
        d: randRange(world.rng, 1.5, 4),
      });
    }
  }
}

function spawnObstacle(world, z, speedT) {
  const styles = ['block', 'pillar', 'wedge', 'thin'];
  const style = pick(world.rng, styles);
  let w, h, d, x;
  if (style === 'pillar') {
    w = randRange(world.rng, 1.2, 2.2);
    d = w;
    h = randRange(world.rng, 3, 10 + speedT * 4);
    x = randRange(world.rng, -C.LANE_HALF + 1, C.LANE_HALF - 1);
  } else if (style === 'thin') {
    w = randRange(world.rng, 0.8, 1.4);
    d = randRange(world.rng, 3, 8);
    h = randRange(world.rng, 2, 5);
    x = randRange(world.rng, -C.LANE_HALF + 2, C.LANE_HALF - 2);
  } else if (style === 'wedge') {
    w = randRange(world.rng, 3, 7);
    d = randRange(world.rng, 2, 4);
    h = randRange(world.rng, 1.5, 4);
    x = randRange(world.rng, -C.LANE_HALF + 2, C.LANE_HALF - 2);
  } else {
    w = randRange(world.rng, 2, 5.5);
    d = randRange(world.rng, 2, 4.5);
    h = randRange(world.rng, 1.8, 6 + speedT * 2);
    x = randRange(world.rng, -C.LANE_HALF + w * 0.5, C.LANE_HALF - w * 0.5);
  }
  // Sometimes low blockers you must pitch over mentally with height feeling
  world.entities.push({
    type: 'obstacle', style, x, z, w, h, d,
    y: 0,
    alive: true,
    nearMissed: false,
  });
}

function spawnGate(world, z, speedT) {
  const gap = randRange(world.rng, 4.5, 8 - speedT * 1.5);
  const gapX = randRange(world.rng, -C.LANE_HALF + gap, C.LANE_HALF - gap);
  const h = randRange(world.rng, 3, 7);
  // left wall
  const leftW = (gapX - gap * 0.5) - (-C.LANE_HALF);
  if (leftW > 1.5) {
    world.entities.push({
      type: 'obstacle', style: 'block',
      x: -C.LANE_HALF + leftW * 0.5,
      z, w: leftW, h, d: 2.5, y: 0, alive: true, nearMissed: false,
    });
  }
  // right wall
  const rightStart = gapX + gap * 0.5;
  const rightW = C.LANE_HALF - rightStart;
  if (rightW > 1.5) {
    world.entities.push({
      type: 'obstacle', style: 'block',
      x: rightStart + rightW * 0.5,
      z, w: rightW, h, d: 2.5, y: 0, alive: true, nearMissed: false,
    });
  }
  // hanging top sometimes
  if (world.rng() < 0.35 + speedT * 0.2) {
    world.entities.push({
      type: 'obstacle', style: 'block',
      x: gapX, z,
      w: gap + 1, h: 2 + speedT * 2, d: 2,
      y: h + 1.5, alive: true, nearMissed: false, hanging: true,
    });
  }
}

function spawnPickup(world, z) {
  const r = world.rng();
  let kind = 'token';
  if (r < 0.45) kind = 'energy';
  else if (r < 0.6) kind = 'multi';
  const x = randRange(world.rng, -C.LANE_HALF + 1.5, C.LANE_HALF - 1.5);
  const y = randRange(world.rng, 0.6, 3.2);
  world.entities.push({
    type: 'pickup', kind, x, y, z,
    r: kind === 'multi' ? 0.7 : 0.55,
    spin: world.rng() * Math.PI * 2,
    alive: true,
  });
}

function spawnRamp(world, z) {
  const x = randRange(world.rng, -C.LANE_HALF + 4, C.LANE_HALF - 4);
  world.entities.push({
    type: 'ramp', x, z,
    w: randRange(world.rng, 4, 8),
    d: randRange(world.rng, 6, 12),
    h: randRange(world.rng, 2, 4),
    alive: true,
  });
}

function ensureWorld(world, playerZ, speed) {
  while (world.nextZ < playerZ + 180) {
    spawnWave(world, playerZ, speed);
  }
  // cull behind
  const minZ = playerZ - 30;
  world.entities = world.entities.filter(e => e.z > minZ && e.alive !== false);
  world.decor = world.decor.filter(d => d.z > minZ - 20);
}

// =============================================================================
// === GAME STATE ==============================================================
// =============================================================================
const state = {
  mode: 'title', // title | playing | gameover
  t: 0,
  seed: 1,
  world: null,
  player: null,
  score: 0,
  distance: 0,
  multiplier: 1,
  multiTimer: 0,
  highScore: 0,
  shake: 0,
  flash: 0,
  speedLines: 0,
  messages: [],
  titlePulse: 0,
  deathReason: '',
};

try {
  state.highScore = parseInt(localStorage.getItem('solarDashHS') || '0', 10) || 0;
} catch (_) {}

function makePlayer() {
  return {
    x: 0, y: 1.2, z: 0,
    vx: 0, vy: 0,
    targetX: 0, targetY: 1.2,
    roll: 0, pitch: 0,
    speed: C.BASE_SPEED,
    energy: C.ENERGY_MAX,
    boosting: false,
    alive: true,
    invuln: 0,
    trail: [],
  };
}

function startRun() {
  tryUnlockAudio();
  sfxUI();
  state.mode = 'playing';
  state.t = 0;
  state.seed = (Date.now() ^ (Math.random() * 1e9)) >>> 0;
  state.world = createWorld(state.seed);
  state.player = makePlayer();
  state.score = 0;
  state.distance = 0;
  state.multiplier = 1;
  state.multiTimer = 0;
  state.shake = 0;
  state.flash = 0;
  state.speedLines = 0;
  state.messages = [];
  state.deathReason = '';
  particles.length = 0;
  // seed some early empty runway
  state.world.nextZ = 40;
  ensureWorld(state.world, 0, C.BASE_SPEED);
}

function gameOver(reason) {
  if (state.mode !== 'playing') return;
  state.mode = 'gameover';
  state.deathReason = reason || 'Destroyed';
  state.flash = 0.8;
  state.shake = 18;
  sfxCrash();
  const p = state.player;
  burst(p.x, p.y, p.z, 60, [255, 180, 80], 22, 1.1);
  burst(p.x, p.y, p.z, 30, [255, 80, 60], 14, 0.8);
  burst(p.x, p.y, p.z, 20, [255, 255, 200], 8, 0.5);
  if (state.score > state.highScore) {
    state.highScore = state.score | 0;
    try { localStorage.setItem('solarDashHS', String(state.highScore)); } catch (_) {}
  }
}

function addMessage(text, color) {
  state.messages.push({ text, color, life: 1.2 });
}

// =============================================================================
// === UPDATE ==================================================================
// =============================================================================
function update(dt) {
  state.t += dt;
  state.titlePulse += dt;

  if (state.shake > 0) state.shake *= Math.pow(C.SHAKE_DECAY, dt * 60);
  if (state.shake < 0.05) state.shake = 0;
  if (state.flash > 0) state.flash = Math.max(0, state.flash - dt * 2.5);

  for (let i = state.messages.length - 1; i >= 0; i--) {
    state.messages[i].life -= dt;
    if (state.messages[i].life <= 0) state.messages.splice(i, 1);
  }

  updateParticles(dt);

  if (state.mode !== 'playing') {
    setDrone(0, false);
    // gently animate title particles
    if (Math.random() < 0.3) {
      spawnParticle((Math.random() - 0.5) * 30, Math.random() * 4, state.t * 20 + Math.random() * 40, {
        vx: (Math.random() - 0.5) * 2, vy: Math.random() * 2, vz: -5 - Math.random() * 10,
        life: 1.5, size: 0.2, color: [255, 180, 100], glow: true,
      });
    }
    return;
  }

  const p = state.player;
  const axis = inputAxis();

  // Speed ramp
  p.speed = Math.min(C.MAX_SPEED, p.speed + C.ACCEL * dt);
  const speedNorm = clamp((p.speed - C.BASE_SPEED) / (C.MAX_SPEED - C.BASE_SPEED), 0, 1);

  // Boost
  p.boosting = false;
  if ((keys[' '] || keys['spacebar']) && p.energy > 5) {
    if (!p._boosted) { sfxBoost(); p._boosted = true; }
    p.boosting = true;
    p.energy -= C.BOOST_DRAIN * dt;
    if (p.energy < 0) p.energy = 0;
  } else {
    p._boosted = false;
  }
  const boostMul = p.boosting ? C.BOOST_MULT : 1;
  const forwardSpeed = p.speed * boostMul;

  // Strafe / pitch with snappy ease + inertia tail
  p.targetX = clamp(p.targetX + axis.x * C.STRAFE_SPEED * dt, -C.LANE_HALF + 0.8, C.LANE_HALF - 0.8);
  // Micro-correct toward target with ease; velocity for inertia feel
  const ease = 1 - Math.pow(1 - C.STRAFE_EASE, dt * 60);
  const desiredVx = (p.targetX - p.x) * (1 / 0.09);
  p.vx = lerp(p.vx, desiredVx, ease);
  p.vx *= Math.pow(C.INERTIA, dt * 60 * 0.15);
  p.x += p.vx * dt;
  p.x = clamp(p.x, -C.LANE_HALF + 0.5, C.LANE_HALF - 0.5);

  p.targetY = clamp(p.targetY + axis.y * C.PITCH_SPEED * dt, 0.6, C.PITCH_RANGE);
  const desiredVy = (p.targetY - p.y) * (1 / 0.1);
  p.vy = lerp(p.vy, desiredVy, ease);
  p.y += p.vy * dt;
  p.y = clamp(p.y, 0.5, C.PITCH_RANGE + 0.5);

  // Visual bank / pitch
  const rollTarget = clamp(-axis.x * 0.55 - p.vx * 0.015, -0.7, 0.7);
  p.roll = lerp(p.roll, rollTarget, 1 - Math.pow(0.001, dt));
  const pitchTarget = clamp((p.targetY - 1.2) * 0.08 - axis.y * 0.15, -0.35, 0.4);
  p.pitch = lerp(p.pitch, pitchTarget, 1 - Math.pow(0.001, dt));

  // Forward
  p.z += forwardSpeed * dt;
  state.distance = p.z;
  state.score += forwardSpeed * dt * C.DIST_SCORE_RATE * state.multiplier;

  // Sun sink
  const sun = getSunHeight();
  if (sun <= 0.02) {
    gameOver('The sun has set');
    return;
  }

  // Energy drain (+ shadow)
  const inShadow = isInShadow(p.x, p.y, p.z);
  let drain = C.ENERGY_DRAIN * (0.7 + speedNorm * 0.5);
  if (inShadow) drain += C.ENERGY_SHADOW;
  // Near edges feel cooler / more drain slightly
  drain += Math.abs(p.x) / C.LANE_HALF * 1.2;
  p.energy -= drain * dt;
  if (p.energy <= 0) {
    p.energy = 0;
    gameOver('Energy depleted');
    return;
  }

  // Multiplier timer
  if (state.multiTimer > 0) {
    state.multiTimer -= dt;
    if (state.multiTimer <= 0) {
      state.multiplier = Math.max(1, state.multiplier - 1);
      if (state.multiplier > 1) state.multiTimer = 4;
    }
  }

  // Trail
  p.trail.push({ x: p.x, y: p.y, z: p.z, life: 0.35 });
  if (p.trail.length > 28) p.trail.shift();
  for (const t of p.trail) t.life -= dt;

  // Thrust particles
  if (Math.random() < 0.9 + speedNorm) {
    const side = (Math.random() - 0.5) * 0.6;
    spawnParticle(p.x + side, p.y - 0.15, p.z - 0.8, {
      vx: -p.vx * 0.1 + (Math.random() - 0.5) * 2,
      vy: (Math.random() - 0.5) * 1.5,
      vz: -forwardSpeed * 0.15 - Math.random() * 8,
      life: 0.25 + Math.random() * 0.2,
      size: 0.12 + Math.random() * 0.2,
      color: p.boosting ? [120, 240, 255] : [255, 160 + Math.random() * 60, 60],
      glow: true,
      drag: 0.96,
    });
  }
  if (p.boosting && Math.random() < 0.7) {
    burst(p.x, p.y, p.z - 1, 2, [100, 220, 255], 6, 0.3);
  }

  ensureWorld(state.world, p.z, p.speed);
  setDrone(speedNorm + (p.boosting ? 0.25 : 0), true);
  state.speedLines = smoothstep(clamp((speedNorm - 0.35) / 0.5, 0, 1));

  // Entity interactions
  const craftHalfW = 0.7, craftHalfH = 0.35, craftHalfD = 1.0;
  for (const e of state.world.entities) {
    if (!e.alive) continue;

    if (e.type === 'pickup') {
      e.spin += dt * 4;
      e.yHover = Math.sin(state.t * 4 + e.spin) * 0.25;
      const dy = (e.y + (e.yHover || 0)) - p.y;
      const dsq = dist2(e.x, e.z, p.x, p.z) + dy * dy;
      if (dsq < (e.r + 1.1) * (e.r + 1.1)) {
        e.alive = false;
        collectPickup(e);
      }
      continue;
    }

    if (e.type === 'ramp') {
      // Soft vertical assist when over ramp footprint
      if (Math.abs(p.x - e.x) < e.w * 0.5 && p.z > e.z - e.d * 0.5 && p.z < e.z + e.d * 0.5) {
        const local = (p.z - (e.z - e.d * 0.5)) / e.d;
        const rampY = local * e.h + 0.8;
        if (p.y < rampY && p.y > rampY - 2) {
          p.y = lerp(p.y, rampY + 0.3, 0.2);
          p.targetY = Math.max(p.targetY, rampY);
        }
      }
      continue;
    }

    if (e.type === 'obstacle') {
      // AABB vs craft
      const ey = e.y || 0;
      const closestX = clamp(p.x, e.x - e.w * 0.5, e.x + e.w * 0.5);
      const closestY = clamp(p.y, ey, ey + e.h);
      const closestZ = clamp(p.z, e.z - e.d * 0.5, e.z + e.d * 0.5);
      const dx = p.x - closestX;
      const dy = p.y - closestY;
      const dz = p.z - closestZ;
      const d2 = dx * dx + dy * dy + dz * dz;
      const rad = 0.55;
      if (d2 < rad * rad) {
        gameOver('Crashed');
        return;
      }
      // Near miss: lateral proximity while passing in z
      if (!e.nearMissed && Math.abs(p.z - e.z) < e.d * 0.5 + 1.5) {
        const lateral = Math.sqrt(dx * dx + dy * dy);
        if (lateral < C.NEAR_MISS_DIST && lateral > rad) {
          e.nearMissed = true;
          state.score += C.NEAR_MISS_SCORE * state.multiplier;
          state.shake = Math.max(state.shake, 5);
          sfxNearMiss();
          addMessage('NEAR MISS +' + (C.NEAR_MISS_SCORE * state.multiplier | 0), C.TOKEN_COL);
          burst(p.x, p.y, p.z, 8, [255, 255, 255], 10, 0.35);
        }
      }
    }
  }
}

function getSunHeight() {
  if (!state.player) return 1;
  const t = state.t;
  const speedBoost = Math.max(0, state.player.speed - C.BASE_SPEED) * C.SUN_SINK_SPEED;
  return clamp(C.SUN_START - t * C.SUN_SINK_RATE - speedBoost * t * 0.15, 0, 1);
}

function isInShadow(x, y, z) {
  // Sun is high on +Z horizon; shadows cast roughly toward -Z and slightly biased by sun height
  // Approximate: if a taller obstacle is ahead (+z) within shadow length covering us
  const sun = getSunHeight();
  const shadowScale = lerp(18, 6, sun); // longer shadows when sun low
  for (const e of state.world.entities) {
    if (e.type !== 'obstacle' || !e.alive) continue;
    const top = (e.y || 0) + e.h;
    if (top < y + 0.5) continue;
    // shadow projects backward (-Z) from obstacle
    const shadowLen = top * shadowScale * 0.15 + 3;
    if (z < e.z && z > e.z - shadowLen) {
      // expand shadow slightly in x
      const expand = (e.z - z) * 0.08;
      if (Math.abs(x - e.x) < e.w * 0.5 + expand + 0.4) {
        // y: full height shadow column (simplified)
        if (y < top) return true;
      }
    }
  }
  return false;
}

function collectPickup(e) {
  const p = state.player;
  if (e.kind === 'energy') {
    p.energy = Math.min(C.ENERGY_MAX, p.energy + C.ENERGY_PICKUP);
    sfxPickup('energy');
    burst(e.x, e.y, e.z, 14, C.ENERGY_COL, 10, 0.5);
    addMessage('+' + C.ENERGY_PICKUP + ' ENERGY', C.ENERGY_COL);
  } else if (e.kind === 'token') {
    const pts = (C.TOKEN_SCORE * state.multiplier) | 0;
    state.score += pts;
    sfxPickup('token');
    burst(e.x, e.y, e.z, 12, C.TOKEN_COL, 9, 0.45);
    addMessage('+' + pts, C.TOKEN_COL);
  } else {
    state.multiplier = Math.min(9, state.multiplier + 1);
    state.multiTimer = 6;
    sfxPickup('multi');
    burst(e.x, e.y, e.z, 20, C.MULTI_COL, 12, 0.6);
    addMessage('x' + state.multiplier + ' MULTIPLIER', C.MULTI_COL);
    state.shake = Math.max(state.shake, 3);
  }
}

// =============================================================================
// === RENDER / CAMERA =========================================================
// =============================================================================
function project(x, y, z, cam) {
  // Camera space
  const dx = x - cam.x;
  const dy = y - cam.y;
  const dz = z - cam.z;
  // Look slightly down the path; simple yaw 0
  if (dz <= 0.15) return null;
  const scale = (H * 0.75) / (dz * C.FOV);
  const sx = W * 0.5 + dx * scale + cam.shakeX;
  const sy = H * 0.52 - dy * scale + cam.shakeY;
  return { sx, sy, scale, dz };
}

function rgb(c, a) {
  if (a == null) return `rgb(${c[0]|0},${c[1]|0},${c[2]|0})`;
  return `rgba(${c[0]|0},${c[1]|0},${c[2]|0},${a})`;
}
function mixColor(a, b, t) {
  return [
    a[0] + (b[0] - a[0]) * t,
    a[1] + (b[1] - a[1]) * t,
    a[2] + (b[2] - a[2]) * t,
  ];
}

function getCamera() {
  const p = state.player || { x: 0, y: 1.2, z: 0, roll: 0 };
  const sh = state.shake;
  const ang = state.t * 40;
  return {
    x: p.x * 0.85,
    y: p.y + C.CAM_HEIGHT,
    z: p.z - C.CAM_DIST,
    shakeX: sh ? (Math.random() - 0.5) * sh * 2 : 0,
    shakeY: sh ? (Math.random() - 0.5) * sh * 2 : 0,
    roll: p.roll * 0.25,
  };
}

function drawSky(cam, sunH) {
  // Vertical gradient backdrop
  const g = ctx.createLinearGradient(0, 0, 0, H);
  const dusk = 1 - sunH;
  const top = mixColor(C.SKY_TOP, [8, 4, 18], dusk * 0.7);
  const mid = mixColor(C.SKY_MID, [60, 20, 50], dusk * 0.5);
  const hor = mixColor(C.SKY_HORIZON, [120, 40, 40], dusk * 0.6);
  g.addColorStop(0, rgb(top));
  g.addColorStop(0.45, rgb(mid));
  g.addColorStop(0.72, rgb(hor));
  g.addColorStop(1, rgb(mixColor(hor, C.GROUND, 0.5)));
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, W, H);

  // Sun
  const sunZ = (state.player ? state.player.z : 0) + 220;
  const sunY = lerp(-2, 28, sunH);
  const sp = project(0, sunY, sunZ, cam);
  if (sp) {
    const R = 40 + sunH * 50;
    // glow layers
    for (let i = 5; i >= 0; i--) {
      const rr = R * (1 + i * 0.55);
      const alpha = 0.07 * (1 - i / 6) * (0.5 + sunH * 0.5);
      const grd = ctx.createRadialGradient(sp.sx, sp.sy, 0, sp.sx, sp.sy, rr);
      grd.addColorStop(0, rgb(C.SUN_GLOW, alpha * 2));
      grd.addColorStop(1, rgb(C.SUN_GLOW, 0));
      ctx.fillStyle = grd;
      ctx.beginPath();
      ctx.arc(sp.sx, sp.sy, rr, 0, Math.PI * 2);
      ctx.fill();
    }
    const core = ctx.createRadialGradient(sp.sx, sp.sy, 0, sp.sx, sp.sy, R);
    core.addColorStop(0, rgb(C.SUN_CORE, 0.95));
    core.addColorStop(0.5, rgb(C.SUN_GLOW, 0.9));
    core.addColorStop(1, rgb(C.SUN_GLOW, 0));
    ctx.fillStyle = core;
    ctx.beginPath();
    ctx.arc(sp.sx, sp.sy, R, 0, Math.PI * 2);
    ctx.fill();
  }

  // Horizon haze line
  const hz = project(0, 0, (state.player ? state.player.z : 0) + 200, cam);
  if (hz) {
    ctx.fillStyle = rgb(hor, 0.15);
    ctx.fillRect(0, hz.sy - 2, W, 8);
  }
}

function drawGround(cam) {
  const p = state.player || { z: 0, x: 0 };
  const sunH = getSunHeight();
  // Perspective ground grid / chevrons toward sun
  ctx.lineWidth = 1;

  // solid ground plane fill via trapezoid bands
  for (let i = 40; i >= 0; i--) {
    const z0 = p.z - 10 + i * 6;
    const z1 = z0 + 6;
    const a = project(-C.LANE_HALF - 30, 0, z0, cam);
    const b = project(C.LANE_HALF + 30, 0, z0, cam);
    const c = project(C.LANE_HALF + 30, 0, z1, cam);
    const d = project(-C.LANE_HALF - 30, 0, z1, cam);
    if (!a || !b || !c || !d) continue;
    const lit = 0.3 + sunH * 0.4 + Math.sin(z0 * 0.05) * 0.03;
    const col = mixColor(C.GROUND, C.GROUND_LIT, lit * (i / 40));
    ctx.fillStyle = rgb(col);
    ctx.beginPath();
    ctx.moveTo(a.sx, a.sy); ctx.lineTo(b.sx, b.sy); ctx.lineTo(c.sx, c.sy); ctx.lineTo(d.sx, d.sy);
    ctx.closePath();
    ctx.fill();
  }

  // Center path strip
  for (let i = 0; i < 50; i++) {
    const z0 = p.z + i * 5;
    const fade = 1 - i / 50;
    const a = project(-C.LANE_HALF, 0.02, z0, cam);
    const b = project(C.LANE_HALF, 0.02, z0, cam);
    if (!a || !b) continue;
    ctx.strokeStyle = rgb([80, 50, 70], 0.12 * fade);
    ctx.beginPath();
    ctx.moveTo(a.sx, a.sy); ctx.lineTo(b.sx, b.sy);
    ctx.stroke();
  }

  // Lane edge rails
  const edges = [-C.LANE_HALF, C.LANE_HALF];
  for (const ex of edges) {
    ctx.beginPath();
    let started = false;
    for (let i = 0; i < 60; i++) {
      const z = p.z - 8 + i * 5;
      const pt = project(ex, 0.05, z, cam);
      if (!pt) continue;
      if (!started) { ctx.moveTo(pt.sx, pt.sy); started = true; }
      else ctx.lineTo(pt.sx, pt.sy);
    }
    ctx.strokeStyle = rgb([255, 140, 80], 0.35);
    ctx.lineWidth = 2;
    ctx.stroke();
  }

  // Grid ticks
  ctx.lineWidth = 1;
  for (let i = 0; i < 40; i++) {
    const z = Math.floor((p.z - 5) / 8) * 8 + i * 8;
    const fade = clamp(1 - (z - p.z) / 160, 0, 1);
    const l = project(-C.LANE_HALF, 0.03, z, cam);
    const r = project(C.LANE_HALF, 0.03, z, cam);
    if (!l || !r) continue;
    ctx.strokeStyle = rgb([255, 180, 100], 0.07 * fade);
    ctx.beginPath(); ctx.moveTo(l.sx, l.sy); ctx.lineTo(r.sx, r.sy); ctx.stroke();
  }
}

function drawBox(e, cam, sunH) {
  const x = e.x, z = e.z, y = e.y || 0;
  const hw = e.w * 0.5, hd = e.d * 0.5, h = e.h;
  // 8 corners → project visible faces roughly as 2.5D extruded poly
  const corners = [
    [x - hw, y, z - hd],
    [x + hw, y, z - hd],
    [x + hw, y, z + hd],
    [x - hw, y, z + hd],
    [x - hw, y + h, z - hd],
    [x + hw, y + h, z - hd],
    [x + hw, y + h, z + hd],
    [x - hw, y + h, z + hd],
  ];
  const pts = corners.map(c => project(c[0], c[1], c[2], cam));
  if (pts.some(p => !p)) return;
  const depth = pts.reduce((s, p) => s + p.dz, 0) / 8;

  // Shadow blob on ground (toward -z)
  const shadowLen = h * lerp(1.8, 0.5, sunH);
  const sa = project(x - hw * 1.05, 0.02, z - hd, cam);
  const sb = project(x + hw * 1.05, 0.02, z - hd, cam);
  const sc = project(x + hw * 1.2, 0.02, z - hd - shadowLen, cam);
  const sd = project(x - hw * 1.2, 0.02, z - hd - shadowLen, cam);
  if (sa && sb && sc && sd) {
    ctx.fillStyle = rgb(C.SHADOW, 0.45);
    ctx.beginPath();
    ctx.moveTo(sa.sx, sa.sy); ctx.lineTo(sb.sx, sb.sy); ctx.lineTo(sc.sx, sc.sy); ctx.lineTo(sd.sx, sd.sy);
    ctx.closePath();
    ctx.fill();
  }

  const litAmt = 0.35 + sunH * 0.5;
  const base = mixColor(C.OBSTACLE, C.OBSTACLE_LIT, litAmt * clamp(1.2 - depth / 120, 0.2, 1));
  const topCol = mixColor(base, C.SUN_GLOW, 0.15 + sunH * 0.2);
  const sideCol = mixColor(base, [0, 0, 0], 0.25);

  // painter-ish: back face, sides, top, front
  function face(idx, col, alpha) {
    ctx.fillStyle = rgb(col, alpha == null ? 1 : alpha);
    ctx.beginPath();
    ctx.moveTo(pts[idx[0]].sx, pts[idx[0]].sy);
    for (let i = 1; i < idx.length; i++) ctx.lineTo(pts[idx[i]].sx, pts[idx[i]].sy);
    ctx.closePath();
    ctx.fill();
    ctx.strokeStyle = rgb([255, 200, 150], 0.08);
    ctx.lineWidth = 1;
    ctx.stroke();
  }

  face([3, 2, 6, 7], sideCol);       // back-ish +z top edges
  face([0, 3, 7, 4], mixColor(sideCol, [0,0,0], 0.15));
  face([1, 2, 6, 5], mixColor(base, C.SUN_GLOW, 0.08));
  face([4, 5, 6, 7], topCol);
  face([0, 1, 5, 4], mixColor(base, [255,255,255], 0.05)); // front

  return depth;
}

function drawRamp(e, cam) {
  const x = e.x, z = e.z, hw = e.w * 0.5, hd = e.d * 0.5, h = e.h;
  const pts = [
    project(x - hw, 0, z - hd, cam),
    project(x + hw, 0, z - hd, cam),
    project(x + hw, h, z + hd, cam),
    project(x - hw, h, z + hd, cam),
  ];
  if (pts.some(p => !p)) return;
  ctx.fillStyle = rgb(mixColor(C.OBSTACLE_LIT, C.SUN_GLOW, 0.2), 0.85);
  ctx.beginPath();
  ctx.moveTo(pts[0].sx, pts[0].sy);
  for (let i = 1; i < 4; i++) ctx.lineTo(pts[i].sx, pts[i].sy);
  ctx.closePath();
  ctx.fill();
  ctx.strokeStyle = rgb([255, 200, 120], 0.4);
  ctx.stroke();
}

function drawPickup(e, cam) {
  const y = e.y + (e.yHover || 0);
  const p = project(e.x, y, e.z, cam);
  if (!p) return;
  const col = e.kind === 'energy' ? C.ENERGY_COL : e.kind === 'multi' ? C.MULTI_COL : C.TOKEN_COL;
  const r = Math.max(3, e.r * p.scale);
  // glow
  const g = ctx.createRadialGradient(p.sx, p.sy, 0, p.sx, p.sy, r * 3);
  g.addColorStop(0, rgb(col, 0.55));
  g.addColorStop(1, rgb(col, 0));
  ctx.fillStyle = g;
  ctx.beginPath(); ctx.arc(p.sx, p.sy, r * 3, 0, Math.PI * 2); ctx.fill();

  ctx.save();
  ctx.translate(p.sx, p.sy);
  ctx.rotate(e.spin);
  ctx.fillStyle = rgb(col);
  ctx.strokeStyle = rgb([255, 255, 255], 0.7);
  ctx.lineWidth = 1.5;
  if (e.kind === 'energy') {
    // bolt-like hex
    ctx.beginPath();
    for (let i = 0; i < 6; i++) {
      const a = (i / 6) * Math.PI * 2;
      const rr = r * (i % 2 ? 0.6 : 1);
      i === 0 ? ctx.moveTo(Math.cos(a) * rr, Math.sin(a) * rr) : ctx.lineTo(Math.cos(a) * rr, Math.sin(a) * rr);
    }
    ctx.closePath(); ctx.fill(); ctx.stroke();
  } else if (e.kind === 'multi') {
    ctx.beginPath();
    for (let i = 0; i < 5; i++) {
      const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
      const a2 = a + Math.PI / 5;
      i === 0 ? ctx.moveTo(Math.cos(a) * r, Math.sin(a) * r) : ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r);
      ctx.lineTo(Math.cos(a2) * r * 0.45, Math.sin(a2) * r * 0.45);
    }
    ctx.closePath(); ctx.fill(); ctx.stroke();
  } else {
    ctx.beginPath();
    ctx.moveTo(0, -r); ctx.lineTo(r * 0.7, 0); ctx.lineTo(0, r); ctx.lineTo(-r * 0.7, 0);
    ctx.closePath(); ctx.fill(); ctx.stroke();
  }
  ctx.restore();
}

function drawDecor(d, cam, sunH) {
  drawBox({ x: d.x, y: 0, z: d.z, w: d.w, h: d.h, d: d.d }, cam, sunH);
}

function drawCraft(cam) {
  const p = state.player;
  if (!p) return;
  // Build a sleek delta craft as projected polygons with roll/pitch
  const cosR = Math.cos(p.roll), sinR = Math.sin(p.roll);
  const cosP = Math.cos(p.pitch), sinP = Math.sin(p.pitch);

  function local(lx, ly, lz) {
    // pitch then roll
    let y1 = ly * cosP - lz * sinP;
    let z1 = ly * sinP + lz * cosP;
    let x1 = lx * cosR - y1 * sinR;
    let y2 = lx * sinR + y1 * cosR;
    return project(p.x + x1, p.y + y2, p.z + z1, cam);
  }

  const nose = local(0, 0, 1.6);
  const left = local(-1.3, 0, -1.1);
  const right = local(1.3, 0, -1.1);
  const tail = local(0, 0.15, -1.3);
  const canopy = local(0, 0.35, 0.2);
  const lWing = local(-1.8, -0.1, -0.3);
  const rWing = local(1.8, -0.1, -0.3);

  if (!nose || !left || !right) return;

  // Trail ribbon
  if (p.trail.length > 2) {
    ctx.beginPath();
    let first = true;
    for (let i = 0; i < p.trail.length; i++) {
      const t = p.trail[i];
      if (t.life <= 0) continue;
      const tp = project(t.x, t.y, t.z, cam);
      if (!tp) continue;
      if (first) { ctx.moveTo(tp.sx, tp.sy); first = false; }
      else ctx.lineTo(tp.sx, tp.sy);
    }
    ctx.strokeStyle = rgb(p.boosting ? C.CRAFT_ACCENT : C.CRAFT, 0.35);
    ctx.lineWidth = 2;
    ctx.stroke();
  }

  // Wing body
  ctx.fillStyle = rgb(C.CRAFT);
  ctx.strokeStyle = rgb([255, 255, 255], 0.5);
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  ctx.moveTo(nose.sx, nose.sy);
  if (rWing) ctx.lineTo(rWing.sx, rWing.sy);
  if (right) ctx.lineTo(right.sx, right.sy);
  if (tail) ctx.lineTo(tail.sx, tail.sy);
  if (left) ctx.lineTo(left.sx, left.sy);
  if (lWing) ctx.lineTo(lWing.sx, lWing.sy);
  ctx.closePath();
  ctx.fill();
  ctx.stroke();

  // Canopy
  if (canopy && tail) {
    ctx.fillStyle = rgb(C.CRAFT_ACCENT, 0.85);
    ctx.beginPath();
    ctx.moveTo(lerp(nose.sx, canopy.sx, 0.3), lerp(nose.sy, canopy.sy, 0.3));
    ctx.lineTo(canopy.sx, canopy.sy);
    ctx.lineTo(lerp(tail.sx, canopy.sx, 0.4), lerp(tail.sy, canopy.sy, 0.4));
    ctx.closePath();
    ctx.fill();
  }

  // Engine glow
  const eng = local(0, 0, -1.4);
  if (eng) {
    const R = (p.boosting ? 14 : 8) + Math.sin(state.t * 30) * 2;
    const eg = ctx.createRadialGradient(eng.sx, eng.sy, 0, eng.sx, eng.sy, R);
    eg.addColorStop(0, rgb(p.boosting ? [180, 255, 255] : [255, 240, 180], 0.9));
    eg.addColorStop(0.4, rgb(p.boosting ? C.CRAFT_ACCENT : C.SUN_GLOW, 0.5));
    eg.addColorStop(1, rgb(C.SUN_GLOW, 0));
    ctx.fillStyle = eg;
    ctx.beginPath(); ctx.arc(eng.sx, eng.sy, R, 0, Math.PI * 2); ctx.fill();
  }
}

function drawParticles(cam) {
  for (const p of particles) {
    const pr = project(p.x, p.y, p.z, cam);
    if (!pr) continue;
    const a = clamp(p.life / p.maxLife, 0, 1);
    const r = Math.max(0.8, p.size * pr.scale);
    if (p.glow) {
      const g = ctx.createRadialGradient(pr.sx, pr.sy, 0, pr.sx, pr.sy, r * 2.5);
      g.addColorStop(0, rgb(p.color, a * 0.8));
      g.addColorStop(1, rgb(p.color, 0));
      ctx.fillStyle = g;
      ctx.beginPath(); ctx.arc(pr.sx, pr.sy, r * 2.5, 0, Math.PI * 2); ctx.fill();
    }
    ctx.fillStyle = rgb(p.color, a);
    ctx.fillRect(pr.sx - r * 0.5, pr.sy - r * 0.5, r, r);
  }
}

function drawSpeedLines(cam) {
  if (state.speedLines <= 0.01 || state.mode !== 'playing') return;
  const p = state.player;
  const n = (30 * state.speedLines) | 0;
  ctx.save();
  for (let i = 0; i < n; i++) {
    const x = (Math.random() - 0.5) * C.LANE_HALF * 2.5;
    const y = Math.random() * 8;
    const z = p.z + 2 + Math.random() * 40;
    const a = project(x, y, z, cam);
    const b = project(x, y, z + 2 + state.speedLines * 4, cam);
    if (!a || !b) continue;
    ctx.strokeStyle = rgb([255, 230, 200], 0.08 + state.speedLines * 0.12);
    ctx.lineWidth = 1 + state.speedLines;
    ctx.beginPath(); ctx.moveTo(a.sx, a.sy); ctx.lineTo(b.sx, b.sy); ctx.stroke();
  }
  ctx.restore();

  // Subtle chromatic edge vignette at high speed
  if (state.speedLines > 0.4) {
    const a = (state.speedLines - 0.4) * 0.15;
    const g = ctx.createRadialGradient(W/2, H/2, H * 0.3, W/2, H/2, H * 0.75);
    g.addColorStop(0, 'rgba(0,0,0,0)');
    g.addColorStop(1, `rgba(40,0,20,${a})`);
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, W, H);
  }
}

function drawHUD() {
  const pad = 22;
  ctx.save();
  ctx.textAlign = 'left';
  ctx.textBaseline = 'top';

  if (state.mode === 'playing' || state.mode === 'gameover') {
    const p = state.player;
    // Score block
    ctx.fillStyle = rgb([255, 230, 200], 0.9);
    ctx.font = '600 13px system-ui,sans-serif';
    ctx.fillText('SCORE', pad, pad);
    ctx.font = '700 32px system-ui,sans-serif';
    ctx.fillText(String(state.score | 0), pad, pad + 16);

    ctx.font = '600 12px system-ui,sans-serif';
    ctx.fillStyle = rgb([255, 200, 140], 0.7);
    ctx.fillText('DIST ' + (state.distance | 0) + ' m', pad, pad + 52);
    ctx.fillText('SPD ' + (p.speed | 0), pad, pad + 68);

    // Multiplier
    if (state.multiplier > 1) {
      ctx.fillStyle = rgb(C.MULTI_COL, 0.9);
      ctx.font = '700 22px system-ui,sans-serif';
      ctx.fillText('x' + state.multiplier, pad, pad + 88);
    }

    // High score
    ctx.textAlign = 'right';
    ctx.fillStyle = rgb([255, 220, 180], 0.55);
    ctx.font = '600 12px system-ui,sans-serif';
    ctx.fillText('BEST ' + state.highScore, W - pad, pad);
    ctx.fillText('SEED ' + (state.seed >>> 0).toString(16).toUpperCase(), W - pad, pad + 18);

    // Energy bar
    const bw = 220, bh = 10;
    const bx = (W - bw) / 2, by = H - 36;
    ctx.textAlign = 'center';
    ctx.fillStyle = rgb([255, 220, 180], 0.5);
    ctx.font = '600 10px system-ui,sans-serif';
    ctx.fillText('ENERGY', W / 2, by - 16);
    ctx.fillStyle = rgb([30, 15, 30], 0.7);
    ctx.fillRect(bx - 2, by - 2, bw + 4, bh + 4);
    const er = p.energy / C.ENERGY_MAX;
    const ecol = er < 0.25 ? [255, 60, 80] : er < 0.5 ? [255, 180, 60] : C.ENERGY_COL;
    ctx.fillStyle = rgb(ecol, 0.9);
    ctx.fillRect(bx, by, bw * er, bh);
    if (p.boosting) {
      ctx.strokeStyle = rgb(C.CRAFT_ACCENT, 0.8);
      ctx.strokeRect(bx - 2, by - 2, bw + 4, bh + 4);
    }

    // Sun meter
    const sunH = getSunHeight();
    const sw = 6, sh = 80;
    const sx = W - pad - sw, sy = H * 0.5 - sh * 0.5;
    ctx.fillStyle = rgb([30, 15, 30], 0.6);
    ctx.fillRect(sx - 2, sy - 2, sw + 4, sh + 4);
    const sunCol = mixColor(C.SUN_GLOW, [255, 60, 40], 1 - sunH);
    ctx.fillStyle = rgb(sunCol);
    const sunFill = sh * sunH;
    ctx.fillRect(sx, sy + sh - sunFill, sw, sunFill);
    ctx.fillStyle = rgb([255, 220, 180], 0.45);
    ctx.font = '600 9px system-ui,sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText('SUN', sx + sw / 2, sy + sh + 10);

    // Floating messages
    ctx.textAlign = 'center';
    ctx.font = '700 16px system-ui,sans-serif';
    for (let i = 0; i < state.messages.length; i++) {
      const m = state.messages[i];
      const a = clamp(m.life, 0, 1);
      ctx.fillStyle = rgb(m.color, a);
      ctx.fillText(m.text, W / 2, H * 0.35 - i * 22 - (1 - a) * 16);
    }
  }

  // Title screen
  if (state.mode === 'title') {
    drawCenterPanel(true);
  }
  if (state.mode === 'gameover') {
    drawCenterPanel(false);
  }

  // Flash
  if (state.flash > 0) {
    ctx.fillStyle = `rgba(255,200,150,${state.flash * 0.55})`;
    ctx.fillRect(0, 0, W, H);
  }

  ctx.restore();
}

function drawCenterPanel(isTitle) {
  const pulse = 0.7 + Math.sin(state.titlePulse * 3) * 0.3;
  ctx.save();
  // dim
  ctx.fillStyle = 'rgba(8,4,16,0.45)';
  ctx.fillRect(0, 0, W, H);

  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  if (isTitle) {
    ctx.fillStyle = rgb(C.SUN_GLOW, 0.95);
    ctx.font = '700 56px system-ui,sans-serif';
    ctx.fillText('SOLAR DASH', W / 2, H * 0.36);
    ctx.fillStyle = rgb([255, 220, 180], 0.55);
    ctx.font = '400 15px system-ui,sans-serif';
    ctx.fillText('Outrun the dusk. Stay in the light.', W / 2, H * 0.36 + 42);

    ctx.fillStyle = rgb([255, 240, 200], pulse);
    ctx.font = '600 18px system-ui,sans-serif';
    ctx.fillText('CLICK / SPACE / ENTER  ·  PLAY', W / 2, H * 0.55);

    ctx.fillStyle = rgb([255, 200, 150], 0.4);
    ctx.font = '400 13px system-ui,sans-serif';
    ctx.fillText('WASD or Arrows to strafe & pitch  ·  Hold Space to boost', W / 2, H * 0.55 + 32);
    if (state.highScore > 0) {
      ctx.fillStyle = rgb(C.TOKEN_COL, 0.7);
      ctx.font = '600 14px system-ui,sans-serif';
      ctx.fillText('BEST SCORE  ' + state.highScore, W / 2, H * 0.55 + 60);
    }
  } else {
    ctx.fillStyle = rgb([255, 100, 90], 0.95);
    ctx.font = '700 42px system-ui,sans-serif';
    ctx.fillText('RUN ENDED', W / 2, H * 0.32);
    ctx.fillStyle = rgb([255, 200, 160], 0.7);
    ctx.font = '400 15px system-ui,sans-serif';
    ctx.fillText(state.deathReason, W / 2, H * 0.32 + 36);

    ctx.fillStyle = rgb(C.CRAFT, 0.95);
    ctx.font = '700 48px system-ui,sans-serif';
    ctx.fillText(String(state.score | 0), W / 2, H * 0.48);
    ctx.fillStyle = rgb([255, 220, 180], 0.55);
    ctx.font = '600 13px system-ui,sans-serif';
    ctx.fillText('SCORE', W / 2, H * 0.48 + 32);
    ctx.fillText('DISTANCE  ' + (state.distance | 0) + ' m', W / 2, H * 0.48 + 54);

    if (state.score >= state.highScore && state.score > 0) {
      ctx.fillStyle = rgb(C.TOKEN_COL, pulse);
      ctx.font = '700 16px system-ui,sans-serif';
      ctx.fillText('★ NEW HIGH SCORE ★', W / 2, H * 0.48 + 82);
    } else {
      ctx.fillStyle = rgb([255, 200, 150], 0.45);
      ctx.font = '600 13px system-ui,sans-serif';
      ctx.fillText('BEST  ' + state.highScore, W / 2, H * 0.48 + 82);
    }

    ctx.fillStyle = rgb([255, 240, 200], pulse);
    ctx.font = '600 18px system-ui,sans-serif';
    ctx.fillText('R / SPACE / CLICK  ·  RETRY', W / 2, H * 0.72);
  }
  ctx.restore();
}

function render() {
  const cam = getCamera();
  const sunH = state.mode === 'title' ? 0.85 : getSunHeight();
  const p = state.player;

  // slight camera roll
  ctx.save();
  if (p && state.mode === 'playing') {
    ctx.translate(W / 2, H / 2);
    ctx.rotate(cam.roll * 0.15);
    ctx.translate(-W / 2, -H / 2);
  }

  drawSky(cam, sunH);
  drawGround(cam);

  // Collect draw list sorted by depth
  const drawList = [];

  if (state.world) {
    for (const d of state.world.decor) {
      const pr = project(d.x, d.h * 0.5, d.z, cam);
      if (pr) drawList.push({ z: pr.dz, fn: () => drawDecor(d, cam, sunH) });
    }
    for (const e of state.world.entities) {
      if (e.alive === false) continue;
      const pr = project(e.x, (e.y || 0) + (e.h || e.r || 1) * 0.5, e.z, cam);
      if (!pr) continue;
      if (e.type === 'obstacle') drawList.push({ z: pr.dz, fn: () => drawBox(e, cam, sunH) });
      else if (e.type === 'ramp') drawList.push({ z: pr.dz, fn: () => drawRamp(e, cam) });
      else if (e.type === 'pickup') drawList.push({ z: pr.dz, fn: () => drawPickup(e, cam) });
    }
  }

  drawList.sort((a, b) => b.z - a.z);
  for (const d of drawList) d.fn();

  drawParticles(cam);
  if (state.mode === 'playing' || (state.mode === 'gameover' && p)) drawCraft(cam);
  drawSpeedLines(cam);

  ctx.restore();

  // Vignette
  const vig = ctx.createRadialGradient(W/2, H/2, H * 0.25, W/2, H/2, H * 0.75);
  vig.addColorStop(0, 'rgba(0,0,0,0)');
  vig.addColorStop(1, 'rgba(6,2,12,0.55)');
  ctx.fillStyle = vig;
  ctx.fillRect(0, 0, W, H);

  drawHUD();
}

// =============================================================================
// === MAIN LOOP ===============================================================
// =============================================================================
let last = performance.now();
let acc = 0;

function frame(now) {
  let dt = (now - last) / 1000;
  last = now;
  // clamp huge tabs stalls
  dt = Math.min(dt, 0.05);

  update(dt);
  render();
  clearJust();
  requestAnimationFrame(frame);
}

// Fake player for title cam drift
state.player = makePlayer();
state.world = createWorld(42);
ensureWorld(state.world, 0, C.BASE_SPEED);

requestAnimationFrame(frame);
</script>
</body>
</html>