← Race the Sun results

Race the Sun

thinkingcap qwen3.6 27b · middle

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 36,172 bytes · SHA-256 94b76dfc771a
<!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>
// ============================================================
//  SOLAR RUN — Endless Runner (Race the Sun inspired)
// ============================================================

// === TUNABLE CONSTANTS ===
const CFG = {
  // Player
  PLAYER_W: 18, PLAYER_H: 24, PLAYER_DEPTH: 30,
  MOVE_SPEED: 9.0,          // lateral/vertical speed (world units per frame)
  INERTIA_EASE: 0.15,       // how fast input is applied (higher = snappier)
  BANK_ANGLE: 0.45,         // max roll angle when strafing
  PITCH_LEAN: 0.25,         // forward lean on pitch

  // World
  WORLD_WIDTH: 320,         // playable width in world units
  LANE_COUNT: 5,            // logical lanes for obstacle placement
  CHUNK_LENGTH: 120,        // distance between spawn points
  VISIBLE_DISTANCE: 800,    // how far we can see

  // Speed & Difficulty
  BASE_SPEED: 3.5,          // starting forward speed
  MAX_SPEED: 14.0,          // cap
  ACCEL_RATE: 0.002,        // speed increase per frame
  SPEED_LINE_THRESHOLD: 6.0,// speed at which speed lines appear

  // Sun & Energy
  SUN_START_Y: 0.35,        // sun start position (0=top, 1=bottom)
  SUN_SET_SPEED: 0.00008,   // how fast sun sinks per frame
  ENERGY_MAX: 100,
  ENERGY_DRAIN_RATE: 0.025, // base drain per frame
  ENERGY_SHADOW_MULT: 3.0,  // multiplier when in shadow
  ENERGY_PICKUP_AMOUNT: 25,

  // Boost
  BOOST_SPEED_MULT: 2.0,
  BOOST_ENERGY_COST: 0.4,
  BOOST_COOLDOWN_FRAMES: 60,

  // Obstacles
  OBSTACLE_MIN_GAP: 80,     // min distance between obstacles
  OBSTACLE_SPAWN_RATE: 0.7, // probability per spawn point
  OBSTACLE_TYPES: ['pillar', 'wall', 'spike'],

  // Particles
  PARTICLE_LIFETIME: 40,
  THRUST_PARTICLES_PER_FRAME: 2,

  // Screen Shake
  SHAKE_NEAR_MISS: 3,
  SHAKE_CRASH: 12,
  SHAKE_DECAY: 0.85,

  // Colors
  SKY_TOP: '#0a0020',
  SKY_MID: '#4a1942',
  SKY_BOT: '#ff6b35',
  SUN_COLOR: '#ffe066',
  SUN_GLOW: '#ff8c00',
  GROUND_COLOR: '#1a0a2e',
  OBSTACLE_COLOR: '#2d1b4e',
  OBSTACLE_HIGHLIGHT: '#5c3d7a',
  PICKUP_ENERGY: '#00ffaa',
  PICKUP_SCORE: '#ffdd00',
  PICKUP_MULTIPLIER: '#ff44ff',
};

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

// Seeded PRNG (mulberry32)
function mulberry32(a) {
  return function() {
    a |= 0; a = a + 0x6D2B79F5 | 0;
    var 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 rng;
let runSeed;

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

// Player
let player = {};
// World objects
let obstacles = [];
let pickups = [];
let particles = [];
// Camera
let camera = {};
// Input
let keys = {};
// Timing
let frameCount = 0;
let lastTime = 0;
let dt = 1/60;

// Score
let score = 0;
let distance = 0;
let multiplier = 1;
let highScore = parseInt(localStorage.getItem('solarRunHigh') || '0');

// Audio context
let audioCtx = null;
let masterGain = null;
let droneOsc = null;
let droneGain = null;

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

// === AUDIO SYSTEM ===
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
  droneOsc = audioCtx.createOscillator();
  droneGain = audioCtx.createGain();
  droneOsc.type = 'sawtooth';
  droneOsc.frequency.value = 55;
  droneGain.gain.value = 0.08;

  // Low-pass filter for wind sound
  const lpf = audioCtx.createBiquadFilter();
  lpf.type = 'lowpass';
  lpf.frequency.value = 200;
  lpf.Q.value = 5;

  droneOsc.connect(lpf);
  lpf.connect(droneGain);
  droneGain.connect(masterGain);
  droneOsc.start();
}

function playPickup(freq, dur) {
  if (!audioCtx) return;
  const osc = audioCtx.createOscillator();
  const g = audioCtx.createGain();
  osc.type = 'sine';
  osc.frequency.value = freq || 800;
  g.gain.setValueAtTime(0.2, audioCtx.currentTime);
  g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + (dur || 0.15));
  osc.connect(g);
  g.connect(masterGain);
  osc.start();
  osc.stop(audioCtx.currentTime + dur + 0.05);
}

function playCrash() {
  if (!audioCtx) return;
  const bufferSize = audioCtx.sampleRate * 0.3;
  const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
  const data = buffer.getChannelData(0);
  for (let i = 0; i < bufferSize; i++) {
    data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / bufferSize, 2);
  }
  const src = audioCtx.createBufferSource();
  src.buffer = buffer;
  const g = audioCtx.createGain();
  g.gain.value = 0.5;
  src.connect(g);
  g.connect(masterGain);
  src.start();

  // Low thud
  const osc = audioCtx.createOscillator();
  const g2 = audioCtx.createGain();
  osc.type = 'sine';
  osc.frequency.setValueAtTime(100, audioCtx.currentTime);
  osc.frequency.exponentialRampToValueAtTime(30, audioCtx.currentTime + 0.3);
  g2.gain.setValueAtTime(0.4, audioCtx.currentTime);
  g2.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.3);
  osc.connect(g2);
  g2.connect(masterGain);
  osc.start();
  osc.stop(audioCtx.currentTime + 0.4);
}

