← Race the Sun results

Race the Sun

Qwen 3.6 35b a3b · composer

Initial view of Race the Sun

Browser check: Artifact runtime error. This is a technical check, not a quality review.

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 47,796 bytes · SHA-256 bb1b1e0129b3
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Race The Sun</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
#gameCanvas { display: block; width: 100%; height: 100%; }

/* HUD */
.hud {
  position: fixed; top: 0; left: 0; width: 100%; height: 100%;
  pointer-events: none; z-index: 10; font-family: 'Courier New', monospace;
}
#hudTop {
  position: absolute; top: 20px; left: 20px; right: 20px;
  display: flex; justify-content: space-between; align-items: flex-start;
}
#hudScore {
  font-size: 36px; font-weight: bold; color: #fff;
  text-shadow: 0 0 10px rgba(255,200,50,0.8), 0 0 30px rgba(255,150,0,0.4);
}
#hudMultiplier {
  font-size: 28px; color: #ffcc00;
  text-shadow: 0 0 8px rgba(255,200,0,0.6);
  transition: transform 0.1s;
}
#hudMultiplier.bump { transform: scale(1.3); }
#hudBottom {
  position: absolute; bottom: 20px; left: 20px; right: 20px;
  display: flex; justify-content: space-between; align-items: flex-end;
}
#hudEnergy {
  width: 200px; height: 12px; background: rgba(255,255,255,0.15);
  border: 1px solid rgba(255,255,255,0.3); border-radius: 6px; overflow: hidden;
}
#hudEnergyBar {
  height: 100%; width: 100%; background: linear-gradient(90deg, #ff6600, #ffcc00, #ffff00);
  border-radius: 6px; transition: width 0.15s;
}
#hudSpeed {
  font-size: 20px; color: #aaa; text-align: right;
}
#hudDistance {
  font-size: 20px; color: #aaa; text-align: right;
}

/* Overlays */
.overlay {
  position: fixed; top: 0; left: 0; width: 100%; height: 100%;
  display: flex; align-items: center; justify-content: center;
  z-index: 20; pointer-events: auto;
  background: rgba(0,0,0,0.7);
  transition: opacity 0.3s;
}
.overlay.hidden { opacity: 0; pointer-events: none; }
.title-content { text-align: center; }
.title-text {
  font-size: 64px; font-weight: bold; color: #ff8800;
  text-shadow: 0 0 20px rgba(255,136,0,0.8), 0 0 60px rgba(255,100,0,0.4);
  margin-bottom: 10px; letter-spacing: 8px;
}
.subtitle {
  font-size: 20px; color: #ffcc88; margin-bottom: 30px;
  text-shadow: 0 0 10px rgba(255,200,136,0.5);
}
.controls-info {
  font-size: 16px; color: #aaa; margin-bottom: 40px; line-height: 2;
}
.action-btn {
  font-family: 'Courier New', monospace; font-size: 24px; font-weight: bold;
  padding: 14px 48px; border: 2px solid #ff8800; background: rgba(255,136,0,0.15);
  color: #ff8800; cursor: pointer; letter-spacing: 4px;
  text-shadow: 0 0 8px rgba(255,136,0,0.5);
  box-shadow: 0 0 20px rgba(255,136,0,0.2);
  transition: all 0.2s;
}
.action-btn:hover {
  background: rgba(255,136,0,0.3);
  box-shadow: 0 0 40px rgba(255,136,0,0.4);
  transform: scale(1.05);
}
.go-stats {
  font-size: 22px; color: #ddd; margin-bottom: 30px; line-height: 2;
}
.go-stats span { color: #ffcc00; font-weight: bold; }
.go-reason {
  font-size: 28px; color: #ff6644; margin-bottom: 20px;
  text-shadow: 0 0 10px rgba(255,100,50,0.5);
}
.go-best {
  font-size: 24px; color: #ffcc00; margin-top: 15px;
  text-shadow: 0 0 15px rgba(255,200,0,0.6);
}
.go-hint {
  font-size: 16px; color: #888; margin-top: 15px;
}
</style>
</head>
<body>
<div id="app"></div>
<canvas id="gameCanvas"></canvas>
<div id="hud" class="hud">
  <div id="hudTop">
    <div id="hudScore">0</div>
    <div id="hudMultiplier">×1</div>
  </div>
  <div id="hudBottom">
    <div id="hudEnergy">
      <div id="hudEnergyBar"></div>
    </div>
    <div id="hudSpeed">0 km/s</div>
    <div id="hudDistance">0 m</div>
  </div>
</div>
<div id="titleScreen" class="overlay">
  <div class="title-content">
    <h1 class="title-text">RACE THE SUN</h1>
    <p class="subtitle">Stay in the light. Survive.</p>
    <div class="controls-info">
      <p>WASD / Arrow Keys — Move</p>
      <p>SPACE — Boost (uses energy)</p>
      <p>R — Restart</p>
    </div>
    <button id="startBtn" class="action-btn">START</button>
  </div>
</div>
<div id="gameOverScreen" class="overlay hidden">
  <div class="title-content">
    <h1 class="title-text">GAME OVER</h1>
    <div id="goStats" class="go-stats"></div>
    <button id="restartBtn" class="action-btn">RESTART</button>
  </div>
</div>
<script>
'use strict';

// ── const: C ──
// === TUNABLE CONSTANTS ===
const C = {
  // World
  SUN_Y: 0.15,
  SUN_SIZE: 120,
  GRID_SIZE: 2000,
  GRID_SEGMENTS: 80,
  GRID_DRAW_DIST: 1800,
  // Player
  PLAYER_SPEED: 3.5,
  PLAYER_MAX_SPEED: 8,
  PLAYER_ACCEL: 0.08,
  PLAYER_DECCEL: 0.04,
  PLAYER_LERP: 0.12,
  PLAYER_BOUNDS: 22,
  PLAYER_HEIGHT: 0.6,
  PLAYER_ROLL_MAX: 0.35,
  PLAYER_PITCH_MAX: 0.2,
  // Energy
  ENERGY_MAX: 100,
  ENERGY_DRAIN: 0.02,
  ENERGY_DRAIN_SHADOW: 0.08,
  ENERGY_BOOST_COST: 0.6,
  ENERGY_PICKUP: 18,
  // Boost
  BOOST_MULTIPLIER: 1.8,
  BOOST_DRAIN: 0.005,
  // Obstacles
  OBSTACLE_MIN_Z: 150,
  OBSTACLE_MAX_Z: 1800,
  OBSTACLE_SPAWN_INTERVAL: 12,
  OBSTACLE_WIDTH: 8,
  OBSTACLE_HEIGHT: 12,
  OBSTACLE_DEPTH: 4,
  // Pickups
  PICKUP_SPAWN_INTERVAL: 20,
  PICKUP_SIZE: 1.5,
  // Scoring
  SCORE_BASE: 0.5,
  SCORE_NEAR_MISS: 50,
  SCORE_MULTIPLIER_INTERVAL: 300,
  // Camera
  CAM_DIST: 18,
  CAM_HEIGHT: 10,
  CAM_LERP: 0.08,
  CAM_SHAKE_NEAR: 0.3,
  CAM_SHAKE_NEAR_DIST: 5,
  CAM_SHAKE_CRASH: 1.5,
  // Particles
  PARTICLE_MAX: 500,
  PARTICLE_LIFE: 1.5,
  // Speed lines
  SPEED_LINES_THRESHOLD: 5,
  // Colors
  COLORS: {
    skyTop: [40, 10, 60],
    skyMid: [180, 60, 40],
    skyBot: [255, 160, 50],
    sun: [255, 220, 80],
    sunGlow: [255, 150, 30],
    gridLit: [255, 200, 100],
    gridShadow: [80, 40, 30],
    gridLine: [255, 180, 60],
    obstacleLit: [120, 80, 60],
    obstacleShadow: [40, 25, 20],
    obstacleEdge: [255, 160, 60],
    playerBody: [200, 220, 255],
    playerAccent: [255, 200, 60],
    playerGlow: [255, 220, 100],
    pickupEnergy: [255, 220, 50],
    pickupScore: [100, 255, 100],
    pickupMulti: [255, 100, 255],
    particleThrust: [255, 180, 50],
    particlePickup: [255, 255, 100],
    particleCrash: [255, 100, 50],
    speedLine: [255, 255, 200],
    shadowOverlay: [30, 10, 20],
  },
  // Game duration
  SUN_SET_DURATION: 90,
  SUN_SET_THRESHOLD: 0.8,
};

// ── function: vec3 ──
// === 3D MATH UTILITIES ===
function vec3(x, y, z) { return [x, y, z]; }

// ── function: v3Add ──
function v3Add(a, b) { return [a[0]+b[0], a[1]+b[1], a[2]+b[2]]; }

// ── function: v3Sub ──
function v3Sub(a, b) { return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]; }

