← Race the Sun results

Race the Sun

thinkingcap qwen3.6 27b · full

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 31,882 bytes · SHA-256 a2e6a7a7635e
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Solar Run</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
canvas { display: block; width: 100%; height: 100%; image-rendering: pixelated; cursor: none; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
// ============================================================
// === CONSTANTS — tune these for balancing ====================
// ============================================================
const C = {
  PLAYER_W: 28, PLAYER_H: 16, PLAYER_DEPTH: 40,
  MOVE_SPEED: 14,
  EASE_FACTOR: 0.15,
  BANK_ANGLE: 0.35,
  PITCH_LEAN: 0.2,
  BASE_SPEED: 4,
  MAX_SPEED: 18,
  ACCEL_RATE: 0.003,
  SPEED_LINE_THRESHOLD: 7,
  LANE_COUNT: 5,
  LANE_WIDTH: 60,
  ROAD_WIDTH: 400,
  CHUNK_LENGTH: 200,
  DRAW_DISTANCE: 1800,
  SUN_START_Y: 0.35,
  SUN_SINK_RATE: 0.00004,
  ENERGY_MAX: 100,
  ENERGY_DRAIN: 0.012,
  ENERGY_SHADOW_MULT: 3.5,
  ENERGY_PICKUP: 20,
  OBSTACLE_MIN_W: 30,
  OBSTACLE_MAX_W: 80,
  OBSTACLE_H_RANGE: [40, 120],
  GAP_CHANCE: 0.35,
  PARTICLE_LIFE: 40,
  THRUST_PARTICLES_PER_FRAME: 2,
  SHAKE_NEAR_MISS: 4,
  SHAKE_CRASH: 16,
  SHAKE_DECAY: 0.85,
  BOOST_SPEED_MULT: 2.2,
  BOOST_DURATION: 30,
  BOOST_ENERGY_COST: 15,
  BOOST_COOLDOWN: 45,
  SKY_TOP: [10, 5, 30],
  SKY_MID: [60, 20, 80],
  SKY_BOT: [200, 80, 40],
  SUN_COLOR: [255, 220, 100],
  SUN_GLOW: [255, 160, 50],
  GROUND_COLOR: [30, 15, 40],
  OBSTACLE_COLORS: [[80, 40, 100], [120, 50, 70], [60, 30, 90]],
  PICKUP_ENERGY: [100, 255, 180],
  PICKUP_SCORE: [255, 220, 80],
  PICKUP_MULTI: [255, 100, 255],
  AUDIO_VOLUME: 0.3,
};

// ============================================================
// === SETUP — canvas, state, seed ===========================
// ============================================================
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let W, H;

function resize() {
  W = canvas.width = window.innerWidth;
  H = canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();

// Seeded PRNG (mulberry32)
function mulberry32(a) {
  return function() {
    a |= 0; a = a + 0x6D2B79F5 | 0;
    let t = Math.imul(a ^ a >>> 15, 1 | a);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}

let seed = Date.now() & 0xFFFFFFFF;
let rng;

const STATE = { TITLE: 0, PLAYING: 1, GAMEOVER: 2 };
let gameState = STATE.TITLE;

// ============================================================
// === AUDIO — procedural WebAudio ===========================
// ============================================================
let audioCtx = null;
let masterGain = null;
let droneOsc = null;
let droneGain = null;

function initAudio() {
  if (audioCtx) return;
  try {
    audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    masterGain = audioCtx.createGain();
    masterGain.gain.value = C.AUDIO_VOLUME;
    masterGain.connect(audioCtx.destination);

    droneOsc = audioCtx.createOscillator();
    droneGain = audioCtx.createGain();
    droneOsc.type = 'sawtooth';
    droneOsc.frequency.value = 55;
    droneGain.gain.value = 0.12;
    const lfo = audioCtx.createOscillator();
    lfo.frequency.value = 0.3;
    const lfoGain = audioCtx.createGain();
    lfoGain.gain.value = 8;
    lfo.connect(lfoGain);
    lfoGain.connect(droneOsc.frequency);
    lfo.start();
    droneOsc.connect(droneGain);
    droneGain.connect(masterGain);
    droneOsc.start();
  } catch(e) { /* audio not available */ }
}

function playTone(freq, dur, type, vol) {
  if (!audioCtx) return;
  try {
    freq = freq || 440; dur = dur || 0.1; type = type || 'square'; vol = vol || 0.3;
    const o = audioCtx.createOscillator();
    const g = audioCtx.createGain();
    o.type = type;
    o.frequency.value = freq;
    g.gain.setValueAtTime(vol, audioCtx.currentTime);
    g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + dur);
    o.connect(g);
    g.connect(masterGain);
    o.start();
    o.stop(audioCtx.currentTime + dur);
  } catch(e) {}
}

function sfxPickup() { playTone(880, 0.12, 'square', 0.25); setTimeout(() => playTone(1320, 0.1, 'sine', 0.2), 60); }
function sfxMulti() { playTone(660, 0.08); setTimeout(() => playTone(990, 0.08), 50); setTimeout(() => playTone(1320, 0.15), 100); }
function sfxCrash() { playTone(60, 0.4, 'sawtooth', 0.5); playTone(40, 0.5, 'square', 0.3); }
function sfxNearMiss() { playTone(200, 0.15, 'triangle', 0.15); }
function sfxBoost() { playTone(300, 0.2, 'sawtooth', 0.2); setTimeout(() => playTone(600, 0.15), 80); }

// ============================================================
// === INPUT ==================================================
// ============================================================
const keys = {};
window.addEventListener('keydown', e => {
  keys[e.code] = true;
  if (e.code === 'KeyR' && gameState === STATE.GAMEOVER) startGame();
  if ((e.code === 'Enter' || e.code === 'Space') && gameState === STATE.TITLE) startGame();
  if (gameState !== STATE.PLAYING) e.preventDefault();
});
window.addEventListener('keyup', e => { keys[e.code] = false; });

// ============================================================
// === GAME OBJECTS ===========================================
// ============================================================
let player, obstacles, pickups, particles;
let distance, speed, energy, multiplier, score, highScore;
let shakeX, shakeY, shakeMag;
let sunY, frameCount;
let boostTimer, boostCooldown;
let nearMissFlash;

highScore = parseInt(localStorage.getItem('solarRunHigh') || '0');

function resetGame() {
  nextSpawnZ = 500;
  rng = mulberry32(seed);
  player = { x: 0, y: 0, targetX: 0, targetY: 0, bankAngle: 0, pitchLean: 0 };
  obstacles = [];
  pickups = [];
  particles = [];
  distance = 0;
  speed = C.BASE_SPEED;
  energy = C.ENERGY_MAX;
  multiplier = 1;
  score = 0;
  shakeX = 0; shakeY = 0; shakeMag = 0;
  sunY = C.SUN_START_Y;
  frameCount = 0;
  boostTimer = 0;
  boostCooldown = 0;
  nearMissFlash = 0;
}

function startGame() {
  seed = Date.now() & 0xFFFFFFFF;
  resetGame();
  gameState = STATE.PLAYING;
  initAudio();
  canvas.onclick = null;
}

// ============================================================
// === WORLD GENERATION =======================================
// ============================================================
let nextSpawnZ = 500;

function spawnObstacleGroup(z) {
  const numGaps = Math.random() < C.GAP_CHANCE ? (Math.random() < 0.3 ? 2 : 1) : 0;
  const gapLanes = [];
  for (let i = 0; i < numGaps; i++) {
    let lane;
    do { lane = Math.floor(rng() * C.LANE_COUNT); } while (gapLanes.includes(lane));
    gapLanes.push(lane);
  }

  for (let l = 0; l < C.LANE_COUNT; l++) {
    if (gapLanes.includes(l)) continue;
    const w = C.OBSTACLE_MIN_W + rng() * (C.OBSTACLE_MAX_W - C.OBSTACLE_MIN_W);
    const hMin = C.OBSTACLE_H_RANGE[0] + rng() * (C.OBSTACLE_H_RANGE[1] - C.OBSTACLE_H_RANGE[0]);
    const colorIdx = Math.floor(rng() * C.OBSTACLE_COLORS.length);

    obstacles.push({
      x: (l - 2) * C.LANE_WIDTH,
      y: 0,
      z: z + rng() * 40,
      w: w, h: hMin, d: 30 + rng() * 20,
      color: C.OBSTACLE_COLORS[colorIdx],
    });
  }

  if (numGaps > 0) {
    for (const gl of gapLanes) {
      const type = rng();
      let pType;
      if (type < 0.5) pType = 'energy';
      else if (type < 0.8) pType = 'score';
      else pType = 'multi';

      pickups.push({
        x: (gl - 2) * C.LANE_WIDTH,
        y: 30 + rng() * 40,
        z: z + 50 + rng() * 60,
        type: pType,
        collected: false,
        bobPhase: rng() * Math.PI * 2,
      });
    }
  }

  if (rng() < 0.4) {
    const lane = Math.floor(rng() * C.LANE_COUNT);
    pickups.push({
      x: (lane - 2) * C.LANE_WIDTH,
      y: 20 + rng() * 50,
      z: z + rng() * 100,
      type: 'energy',
      collected: false,
      bobPhase: rng() * Math.PI * 2,
    });
  }
}

// ============================================================
// === PARTICLES ==============================================
// ============================================================
function emitParticles(x, y, z, count, color, spread, lifeScale) {
  spread = spread || 3; lifeScale = lifeScale || 1;
  for (let i = 0; i < count; i++) {
    particles.push({
      x: x + (rng() - 0.5) * spread,
      y: y + (rng() - 0.5) * spread,
      z: z,
      vx: (rng() - 0.5) * 4,
      vy: (rng() - 0.5) * 4,
      vz: rng() * 2,
      life: C.PARTICLE_LIFE * lifeScale,
      maxLife: C.PARTICLE_LIFE * lifeScale,
      color: color,
      size: 1 + rng() * 3,
    });
  }
}

// ============================================================
// === PROJECTION =============================================
// ============================================================
const FOV = 500;
const CAM_Y = -80;
const CAM_Z = -200;

function project(wx, wy, wz) {
  const relZ = wz + CAM_Z;
  if (relZ <= 1) return null;
  const scale = FOV / relZ;
  const sx = W / 2 + wx * scale;
  const sy = H / 2 - (wy + CAM_Y) * scale;
  return { x: sx, y: sy, scale };
}

// ============================================================
// === UPDATE ================================================
// ============================================================
function update() {
  if (gameState !== STATE.PLAYING) return;
  frameCount++;

  speed = Math.min(C.MAX_SPEED, C.BASE_SPEED + distance * C.ACCEL_RATE);

  let currentSpeed = speed;
  if (boostTimer > 0) {
    boostTimer--;
    currentSpeed *= C.BOOST_SPEED_MULT;
    energy -= 0.15;
  } else if (keys['Space'] && boostCooldown <= 0 && energy >= C.BOOST_ENERGY_COST) {
    boostTimer = C.BOOST_DURATION;
    boostCooldown = C.BOOST_COOLDOWN;
    energy -= C.BOOST_ENERGY_COST;
    sfxBoost();
  }
  if (boostCooldown > 0) boostCooldown--;

  let moveX = 0, moveY = 0;
  if (keys['ArrowLeft'] || keys['KeyA']) moveX -= 1;
  if (keys['ArrowRight'] || keys['KeyD']) moveX += 1;
  if (keys['ArrowUp'] || keys['KeyW']) moveY -= 1;
  if (keys['ArrowDown'] || keys['KeyS']) moveY += 1;

  player.targetX += moveX * C.MOVE_SPEED;
  player.targetY += moveY * C.MOVE_SPEED;

  const halfRoad = C.ROAD_WIDTH / 2 - C.PLAYER_W / 2;
  player.targetX = Math.max(-halfRoad, Math.min(halfRoad, player.targetX));
  player.targetY = Math.max(-60, Math.min(120, player.targetY));

  const dx = player.targetX - player.x;
  const dy = player.targetY - player.y;
  player.x += dx * C.EASE_FACTOR;
  player.y += dy * C.EASE_FACTOR;

  const targetBank = -moveX * C.BANK_ANGLE;
  player.bankAngle += (targetBank - player.bankAngle) * 0.2;

  const targetPitch = moveY * C.PITCH_LEAN;
  player.pitchLean += (targetPitch - player.pitchLean) * 0.15;

  distance += currentSpeed;
  score += Math.floor(currentSpeed * multiplier);

  sunY += C.SUN_SINK_RATE;

  let shadowed = false;
  for (const ob of obstacles) {
    if (ob.z > -50 && ob.z < 100 && Math.abs(ob.x - player.x) < (ob.w + C.PLAYER_W) / 2) {
      shadowed = true; break;
    }
  }
  const drainMult = shadowed ? C.ENERGY_SHADOW_MULT : 1;
  energy -= C.ENERGY_DRAIN * drainMult;

  nextSpawnZ += currentSpeed;
  while (nextSpawnZ < distance + C.DRAW_DISTANCE) {
    spawnObstacleGroup(nextSpawnZ);
    nextSpawnZ += C.CHUNK_LENGTH;
  }

  for (let i = obstacles.length - 1; i >= 0; i--) {
    const ob = obstacles[i];
    ob.z -= currentSpeed;

    if (ob.z < -200) { obstacles.splice(i, 1); continue; }

    if (ob.z > -C.PLAYER_DEPTH && ob.z < C.PLAYER_DEPTH / 2) {
      const hw = (ob.w + C.PLAYER_W) / 2;
      if (Math.abs(ob.x - player.x) < hw && Math.abs(player.y - ob.h / 2) < (ob.h + C.PLAYER_H) / 2) {
        emitParticles(player.x, player.y, 0, 60, [255, 100, 50], 8, 1.5);
        shakeMag = C.SHAKE_CRASH;
        sfxCrash();
        gameOver();
        return;
      }

      if (Math.abs(ob.x - player.x) < hw * 1.4 && Math.abs(player.y - ob.h / 2) < (ob.h + C.PLAYER_H) * 0.7) {
        nearMissFlash = 8;
        shakeMag = C.SHAKE_NEAR_MISS;
        score += 50 * multiplier;
        sfxNearMiss();
      }
    }
  }

  for (let i = pickups.length - 1; i >= 0; i--) {
    const pk = pickups[i];
    pk.z -= currentSpeed;
    if (pk.collected || pk.z < -200) { pickups.splice(i, 1); continue; }

    if (pk.z > -30 && pk.z < 40) {
      const pdx = Math.abs(pk.x - player.x);
      const pdy = Math.abs(pk.y - player.y);
      if (pdx < 40 && pdy < 50) {
        pk.collected = true;
        if (pk.type === 'energy') {
          energy = Math.min(C.ENERGY_MAX, energy + C.ENERGY_PICKUP);
          emitParticles(pk.x, pk.y, pk.z, 12, C.PICKUP_ENERGY, 4);
        } else if (pk.type === 'score') {
          score += 100 * multiplier;
          emitParticles(pk.x, pk.y, pk.z, 12, C.PICKUP_SCORE, 4);
        } else {
          multiplier = Math.min(8, multiplier + 1);
          emitParticles(pk.x, pk.y, pk.z, 20, C.PICKUP_MULTI, 6);
          sfxMulti();
        }
        sfxPickup();
      }
    }
  }

  if (frameCount % 2 === 0) {
    emitParticles(player.x - 15, player.y + 5, -10, C.THRUST_PARTICLES_PER_FRAME, [255, 180, 60], 3, 0.6);
  }

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

  if (shakeMag > 0.5) {
    shakeX = (Math.random() - 0.5) * shakeMag;
    shakeY = (Math.random() - 0.5) * shakeMag;
    shakeMag *= C.SHAKE_DECAY;
  } else {
    shakeX = 0; shakeY = 0; shakeMag = 0;
  }

  if (nearMissFlash > 0) nearMissFlash--;

  if (energy <= 0 || sunY >= 1.0) gameOver();

  if (droneOsc) droneOsc.frequency.value = 55 + currentSpeed * 3;
}

function gameOver() {
  gameState = STATE.GAMEOVER;
  if (score > highScore) {
    highScore = score;
    localStorage.setItem('solarRunHigh', String(highScore));
  }
}

// ============================================================
// === RENDER ================================================
// ============================================================
function render() {
  ctx.save();
  ctx.translate(shakeX || 0, shakeY || 0);

  const sy = sunY || C.SUN_START_Y;
  const fc = frameCount || 0;

  // Sky gradient based on sun position
  const t1 = Math.min(1, sy * 2);
  const t2 = Math.min(1, sy * 1.8);
  const skyTop = lerpColor(C.SKY_TOP, [0, 0, 5], t1);
  const skyMid = lerpColor(C.SKY_MID, [10, 5, 30], t2);
  const skyBot = lerpColor(C.SKY_BOT, [40, 10, 50], t1);

  const grad = ctx.createLinearGradient(0, 0, 0, H);
  grad.addColorStop(0, `rgb(${skyTop[0]},${skyTop[1]},${skyTop[2]})`);
  grad.addColorStop(0.5, `rgb(${skyMid[0]},${skyMid[1]},${skyMid[2]})`);
  grad.addColorStop(1, `rgb(${skyBot[0]},${skyBot[1]},${skyBot[2]})`);
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, W, H);

  // Sun
  const sunScreenY = H * (0.1 + sy * 0.5);
  const sunSize = 60 + Math.sin(fc * 0.02) * 3;
  const sunGlowGrad = ctx.createRadialGradient(W / 2, sunScreenY, 0, W / 2, sunScreenY, sunSize * 4);
  sunGlowGrad.addColorStop(0, `rgba(${C.SUN_GLOW[0]},${C.SUN_GLOW[1]},${C.SUN_GLOW[2]}, 0.6)`);
  sunGlowGrad.addColorStop(0.3, `rgba(${C.SUN_GLOW[0]},${C.SUN_GLOW[1]},${C.SUN_GLOW[2]}, 0.15)`);
  sunGlowGrad.addColorStop(1, 'rgba(0,0,0,0)');
  ctx.fillStyle = sunGlowGrad;
  ctx.fillRect(0, 0, W, H);

  const sunDiscGrad = ctx.createRadialGradient(W / 2, sunScreenY, 0, W / 2, sunScreenY, sunSize);
  sunDiscGrad.addColorStop(0, `rgb(${C.SUN_COLOR[0]},${C.SUN_COLOR[1]},${C.SUN_COLOR[2]})`);
  sunDiscGrad.addColorStop(0.7, `rgba(${C.SUN_GLOW[0]},${C.SUN_GLOW[1]},${C.SUN_GLOW[2]}, 0.8)`);
  sunDiscGrad.addColorStop(1, 'rgba(255,160,50,0)');
  ctx.fillStyle = sunDiscGrad;
  ctx.beginPath();
  ctx.arc(W / 2, sunScreenY, sunSize, 0, Math.PI * 2);
  ctx.fill();

  // Ground plane
  const horizonY = H * (0.35 + sy * 0.15);
  ctx.fillStyle = `rgb(${C.GROUND_COLOR[0]},${C.GROUND_COLOR[1]},${C.GROUND_COLOR[2]})`;
  ctx.fillRect(0, horizonY, W, H - horizonY);

  // Road lines (perspective)
  const vanishX = W / 2;
  for (let i = 0; i < 30; i++) {
    const tt = Math.pow((i + ((distance || 0) * (speed || C.BASE_SPEED) * 0.01) % 1) / 30, 1.5);
    if (tt <= 0 || tt > 1) continue;
    const y = horizonY + (H - horizonY) * tt;
    const spread = (y - horizonY) * 2.5;
    const alpha = Math.min(1, tt * 3);
    ctx.strokeStyle = `rgba(${C.SUN_GLOW[0]},${C.SUN_GLOW[1]},${C.SUN_GLOW[2]}, ${alpha * 0.3})`;
    ctx.lineWidth = 1 + tt * 2;
    ctx.beginPath();
    ctx.moveTo(vanishX - spread, y);
    ctx.lineTo(vanishX + spread, y);
    ctx.stroke();
  }

  // Lane markers
  for (let l = 0; l <= C.LANE_COUNT; l++) {
    const laneX = (l - C.LANE_COUNT / 2) * C.LANE_WIDTH;
    const p1 = project(laneX, 0, 50);
    const p2 = project(laneX, 0, C.DRAW_DISTANCE);
    if (!p1 || !p2) continue;
    ctx.strokeStyle = `rgba(${C.SUN_GLOW[0]},${C.SUN_GLOW[1]},${C.SUN_GLOW[2]}, 0.15)`;
    ctx.lineWidth = Math.max(1, p1.scale * 0.5);
    ctx.beginPath();
    ctx.moveTo(p1.x, p1.y);
    ctx.lineTo(p2.x, p2.y);
    ctx.stroke();
  }

  // Only render game objects if in playing state
  if (gameState === STATE.PLAYING && Array.isArray(obstacles)) {
    const sortedObs = obstacles.slice().sort((a, b) => b.z - a.z);

    for (const ob of sortedObs) {
      if (ob.z < 10 || ob.z > C.DRAW_DISTANCE) continue;
      const p = project(ob.x, ob.y + ob.h / 2, ob.z);
      if (!p) continue;

      const sw = ob.w * p.scale;
      const sh = ob.h * p.scale;
      const sd = ob.d * p.scale;

      const shadowFactor = Math.min(1, sy * 2);
      const r = Math.floor(ob.color[0] * (1 - shadowFactor * 0.5));
      const g = Math.floor(ob.color[1] * (1 - shadowFactor * 0.5));
      const b = Math.floor(ob.color[2] * (1 - shadowFactor * 0.3));

      ctx.fillStyle = `rgb(${r},${g},${b})`;
      ctx.fillRect(p.x - sw / 2, p.y - sh / 2, sw, sh);

      ctx.fillStyle = `rgb(${Math.min(255, r + 40)},${Math.min(255, g + 30)},${Math.min(255, b + 50)})`;
      ctx.beginPath();
      ctx.moveTo(p.x - sw / 2, p.y - sh / 2);
      ctx.lineTo(p.x + sw / 2, p.y - sh / 2);
      ctx.lineTo(p.x + sw / 2 + sd * 0.3, p.y - sh / 2 - sd * 0.5);
      ctx.lineTo(p.x - sw / 2 + sd * 0.3, p.y - sh / 2 - sd * 0.5);
      ctx.closePath();
      ctx.fill();

      ctx.fillStyle = `rgb(${Math.floor(r * 0.6)},${Math.floor(g * 0.6)},${Math.floor(b * 0.7)})`;
      ctx.beginPath();
      ctx.moveTo(p.x + sw / 2, p.y - sh / 2);
      ctx.lineTo(p.x + sw / 2, p.y + sh / 2);
      ctx.lineTo(p.x + sw / 2 + sd * 0.3, p.y + sh / 2 - sd * 0.5);
      ctx.lineTo(p.x + sw / 2 + sd * 0.3, p.y - sh / 2 - sd * 0.5);
      ctx.closePath();
      ctx.fill();

      ctx.strokeStyle = `rgba(255,200,150,${Math.min(0.4, 0.8 / (ob.z * 0.01))})`;
      ctx.lineWidth = Math.max(1, p.scale);
      ctx.strokeRect(p.x - sw / 2, p.y - sh / 2, sw, sh);
    }

    // Pickups
    if (pickups) {
      const sortedPks = [...pickups.filter(pk => !pk.collected)].sort((a, b) => b.z - a.z);
      for (const pk of sortedPks) {
        if (pk.z < 10 || pk.z > C.DRAW_DISTANCE) continue;
        const bob = Math.sin(fc * 0.08 + pk.bobPhase) * 5;
        const pp = project(pk.x, pk.y + bob, pk.z);
        if (!pp) continue;

        const size = 12 * pp.scale;
        let color;
        if (pk.type === 'energy') color = C.PICKUP_ENERGY;
        else if (pk.type === 'score') color = C.PICKUP_SCORE;
        else color = C.PICKUP_MULTI;

        const glowGrad = ctx.createRadialGradient(pp.x, pp.y, 0, pp.x, pp.y, size * 2);
        glowGrad.addColorStop(0, `rgba(${color[0]},${color[1]},${color[2]}, 0.5)`);
        glowGrad.addColorStop(1, 'rgba(0,0,0,0)');
        ctx.fillStyle = glowGrad;
        ctx.beginPath();
        ctx.arc(pp.x, pp.y, size * 2, 0, Math.PI * 2);
        ctx.fill();

        ctx.fillStyle = `rgb(${color[0]},${color[1]},${color[2]})`;
        ctx.save();
        ctx.translate(pp.x, pp.y);
        ctx.rotate(fc * 0.05 + pk.bobPhase);
        ctx.beginPath();
        ctx.moveTo(0, -size);
        ctx.lineTo(size * 0.6, 0);
        ctx.lineTo(0, size);
        ctx.lineTo(-size * 0.6, 0);
        ctx.closePath();
        ctx.fill();
        ctx.restore();
      }
    }

    // Particles
    if (particles) {
      for (const pt of particles) {
        const pp = project(pt.x, pt.y, pt.z);
        if (!pp) continue;
        const alpha = pt.life / pt.maxLife;
        const size = pt.size * pp.scale * alpha;
        ctx.fillStyle = `rgba(${pt.color[0]},${pt.color[1]},${pt.color[2]}, ${alpha})`;
        ctx.beginPath();
        ctx.arc(pp.x, pp.y, Math.max(1, size), 0, Math.PI * 2);
        ctx.fill();
      }
    }

    // Player craft
    renderCraft(fc);

    // Speed lines at high speed
    const cs = boostTimer > 0 ? speed * C.BOOST_SPEED_MULT : speed;
    if (cs > C.SPEED_LINE_THRESHOLD) {
      const intensity = Math.min(1, (cs - C.SPEED_LINE_THRESHOLD) / 8);
      ctx.strokeStyle = `rgba(255,200,150,${intensity * 0.3})`;
      ctx.lineWidth = 1;
      for (let i = 0; i < 20; i++) {
        const lx = Math.random() * W;
        const ly = H * 0.4 + Math.random() * H * 0.5;
        const len = 30 + intensity * 80;
        ctx.beginPath();
        ctx.moveTo(lx, ly);
        ctx.lineTo(lx + (lx - W / 2) * 0.1, ly + len);
        ctx.stroke();
      }
    }

    // Near miss flash overlay
    if (nearMissFlash > 0) {
      ctx.fillStyle = `rgba(255,255,200,${nearMissFlash * 0.03})`;
      ctx.fillRect(0, 0, W, H);
    }

    // Chromatic aberration at very high speed
    if (cs > C.MAX_SPEED * 0.7) {
      const caIntensity = (cs - C.MAX_SPEED * 0.7) / (C.MAX_SPEED * 0.3);
      ctx.globalCompositeOperation = 'screen';
      ctx.fillStyle = `rgba(255,0,0,${caIntensity * 0.04})`;
      ctx.fillRect(2, 0, W, H);
      ctx.fillStyle = `rgba(0,0,255,${caIntensity * 0.04})`;
      ctx.fillRect(-2, 0, W, H);
      ctx.globalCompositeOperation = 'source-over';
    }

    // HUD
    renderHUD();
  }

  ctx.restore();

  if (gameState === STATE.TITLE) renderTitleScreen();
  else if (gameState === STATE.GAMEOVER) renderGameOverScreen();
}

// ============================================================
// === RENDER CRAFT ===========================================
// ============================================================
function renderCraft(fc) {
  const cx = W / 2 + (shakeX || 0);
  const cy = H * 0.65 + (shakeY || 0);

  ctx.save();
  ctx.translate(cx, cy);
  if (player && player.bankAngle) ctx.rotate(player.bankAngle);

  // Thrust glow during boost
  if (boostTimer > 0) {
    const thrustGrad = ctx.createRadialGradient(0, 20, 0, 0, 20, 40);
    thrustGrad.addColorStop(0, 'rgba(100,200,255,0.8)');
    thrustGrad.addColorStop(1, 'rgba(50,100,255,0)');
    ctx.fillStyle = thrustGrad;
    ctx.beginPath();
    ctx.arc(0, 20, 40 + Math.random() * 10, 0, Math.PI * 2);
    ctx.fill();
  }

  const lean = (player && player.pitchLean) || 0;

  // Main hull
  ctx.fillStyle = '#e8e0f0';
  ctx.beginPath();
  ctx.moveTo(0, -C.PLAYER_H * 1.5);
  ctx.lineTo(-C.PLAYER_W / 2 + 4, C.PLAYER_H * 0.3);
  ctx.lineTo(-C.PLAYER_W / 2, C.PLAYER_H);
  ctx.lineTo(C.PLAYER_W / 2, C.PLAYER_H);
  ctx.lineTo(C.PLAYER_W / 2 - 4, C.PLAYER_H * 0.3);
  ctx.closePath();
  ctx.fill();

  // Cockpit highlight
  ctx.fillStyle = '#c8b8ff';
  ctx.beginPath();
  ctx.moveTo(0, -C.PLAYER_H * 1.2 + lean * 10);
  ctx.lineTo(-6, C.PLAYER_H * 0.1);
  ctx.lineTo(6, C.PLAYER_H * 0.1);
  ctx.closePath();
  ctx.fill();

  // Wing accents
  ctx.strokeStyle = '#ff8844';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(-C.PLAYER_W / 2 + 2, C.PLAYER_H * 0.5);
  ctx.lineTo(0, -C.PLAYER_H * 1.3);
  ctx.lineTo(C.PLAYER_W / 2 - 2, C.PLAYER_H * 0.5);
  ctx.stroke();

  // Engine glow
  const engineGlow = 0.4 + Math.sin((fc || 0) * 0.2) * 0.15;
  ctx.fillStyle = `rgba(255,${boostTimer > 0 ? '200' : '150'},80,${engineGlow})`;
  ctx.beginPath();
  ctx.arc(-6, C.PLAYER_H + 2, 4, 0, Math.PI * 2);
  ctx.fill();
  ctx.beginPath();
  ctx.arc(6, C.PLAYER_H + 2, 4, 0, Math.PI * 2);
  ctx.fill();

  // Boost flame
  if (boostTimer > 0) {
    const flameLen = 15 + Math.random() * 15;
    ctx.fillStyle = 'rgba(80,180,255,0.7)';
    ctx.beginPath();
    ctx.moveTo(-8, C.PLAYER_H);
    ctx.lineTo(0, C.PLAYER_H + flameLen);
    ctx.lineTo(8, C.PLAYER_H);
    ctx.closePath();
    ctx.fill();
  }

  ctx.restore();
}

// ============================================================
// === HUD ====================================================
// ============================================================
function renderHUD() {
  const pad = 20;
  const barW = 160;
  const barH = 14;

  // Distance & Score panel
  ctx.fillStyle = 'rgba(0,0,0,0.5)';
  roundRect(pad - 5, pad - 5, 200, 80, 8);
  ctx.fill();

  ctx.font = 'bold 14px monospace';
  ctx.fillStyle = '#aab';
  ctx.fillText('DISTANCE', pad, pad + 12);
  ctx.font = 'bold 22px monospace';
  ctx.fillStyle = '#fff';
  ctx.fillText(`${Math.floor(distance)}m`, pad, pad + 36);

  ctx.font = 'bold 14px monospace';
  ctx.fillStyle = '#aab';
  ctx.fillText('SCORE', pad, pad + 58);
  ctx.font = 'bold 20px monospace';
  ctx.fillStyle = `rgb(${C.PICKUP_SCORE[0]},${C.PICKUP_SCORE[1]},${C.PICKUP_SCORE[2]})`;
  ctx.fillText(`${score.toLocaleString()}`, pad, pad + 76);

  if (multiplier > 1) {
    ctx.font = 'bold 18px monospace';
    ctx.fillStyle = `rgb(${C.PICKUP_MULTI[0]},${C.PICKUP_MULTI[1]},${C.PICKUP_MULTI[2]})`;
    ctx.fillText(`x${multiplier}`, pad + barW - 30, pad + 76);
  }

  // Energy bar
  const ey = pad + 95;
  ctx.fillStyle = 'rgba(0,0,0,0.5)';
  roundRect(pad - 5, ey - 5, barW + 10, 30, 8);
  ctx.fill();

  ctx.font = 'bold 12px monospace';
  ctx.fillStyle = '#aab';
  ctx.fillText('ENERGY', pad, ey + 6);

  const energyPct = Math.max(0, energy / C.ENERGY_MAX);
  const eColor = energyPct > 0.3 ? `rgb(${C.PICKUP_ENERGY[0]},${C.PICKUP_ENERGY[1]},${C.PICKUP_ENERGY[2]})` : '#ff4444';
  ctx.fillStyle = 'rgba(255,255,255,0.1)';
  roundRect(pad, ey + 10, barW, barH, 4);
  ctx.fill();

  if (energyPct > 0) {
    ctx.fillStyle = eColor;
    roundRect(pad, ey + 10, barW * energyPct, barH, 4);
    ctx.fill();
  }

  // Speed indicator
  const speedPct = Math.max(0, (speed - C.BASE_SPEED) / (C.MAX_SPEED - C.BASE_SPEED));
  ctx.fillStyle = 'rgba(0,0,0,0.5)';
  roundRect(pad - 5, ey + 35, barW + 10, 28, 8);
  ctx.fill();

  ctx.font = 'bold 12px monospace';
  ctx.fillStyle = '#aab';
  ctx.fillText('SPEED', pad, ey + 48);

  ctx.fillStyle = 'rgba(255,255,255,0.1)';
  roundRect(pad, ey + 52, barW, barH - 4, 3);
  ctx.fill();

  const speedColor = boostTimer > 0 ? '#66ccff' : `rgb(${C.SUN_GLOW[0]},${C.SUN_GLOW[1]},${C.SUN_GLOW[2]})`;
  ctx.fillStyle = speedColor;
  roundRect(pad, ey + 52, barW * Math.min(1, speedPct), barH - 4, 3);
  ctx.fill();

  // Sun warning
  if (sunY > 0.6) {
    const warnAlpha = Math.sin(frameCount * 0.15) * 0.3 + 0.3;
    ctx.font = 'bold 14px monospace';
    ctx.fillStyle = `rgba(255,80,50,${warnAlpha})`;
    ctx.fillText('SUN SETTING', pad, ey + 90);
  }

  // High score
  ctx.font = 'bold 12px monospace';
  ctx.fillStyle = '#667';
  ctx.textAlign = 'right';
  ctx.fillText(`HI ${highScore.toLocaleString()}`, W - pad, pad + 14);
  ctx.textAlign = 'left';
}

function roundRect(x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.lineTo(x + w - r, y);
  ctx.quadraticCurveTo(x + w, y, x + w, y + r);
  ctx.lineTo(x + w, y + h - r);
  ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
  ctx.lineTo(x + r, y + h);
  ctx.quadraticCurveTo(x, y + h, x, y + h - r);
  ctx.lineTo(x, y + r);
  ctx.quadraticCurveTo(x, y, x + r, y);
  ctx.closePath();
}

// ============================================================
// === TITLE SCREEN ===========================================
// ============================================================
function renderTitleScreen() {
  ctx.fillStyle = 'rgba(0,0,0,0.6)';
  ctx.fillRect(0, 0, W, H);

  ctx.textAlign = 'center';
  const titleSize = Math.min(72, W * 0.08);
  ctx.font = `bold ${titleSize}px monospace`;
  const titleGrad = ctx.createLinearGradient(W / 2 - 150, 0, W / 2 + 150, 0);
  titleGrad.addColorStop(0, '#ffaa44');
  titleGrad.addColorStop(0.5, '#ffdd88');
  titleGrad.addColorStop(1, '#ff6633');
  ctx.fillStyle = titleGrad;
  ctx.fillText('SOLAR RUN', W / 2, H * 0.3);

  const subSize = Math.min(24, W * 0.03);
  ctx.font = `${subSize}px monospace`;
  ctx.fillStyle = '#aab';
  ctx.fillText('Endless solar flight through a geometric world', W / 2, H * 0.38);

  const cy = H * 0.5;
  const ctrlSize = Math.min(18, W * 0.025);
  ctx.font = `${ctrlSize}px monospace`;
  ctx.fillStyle = '#99a';
  ctx.fillText('WASD / Arrow Keys — Move', W / 2, cy);
  ctx.fillText('Space — Boost (costs energy)', W / 2, cy + 30);

  const pulseAlpha = Math.sin(Date.now() * 0.005) * 0.3 + 0.7;
  const promptSize = Math.min(28, W * 0.035);
  ctx.font = `bold ${promptSize}px monospace`;
  ctx.fillStyle = `rgba(255,220,100,${pulseAlpha})`;
  ctx.fillText('Press ENTER or SPACE to launch', W / 2, H * 0.7);

  if (highScore > 0) {
    ctx.font = 'bold 16px monospace';
    ctx.fillStyle = '#889';
    ctx.fillText(`High Score: ${highScore.toLocaleString()}`, W / 2, H * 0.78);
  }

  ctx.textAlign = 'left';
}

// ============================================================
// === GAME OVER SCREEN =======================================
// ============================================================
function renderGameOverScreen() {
  ctx.fillStyle = 'rgba(10,5,20,0.75)';
  ctx.fillRect(0, 0, W, H);

  ctx.textAlign = 'center';

  const goSize = Math.min(64, W * 0.07);
  ctx.font = `bold ${goSize}px monospace`;
  ctx.fillStyle = '#ff5544';
  ctx.fillText('GAME OVER', W / 2, H * 0.28);

  const cy = H * 0.38;
  const statSize = Math.min(22, W * 0.03);
  ctx.font = `bold ${statSize}px monospace`;
  ctx.fillStyle = '#dde';
  ctx.fillText(`Distance: ${Math.floor(distance)}m`, W / 2, cy);
  ctx.fillText(`Score: ${score.toLocaleString()}`, W / 2, cy + 35);

  if (score >= highScore && score > 0) {
    const pulseAlpha = Math.sin(Date.now() * 0.006) * 0.4 + 0.6;
    ctx.font = `bold ${Math.min(28, W * 0.035)}px monospace`;
    ctx.fillStyle = `rgba(${C.PICKUP_SCORE[0]},${C.PICKUP_SCORE[1]},${C.PICKUP_SCORE[2]},${pulseAlpha})`;
    ctx.fillText('NEW HIGH SCORE', W / 2, cy + 75);
  }

  const pulseAlpha = Math.sin(Date.now() * 0.005) * 0.3 + 0.7;
  ctx.font = `bold ${Math.min(24, W * 0.03)}px monospace`;
  ctx.fillStyle = `rgba(255,220,100,${pulseAlpha})`;
  ctx.fillText('Press R or click to restart', W / 2, H * 0.7);

  ctx.textAlign = 'left';

  canvas.onclick = () => { startGame(); canvas.onclick = null; };
}

// ============================================================
// === UTILITIES ==============================================
// ============================================================
function lerpColor(a, b, t) {
  t = Math.max(0, Math.min(1, t));
  return [
    Math.round(a[0] + (b[0] - a[0]) * t),
    Math.round(a[1] + (b[1] - a[1]) * t),
    Math.round(a[2] + (b[2] - a[2]) * t),
  ];
}

// ============================================================
// === MAIN LOOP ==============================================
// ============================================================
let lastTime = 0;

function gameLoop(timestamp) {
  requestAnimationFrame(gameLoop);
  const dt = Math.min(32, timestamp - lastTime);
  lastTime = timestamp;
  update();
  render();
}

requestAnimationFrame(gameLoop);
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"full","sessionId":"c5a7dab7-d44a-459e-bb35-b65489f32632","tokensIn":1487838,"tokensOut":31178,"tokensTotal":1519016,"turns":25,"toolCalls":24,"failedToolCalls":0,"timestamp":"2026-07-18T21:57:21.738Z"} -->