function playNearMiss() {
  if (!audioCtx) return;
  const osc = audioCtx.createOscillator();
  const g = audioCtx.createGain();
  osc.type = 'triangle';
  osc.frequency.value = 1200;
  g.gain.setValueAtTime(0.1, audioCtx.currentTime);
  g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.08);
  osc.connect(g);
  g.connect(masterGain);
  osc.start();
  osc.stop(audioCtx.currentTime + 0.1);
}

function playBoost() {
  if (!audioCtx) return;
  const osc = audioCtx.createOscillator();
  const g = audioCtx.createGain();
  osc.type = 'square';
  osc.frequency.setValueAtTime(200, audioCtx.currentTime);
  osc.frequency.exponentialRampToValueAtTime(600, audioCtx.currentTime + 0.15);
  g.gain.setValueAtTime(0.15, audioCtx.currentTime);
  g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.2);
  osc.connect(g);
  g.connect(masterGain);
  osc.start();
  osc.stop(audioCtx.currentTime + 0.25);
}

function updateDrone(speed) {
  if (!droneOsc) return;
  droneOsc.frequency.value = 40 + speed * 8;
  droneGain.gain.value = Math.min(0.15, 0.03 + speed * 0.01);
}

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

canvas.addEventListener('click', () => {
  if (gameState === STATE.TITLE) startGame();
  else if (gameState === STATE.GAMEOVER) startGame();
});

// === PROJECTION HELPERS ===
const CAM_HEIGHT = 60;
const CAM_DIST = 80;
const FOV = 500;

function project(x, y, z) {
  // Camera is behind player at (player.x, CAM_HEIGHT, player.z - CAM_DIST)
  const cx = camera.shakeX || 0;
  const cy = camera.shakeY || 0;

  const relX = x - player.x + cx;
  const relY = y - CAM_HEIGHT + cy;
  const relZ = z - player.z + CAM_DIST;

  if (relZ <= 1) return null; // behind camera

  const scale = FOV / relZ;
  return {
    sx: W/2 + relX * scale,
    sy: H/2 - relY * scale,
    scale: scale,
    z: relZ
  };
}

// === GAME INIT ===
function startGame() {
  initAudio();
  runSeed = Date.now() ^ (Math.random() * 0xFFFFFFFF);
  rng = mulberry32(runSeed);

  gameState = STATE.PLAYING;
  frameCount = 0;
  score = 0;
  distance = 0;
  multiplier = 1;

  player = {
    x: 0, y: 0, z: 0,
    vx: 0, vy: 0,
    speed: CFG.BASE_SPEED,
    energy: CFG.ENERGY_MAX,
    bankAngle: 0,
    pitchLean: 0,
    boosting: false,
    boostCooldown: 0,
    alive: true,
    nearMissTimer: 0,
  };

  camera = { shakeX: 0, shakeY: 0, shakeMag: 0 };

  obstacles = [];
  pickups = [];
  particles = [];

  // Pre-populate some obstacles ahead
  for (let z = 200; z < CFG.VISIBLE_DISTANCE; z += CFG.CHUNK_LENGTH) {
    spawnChunk(z);
  }
}

function gameOver() {
  gameState = STATE.GAMEOVER;
  player.alive = false;
  playCrash();
  camera.shakeMag = CFG.SHAKE_CRASH;

  if (score > highScore) {
    highScore = score;
    localStorage.setItem('solarRunHigh', String(highScore));
  }

  // Explosion particles
  for (let i = 0; i < 60; i++) {
    spawnParticle(player.x, player.y + CFG.PLAYER_H/2, player.z,
      (rng()-0.5)*8, (rng()-0.5)*8 - 3, rng()*4+1,
      ['#ff4400','#ffaa00','#ffffff','#ff6600'][Math.floor(rng()*4)],
      30 + Math.floor(rng()*30)));
  }
}

// === SPAWNING ===
function spawnChunk(z) {
  if (rng() < CFG.OBSTACLE_SPAWN_RATE) {
    const type = CFG.OBSTACLE_TYPES[Math.floor(rng() * CFG.OBSTACLE_TYPES.length)];
    const laneWidth = CFG.WORLD_WIDTH / CFG.LANE_COUNT;

    // Pick 1-3 lanes to block
    const numBlocks = Math.min(3, 1 + Math.floor(distance / 500));
    const blockedLanes = new Set();
    for (let i = 0; i < numBlocks; i++) {
      blockedLanes.add(Math.floor(rng() * CFG.LANE_COUNT));
    }

    for (const lane of blockedLanes) {
      const ox = -CFG.WORLD_WIDTH/2 + laneWidth * lane + laneWidth/2;
      let ow, oh, od;

      if (type === 'pillar') {
        ow = 16 + rng()*10; oh = 40 + rng()*50; od = 16 + rng()*10;
      } else if (type === 'wall') {
        ow = laneWidth * 0.9; oh = 20 + rng()*30; od = 8;
      } else { // spike
        ow = 20; oh = 25 + rng()*20; od = 20;
      }

      obstacles.push({
        x: ox, y: -oh/2, z: z + (rng()-0.5)*30,
        w: ow, h: oh, d: od, type: type,
        passed: false, nearMissChecked: false
      });
    }

    // Spawn pickups in open lanes
    for (let l = 0; l < CFG.LANE_COUNT; l++) {
      if (!blockedLanes.has(l) && rng() < 0.4) {
        const px = -CFG.WORLD_WIDTH/2 + laneWidth * l + laneWidth/2;
        const types = ['energy', 'score', 'multiplier'];
        const weights = [0.5, 0.35, 0.15];
        let r = rng(), cum = 0, ptype = 'energy';
        for (let i = 0; i < types.length; i++) {
          cum += weights[i];
          if (r < cum) { ptype = types[i]; break; }
        }
        pickups.push({
          x: px, y: -15 + rng()*20, z: z + (rng()-0.5)*30,
          type: ptype, collected: false, bobPhase: rng() * Math.PI*2
        });
      }
    }
  }
}