// ── function: v3Scale ──
function v3Scale(v, s) { return [v[0]*s, v[1]*s, v[2]*s]; }

// ── function: v3Len ──
function v3Len(v) { return Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]); }

// ── function: v3Norm ──
function v3Norm(v) { const l = v3Len(v) || 1; return [v[0]/l, v[1]/l, v[2]/l]; }

// ── function: v3Dot ──
function v3Dot(a, b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }

// ── function: v3Cross ──
function v3Cross(a, b) { return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]; }

// ── function: rotateY ──
function rotateY(v, a) {
  const c = Math.cos(a), s = Math.sin(a);
  return [v[0]*c + v[2]*s, v[1], -v[0]*s + v[2]*c];
}

// ── function: rotateX ──
function rotateX(v, a) {
  const c = Math.cos(a), s = Math.sin(a);
  return [v[0], v[1]*c - v[2]*s, v[1]*s + v[2]*c];
}

// ── function: rotateZ ──
function rotateZ(v, a) {
  const c = Math.cos(a), s = Math.sin(a);
  return [v[0]*c - v[1]*s, v[0]*s + v[1]*c, v[2]];
}

// ── function: project ──
function project(p, w, h, fov, camPos) {
  const rel = v3Sub(p, camPos);
  if (rel[2] <= 0.1) return null;
  const scale = fov / rel[2];
  return [w/2 + rel[0] * scale, h/2 - rel[1] * scale, rel[2]];
}

// ── function: lerp ──
function lerp(a, b, t) { return a + (b - a) * t; }

// ── function: clamp ──
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }

// ── function: smoothstep ──
function smoothstep(t) { return t * t * (3 - 2 * t); }

// ── function: rand ──
function rand(lo, hi) { return lo + Math.random() * (hi - lo); }

// ── function: randInt ──
function randInt(lo, hi) { return Math.floor(rand(lo, hi + 1)); }

// ── function: createPlayer ──
// === PLAYER ===
function createPlayer() {
  return {
    pos: [0, 0.5, 0],
    vel: [0, 0, 0],
    targetX: 0,
    targetY: 0.5,
    speed: C.PLAYER_SPEED,
    baseSpeed: C.PLAYER_SPEED,
    pitch: 0,
    targetPitch: 0,
    roll: 0,
    targetRoll: 0,
    energy: C.ENERGY_MAX,
    boosting: false,
    boostTimer: 0,
    score: 0,
    distance: 0,
    multiplier: 1,
    nearMissTimer: 0,
    flashTimer: 0,
    alive: true,
  };
}

// ── function: createCamera ──
// === CAMERA ===
function createCamera() {
  return {
    pos: [0, 5, -10],
    targetPos: [0, 5, -10],
    shake: [0, 0, 0],
    shakeDecay: 0.9,
    nearMissShake: 0,
    flashIntensity: 0,
  };
}

// ── function: applyCameraShake ──
function applyCameraShake(cam, intensity) {
  cam.nearMissShake = Math.max(cam.nearMissShake, intensity);
}

// ── function: updateCamera ──
function updateCamera(cam, player, dt) {
  const camTargetX = player.pos[0] * 0.7;
  const camTargetY = player.pos[1] + C.CAM_HEIGHT;
  const camTargetZ = player.pos[2] - C.CAM_DIST;

  cam.targetPos = v3Add(
    v3Scale(v3Norm(v3Sub([0, 0, player.pos[2]], [0, 0, player.pos[2]])), 0),
    [camTargetX, camTargetY, camTargetZ]
  );

  cam.pos[0] = lerp(cam.pos[0], camTargetX, C.CAM_LERP);
  cam.pos[1] = lerp(cam.pos[1], camTargetY, C.CAM_LERP);
  cam.pos[2] = lerp(cam.pos[2], camTargetZ, C.CAM_LERP);

  // Shake
  cam.shake[0] = (Math.random() - 0.5) * cam.nearMissShake * 2;
  cam.shake[1] = (Math.random() - 0.5) * cam.nearMissShake * 2;
  cam.nearMissShake *= cam.shakeDecay;
  if (cam.nearMissShake < 0.01) cam.nearMissShake = 0;

  cam.flashIntensity *= 0.9;
  if (cam.flashIntensity < 0.01) cam.flashIntensity = 0;
}

// ── function: createWorld ──
// === WORLD GENERATION ===
function createWorld() {
  return {
    obstacles: [],
    pickups: [],
    particles: [],
    speedLines: [],
    seed: Math.floor(Math.random() * 999999),
    spawnTimer: 0,
    pickupTimer: 0,
    time: 0,
    sunAngle: 0,
    gridOffset: 0,
    lastSpawnZ: 0,
  };
}

// ── function: seededRandom ──
// Seeded random for procedural generation
function seededRandom(seed) {
  let s = seed;
  return function() {
    s = (s * 16807 + 0) % 2147483647;
    return (s - 1) / 2147483646;
  };
}

// ── function: spawnObstacle ──
function spawnObstacle(world, rng) {
  const z = world.lastSpawnZ + rand(120, 250);
  const x = (rng() - 0.5) * 40;
  const type = rng();
  let w, h, d;
  if (type < 0.4) {
    w = rand(4, 10); h = rand(8, 20); d = rand(2, 6);
  } else if (type < 0.7) {
    w = rand(10, 20); h = rand(3, 8); d = rand(2, 4);
  } else {
    w = rand(3, 6); h = rand(15, 30); d = rand(2, 4);
  }
  const obs = {
    pos: [x, h/2, z],
    size: [w, h, d],
    type: type < 0.4 ? 'tower' : type < 0.7 ? 'wall' : 'pillar',
    scored: false,
    nearMissed: false,
    hit: false,
  };
  world.obstacles.push(obs);
  world.lastSpawnZ = z;
  return obs;
}