// === PARTICLES ===
function spawnParticle(x, y, z, vx, vy, vz, color, life) {
  particles.push({ x, y, z, vx, vy, vz, color, life, maxLife: life || CFG.PARTICLE_LIFETIME });
}

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

  // Speed ramp
  player.speed = Math.min(CFG.MAX_SPEED, player.speed + CFG.ACCEL_RATE);

  // Input with inertia
  let inputX = 0, inputY = 0;
  if (keys['ArrowLeft'] || keys['KeyA']) inputX -= 1;
  if (keys['ArrowRight'] || keys['KeyD']) inputX += 1;
  if (keys['ArrowUp'] || keys['KeyW']) inputY -= 1;
  if (keys['ArrowDown'] || keys['KeyS']) inputY += 1;

  // Boost
  player.boosting = false;
  if ((keys['Space']) && player.energy > 5 && player.boostCooldown <= 0) {
    player.boosting = true;
    player.speed *= CFG.BOOST_SPEED_MULT;
    player.energy -= CFG.BOOST_ENERGY_COST;
    player.boostCooldown = CFG.BOOST_COOLDOWN_FRAMES;
    playBoost();
  }
  if (player.boostCooldown > 0) player.boostCooldown--;

  // Apply movement with easing
  const targetVx = inputX * CFG.MOVE_SPEED;
  const targetVy = inputY * CFG.MOVE_SPEED;
  player.vx += (targetVx - player.vx) * CFG.INERTIA_EASE;
  player.vy += (targetVy - player.vy) * CFG.INERTIA_EASE;

  // Bank angle from lateral movement
  const targetBank = inputX * CFG.BANK_ANGLE;
  player.bankAngle += (targetBank - player.bankAngle) * 0.2;

  // Pitch lean from vertical movement
  const targetPitch = inputY * CFG.PITCH_LEAN;
  player.pitchLean += (targetPitch - player.pitchLean) * 0.2;

  player.x += player.vx;
  player.y += player.vy;
  player.z += player.speed;

  // Clamp to world bounds
  const halfW = CFG.WORLD_WIDTH / 2 - CFG.PLAYER_W/2;
  player.x = Math.max(-halfW, Math.min(halfW, player.x));
  player.y = Math.max(-80, Math.min(40, player.y));

  // Distance & score
  distance += player.speed * 0.1;
  score += Math.floor(player.speed * multiplier * 0.5);

  // Energy drain
  let inShadow = false;
  for (const obs of obstacles) {
    if (!obs.passed && Math.abs(obs.z - player.z) < 30 && Math.abs(obs.x - player.x) < obs.w/2 + CFG.PLAYER_W) {
      inShadow = true;
      break;
    }
  }
  const drainMult = inShadow ? CFG.ENERGY_SHADOW_MULT : 1;
  player.energy -= CFG.ENERGY_DRAIN_RATE * drainMult;

  if (player.energy <= 0) {
    player.energy = 0;
    gameOver();
    return;
  }

  // Sun position
  const sunY = CFG.SUN_START_Y - frameCount * CFG.SUN_SET_SPEED;
  if (sunY < -0.15) {
    gameOver();
    return;
  }

  // Collision detection
  for (const obs of obstacles) {
    if (obs.passed) continue;

    const dx = Math.abs(player.x - obs.x);
    const dy = Math.abs((player.y + CFG.PLAYER_H/2) - (obs.y + obs.h/2));
    const dz = Math.abs(player.z - obs.z);

    // AABB collision
    if (dx < (CFG.PLAYER_W + obs.w)/2 &&
        dy < (CFG.PLAYER_H + obs.h)/2 &&
        dz < (CFG.PLAYER_DEPTH + obs.d)/2) {
      gameOver();
      return;
    }

    // Near miss detection
    if (!obs.nearMissChecked && player.z > obs.z + obs.d/2 + CFG.PLAYER_DEPTH/2) {
      const nearDx = (CFG.PLAYER_W + obs.w)/2 * 1.8;
      const nearDz = (CFG.PLAYER_DEPTH + obs.d)/2 * 1.5;
      if (dx < nearDx && dz < nearDz) {
        score += 50 * multiplier;
        camera.shakeMag = CFG.SHAKE_NEAR_MISS;
        player.nearMissTimer = 15;
        playNearMiss();

        // Near miss particles
        for (let i = 0; i < 8; i++) {
          spawnParticle(player.x + (rng()-0.5)*20, player.y + rng()*20, player.z - 10,
            (rng()-0.5)*4, rng()*3+1, rng()*2, '#ffffff', 15);
        }
      }
      obs.nearMissChecked = true;
    }

    if (player.z > obs.z + obs.d/2 + CFG.PLAYER_DEPTH) {
      obs.passed = true;
    }
  }

  // Pickup collection
  for (const pk of pickups) {
    if (pk.collected) continue;
    const dx = Math.abs(player.x - pk.x);
    const dy = Math.abs((player.y + CFG.PLAYER_H/2) - pk.y);
    const dz = Math.abs(player.z - pk.z);

    if (dx < 25 && dy < 25 && dz < 20) {
      pk.collected = true;
      playPickup(pk.type === 'energy' ? 600 : pk.type === 'score' ? 900 : 1400, 0.12);

      if (pk.type === 'energy') player.energy = Math.min(CFG.ENERGY_MAX, player.energy + CFG.ENERGY_PICKUP_AMOUNT);
      else if (pk.type === 'score') score += 100 * multiplier;
      else if (pk.type === 'multiplier') { multiplier = Math.min(8, multiplier + 1); }

      // Pickup particles
      const colors = { energy: CFG.PICKUP_ENERGY, score: CFG.PICKUP_SCORE, multiplier: CFG.PICKUP_MULTIPLIER };
      for (let i = 0; i < 12; i++) {
        spawnParticle(pk.x, pk.y, pk.z,
          (rng()-0.5)*5, rng()*4+1, (rng()-0.5)*3,
          colors[pk.type], 20);
      }
    }
  }

  // Thrust particles
  if (player.alive) {
    for (let i = 0; i < CFG.THRUST_PARTICLES_PER_FRAME; i++) {
      spawnParticle(
        player.x + (rng()-0.5)*6,
        player.y - 2 + rng()*4,
        player.z - CFG.PLAYER_DEPTH/2 - rng()*5,
        (rng()-0.5)*1.5, (rng()-0.5)*1.5, -rng()*3-1,
        player.boosting ? '#ffaa00' : '#66aaff',
        15 + Math.floor(rng()*10)
      );
    }
  }

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

  // Update obstacles - remove far behind
  for (let i = obstacles.length - 1; i >= 0; i--) {
    if (obstacles[i].z < player.z - 200) obstacles.splice(i, 1);
  }

  // Remove collected pickups
  for (let i = pickups.length - 1; i >= 0; i--) {
    if (pickups[i].collected || pickups[i].z < player.z - 200) pickups.splice(i, 1);
  }

  // Spawn new chunks ahead
  const furthestZ = obstacles.reduce((m, o) => Math.max(m, o.z), 0);
  if (furthestZ < player.z + CFG.VISIBLE_DISTANCE) {
    spawnChunk(furthestZ + CFG.CHUNK_LENGTH);
  }

  // Screen shake decay
  if (camera.shakeMag > 0.1) {
    camera.shakeX = (Math.random()-0.5) * camera.shakeMag;
    camera.shakeY = (Math.random()-0.5) * camera.shakeMag;
    camera.shakeMag *= CFG.SHAKE_DECAY;
  } else {
    camera.shakeX = 0; camera.shakeY = 0; camera.shakeMag = 0;
  }

  // Near miss timer
  if (player.nearMissTimer > 0) player.nearMissTimer--;

  // Update drone sound
  updateDrone(player.speed);
}