// ── function: spawnPickup ──
function spawnPickup(world, rng) {
  const z = world.lastSpawnZ + rand(80, 160);
  const x = (rng() - 0.5) * 30;
  const type = rng();
  let pType;
  if (type < 0.5) pType = 'energy';
  else if (type < 0.8) pType = 'score';
  else pType = 'multiplier';
  return {
    pos: [x, 2, z],
    size: C.PICKUP_SIZE,
    type: pType,
    collected: false,
    bobPhase: rng() * Math.PI * 2,
    glowPhase: rng() * Math.PI * 2,
  };
}

// ── function: createParticle ──
// === PARTICLE SYSTEM ===
function createParticle(particlePool) {
  if (particlePool.length < C.PARTICLE_MAX) {
    particlePool.push({
      pos: [0, 0, 0],
      vel: [0, 0, 0],
      life: 0,
      maxLife: C.PARTICLE_LIFE,
      color: [255, 255, 255],
      size: 1,
      active: true,
    });
    return particlePool[particlePool.length - 1];
  }
  return null;
}

// ── function: emitThrust ──
function emitThrust(particlePool, playerPos) {
  for (let i = 0; i < 3; i++) {
    const p = createParticle(particlePool);
    if (!p) continue;
    p.pos = [playerPos[0] + rand(-0.3, 0.3), playerPos[1] + rand(-0.1, 0.1), playerPos[2] + 0.5];
    p.vel = [rand(-0.2, 0.2), rand(-0.1, 0.1), rand(0.3, 0.8)];
    p.life = rand(0.3, 0.8);
    p.maxLife = p.life;
    p.color = [...C.COLORS.particleThrust];
    p.size = rand(0.3, 0.8);
  }
}

// ── function: emitPickup ──
function emitPickup(particlePool, pos, color) {
  for (let i = 0; i < 15; i++) {
    const p = createParticle(particlePool);
    if (!p) continue;
    const angle = rand(0, Math.PI * 2);
    const speed = rand(1, 4);
    p.pos = [pos[0], pos[1], pos[2]];
    p.vel = [Math.cos(angle) * speed, rand(0.5, 3), Math.sin(angle) * speed];
    p.life = rand(0.5, 1.2);
    p.maxLife = p.life;
    p.color = [...color];
    p.size = rand(0.3, 0.7);
  }
}

// ── function: emitCrash ──
function emitCrash(particlePool, pos) {
  for (let i = 0; i < 40; i++) {
    const p = createParticle(particlePool);
    if (!p) continue;
    const angle = rand(0, Math.PI * 2);
    const elev = rand(0, Math.PI);
    const speed = rand(2, 8);
    p.pos = [pos[0], pos[1], pos[2]];
    p.vel = [Math.cos(angle) * Math.cos(elev) * speed, Math.sin(elev) * speed, Math.sin(angle) * Math.cos(elev) * speed];
    p.life = rand(0.5, 2);
    p.maxLife = p.life;
    p.color = [...C.COLORS.particleCrash];
    p.size = rand(0.5, 1.5);
  }
}

// ── function: updateParticles ──
function updateParticles(particlePool, dt) {
  for (let i = particlePool.length - 1; i >= 0; i--) {
    const p = particlePool[i];
    if (!p.active) continue;
    p.pos = v3Add(p.pos, v3Scale(p.vel, dt));
    p.vel[1] -= dt * 2; // gravity
    p.life -= dt;
    if (p.life <= 0) {
      p.active = false;
      particlePool.splice(i, 1);
    }
  }
}

// ── let: audioCtx ──
// === AUDIO SYSTEM ===
let audioCtx = null;

// ── let: masterGain ──
let masterGain = null;

// ── let: windNode ──
let windNode = null;

// ── let: windGain ──
let windGain = null;

// ── function: initAudio ──
function initAudio() {
  if (audioCtx) return;
  audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  masterGain = audioCtx.createGain();
  masterGain.gain.value = 0.3;
  masterGain.connect(audioCtx.destination);

  // Wind / drone loop
  const bufferSize = audioCtx.sampleRate * 2;
  const noiseBuffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
  const data = noiseBuffer.getChannelData(0);
  for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1) * 0.5;

  windNode = audioCtx.createBufferSource();
  windNode.buffer = noiseBuffer;
  windNode.loop = true;

  const filter = audioCtx.createBiquadFilter();
  filter.type = 'lowpass';
  filter.frequency.value = 200;
  filter.Q.value = 0.5;

  windGain = audioCtx.createGain();
  windGain.gain.value = 0.15;

  windNode.connect(filter);
  filter.connect(windGain);
  windGain.connect(masterGain);
  windNode.start();
}

// ── function: playPickupSound ──
function playPickupSound(type) {
  if (!audioCtx) return;
  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();
  osc.connect(gain);
  gain.connect(masterGain);

  if (type === 'energy') {
    osc.type = 'sine';
    osc.frequency.value = 880;
    gain.gain.setValueAtTime(0.15, audioCtx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.2);
    osc.frequency.exponentialRampToValueAtTime(1760, audioCtx.currentTime + 0.15);
  } else if (type === 'score') {
    osc.type = 'triangle';
    osc.frequency.value = 660;
    gain.gain.setValueAtTime(0.12, audioCtx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.25);
    osc.frequency.exponentialRampToValueAtTime(1320, audioCtx.currentTime + 0.2);
  } else {
    osc.type = 'sine';
    osc.frequency.value = 550;
    gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.3);
    osc.frequency.exponentialRampToValueAtTime(1100, audioCtx.currentTime + 0.25);
  }

  osc.start();
  osc.stop(audioCtx.currentTime + 0.4);
}

// ── function: playNearMissSound ──
function playNearMissSound() {
  if (!audioCtx) return;
  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();
  osc.type = 'sawtooth';
  osc.frequency.value = 200;
  osc.connect(gain);
  gain.connect(masterGain);
  gain.gain.setValueAtTime(0.06, audioCtx.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.15);
  osc.frequency.exponentialRampToValueAtTime(80, audioCtx.currentTime + 0.15);
  osc.start();
  osc.stop(audioCtx.currentTime + 0.2);
}

// ── function: playCrashSound ──
function playCrashSound() {
  if (!audioCtx) return;
  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();
  osc.type = 'sawtooth';
  osc.frequency.value = 100;
  osc.connect(gain);
  gain.connect(masterGain);
  gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.5);
  osc.frequency.exponentialRampToValueAtTime(20, audioCtx.currentTime + 0.5);
  osc.start();
  osc.stop(audioCtx.currentTime + 0.6);
}

// ── function: updateWind ──
function updateWind(speed) {
  if (!windGain || !audioCtx) return;
  const t = audioCtx.currentTime;
  const vol = 0.05 + (speed / C.PLAYER_MAX_SPEED) * 0.2;
  windGain.gain.setTargetAtTime(vol, t, 0.1);
}