// === RENDERING ===
function render() {
  ctx.clearRect(0, 0, W, H);

  const sunY = gameState === STATE.PLAYING ? CFG.SUN_START_Y - frameCount * CFG.SUN_SET_SPEED : 0.3;

  // Sky gradient
  drawSky(sunY);

  if (gameState === STATE.TITLE) {
    drawTitleScreen();
    return;
  }

  // Ground plane
  drawGround(sunY);

  // Collect all renderable objects with depth for sorting
  let renderList = [];

  // Obstacles
  for (const obs of obstacles) {
    const relZ = obs.z - player.z + CAM_DIST;
    if (relZ > 0 && relZ < CFG.VISIBLE_DISTANCE) {
      renderList.push({ type: 'obstacle', data: obs, z: relZ });
    }
  }

  // Pickups
  for (const pk of pickups) {
    const relZ = pk.z - player.z + CAM_DIST;
    if (relZ > 0 && relZ < CFG.VISIBLE_DISTANCE) {
      renderList.push({ type: 'pickup', data: pk, z: relZ });
    }
  }

  // Particles
  for (const p of particles) {
    const relZ = p.z - player.z + CAM_DIST;
    if (relZ > 0 && relZ < CFG.VISIBLE_DISTANCE) {
      renderList.push({ type: 'particle', data: p, z: relZ });
    }
  }

  // Sort back to front
  renderList.sort((a, b) => b.z - a.z);

  for (const item of renderList) {
    if (item.type === 'obstacle') drawObstacle(item.data, sunY);
    else if (item.type === 'pickup') drawPickup(item.data);
    else if (item.type === 'particle') drawParticle(item.data);
  }

  // Player craft
  if (player.alive) drawPlayer();

  // Speed lines at high speed
  if (player.speed > CFG.SPEED_LINE_THRESHOLD && player.alive) {
    drawSpeedLines(player.speed);
  }

  // Chromatic aberration flash on near miss
  if (player.nearMissTimer > 0) {
    ctx.globalAlpha = player.nearMissTimer / 15 * 0.3;
    ctx.fillStyle = '#ffffff';
    ctx.fillRect(0, 0, W, H);
    ctx.globalAlpha = 1;
  }

  // HUD
  drawHUD(sunY);

  if (gameState === STATE.GAMEOVER) {
    drawGameOver();
  }
}