// ── function: drawSky ──
// === RENDERING HELPERS ===
function drawSky(ctx, w, h, sunY) {
  const grad = ctx.createLinearGradient(0, 0, 0, h);
  const top = C.COLORS.skyTop;
  const mid = C.COLORS.skyMid;
  const bot = C.COLORS.skyBot;
  const t = clamp(sunY, 0, 1);
  const r1 = Math.round(lerp(top[0], mid[0], t));
  const g1 = Math.round(lerp(top[1], mid[1], t));
  const b1 = Math.round(lerp(top[2], mid[2], t));
  const r2 = Math.round(lerp(mid[0], bot[0], t));
  const g2 = Math.round(lerp(mid[1], bot[1], t));
  const b2 = Math.round(lerp(mid[2], bot[2], t));
  grad.addColorStop(0, `rgb(${r1},${g1},${b1})`);
  grad.addColorStop(0.6, `rgb(${r2},${g2},${b2})`);
  grad.addColorStop(1, `rgb(${bot[0]},${bot[1]},${bot[2]})`);
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, w, h);
}

// ── function: drawSun ──
function drawSun(ctx, w, h, sunY, sunAngle) {
  const sunX = w / 2 + Math.sin(sunAngle) * w * 0.3;
  const sunScreenY = sunY * h;
  const sunSize = C.SUN_SIZE;

  // Outer glow
  const glow = ctx.createRadialGradient(sunX, sunScreenY, sunSize * 0.5, sunX, sunScreenY, sunSize * 4);
  glow.addColorStop(0, 'rgba(255,150,30,0.3)');
  glow.addColorStop(0.3, 'rgba(255,100,20,0.1)');
  glow.addColorStop(1, 'rgba(255,50,0,0)');
  ctx.fillStyle = glow;
  ctx.fillRect(0, 0, w, h);

  // Sun disc
  const disc = ctx.createRadialGradient(sunX, sunScreenY, 0, sunX, sunScreenY, sunSize);
  disc.addColorStop(0, 'rgba(255,255,200,1)');
  disc.addColorStop(0.4, 'rgba(255,220,80,1)');
  disc.addColorStop(0.8, 'rgba(255,150,30,0.8)');
  disc.addColorStop(1, 'rgba(255,80,0,0)');
  ctx.fillStyle = disc;
  ctx.beginPath();
  ctx.arc(sunX, sunScreenY, sunSize, 0, Math.PI * 2);
  ctx.fill();
}

// ── function: drawGrid ──
// === DRAW GRID (improved) ===
function drawGrid(ctx, w, h, camPos, playerZ, speed) {
  const segLen = C.GRID_SIZE / C.GRID_SEGMENTS;
  const totalWidth = C.GRID_SIZE / 2;
  const offset = (playerZ % segLen + segLen) % segLen;

  // Draw longitudinal lines (along Z)
  for (let i = -C.GRID_SEGMENTS; i <= C.GRID_SEGMENTS; i++) {
    const x = i * (totalWidth / C.GRID_SEGMENTS);
    const nearZ = playerZ + 1;
    const farZ = playerZ + C.GRID_DRAW_DIST;

    const near = project([x, 0, nearZ], w, h, 600, camPos);
    const far = project([x, 0, farZ], w, h, 600, camPos);
    if (!near || !far) continue;
    if (near[2] <= 0.1 || far[2] <= 0.1) continue;

    const dist = far[2];
    const alpha = clamp(0.6 - dist / C.GRID_DRAW_DIST * 0.55, 0.02, 0.5);

    // Brighter near the center (sunlit path)
    const centerDist = Math.abs(x) / totalWidth;
    const brightness = clamp(1 - centerDist * 1.5, 0.3, 1);

    const r = Math.round(lerp(180, 255, brightness));
    const g = Math.round(lerp(100, 200, brightness));
    const b = Math.round(lerp(40, 80, brightness));

    ctx.strokeStyle = `rgba(${r},${g},${b},${alpha})`;
    ctx.lineWidth = Math.max(0.5, 2 - dist / 1000);
    ctx.beginPath();
    ctx.moveTo(near[0], near[1]);
    ctx.lineTo(far[0], far[1]);
    ctx.stroke();
  }

  // Draw transverse lines (along X)
  const startIdx = Math.floor(playerZ / segLen);
  for (let i = startIdx - 3; i < startIdx + C.GRID_SEGMENTS; i++) {
    const z = i * segLen;
    if (z < playerZ + 2) continue;
    if (z > playerZ + C.GRID_DRAW_DIST) break;

    const near = project([-totalWidth, 0, z], w, h, 600, camPos);
    const far = project([totalWidth, 0, z], w, h, 600, camPos);
    if (!near || !far) continue;
    if (near[2] <= 0.1) continue;

    const dist = near[2];
    const alpha = clamp(0.5 - dist / C.GRID_DRAW_DIST * 0.45, 0.02, 0.4);

    ctx.strokeStyle = `rgba(255,180,60,${alpha})`;
    ctx.lineWidth = Math.max(0.5, 1.5 - dist / 1200);
    ctx.beginPath();
    ctx.moveTo(near[0], near[1]);
    ctx.lineTo(far[0], far[1]);
    ctx.stroke();
  }
}

// ── function: drawObstacle ──
// === DRAW OBSTACLES ===
function drawObstacle(ctx, w, h, camPos, obs) {
  const corners = getBoxCorners(obs.pos, obs.size);
  const projected = corners.map(c => project(c, w, h, 600, camPos));
  const visible = projected.filter(p => p && p[2] > 0.1 && p[2] < 2000);
  if (visible.length < 3) return;

  // Determine if lit (near sun direction)
  const sunDir = v3Norm([0, 1, -1]);
  const toObs = v3Norm(v3Sub(obs.pos, camPos));
  const lightFactor = clamp(v3Dot(toObs, sunDir) * 0.5 + 0.5, 0, 1);

  const lit = C.COLORS.obstacleLit;
  const shad = C.COLORS.obstacleShadow;
  const edge = C.COLORS.obstacleEdge;

  const rLit = Math.round(lerp(shad[0], lit[0], lightFactor));
  const gLit = Math.round(lerp(shad[1], lit[1], lightFactor));
  const bLit = Math.round(lerp(shad[2], lit[2], lightFactor));

  // Draw back faces first, then front (simple z-sort by drawing order)
  const faces = [
    { verts: [projected[0], projected[1], projected[3], projected[2]], z: (projected[0][2]+projected[1][2]+projected[3][2]+projected[2][2])/4 },
    { verts: [projected[4], projected[5], projected[7], projected[6]], z: (projected[4][2]+projected[5][2]+projected[7][2]+projected[6][2])/4 },
    { verts: [projected[0], projected[4], projected[5], projected[1]], z: (projected[0][2]+projected[4][2]+projected[5][2]+projected[1][2])/4 },
    { verts: [projected[2], projected[3], projected[7], projected[6]], z: (projected[2][2]+projected[3][2]+projected[7][2]+projected[6][2])/4 },
    { verts: [projected[1], projected[5], projected[7], projected[3]], z: (projected[1][2]+projected[5][2]+projected[7][2]+projected[3][2])/4 },
    { verts: [projected[0], projected[2], projected[6], projected[4]], z: (projected[0][2]+projected[2][2]+projected[6][2]+projected[4][2])/4 },
  ];

  faces.sort((a, b) => b.z - a.z);

  for (const face of faces) {
    const alpha = clamp(1 - face.z / 1800, 0.05, 0.85);
    ctx.fillStyle = `rgba(${rLit},${gLit},${bLit},${alpha})`;
    ctx.strokeStyle = `rgba(${edge[0]},${edge[1]},${edge[2]},${alpha * 0.7})`;
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(face.verts[0][0], face.verts[0][1]);
    for (let i = 1; i < face.verts.length; i++) {
      ctx.lineTo(face.verts[i][0], face.verts[i][1]);
    }
    ctx.closePath();
    ctx.fill();
    ctx.stroke();
  }
}