function drawSky(sunNormY) {
  const sunScreenY = H * (0.5 - sunNormY);

  const grad = ctx.createLinearGradient(0, 0, 0, H);
  grad.addColorStop(0, CFG.SKY_TOP);
  grad.addColorStop(0.4, CFG.SKY_MID);
  grad.addColorStop(0.75, CFG.SKY_BOT);
  grad.addColorStop(1, '#ff8844');
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, W, H);

  // Sun glow
  const sunR = Math.min(W, H) * 0.12;
  const glowGrad = ctx.createRadialGradient(W/2, sunScreenY, 0, W/2, sunScreenY, sunR*3);
  glowGrad.addColorStop(0, 'rgba(255,224,102,0.8)');
  glowGrad.addColorStop(0.3, 'rgba(255,140,0,0.3)');
  glowGrad.addColorStop(1, 'rgba(255,100,0,0)');
  ctx.fillStyle = glowGrad;
  ctx.fillRect(W/2 - sunR*3, sunScreenY - sunR*3, sunR*6, sunR*6);

  // Sun disc
  const sunDiscGrad = ctx.createRadialGradient(W/2, sunScreenY, 0, W/2, sunScreenY, sunR);
  sunDiscGrad.addColorStop(0, '#fff8e0');
  sunDiscGrad.addColorStop(0.7, CFG.SUN_COLOR);
  sunDiscGrad.addColorStop(1, CFG.SUN_GLOW);
  ctx.fillStyle = sunDiscGrad;
  ctx.beginPath();
  ctx.arc(W/2, sunScreenY, sunR, 0, Math.PI*2);
  ctx.fill();

  // Horizon line
  const horizonY = H * 0.55;
  ctx.strokeStyle = 'rgba(255,140,0,0.3)';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(0, horizonY);
  ctx.lineTo(W, horizonY);
  ctx.stroke();
}

function drawGround(sunNormY) {
  const horizonY = H * 0.55;

  // Ground gradient
  const gGrad = ctx.createLinearGradient(0, horizonY, 0, H);
  gGrad.addColorStop(0, '#1a0832');
  gGrad.addColorStop(0.5, '#0d0418');
  gGrad.addColorStop(1, '#060110');
  ctx.fillStyle = gGrad;
  ctx.fillRect(0, horizonY, W, H - horizonY);

  // Grid lines on ground for depth perception
  const gridSpacing = 30;
  ctx.strokeStyle = 'rgba(100,50,150,0.2)';
  ctx.lineWidth = 1;

  // Horizontal lines (depth)
  for (let z = 10; z < CFG.VISIBLE_DISTANCE; z += gridSpacing) {
    const p1 = project(-CFG.WORLD_WIDTH/2 - 40, 0, player.z + z);
    const p2 = project(CFG.WORLD_WIDTH/2 + 40, 0, player.z + z);
    if (p1 && p2) {
      ctx.globalAlpha = Math.max(0, 1 - z / CFG.VISIBLE_DISTANCE) * 0.3;
      ctx.beginPath();
      ctx.moveTo(p1.sx, p1.sy);
      ctx.lineTo(p2.sx, p2.sy);
      ctx.stroke();
    }
  }

  // Vertical lines (lanes)
  for (let x = -CFG.WORLD_WIDTH/2; x <= CFG.WORLD_WIDTH/2; x += CFG.WORLD_WIDTH / CFG.LANE_COUNT) {
    const p1 = project(x, 0, player.z + 5);
    const p2 = project(x, 0, player.z + CFG.VISIBLE_DISTANCE);
    if (p1 && p2) {
      ctx.globalAlpha = 0.15;
      ctx.beginPath();
      ctx.moveTo(p1.sx, p1.sy);
      ctx.lineTo(p2.sx, p2.sy);
      ctx.stroke();
    }
  }

  // World boundary lines (brighter)
  for (const side of [-CFG.WORLD_WIDTH/2 - 5, CFG.WORLD_WIDTH/2 + 5]) {
    const p1 = project(side, 0, player.z + 5);
    const p2 = project(side, 0, player.z + CFG.VISIBLE_DISTANCE);
    if (p1 && p2) {
      ctx.globalAlpha = 0.4;
      ctx.strokeStyle = '#aa66ff';
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(p1.sx, p1.sy);
      ctx.lineTo(p2.sx, p2.sy);
      ctx.stroke();
    }
  }

  ctx.globalAlpha = 1;
}

function drawObstacle(obs, sunNormY) {
  const relZ = obs.z - player.z + CAM_DIST;
  if (relZ <= 0 || relZ >= CFG.VISIBLE_DISTANCE) return;

  // Determine shadow factor based on position relative to sun
  const sunDirX = 0; // sun is centered horizontally
  const inShadow = Math.abs(obs.x) > CFG.WORLD_WIDTH * 0.3 ? 0.5 : 1.0;

  const hw = obs.w/2, hh = obs.h/2, hd = obs.d/2;

  // Project all 8 corners of the box
  const corners = [
    project(obs.x - hw, obs.y + obs.h, obs.z - hd), // front top-left
    project(obs.x + hw, obs.y + obs.h, obs.z - hd), // front top-right
    project(obs.x + hw, obs.y,         obs.z - hd), // front bot-right
    project(obs.x - hw, obs.y,         obs.z - hd), // front bot-left
    project(obs.x - hw, obs.y + obs.h, obs.z + hd), // back top-left
    project(obs.x + hw, obs.y + obs.h, obs.z + hd), // back top-right
    project(obs.x + hw, obs.y,         obs.z + hd), // back bot-right
    project(obs.x - hw, obs.y,         obs.z + hd), // back bot-left
  ];

  if (corners.some(c => c === null)) return;

  const alpha = Math.max(0.15, 1 - relZ / CFG.VISIBLE_DISTANCE);

  // Draw faces: top, front, right side
  ctx.globalAlpha = alpha;

  // Top face
  ctx.fillStyle = `rgba(${Math.floor(92*inShadow)},${Math.floor(61*inShadow)},${Math.floor(122*inShadow)},1)`;
  drawQuad(corners[0], corners[1], corners[5], corners[4]);

  // Front face
  ctx.fillStyle = `rgba(${Math.floor(45*inShadow)},${Math.floor(27*inShadow)},${Math.floor(78*inShadow)},1)`;
  drawQuad(corners[0], corners[1], corners[2], corners[3]);

  // Right face
  ctx.fillStyle = `rgba(${Math.floor(65*inShadow)},${Math.floor(40*inShadow)},${Math.floor(100*inShadow)},1)`;
  drawQuad(corners[1], corners[5], corners[6], corners[2]);

  // Edge highlight
  ctx.strokeStyle = `rgba(${Math.floor(140*inShadow)},${Math.floor(90*inShadow)},${Math.floor(200*inShadow)},0.6)`;
  ctx.lineWidth = Math.max(1, 3 * (FOV / relZ));

  // Spike type gets special rendering
  if (obs.type === 'spike') {
    const topP = project(obs.x, obs.y + obs.h + 15, obs.z);
    if (topP) {
      ctx.fillStyle = `rgba(${Math.floor(180*inShadow)},${Math.floor(40*inShadow)},${Math.floor(60*inShadow)},1)`;
      drawTriangle(corners[0], corners[1], topP);
    }
  }

  ctx.globalAlpha = 1;
}