// ── function: getBoxCorners ──
function getBoxCorners(pos, size) {
  const [x, y, z] = pos;
  const [hx, hy, hz] = v3Scale(size, 0.5);
  return [
    [x-hx, y-hy, z-hz], [x+hx, y-hy, z-hz], [x-hx, y+hy, z-hz], [x+hx, y+hy, z-hz],
    [x-hx, y-hy, z+hz], [x+hx, y-hy, z+hz], [x-hx, y+hy, z+hz], [x+hx, y+hy, z+hz],
  ];
}

// ── function: drawPlayer ──
// === DRAW PLAYER CRAFT (improved) ===
function drawPlayer(ctx, w, h, camPos, player) {
  if (!player.alive) return;

  const craftPos = [player.pos[0], player.pos[1], player.pos[2] + 3];
  const p = project(craftPos, w, h, 600, camPos);
  if (!p || p[2] < 0.1) return;

  const scale = 600 / p[2];
  const roll = player.roll;
  const pitch = player.pitch;

  ctx.save();
  ctx.translate(p[0], p[1]);
  ctx.rotate(-roll);

  // Engine glow behind craft
  const engineGlow = ctx.createRadialGradient(0, 10 * scale, 0, 0, 10 * scale, 50 * scale);
  engineGlow.addColorStop(0, 'rgba(255,200,60,0.4)');
  engineGlow.addColorStop(0.5, 'rgba(255,150,30,0.15)');
  engineGlow.addColorStop(1, 'rgba(255,80,0,0)');
  ctx.fillStyle = engineGlow;
  ctx.fillRect(-60 * scale, -60 * scale, 120 * scale, 120 * scale);

  // Craft body - sleek triangle
  const bodyLen = 14 * scale;
  const bodyW = 6 * scale;

  // Shadow underneath
  ctx.fillStyle = 'rgba(0,0,0,0.15)';
  ctx.beginPath();
  ctx.ellipse(0, bodyLen * 0.8, bodyW * 1.2, bodyW * 0.4, 0, 0, Math.PI * 2);
  ctx.fill();

  // Main body
  ctx.fillStyle = '#d0d8e8';
  ctx.strokeStyle = '#ffcc44';
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  ctx.moveTo(0, -bodyLen);
  ctx.lineTo(-bodyW, bodyLen * 0.3);
  ctx.lineTo(-bodyW * 0.6, bodyLen * 0.55);
  ctx.lineTo(-bodyW * 0.3, bodyLen * 0.45);
  ctx.lineTo(0, bodyLen * 0.35);
  ctx.lineTo(bodyW * 0.3, bodyLen * 0.45);
  ctx.lineTo(bodyW * 0.6, bodyLen * 0.55);
  ctx.lineTo(bodyW, bodyLen * 0.3);
  ctx.closePath();
  ctx.fill();
  ctx.stroke();

  // Cockpit
  const cockpitGrad = ctx.createLinearGradient(0, -bodyLen * 0.6, 0, -bodyLen * 0.2);
  cockpitGrad.addColorStop(0, '#ffee88');
  cockpitGrad.addColorStop(1, '#ffaa22');
  ctx.fillStyle = cockpitGrad;
  ctx.beginPath();
  ctx.ellipse(0, -bodyLen * 0.45, bodyW * 0.3, bodyLen * 0.18, 0, 0, Math.PI * 2);
  ctx.fill();

  // Wing accents
  ctx.strokeStyle = '#ff8800';
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  ctx.moveTo(-bodyW, bodyLen * 0.15);
  ctx.lineTo(-bodyW * 1.8, bodyLen * 0.5);
  ctx.stroke();
  ctx.beginPath();
  ctx.moveTo(bodyW, bodyLen * 0.15);
  ctx.lineTo(bodyW * 1.8, bodyLen * 0.5);
  ctx.stroke();

  // Wing tip lights
  ctx.fillStyle = '#ff4444';
  ctx.beginPath();
  ctx.arc(-bodyW * 1.8, bodyLen * 0.5, 2 * scale, 0, Math.PI * 2);
  ctx.fill();
  ctx.fillStyle = '#44ff44';
  ctx.beginPath();
  ctx.arc(bodyW * 1.8, bodyLen * 0.5, 2 * scale, 0, Math.PI * 2);
  ctx.fill();

  // Engine thrust
  const thrustLen = (8 + (player.boosting ? 15 : 0)) * scale;
  const thrustGrad = ctx.createLinearGradient(0, bodyLen * 0.35, 0, bodyLen * 0.35 + thrustLen);
  thrustGrad.addColorStop(0, 'rgba(255,220,80,0.9)');
  thrustGrad.addColorStop(0.3, 'rgba(255,150,30,0.6)');
  thrustGrad.addColorStop(0.7, 'rgba(255,80,0,0.3)');
  thrustGrad.addColorStop(1, 'rgba(255,50,0,0)');
  ctx.fillStyle = thrustGrad;
  ctx.beginPath();
  ctx.moveTo(-bodyW * 0.3, bodyLen * 0.35);
  ctx.lineTo(0, bodyLen * 0.35 + thrustLen);
  ctx.lineTo(bodyW * 0.3, bodyLen * 0.35);
  ctx.closePath();
  ctx.fill();

  // Boost flame (extra)
  if (player.boosting) {
    const boostGrad = ctx.createRadialGradient(0, bodyLen * 0.35 + thrustLen * 0.5, 0, 0, bodyLen * 0.35 + thrustLen * 0.5, 20 * scale);
    boostGrad.addColorStop(0, 'rgba(255,255,200,0.5)');
    boostGrad.addColorStop(1, 'rgba(255,200,100,0)');
    ctx.fillStyle = boostGrad;
    ctx.beginPath();
    ctx.arc(0, bodyLen * 0.35 + thrustLen * 0.5, 20 * scale, 0, Math.PI * 2);
    ctx.fill();
  }

  ctx.restore();
}