function drawQuad(a, b, c, d) {
  if (!a || !b || !c || !d) return;
  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();
}

function drawTriangle(a, b, c) {
  if (!a || !b || !c) return;
  ctx.beginPath();
  ctx.moveTo(a.sx, a.sy);
  ctx.lineTo(b.sx, b.sy);
  ctx.lineTo(c.sx, c.sy);
  ctx.closePath();
  ctx.fill();
}

function drawPickup(pk) {
  const relZ = pk.z - player.z + CAM_DIST;
  if (relZ <= 0 || relZ >= CFG.VISIBLE_DISTANCE) return;

  const bob = Math.sin(frameCount * 0.1 + pk.bobPhase) * 5;
  const p = project(pk.x, pk.y + bob, pk.z);
  if (!p) return;

  const size = Math.max(3, 12 * p.scale);
  const alpha = Math.max(0.2, 1 - relZ / CFG.VISIBLE_DISTANCE);

  ctx.globalAlpha = alpha;

  let color;
  if (pk.type === 'energy') color = CFG.PICKUP_ENERGY;
  else if (pk.type === 'score') color = CFG.PICKUP_SCORE;
  else color = CFG.PICKUP_MULTIPLIER;

  // Glow
  const glowGrad = ctx.createRadialGradient(p.sx, p.sy, 0, p.sx, p.sy, size*2);
  glowGrad.addColorStop(0, color + '88');
  glowGrad.addColorStop(1, color + '00');
  ctx.fillStyle = glowGrad;
  ctx.fillRect(p.sx - size*2, p.sy - size*2, size*4, size*4);

  // Diamond shape
  ctx.fillStyle = color;
  ctx.beginPath();
  ctx.moveTo(p.sx, p.sy - size);
  ctx.lineTo(p.sx + size * 0.7, p.sy);
  ctx.lineTo(p.sx, p.sy + size);
  ctx.lineTo(p.sx - size * 0.7, p.sy);
  ctx.closePath();
  ctx.fill();

  // Inner highlight
  ctx.fillStyle = '#ffffff88';
  ctx.beginPath();
  ctx.moveTo(p.sx, p.sy - size*0.5);
  ctx.lineTo(p.sx + size * 0.3, p.sy);
  ctx.lineTo(p.sx, p.sy + size*0.2);
  ctx.lineTo(p.sx - size * 0.3, p.sy);
  ctx.closePath();
  ctx.fill();

  ctx.globalAlpha = 1;
}

function drawParticle(p) {
  const relZ = p.z - player.z + CAM_DIST;
  if (relZ <= 0 || relZ >= CFG.VISIBLE_DISTANCE) return;

  const proj = project(p.x, p.y, p.z);
  if (!proj) return;

  const size = Math.max(1, 4 * proj.scale * (p.life / p.maxLife));
  ctx.globalAlpha = p.life / p.maxLife;
  ctx.fillStyle = p.color;
  ctx.fillRect(proj.sx - size/2, proj.sy - size/2, size, size);
  ctx.globalAlpha = 1;
}