// ── function: drawPickup ──
// === DRAW PICKUPS ===
function drawPickup(ctx, w, h, camPos, pickup, time) {
  const bob = Math.sin(time * 3 + pickup.bobPhase) * 0.5;
  const glow = Math.sin(time * 5 + pickup.glowPhase) * 0.3 + 0.7;

  const pos3D = [pickup.pos[0], pickup.pos[1] + bob, pickup.pos[2]];
  const p = project(pos3D, w, h, 600, camPos);
  if (!p || p[2] < 0.1 || p[2] > 2000) return;

  const scale = 600 / p[2];
  const size = pickup.size * scale * 15;

  let color;
  if (pickup.type === 'energy') color = C.COLORS.pickupEnergy;
  else if (pickup.type === 'score') color = C.COLORS.pickupScore;
  else color = C.COLORS.pickupMulti;

  const r = color[0], g = color[1], b = color[2];

  // Glow
  const glowGrad = ctx.createRadialGradient(p[0], p[1], 0, p[0], p[1], size * 2);
  glowGrad.addColorStop(0, `rgba(${r},${g},${b},${0.4 * glow})`);
  glowGrad.addColorStop(1, `rgba(${r},${g},${b},0)`);
  ctx.fillStyle = glowGrad;
  ctx.beginPath();
  ctx.arc(p[0], p[1], size * 2, 0, Math.PI * 2);
  ctx.fill();

  // Core shape
  ctx.fillStyle = `rgba(${r},${g},${b},${0.8 * glow})`;
  ctx.strokeStyle = `rgba(255,255,255,${0.6 * glow})`;
  ctx.lineWidth = 1;

  if (pickup.type === 'energy') {
    // Lightning bolt shape
    ctx.beginPath();
    ctx.moveTo(p[0], p[1] - size);
    ctx.lineTo(p[0] + size * 0.3, p[1] - size * 0.2);
    ctx.lineTo(p[0] - size * 0.1, p[1] - size * 0.1);
    ctx.lineTo(p[0] + size * 0.1, p[1] + size);
    ctx.lineTo(p[0] - size * 0.3, p[1] + size * 0.2);
    ctx.lineTo(p[0] + size * 0.1, p[1] + size * 0.1);
    ctx.closePath();
    ctx.fill();
    ctx.stroke();
  } else if (pickup.type === 'score') {
    // Diamond
    ctx.beginPath();
    ctx.moveTo(p[0], p[1] - size);
    ctx.lineTo(p[0] + size, p[1]);
    ctx.lineTo(p[0], p[1] + size);
    ctx.lineTo(p[0] - size, p[1]);
    ctx.closePath();
    ctx.fill();
    ctx.stroke();
  } else {
    // Circle (multiplier)
    ctx.beginPath();
    ctx.arc(p[0], p[1], size, 0, Math.PI * 2);
    ctx.fill();
    ctx.stroke();
    ctx.beginPath();
    ctx.arc(p[0], p[1], size * 0.5, 0, Math.PI * 2);
    ctx.strokeStyle = `rgba(${r},${g},${b},${0.5 * glow})`;
    ctx.stroke();
  }
}

// ── function: drawParticle ──
// === DRAW PARTICLES ===
function drawParticle(ctx, w, h, camPos, p) {
  const proj = project(p.pos, w, h, 600, camPos);
  if (!proj || proj[2] < 0.1) return;
  const scale = 600 / proj[2];
  const alpha = clamp(p.life / p.maxLife, 0, 1);
  const size = p.size * scale * 8;
  const r = Math.round(lerp(p.color[0], 255, 1 - alpha));
  const g = Math.round(lerp(p.color[1], 255, 1 - alpha));
  const b = Math.round(lerp(p.color[2], 255, 1 - alpha));
  ctx.fillStyle = `rgba(${r},${g},${b},${alpha})`;
  ctx.beginPath();
  ctx.arc(proj[0], proj[1], size, 0, Math.PI * 2);
  ctx.fill();
}

// ── function: drawSpeedLines ──
// === DRAW SPEED LINES (improved) ===
function drawSpeedLines(ctx, w, h, speed) {
  if (speed < C.SPEED_LINES_THRESHOLD) return;
  const intensity = clamp((speed - C.SPEED_LINES_THRESHOLD) / (C.PLAYER_MAX_SPEED - C.SPEED_LINES_THRESHOLD), 0, 1);
  const count = Math.floor(intensity * 40);

  for (let i = 0; i < count; i++) {
    const x = Math.random() * w;
    const y = Math.random() * h;
    const len = 30 + Math.random() * 80 * intensity;
    const alpha = intensity * 0.2;

    // Chromatic aberration effect - offset R and B channels
    ctx.strokeStyle = `rgba(255,255,200,${alpha * 0.5})`;
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.lineTo(x + (x - w/2) * 0.05, y + len);
    ctx.stroke();

    ctx.strokeStyle = `rgba(255,100,100,${alpha * 0.3})`;
    ctx.beginPath();
    ctx.moveTo(x - 2, y);
    ctx.lineTo(x + (x - w/2) * 0.05 - 3, y + len);
    ctx.stroke();

    ctx.strokeStyle = `rgba(100,100,255,${alpha * 0.3})`;
    ctx.beginPath();
    ctx.moveTo(x + 2, y);
    ctx.lineTo(x + (x - w/2) * 0.05 + 3, y + len);
    ctx.stroke();
  }
}

// ── function: drawShadowOverlay ──
// === DRAW SHADOW OVERLAY ===
function drawShadowOverlay(ctx, w, h, player, world) {
  let shadowAmount = 0;

  // Check if player is near a large obstacle (shadow effect)
  for (const obs of world.obstacles) {
    const dist = v3Len(v3Sub(player.pos, obs.pos));
    if (dist < 15) {
      const overlap = 1 - dist / 15;
      shadowAmount = Math.max(shadowAmount, overlap * 0.3);
    }
  }

  if (shadowAmount > 0) {
    ctx.fillStyle = `rgba(20,10,30,${shadowAmount})`;
    ctx.fillRect(0, 0, w, h);
  }
}

// ── function: updatePlayer ──
function updatePlayer(player, input, dt) {
  if (!player.alive) return;

  // Input handling with lerp for smooth movement
  let targetX = 0, targetY = 0;
  if (input.left) targetX -= 1;
  if (input.right) targetX += 1;
  if (input.up) targetY += 1;
  if (input.down) targetY -= 1;

  player.targetX = lerp(player.targetX, targetX, 0.3);
  player.targetY = lerp(player.targetY, targetY, 0.3);

  // Strafe
  const strafeSpeed = 12;
  player.pos[0] += player.targetX * strafeSpeed * dt;
  player.pos[0] = clamp(player.pos[0], -C.PLAYER_BOUNDS, C.PLAYER_BOUNDS);

  // Pitch (vertical)
  player.pos[1] = lerp(player.pos[1], 0.5 + player.targetY * 3, 0.15);
  player.pos[1] = clamp(player.pos[1], -1, 8);

  // Roll and pitch visual
  player.targetRoll = player.targetX * C.PLAYER_ROLL_MAX;
  player.targetPitch = player.targetY * C.PLAYER_PITCH_MAX;
  player.roll = lerp(player.roll, player.targetRoll, C.PLAYER_LERP * 3);
  player.pitch = lerp(player.pitch, player.targetPitch, C.PLAYER_LERP * 3);

  // Speed management
  if (player.boosting && player.energy > 0) {
    player.speed = lerp(player.speed, C.PLAYER_SPEED * C.BOOST_MULTIPLIER, 0.1);
    player.energy -= C.BOOST_DRAIN;
  } else {
    player.speed = lerp(player.speed, player.baseSpeed, 0.05);
  }

  // Move forward
  player.pos[2] += player.speed * dt;
  player.distance += player.speed * dt;
  player.score += player.speed * dt * C.SCORE_BASE * player.multiplier;

  // Energy drain
  let drain = C.ENERGY_DRAIN;
  for (const obs of world.obstacles) {
    const dist = v3Len(v3Sub(player.pos, obs.pos));
    if (dist < 10) {
      drain = C.ENERGY_DRAIN_SHADOW;
      break;
    }
  }
  player.energy -= drain;
  player.energy = clamp(player.energy, 0, C.ENERGY_MAX);

  // Check for sun setting (game over)
  const sunFraction = world.time / C.SUN_SET_DURATION;
  if (sunFraction >= C.SUN_SET_THRESHOLD) {
    player.alive = false;
  }

  // Near-miss detection
  for (const obs of world.obstacles) {
    if (obs.nearMissed) continue;
    const dist = v3Len(v3Sub([player.pos[0], player.pos[1]], [obs.pos[0], obs.pos[1]]));
    const zDist = Math.abs(player.pos[2] - obs.pos[2]);
    if (zDist < 5 && dist > obs.size[0] * 0.5 + 2 && dist < obs.size[0] * 0.5 + 8) {
      obs.nearMissed = true;
      player.score += C.SCORE_NEAR_MISS * player.multiplier;
      player.nearMissTimer = 0.5;
      applyCameraShake(camera, C.CAM_SHAKE_NEAR);
      playNearMissSound();
      const canvas = document.getElementById('gameCanvas');
      const p = project([player.pos[0], player.pos[1] + 2, player.pos[2] + 3], canvas.width, canvas.height, 600, camera.pos);
      if (p) {
        addScorePopup(p[0], p[1], `+${Math.floor(C.SCORE_NEAR_MISS * player.multiplier)}`, 'rgba(255,255,100,1)');
      }
    }
  }

  // Boost energy check
  if (player.boosting && player.energy <= 0) {
    player.boosting = false;
  }

  player.nearMissTimer = Math.max(0, player.nearMissTimer - dt);
  player.flashTimer = Math.max(0, player.flashTimer - dt);
}

// ── function: checkCollisions ──
function checkCollisions(player, world) {
  if (!player.alive) return;

  for (const obs of world.obstacles) {
    if (obs.hit) continue;
    const dx = Math.abs(player.pos[0] - obs.pos[0]);
    const dy = Math.abs(player.pos[1] - obs.pos[1]);
    const dz = Math.abs(player.pos[2] - obs.pos[2]);

    if (dx < obs.size[0] * 0.5 + 0.5 &&
        dy < obs.size[1] * 0.5 + 0.3 &&
        dz < obs.size[2] * 0.5 + 0.5) {
      obs.hit = true;
      player.alive = false;
      player.flashTimer = 0.3;
      applyCameraShake(camera, C.CAM_SHAKE_CRASH);
      emitCrash(particlePool, player.pos);
      playCrashSound();
      return;
    }
  }

  for (const pickup of world.pickups) {
    if (pickup.collected) continue;
    const dx = player.pos[0] - pickup.pos[0];
    const dy = player.pos[1] - pickup.pos[1];
    const dz = player.pos[2] - pickup.pos[2];
    const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);

    if (dist < pickup.size + 1.5) {
      pickup.collected = true;
      const pColor = pickup.type === 'energy' ? C.COLORS.pickupEnergy :
                     pickup.type === 'score' ? C.COLORS.pickupScore : C.COLORS.pickupMulti;
      emitPickup(particlePool, pickup.pos, pColor);

      if (pickup.type === 'energy') {
        player.energy = Math.min(C.ENERGY_MAX, player.energy + C.ENERGY_PICKUP);
      } else if (pickup.type === 'score') {
        player.score += 100 * player.multiplier;
      } else {
        player.multiplier += 1;
      }
      playPickupSound(pickup.type);
    }
  }
}

// ── function: updateWorld ──
function updateWorld(world, dt) {
  world.time += dt;
  // Sun sinks linearly over SUN_SET_DURATION seconds
  world.sunAngle = (world.time / C.SUN_SET_DURATION) * Math.PI;

  // Spawn obstacles
  world.spawnTimer += dt;
  const spawnInterval = Math.max(4, C.OBSTACLE_SPAWN_INTERVAL - world.time * 0.01);
  if (world.spawnTimer >= spawnInterval) {
    world.spawnTimer = 0;
    const rng = seededRandom(Math.floor(world.time * 100) + world.seed);
    const count = randInt(1, 3);
    for (let i = 0; i < count; i++) {
      spawnObstacle(world, rng);
    }
  }

  // Spawn pickups
  world.pickupTimer += dt;
  if (world.pickupTimer >= C.PICKUP_SPAWN_INTERVAL) {
    world.pickupTimer = 0;
    const rng = seededRandom(Math.floor(world.time * 200) + world.seed + 999);
    spawnPickup(world, rng);
  }

  // Clean up old objects
  const cleanupZ = player.pos[2] - 50;
  world.obstacles = world.obstacles.filter(o => o.pos[2] > cleanupZ);
  world.pickups = world.pickups.filter(p => p.pos[2] > cleanupZ);
}

// ── let: gameRunning ──
// === GAME STATE ===
let gameRunning = false;

// ── let: gameState ──
let gameState = 'title';

// ── let: player ──
let player;

// ── let: camera ──
let camera;

// ── let: world ──
let world;

// ── let: particlePool ──
let particlePool;

// ── let: input ──
let input = { left: false, right: false, up: false, down: false, boost: false };

// ── let: lastTime ──
let lastTime = 0;

// ── let: multiplierTimer ──
let multiplierTimer = 0;

// ── let: highScore ──
let highScore = 0;

// ── let: gameTime ──
let gameTime = 0;

// ── function: initGame ──
function initGame() {
  player = createPlayer();
  camera = createCamera();
  world = createWorld();
  particlePool = [];
  input = { left: false, right: false, up: false, down: false, boost: false };
  multiplierTimer = 0;
  gameTime = 0;
  gameRunning = true;
}

// ── function: updateHUD ──
function updateHUD() {
  const hudScore = document.getElementById('hudScore');
  const hudMult = document.getElementById('hudMultiplier');
  const hudEnergy = document.getElementById('hudEnergyBar');
  const hudSpeed = document.getElementById('hudSpeed');
  const hudDist = document.getElementById('hudDistance');

  if (hudScore) hudScore.textContent = Math.floor(player.score).toLocaleString();
  if (hudMult) {
    hudMult.textContent = `×${player.multiplier}`;
    if (player.nearMissTimer > 0) {
      hudMult.classList.add('bump');
    } else {
      hudMult.classList.remove('bump');
    }
  }
  if (hudEnergy) hudEnergy.style.width = `${(player.energy / C.ENERGY_MAX) * 100}%`;
  if (hudSpeed) hudSpeed.textContent = `${Math.floor(player.speed * 100)} km/s`;
  if (hudDist) hudDist.textContent = `${Math.floor(player.distance)} m`;
}

// ── function: showGameOver ──
function showGameOver() {
  gameState = 'gameover';
  gameRunning = false;

  const goScreen = document.getElementById('gameOverScreen');
  const goStats = document.getElementById('goStats');

  if (player.score > highScore) {
    highScore = player.score;
    localStorage.setItem('raceTheSun_highScore', highScore);
  }

  let reason = '';
  if (world.sunAngle > Math.PI * 0.8) {
    reason = '<p class="go-reason">The sun has set.</p>';
  } else {
    reason = '<p class="go-reason">Craft destroyed.</p>';
  }

  const dist = Math.floor(player.distance);
  const score = Math.floor(player.score);
  const speed = Math.floor(player.speed * 100);
  const time = Math.floor(gameTime);
  const isBest = score >= highScore;

  goStats.innerHTML = `
    ${reason}
    <p>Distance: <span>${dist} m</span></p>
    <p>Score: <span>${score.toLocaleString()}</span></p>
    <p>Top Speed: <span>${speed} km/s</span></p>
    <p>Time: <span>${time}s</span></p>
    <p>Multiplier: <span>×${player.multiplier}</span></p>
    ${isBest ? '<p class="go-best">★ NEW HIGH SCORE ★</p>' : '<p>High Score: <span>' + Math.floor(highScore).toLocaleString() + '</span></p>'}
    <p class="go-hint">Press R or click to restart</p>
  `;

  goScreen.classList.remove('hidden');
}