function drawPlayer() {
  const px = W/2 + (camera.shakeX || 0);
  const py = H * 0.65 + (camera.shakeY || 0);

  ctx.save();
  ctx.translate(px, py);
  ctx.rotate(player.bankAngle);

  // Craft body - sleek triangle shape
  const cw = CFG.PLAYER_W * 2;
  const ch = CFG.PLAYER_H * 1.5;

  // Engine glow
  if (player.alive) {
    const engineGlow = ctx.createRadialGradient(0, ch/2 + 5, 0, 0, ch/2 + 5, 20);
    engineGlow.addColorStop(0, player.boosting ? 'rgba(255,170,0,0.8)' : 'rgba(100,170,255,0.6)');
    engineGlow.addColorStop(1, 'rgba(100,170,255,0)');
    ctx.fillStyle = engineGlow;
    ctx.fillRect(-20, ch/2 - 5, 40, 30);
  }

  // Main body
  ctx.fillStyle = '#c8b8ff';
  ctx.beginPath();
  ctx.moveTo(0, -ch/2);           // nose
  ctx.lineTo(cw/2, ch/4);         // right wing tip
  ctx.lineTo(cw/3, ch/2);         // right engine
  ctx.lineTo(-cw/3, ch/2);        // left engine
  ctx.lineTo(-cw/2, ch/4);        // left wing tip
  ctx.closePath();
  ctx.fill();

  // Cockpit highlight
  ctx.fillStyle = '#e8ddff';
  ctx.beginPath();
  ctx.moveTo(0, -ch/3);
  ctx.lineTo(cw/6, ch/6);
  ctx.lineTo(-cw/6, ch/6);
  ctx.closePath();
  ctx.fill();

  // Wing accents
  ctx.strokeStyle = '#8866cc';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(0, -ch/4);
  ctx.lineTo(cw/3, ch/3);
  ctx.stroke();
  ctx.beginPath();
  ctx.moveTo(0, -ch/4);
  ctx.lineTo(-cw/3, ch/3);
  ctx.stroke();

  // Boost flame
  if (player.boosting) {
    const flameLen = 15 + Math.random() * 15;
    ctx.fillStyle = '#ffaa00';
    ctx.beginPath();
    ctx.moveTo(-cw/4, ch/2);
    ctx.lineTo(0, ch/2 + flameLen);
    ctx.lineTo(cw/4, ch/2);
    ctx.closePath();
    ctx.fill();

    ctx.fillStyle = '#ffffff';
    ctx.beginPath();
    ctx.moveTo(-cw/8, ch/2);
    ctx.lineTo(0, ch/2 + flameLen * 0.6);
    ctx.lineTo(cw/8, ch/2);
    ctx.closePath();
    ctx.fill();
  }

  ctx.restore();
}

function drawSpeedLines(speed) {
  const intensity = Math.min(1, (speed - CFG.SPEED_LINE_THRESHOLD) / (CFG.MAX_SPEED - CFG.SPEED_LINE_THRESHOLD));
  const numLines = Math.floor(intensity * 20);

  ctx.globalAlpha = intensity * 0.4;
  ctx.strokeStyle = '#ffffff';
  ctx.lineWidth = 1;

  for (let i = 0; i < numLines; i++) {
    const x = rng() * W;
    const y = H * 0.3 + rng() * H * 0.5;
    const len = 20 + rng() * 60 * intensity;

    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.lineTo(x + (rng()-0.5)*4, y + len);
    ctx.stroke();
  }

  // Chromatic aberration at very high speed
  if (speed > CFG.MAX_SPEED * 0.7) {
    const caIntensity = (speed - CFG.MAX_SPEED * 0.7) / (CFG.MAX_SPEED * 0.3);
    ctx.globalAlpha = caIntensity * 0.15;

    // Red channel offset
    ctx.globalCompositeOperation = 'screen';
    ctx.drawImage(canvas, -caIntensity*8, 0);
    ctx.globalCompositeOperation = 'source-over';
  }

  ctx.globalAlpha = 1;
}

function drawHUD(sunNormY) {
  const pad = 20;
  const barW = 200;
  const barH = 14;

  // Semi-transparent HUD background
  ctx.fillStyle = 'rgba(0,0,0,0.35)';
  roundRect(ctx, pad - 5, pad - 5, barW + 60, 180, 8);
  ctx.fill();

  ctx.font = 'bold 14px monospace';
  ctx.textBaseline = 'top';

  // Score
  ctx.fillStyle = '#ffffff';
  ctx.fillText(`SCORE: ${score.toLocaleString()}`, pad, pad + 5);

  // Distance
  ctx.fillStyle = '#aabbcc';
  ctx.fillText(`DIST: ${Math.floor(distance)}m`, pad, pad + 24);

  // Multiplier
  ctx.fillStyle = multiplier > 1 ? CFG.PICKUP_MULTIPLIER : '#888888';
  ctx.fillText(`MULTI: x${multiplier}`, pad, pad + 43);

  // Speed
  const speedPct = (player.speed - CFG.BASE_SPEED) / (CFG.MAX_SPEED - CFG.BASE_SPEED);
  ctx.fillStyle = speedPct > 0.7 ? '#ff6644' : '#88ccff';
  ctx.fillText(`SPD: ${player.speed.toFixed(1)}`, pad, pad + 62);

  // Energy bar
  const energyPct = player.energy / CFG.ENERGY_MAX;
  ctx.fillStyle = 'rgba(0,0,0,0.5)';
  roundRect(ctx, pad, pad + 84, barW, barH, 4);
  ctx.fill();

  const eColor = energyPct > 0.3 ? '#00ddaa' : '#ff4444';
  ctx.fillStyle = eColor;
  roundRect(ctx, pad, pad + 84, barW * energyPct, barH, 4);
  ctx.fill();

  ctx.fillStyle = '#ffffff';
  ctx.font = 'bold 10px monospace';
  ctx.fillText('ENERGY', pad + 5, pad + 86);
  ctx.font = 'bold 14px monospace';

  // Sun indicator
  const sunPct = Math.max(0, (sunNormY + 0.15) / (CFG.SUN_START_Y + 0.15));
  ctx.fillStyle = '#ffaa44';
  ctx.fillText(`☀ ${Math.floor(sunPct * 100)}%`, pad, pad + 108);

  // Sun bar
  ctx.fillStyle = 'rgba(0,0,0,0.5)';
  roundRect(ctx, pad, pad + 126, barW, barH, 4);
  ctx.fill();
  ctx.fillStyle = '#ffaa44';
  roundRect(ctx, pad, pad + 126, barW * sunPct, barH, 4);
  ctx.fill();

  // High score
  ctx.fillStyle = 'rgba(0,0,0,0.35)';
  roundRect(ctx, W - pad - 120, pad - 5, 125, 35, 8);
  ctx.fill();
  ctx.fillStyle = '#ffdd44';
  ctx.font = 'bold 12px monospace';
  ctx.textAlign = 'center';
  ctx.fillText(`HI: ${highScore.toLocaleString()}`, W - pad - 57, pad + 8);
  ctx.textAlign = 'left';

  // Boost indicator
  if (player.boostCooldown > 0) {
    ctx.fillStyle = '#ff664488';
    ctx.font = 'bold 12px monospace';
    ctx.fillText('BOOST COOLDOWN', pad, H - pad - 30);
  } else if (player.energy > 5) {
    ctx.fillStyle = '#66aaff88';
    ctx.font = 'bold 12px monospace';
    ctx.fillText('[SPACE] BOOST READY', pad, H - pad - 30);
  }

  // Controls hint (fades out after 5 seconds)
  if (frameCount < 300 && gameState === STATE.PLAYING) {
    const fadeAlpha = frameCount > 240 ? (300 - frameCount) / 60 : 1;
    ctx.globalAlpha = fadeAlpha * 0.7;
    ctx.fillStyle = '#ffffff';
    ctx.font = '13px monospace';
    ctx.textAlign = 'center';
    ctx.fillText('WASD/Arrows: Move | Space: Boost', W/2, H - 40);
    ctx.textAlign = 'left';
    ctx.globalAlpha = 1;
  }
}