// ── function: startGame ──
function startGame() {
  document.getElementById('titleScreen').classList.add('hidden');
  document.getElementById('gameOverScreen').classList.add('hidden');
  initAudio();
  initGame();
  gameState = 'playing';
}

// ── function: restartGame ──
function restartGame() {
  document.getElementById('gameOverScreen').classList.add('hidden');
  initAudio();
  initGame();
  gameState = 'playing';
}

// ── function: render ──
function render(timestamp) {
  requestAnimationFrame(render);

  if (!lastTime) lastTime = timestamp;
  const dt = Math.min((timestamp - lastTime) / 1000, 0.05);
  lastTime = timestamp;

  const canvas = document.getElementById('gameCanvas');
  const ctx = canvas.getContext('2d');
  if (!ctx) return;

  const w = canvas.width = window.innerWidth;
  const h = canvas.height = window.innerHeight;

  if (gameState === 'playing') {
    gameTime += dt;
    player.baseSpeed = clamp(C.PLAYER_SPEED + gameTime * 0.03, C.PLAYER_SPEED, C.PLAYER_MAX_SPEED);

    updatePlayer(player, input, dt);
    updateCamera(camera, player, dt);
    updateWorld(world, dt);
    checkCollisions(player, world);
    updateParticles(particlePool, dt);
    updateWind(player.speed);
    updateScorePopups(dt);
    emitThrust(particlePool, player.pos);

    const sunScreenY = C.SUN_Y * h + world.sunAngle * h * 0.3;
    drawSky(ctx, w, h, clamp(sunScreenY / h, 0, 1));
    drawSun(ctx, w, h, sunScreenY / h, world.sunAngle);
    drawGrid(ctx, w, h, camera.pos, player.pos[2], player.speed);

    const sortedObs = [...world.obstacles].filter(o => {
      const p = project(o.pos, w, h, 600, camera.pos);
      return p && p[2] > 0.1;
    }).sort((a, b) => {
      const pa = project(a.pos, w, h, 600, camera.pos);
      const pb = project(b.pos, w, h, 600, camera.pos);
      return (pb[2] || 0) - (pa[2] || 0);
    });

    for (const obs of sortedObs) drawObstacle(ctx, w, h, camera.pos, obs);
    for (const pickup of world.pickups) drawPickup(ctx, w, h, camera.pos, pickup, world.time);
    for (const p of particlePool) drawParticle(ctx, w, h, camera.pos, p);
    drawPlayer(ctx, w, h, camera.pos, player);
    drawShadowOverlay(ctx, w, h, player, world);

    if (player.speed > C.SPEED_LINES_THRESHOLD) drawSpeedLines(ctx, w, h, player.speed);
    drawScorePopups(ctx);
    drawLowEnergyWarning(ctx, w, h, player);

    if (player.flashTimer > 0) {
      ctx.fillStyle = `rgba(255,100,50,${player.flashTimer * 0.5})`;
      ctx.fillRect(0, 0, w, h);
    }
    if (player.nearMissTimer > 0) {
      ctx.fillStyle = `rgba(255,255,200,${player.nearMissTimer * 0.3})`;
      ctx.fillRect(0, 0, w, h);
    }

    updateHUD();

    if (!player.alive) showGameOver();
  }

  if (gameState === 'title') {
    world.time += dt;
    drawSky(ctx, w, h, 0.5 + Math.sin(world.time * 0.5) * 0.1);
    drawSun(ctx, w, h, 0.45, world.time * 0.1);
    drawGrid(ctx, w, h, [0, 5, -10], 0, 2);
  }

  if (gameState === 'gameover') {
    drawSky(ctx, w, h, 0.6);
    drawSun(ctx, w, h, 0.5, world.sunAngle);
    drawGrid(ctx, w, h, camera.pos, player.pos[2], 0);
    updateHUD();
  }
}

// ── let: scorePopups ──
// === SCORE POPUPS ===
let scorePopups = [];

// ── function: addScorePopup ──
function addScorePopup(x, y, text, color) {
  scorePopups.push({
    x: x, y: y, text: text, color: color,
    life: 1.5, maxLife: 1.5,
    vy: -50,
  });
}

// ── function: updateScorePopups ──
function updateScorePopups(dt) {
  for (let i = scorePopups.length - 1; i >= 0; i--) {
    const p = scorePopups[i];
    p.y += p.vy * dt;
    p.life -= dt;
    if (p.life <= 0) scorePopups.splice(i, 1);
  }
}

// ── function: drawScorePopups ──
function drawScorePopups(ctx) {
  for (const p of scorePopups) {
    const alpha = clamp(p.life / p.maxLife, 0, 1);
    ctx.font = `bold ${16 + (1 - alpha) * 8}px 'Courier New', monospace`;
    ctx.textAlign = 'center';
    ctx.fillStyle = p.color.replace('1)', `${alpha})`);
    ctx.fillText(p.text, p.x, p.y);
  }
}

// ── function: drawLowEnergyWarning ──
function drawLowEnergyWarning(ctx, w, h, player) {
  if (player.energy > 20) return;
  const intensity = (1 - player.energy / 20) * 0.3;
  const pulse = Math.sin(gameTime * 8) * 0.5 + 0.5;

  // Edge glow
  const grad = ctx.createRadialGradient(w/2, h/2, Math.min(w,h) * 0.3, w/2, h/2, Math.max(w,h) * 0.7);
  grad.addColorStop(0, 'rgba(255,0,0,0)');
  grad.addColorStop(1, `rgba(255,0,0,${intensity * pulse})`);
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, w, h);
}

// ── main ──
// === MAIN BLOCK ===
// Load high score
const saved = localStorage.getItem('raceTheSun_highScore');
if (saved) highScore = parseFloat(saved);

// Initialize world for title screen
world = createWorld();

// Canvas setup
const canvas = document.getElementById('gameCanvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

// Input handling
document.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') input.left = true;
  if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') input.right = true;
  if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') input.up = true;
  if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') input.down = true;
  if (e.key === ' ') { input.boost = true; e.preventDefault(); }
  if (e.key === 'r' || e.key === 'R') {
    if (gameState === 'playing' || gameState === 'gameover') restartGame();
  }
  if (e.key === 'Enter' && gameState === 'title') startGame();
});

document.addEventListener('keyup', (e) => {
  if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') input.left = false;
  if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') input.right = false;
  if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') input.up = false;
  if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') input.down = false;
  if (e.key === ' ') input.boost = false;
});

// Button handlers
document.getElementById('startBtn').addEventListener('click', startGame);
document.getElementById('restartBtn').addEventListener('click', restartGame);

// Resize handling
window.addEventListener('resize', () => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
});

// Start render loop
requestAnimationFrame(render);
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen/qwen3.6-35b-a3b","provider":"lmstudio","persona":"composer","sessionId":"1669f200-1789-4698-9c77-f06752b52618","tokensIn":4148031,"tokensOut":46433,"tokensTotal":4194464,"turns":98,"toolCalls":97,"failedToolCalls":5,"timestamp":"2026-07-20T23:56:28.456Z"} -->