function roundRect(ctx, 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();
}

function drawTitleScreen() {
  // Animated background particles
  for (let i = 0; i < 30; i++) {
    const x = ((i * 137.5 + frameCount * 0.5) % W);
    const y = ((i * 97.3 + Math.sin(frameCount*0.02+i)*20) % H);
    ctx.globalAlpha = 0.15;
    ctx.fillStyle = '#aa66ff';
    ctx.fillRect(x, y, 3, 3);
  }
  ctx.globalAlpha = 1;

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

  // Glow behind title
  const titleY = H * 0.35;
  const glowGrad = ctx.createRadialGradient(W/2, titleY, 0, W/2, titleY, 200);
  glowGrad.addColorStop(0, 'rgba(255,140,0,0.15)');
  glowGrad.addColorStop(1, 'rgba(255,140,0,0)');
  ctx.fillStyle = glowGrad;
  ctx.fillRect(W/2 - 200, titleY - 100, 400, 200);

  ctx.font = `bold ${Math.min(72, W*0.08)}px monospace`;
  ctx.fillStyle = '#ffe066';
  ctx.fillText('SOLAR RUN', W/2, titleY);

  ctx.font = `${Math.min(18, W*0.025)}px monospace`;
  ctx.fillStyle = '#cc99ff';
  ctx.fillText('Endless Runner', W/2, titleY + 40);

  // Instructions
  const instrY = H * 0.6;
  ctx.font = `${Math.min(16, W*0.02)}px monospace`;
  ctx.fillStyle = '#aabbcc';
  ctx.fillText('WASD / Arrow Keys — Move', W/2, instrY);
  ctx.fillText('Space — Boost (costs energy)', W/2, instrY + 25);

  // Start prompt
  const pulseAlpha = 0.5 + Math.sin(frameCount * 0.08) * 0.4;
  ctx.globalAlpha = pulseAlpha;
  ctx.font = `bold ${Math.min(22, W*0.03)}px monospace`;
  ctx.fillStyle = '#ffffff';
  ctx.fillText('[ PRESS SPACE OR CLICK TO START ]', W/2, H * 0.78);
  ctx.globalAlpha = 1;

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

  ctx.textAlign = 'left';
}

function drawGameOver() {
  // Dark overlay
  ctx.fillStyle = 'rgba(10,0,30,0.75)';
  ctx.fillRect(0, 0, W, H);

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

  const centerY = H * 0.4;

  // Title
  ctx.font = `bold ${Math.min(60, W*0.07)}px monospace`;
  ctx.fillStyle = '#ff4444';
  ctx.fillText('GAME OVER', W/2, centerY - 60);

  // Stats
  ctx.font = `${Math.min(20, W*0.025)}px monospace`;
  ctx.fillStyle = '#ffffff';
  ctx.fillText(`Score: ${score.toLocaleString()}`, W/2, centerY + 10);
  ctx.fillText(`Distance: ${Math.floor(distance)}m`, W/2, centerY + 40);

  if (score >= highScore && score > 0) {
    ctx.fillStyle = '#ffdd44';
    ctx.font = `bold ${Math.min(22, W*0.03)}px monospace`;
    ctx.fillText('★ NEW HIGH SCORE ★', W/2, centerY + 80);
  }

  // Restart prompt
  const pulseAlpha = 0.5 + Math.sin(frameCount * 0.08) * 0.4;
  ctx.globalAlpha = pulseAlpha;
  ctx.font = `bold ${Math.min(18, W*0.025)}px monospace`;
  ctx.fillStyle = '#ffffff';
  ctx.fillText('[ PRESS R OR CLICK TO RESTART ]', W/2, centerY + 130);
  ctx.globalAlpha = 1;

  ctx.textAlign = 'left';
}

// === MAIN LOOP ===
function gameLoop(timestamp) {
  if (!lastTime) lastTime = timestamp;
  const delta = (timestamp - lastTime) / 1000;
  lastTime = timestamp;

  // Cap dt to prevent spiral of death
  dt = Math.min(delta, 1/30);

  update();
  render();

  requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"middle","sessionId":"e07755bf-06c6-4e96-93f0-9962fbda5960","tokensIn":17037,"tokensOut":13780,"tokensTotal":30817,"turns":2,"toolCalls":1,"failedToolCalls":0,"timestamp":"2026-07-18T23:47:33.526Z"} -->