Race the Sun
Claude haiku 5.5 · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Task text captured before this run. Effective runtime system instructions were not captured.
Generation prompt · ed8f7d7215
# Task: Build an HTML Game Create an endless-runner game (vanilla JS, no external assets, no CDN, no libraries) in the spirit of *Race the Sun*. The top priority is **fun, tight, juicy gameplay** — every mechanic should feel responsive and satisfying. ## Core Fantasy Pilot a sleek solar craft endlessly forward through a procedural geometric world. The world revolves around a distant sun on the horizon. Stay in the light, dodge obstacles, grab energy, and survive as long as possible as speed ramps up. ## Gameplay Specification **Movement** - Auto-forward motion; speed gradually accelerates over time - Player controls: Left/Right strafe, Up/Down pitch (WASD or arrow keys) - Bank/roll the craft when strafing (visual tilt + slight forward lean on pitch) - Snappy controls: short easing on input (≈80–120ms), but with a tiny inertia tail so it feels weighty, not floaty - Optional: small "boost" on tap (space) limited by an energy bar **World & Camera** - Endless procedural course seeded by a per-run seed - Three lanes or a wide 2D plane; obstacles spawn in waves and ramps - Camera fixed behind/above the craft, with subtle screen-shake on near-misses and bigger shake on collisions - Speed lines / chromatic-aberration flourish when above a velocity threshold **Sun & Energy** - The sun sits on the horizon and slowly sinks during a run; if it sets, game over - Craft has an energy meter; drains slowly, drains faster when in shadow (behind tall obstacles or off-sun side) - Pickups: ⚡ energy cells, ✦ score tokens, and chain-multiplier orbs - Crashing = game over; near-misses = score bonus + screen-shake **Scoring & Loop** - Score = distance × multiplier from collected tokens - Show distance, score, multiplier, energy, and current speed on HUD - Local high score saved via `localStorage` - Title screen → play → game-over screen with restart (R or click) ## Visual & Audio Style - **Look:** minimalist low-poly geometry, bold silhouettes, vibrant gradient sky (sunset palette), strong contrast between sunlit and shadowed zones - **FX:** particles for thrust, pickups, and crashes; brief flash on damage; speed-line overlay at high speed - **Sound:** procedurally generated via WebAudio (short synth blips for pickup, low thud for crash, wind/drone loop tied to speed) — keep it simple but punchy ## Technical Requirements - HTML5 Canvas, requestAnimationFrame, target 60 FPS - Procedural generation with a deterministic seed per run - Simple geometric collision (AABB or circle vs. AABB) - Clear section comments (`// === SETUP ===`, etc.) - Tunable constants block at the top (speeds, gravity, spawn rates, colors) for easy balancing - Aim for 600–1500 lines of focused, readable code ## Quality Bar (must hit all) - Controls feel *immediately* responsive — no input lag, no sticky movement - Difficulty curve is fair: easy to learn, tense by 30s, frantic by 90s - Every interaction has feedback: particles, shake, sound, color - It must be fun to fail — restart-to-play should be one keystroke - Runs smoothly in a modern Chrome/Firefox tab at 1080p
Inspect original source 38,773 bytes · SHA-256 ed91005e32c9
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SOLAR DASH</title>
<style>
html,body{margin:0;height:100%;background:#0b0415;overflow:hidden;font-family:"Trebuchet MS","Segoe UI",Verdana,sans-serif;user-select:none}
canvas{position:fixed;left:0;top:0;width:100vw;height:100vh;display:block}
#ov{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none}
.panel{display:none;text-align:center;color:#fff8ec;background:rgba(14,5,30,.62);padding:26px 42px;border-radius:16px;border:1px solid rgba(255,200,140,.28);box-shadow:0 0 60px rgba(255,120,60,.25);backdrop-filter:blur(4px);pointer-events:auto;cursor:pointer;min-width:360px}
.panel.show{display:block;animation:pin .35s ease-out}
@keyframes pin{from{transform:scale(.94);opacity:0}to{transform:none;opacity:1}}
h1{font-size:68px;letter-spacing:10px;margin:0 0 4px;font-weight:900;background:linear-gradient(#fff6d0,#ffa050 55%,#ff2e7a);-webkit-background-clip:text;background-clip:text;color:transparent}
.sub{letter-spacing:4px;font-size:13px;color:#ffc89a;opacity:.85;margin-bottom:18px}
.keys{display:grid;grid-template-columns:auto 1fr;gap:6px 16px;text-align:left;font-size:14px;margin:14px auto 18px;max-width:340px}
.keys b{color:#ffd27a;font-weight:700}
.big{font-size:20px;font-weight:700;letter-spacing:2px;color:#ffe6b8;margin-top:8px}
.stat{font-size:15px;line-height:1.8;color:#ffe9cf}
.stat span{color:#ffd27a;font-weight:700}
.blink{animation:bl 1.1s steps(2,start) infinite}
@keyframes bl{to{visibility:hidden}}
.tip{font-size:12px;opacity:.6;margin-top:12px}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="ov">
<div id="title" class="panel show">
<h1>SOLAR DASH</h1>
<div class="sub">RIDE THE LIGHT · OUTRUN THE SUNSET</div>
<div class="keys">
<b>A / D · ← →</b><span>strafe & bank</span>
<b>W / S · ↑ ↓</b><span>pitch altitude</span>
<b>SPACE / SHIFT</b><span>hold to boost (burns energy)</span>
<b>M</b><span>mute</span>
</div>
<div class="big blink">PRESS ENTER OR SPACE</div>
<div class="tip">Stay in the sunlight. Collect ⚡ cells, ✦ tokens and ◎ orbs. Don't set.</div>
</div>
<div id="over" class="panel">
<h1 style="font-size:46px;letter-spacing:6px">SUNSET</h1>
<div class="stat" id="ostat"></div>
<div class="big blink" style="font-size:16px">R OR CLICK TO RUN AGAIN</div>
</div>
</div>
<script>
// === CONFIG (tunable) ===
const CFG = {
BASE_SPEED: 24, // units/s at start
SPEED_RAMP: 0.33, // extra units/s gained per second survived
MAX_SPEED: 62,
BOOST_MULT: 1.35, // speed multiplier while boosting
BOOST_DRAIN: 9, // energy/s while boosting
BOOST_MIN: 8, // energy needed to start a boost
SPEED_EASE: 1.4, // forward speed response (1/s)
MAX_VX: 9, // lateral units/s
MAX_VY: 6, // vertical units/s
EASE_IN: 18, // input response (~55ms time constant)
EASE_OUT: 7, // release rate (gives a small inertia tail)
PX_MAX: 3.4,
PY_MIN: 0.6, PY_MAX: 4.6,
LANES: [-2, 0, 2],
SUN_SINK: 0.0065, // sun level lost per second (1 -> 0 in ~150s)
SUN_ORB: 0.05, // sun restored by an orb
SUN_X_SHADOW: 2.7, // |x| beyond this = off-sun side (shadow)
ENERGY_MAX: 100,
DRAIN_LIT: 1.1, // energy/s in sunlight
DRAIN_SHADOW: 3.2, // energy/s in shadow
CELL_GAIN: 22,
NEAR_MISS: 0.42, // gap (units) that counts as a near miss
SPAWN_AHEAD: 300, // generate world this far ahead
DRAW_FAR: 230, // draw distance
CAM_DIST: 9, // camera behind craft
CAM_H: 3.2, // camera height
FOCAL: 0.85, // focal length = H * FOCAL
HORIZON: 0.5, // horizon line as fraction of screen height
MAX_PARTS: 500,
COL: {
skyTop: '#0e0724', skyMid: '#3a1152', skyLow: '#9b2f5e', skyHorizon: '#ff6a3d', skyGlow: '#ffb35c',
groundTop: '#3a1838', groundBot: '#07030c',
sunCore: '#fffbe0', sunMid: '#ffd36b', sunEdge: '#ff7a3d',
blockTop: '#ffb36b', blockFront: '#2a1842', blockSide: '#4a2358',
archFront: '#3a1a3c', archSide: '#6a2a5c', edge: 'rgba(255,190,120,0.28)',
token: '127,246,255', cell: '255,225,77', orb: '255,90,220',
craftA: '#fff4da', craftB: '#ff8a4e', craftShadow: '#b89cff', canopy: '#27e0ff'
}
};
// === SETUP ===
const cv = document.getElementById('c');
const ctx = cv.getContext('2d');
let W = 0, H = 0, DPR = 1, F = 600, HZ = 0;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 1.5);
W = innerWidth; H = innerHeight;
cv.width = Math.floor(W * DPR); cv.height = Math.floor(H * DPR);
F = H * CFG.FOCAL; HZ = H * CFG.HORIZON;
}
addEventListener('resize', resize); resize();
const $title = document.getElementById('title');
const $over = document.getElementById('over');
const $ostat = document.getElementById('ostat');
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const lerp = (a, b, t) => a + (b - a) * t;
const HI_KEY = 'solar-dash-hi';
let hiScore = 0;
try { hiScore = +localStorage.getItem(HI_KEY) || 0; } catch (e) {}
// Seeded RNG (mulberry32) — one stream per run drives all procedural content
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
// Global run state
const S = {
mode: 'title', // title | play | dying | over
t: 0, // run time (s)
time: 0, // wall-ish time for animation
seed: 0, rnd: Math.random, count: 0,
pz: 0, px: 0, py: 2.2, vx: 0, vy: 0, pitch: 0, roll: 0, speed: 0,
score: 0, chain: 0, mult: 1, energy: CFG.ENERGY_MAX, sun: 1,
boost: false, shadowed: false, shake: 0, flash: 0, flashC: '255,255,255',
cause: '', deadT: 0, overT: 0, cursorZ: 0,
obs: [], pick: [], parts: [], pops: []
};
let camX = 0, camY = CFG.CAM_H, camZ = 0;
// Projection: world (x,y,z) -> screen [sx, sy, depth]
function proj(x, y, z) {
const d = z - camZ, k = F / d;
return [W * 0.5 + (x - camX) * k, HZ + (camY - y) * k, d];
}
// === AUDIO (procedural WebAudio) ===
const AU = { ctx: null, master: null, windG: null, windF: null, drone: null, noise: null, muted: false };
function audioInit() {
if (AU.ctx) { if (AU.ctx.state === 'suspended') AU.ctx.resume(); return; }
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
const c = new AC(); AU.ctx = c;
AU.master = c.createGain(); AU.master.gain.value = AU.muted ? 0 : 0.55; AU.master.connect(c.destination);
const nb = c.createBuffer(1, c.sampleRate * 2, c.sampleRate), nd = nb.getChannelData(0);
for (let i = 0; i < nd.length; i++) nd[i] = Math.random() * 2 - 1;
AU.noise = nb;
// wind: looped noise through a bandpass whose cutoff rides the speed
const src = c.createBufferSource(); src.buffer = nb; src.loop = true;
const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 400; bp.Q.value = 0.7;
AU.windG = c.createGain(); AU.windG.gain.value = 0;
src.connect(bp); bp.connect(AU.windG); AU.windG.connect(AU.master); AU.windF = bp; src.start();
// drone: low sawtooth through lowpass
const o = c.createOscillator(); o.type = 'sawtooth'; o.frequency.value = 55;
const lp = c.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 240; lp.Q.value = 3;
const dg = c.createGain(); dg.gain.value = 0;
o.connect(lp); lp.connect(dg); dg.connect(AU.master); o.start();
AU.drone = { o, lp, g: dg };
}
function tone(f0, f1, dur, type, vol, delay = 0) {
if (!AU.ctx) return;
const c = AU.ctx, t = c.currentTime + delay;
const o = c.createOscillator(), g = c.createGain();
o.type = type;
o.frequency.setValueAtTime(f0, t);
o.frequency.exponentialRampToValueAtTime(f1, t + dur);
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.0008, t + dur);
o.connect(g); g.connect(AU.master);
o.start(t); o.stop(t + dur + 0.03);
}
function thud() {
if (!AU.ctx) return;
const c = AU.ctx, t = c.currentTime;
const o = c.createOscillator(), g = c.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(110, t); o.frequency.exponentialRampToValueAtTime(28, t + 0.5);
g.gain.setValueAtTime(0.9, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.55);
o.connect(g); g.connect(AU.master); o.start(t); o.stop(t + 0.6);
const n = c.createBufferSource(); n.buffer = AU.noise;
const lp = c.createBiquadFilter(); lp.type = 'lowpass';
lp.frequency.setValueAtTime(900, t); lp.frequency.exponentialRampToValueAtTime(120, t + 0.4);
const ng = c.createGain(); ng.gain.setValueAtTime(0.6, t); ng.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
n.connect(lp); lp.connect(ng); ng.connect(AU.master);
n.start(t, Math.random() * 1.5); n.stop(t + 0.5);
}
function audioFrame(speed, boosting, live) {
if (!AU.ctx) return;
const c = AU.ctx, t = c.currentTime;
const k = clamp((speed - 18) / 50, 0, 1);
AU.windG.gain.setTargetAtTime(live ? 0.05 + k * 0.28 + (boosting ? 0.08 : 0) : 0, t, 0.08);
AU.windF.frequency.setTargetAtTime(280 + k * 900 + (boosting ? 300 : 0), t, 0.1);
AU.drone.o.frequency.setTargetAtTime(48 + k * 40, t, 0.2);
AU.drone.lp.frequency.setTargetAtTime(160 + k * 500, t, 0.2);
AU.drone.g.gain.setTargetAtTime(live ? 0.05 + k * 0.1 : 0, t, 0.15);
}
// === INPUT ===
const keys = { left: false, right: false, up: false, down: false, boost: false };
const KMAP = {
ArrowLeft: 'left', KeyA: 'left', ArrowRight: 'right', KeyD: 'right',
ArrowUp: 'up', KeyW: 'up', ArrowDown: 'down', KeyS: 'down',
Space: 'boost', ShiftLeft: 'boost', ShiftRight: 'boost'
};
addEventListener('keydown', e => {
audioInit();
const k = KMAP[e.code];
if (k) { keys[k] = true; e.preventDefault(); }
if (e.repeat) return;
if (e.code === 'KeyM') { AU.muted = !AU.muted; if (AU.master) AU.master.gain.value = AU.muted ? 0 : 0.55; }
if (e.code === 'Enter' || e.code === 'Space' || e.code === 'KeyR') onAction(e.code);
});
addEventListener('keyup', e => { const k = KMAP[e.code]; if (k) keys[k] = false; });
addEventListener('blur', () => { for (const k in keys) keys[k] = false; });
addEventListener('pointerdown', () => { audioInit(); onAction('click'); });
// === WORLD GENERATION (seeded, deterministic per run) ===
const PW = 0.4, PH = 0.22, PD = 0.5; // craft half-extents for collision
const SEG = {};
function rnd() { return S.rnd(); }
function pick(arr) { return arr[Math.floor(rnd() * arr.length)]; }
function addBlock(x, z, hw, hh, hd = 1.2) {
S.obs.push({ k: 'block', x, y: 0, z, hw, hh, hd, minSep: 99, live: false });
}
function addBeam(x0, x1, z, y) {
// two overhead segments with a gap between x0..x1 handled by caller
S.obs.push({ k: 'beam', x: (x0 + x1) / 2, y, z, hw: (x1 - x0) / 2, hh: 0.6, hd: 1.0, minSep: 99, live: false });
}
function addPickup(type, x, y, z) {
S.pick.push({ type, x, y, z, ph: rnd() * 6.28, taken: false });
}
// Pattern: wall of blocks with one open lane
SEG.wall = (z, d) => {
const gap = Math.floor(rnd() * 3);
for (let i = 0; i < 3; i++) {
if (i === gap) continue;
const hh = 1.3 + rnd() * (1.4 + d * 1.4);
addBlock(CFG.LANES[i], z, 0.85, hh);
}
const gx = CFG.LANES[gap];
for (let j = 0; j < 4; j++) addPickup(rnd() < 0.6 ? 'token' : 'cell', gx, 1.4 + rnd() * 1.6, z - 6 + j * 4);
};
// Pattern: overhead beam with a gap; must pitch above/below or use the gap
SEG.beam = (z, d) => {
const gap = Math.floor(rnd() * 3);
const y = 1.7 + rnd() * (1.1 + d * 0.8);
const gx = CFG.LANES[gap];
addBeam(-3.6, gx - 1.1, z, y);
addBeam(gx + 1.1, 3.6, z, y);
for (let j = 0; j < 5; j++) addPickup('token', gx, 1.6 + j * 0.25, z - 8 + j * 4);
};
// Pattern: zig-zag slalom of blocks
SEG.slalom = (z, d) => {
let a = Math.floor(rnd() * 3);
for (let j = 0; j < 3; j++) {
const lane = (j % 2 === 0) ? (a === 0 ? 2 : 0) : (a === 2 ? -2 : 0);
const x = j % 2 === 0 ? (rnd() < 0.5 ? -2 : 2) : 0;
addBlock(x, z + j * 9, 0.85, 2.2 + rnd() * 1.2 + d);
addPickup('cell', x * 0.35 + (rnd() - 0.5) * 0.4, 1.8, z + j * 9 + 4.5);
}
};
// Pattern: stripe of cells / tokens to reward line-riding
SEG.line = (z, d) => {
const lane = pick(CFG.LANES);
const y0 = 1.0 + rnd() * 2.4;
for (let j = 0; j < 8; j++) {
const y = y0 + Math.sin(j * 0.7) * 0.8;
addPickup(j % 3 === 2 ? 'cell' : 'token', lane + Math.sin(j * 0.4) * 0.5, y, z + j * 3.2);
}
};
// Pattern: low ground blocks (cheap to hop over)
SEG.low = (z, d) => {
const gap = Math.floor(rnd() * 3);
for (let i = 0; i < 3; i++) {
if (i !== gap) addBlock(CFG.LANES[i], z, 0.85, 0.7 + rnd() * 0.6);
}
addPickup('token', CFG.LANES[gap], 1.2, z - 3);
addPickup('token', CFG.LANES[gap], 1.3, z + 3);
};
// Pattern: lone orb (multiplier + sun restore)
SEG.orb = (z) => {
addPickup('orb', pick(CFG.LANES), 2.0 + rnd() * 1.2, z);
};
function spawnSegment(z) {
const d = clamp(S.t / 90, 0, 1);
const r = rnd();
if (S.t < 6) SEG.line(z, d);
else if (r < 0.28 - d * 0.05) SEG.wall(z, d);
else if (r < 0.50) SEG.beam(z, d);
else if (r < 0.70 + d * 0.1) SEG.slalom(z, d);
else if (r < 0.88) SEG.line(z, d);
else if (r < 0.95) SEG.low(z, d);
else SEG.orb(z);
if (rnd() < 0.25 + d * 0.15) SEG.line(z + 10, d);
}
function nextGap() {
const d = clamp(S.t / 90, 0, 1);
return lerp(34, 15, d) + rnd() * 8;
}
function ensureWorld() {
while (S.nextZ < S.pz + CFG.SPAWN_AHEAD) {
spawnSegment(S.nextZ);
S.nextZ += nextGap();
}
}
// === STATE RESET ===
function newRun() {
const urlSeed = new URLSearchParams(location.search).get('seed');
S.seed = urlSeed ? (parseInt(urlSeed, 36) || 1) : ((Date.now() ^ (Math.random() * 1e9)) >>> 0);
S.rnd = mulberry32(S.seed);
S.mode = 'play'; S.t = 0; S.dist = 0; S.time = 0;
S.pz = 0; S.px = 0; S.py = 2.2; S.vx = 0; S.vy = 0; S.pitch = 0; S.roll = 0; S.speed = CFG.BASE_SPEED;
S.score = 0; S.chain = 0; S.chainT = 0; S.mult = 1; S.energy = CFG.ENERGY_MAX; S.sun = 1;
S.boost = false; S.shadowed = false; S.shake = 0; S.flash = 0; S.flashC = '255,255,255';
S.cause = ''; S.deadT = 0; S.nextZ = 40; S.dist = 0; S.nearMisses = 0; S.tokens = 0; S.stall = false;
S.obs = []; S.pick = []; S.parts = []; S.pops = [];
camX = 0; camY = CFG.CAM_H; camZ = -CFG.CAM_DIST;
S.sunWarn = 0; S.rotor = 0;
ensureWorld();
$title.classList.remove('show'); $over.classList.remove('show');
}
// Particle + pop helpers (world-space)
function burst(x, y, z, n, col, spd, life, size, grav = 0) {
for (let i = 0; i < n; i++) {
if (S.parts.length >= CFG.MAX_PARTS) return;
const a = rnd() * 6.283, b = (rnd() - 0.5) * 2, s = spd * (0.3 + rnd() * 0.7);
S.parts.push({ x, y, z, vx: Math.cos(a) * s * Math.sqrt(1 - b * b), vy: b * s, vz: Math.sin(a) * s * Math.sqrt(1 - b * b) * 0.5,
life, max: life, col, size, grav });
}
}
function pop(text, x, y, z, col) { S.pops.push({ text, x, y, z, life: 1, col }); }
function addShake(v) { S.shake = Math.min(1.6, S.shake + v); }
function flash(c, v) { S.flash = Math.max(S.flash, v); S.flashC = c; }
// === UPDATE ===
function nearMiss(o) {
S.nearMisses++;
const bonus = Math.round(60 * S.mult);
S.score += bonus;
addShake(0.28);
burst(S.px, S.py, S.pz + 0.5, 10, '255,255,255', 6, 0.45, 0.07);
pop('NEAR +' + bonus, S.px, S.py + 0.8, S.pz, '255,240,160');
tone(1400, 2200, 0.09, 'triangle', 0.14);
tone(900, 1500, 0.12, 'sine', 0.1, 0.04);
}
function collectPickup(p) {
p.taken = true;
if (p.type === 'token') {
S.tokens++;
S.chain++; S.chainT = 3.5;
S.mult = Math.min(8, 1 + Math.floor(S.chain / 6));
const add = Math.round(30 * S.mult); S.score += add;
burst(p.x, p.y, p.z, 10, CFG.COL.token, 5, 0.5, 0.09);
pop('+' + add, p.x, p.y + 0.4, p.z, '150,250,255');
tone(880 + S.chain * 25, 1760 + S.chain * 40, 0.12, 'square', 0.09);
flash('127,246,255', 0.05);
} else if (p.type === 'cell') {
S.energy = Math.min(CFG.ENERGY_MAX, S.energy + CFG.CELL_GAIN);
const add = Math.round(15 * S.mult); S.score += add;
burst(p.x, p.y, p.z, 14, CFG.COL.cell, 6, 0.6, 0.11);
pop('+ENERGY', p.x, p.y + 0.4, p.z, '255,225,77');
tone(523, 1046, 0.22, 'triangle', 0.16);
tone(784, 1568, 0.2, 'sine', 0.1, 0.06);
} else if (p.type === 'orb') {
S.chain += 6; S.chainT = 3.5;
S.mult = Math.min(8, 1 + Math.floor(S.chain / 6));
S.sun = Math.min(1, S.sun + CFG.SUN_ORB);
S.score += 150 * S.mult;
burst(p.x, p.y, p.z, 24, CFG.COL.orb, 9, 0.8, 0.13);
pop('ORB x' + S.mult, p.x, p.y + 0.5, p.z, '255,120,230');
tone(440, 1320, 0.35, 'sawtooth', 0.09);
tone(660, 1980, 0.3, 'sine', 0.12, 0.08);
addShake(0.12);
}
}
function crash(cause) {
if (S.mode !== 'play') return;
S.mode = 'dying'; S.cause = cause; S.deadT = 0;
addShake(1.4); flash('255,80,60', 0.7);
burst(S.px, S.py, S.pz, 60, '255,160,90', 14, 1.1, 0.2, 2);
burst(S.px, S.py, S.pz, 30, '180,140,255', 8, 1.4, 0.14, 1);
thud();
S.best = Math.max(hiScore, Math.floor(S.score));
}
function update(dt) {
const K = keys;
const ix = (K.right ? 1 : 0) - (K.left ? 1 : 0);
const iy = (K.up ? 1 : 0) - (K.down ? 1 : 0);
const dying = S.mode === 'dying';
S.time += dt;
// Speed: ramps with time; boost multiplies; empty energy stalls the craft
let target = Math.min(CFG.MAX_SPEED, CFG.BASE_SPEED + CFG.SPEED_RAMP * S.t);
S.boost = !dying && K.boost && S.energy > 0.5;
if (S.boost) target *= CFG.BOOST_MULT;
if (S.energy <= 0) { target *= 0.55; S.stall = true; } else S.stall = false;
if (dying) target = 0;
const kS = S.boost ? 3.2 : 1.1;
S.speed += (target - S.speed) * (1 - Math.exp(-kS * dt));
if (dying) S.speed = Math.max(0, S.speed - 6 * dt);
const sd = S.speed * dt;
S.pz += sd; S.dist += sd;
// Lateral + vertical easing: snappy on press, short inertia tail on release
if (!dying) {
const tvx = ix * CFG.MAX_VX, tvy = iy * CFG.MAX_VY;
const rx = ix !== 0 ? CFG.EASE_IN : CFG.EASE_OUT;
const ry = iy !== 0 ? CFG.EASE_IN : CFG.EASE_OUT;
S.vx += (tvx - S.vx) * (1 - Math.exp(-rx * dt));
S.vy += (tvy - S.vy) * (1 - Math.exp(-ry * dt));
S.px += S.vx * dt; S.py += S.vy * dt;
if (S.px < -CFG.PX_MAX) { S.px = -CFG.PX_MAX; S.vx = 0; }
if (S.px > CFG.PX_MAX) { S.px = CFG.PX_MAX; S.vx = 0; }
if (S.py < CFG.PY_MIN) { S.py = CFG.PY_MIN; S.vy = 0; }
if (S.py > CFG.PY_MAX) { S.py = CFG.PY_MAX; S.vy = 0; }
} else {
S.vx *= Math.exp(-3 * dt); S.vy *= Math.exp(-3 * dt);
}
// visual bank/pitch
const rollT = -(S.vx / CFG.MAX_VX) * 0.7;
S.roll += (rollT - S.roll) * (1 - Math.exp(-12 * dt));
const pitchT = (S.vy / CFG.MAX_VY) * 0.35 + (S.boost ? 0.12 : 0);
S.pitch += (pitchT - S.pitch) * (1 - Math.exp(-10 * dt));
S.rotor += dt * (10 + S.speed * 0.4);
if (!dying) {
// Shadow check: off the sun's cone, or directly behind a block
let sh = Math.abs(S.px) > CFG.SUN_X_SHADOW;
for (const o of S.obs) {
if (o.k !== 'block') continue;
const behind = S.pz - (o.z + o.hd);
if (behind > 0 && behind < 9 && Math.abs(S.px - o.x) < o.hw + 0.35) { sh = true; break; }
}
S.shadowed = sh;
// Energy
let drain = (sh ? CFG.DRAIN_SHADOW : CFG.DRAIN_LIT);
if (S.boost) drain += CFG.BOOST_DRAIN;
S.energy = Math.max(0, S.energy - drain * dt);
// Sun sinks; hits zero => sunset game over
S.sun = Math.max(0, S.sun - CFG.SUN_SINK * dt);
S.sunWarn = S.sun < 0.18 ? S.sunWarn + dt : 0;
// Chain decay
if (S.chain > 0) { S.chainT -= dt; if (S.chainT <= 0) { S.chain = 0; S.mult = 1; } }
// Distance score
S.score += S.speed * dt * 0.5 * S.mult;
}
// World obstacles: collision + near-miss tracking
if (!dying) {
for (const o of S.obs) {
const dz = Math.abs(S.pz - o.z);
if (dz < o.hd + PD) {
const gx = Math.abs(S.px - o.x) - (o.hw + PW);
const gy1 = S.py - PH - (o.y + o.hh), gy2 = o.y - (S.py + PH);
const gy = Math.max(gy1, gy2);
const sep = Math.max(gx, gy);
o.minSep = Math.min(o.minSep, sep);
if (sep < 0) { crash(o.k === 'beam' ? 'BEAM' : 'IMPACT'); break; }
o.live = true;
} else if (o.live && S.pz > o.z) {
o.live = false;
if (o.minSep >= 0 && o.minSep < CFG.NEAR_MISS) nearMiss(o);
o.minSep = 99;
o.done = true;
}
}
// Pickups
for (const p of S.pick) {
if (p.taken) continue;
if (Math.abs(p.z - S.pz) < 1.0 && Math.abs(p.x - S.px) < 0.9 && Math.abs(p.y - S.py) < 0.9) collectPickup(p);
}
}
// Thrust trail + flicker particles
if (S.mode === 'play' || dying) {
const rate = dying ? 0 : (S.boost ? 3 : 1);
const n = Math.random() < (0.5 + S.speed / 60) * rate ? 1 : 0;
if (n) S.parts.push({ x: S.px + (rnd() - 0.5) * 0.3, y: S.py - 0.05, z: S.pz - 0.6, vx: (rnd() - 0.5) * 0.6, vy: (rnd() - 0.5) * 0.4, vz: -S.speed * 0.2,
life: 0.35, max: 0.35, col: S.boost ? '255,220,120' : '255,140,80', size: 0.12, grav: 0 });
if (S.boost && Math.random() < 0.5) S.parts.push({ x: S.px, y: S.py, z: S.pz - 0.8, vx: (rnd() - 0.5), vy: (rnd() - 0.5), vz: -S.speed * 0.4, life: 0.3, max: 0.3, col: '255,255,255', size: 0.1, grav: 0 });
}
// Particle physics
for (let i = S.parts.length - 1; i >= 0; i--) {
const q = S.parts[i];
q.life -= dt;
if (q.life <= 0) { S.parts[i] = S.parts[S.parts.length - 1]; S.parts.pop(); continue; }
q.x += q.vx * dt; q.y += q.vy * dt; q.z += q.vz * dt;
q.vy -= q.grav * dt * 9; q.vx *= Math.exp(-2 * dt); q.vz *= Math.exp(-1 * dt);
}
for (let i = S.pops.length - 1; i >= 0; i--) {
S.pops[i].life -= dt * 0.9;
if (S.pops[i].life <= 0) S.pops.splice(i, 1);
}
// World streaming
ensureWorld();
if (S.obs.length > 200 || S.pz % 10 < dt) {
S.obs = S.obs.filter(o => o.z > S.pz - 40);
S.pick = S.pick.filter(p => p.z > S.pz - 10 && !p.taken);
}
// Camera (behind + above, lags the craft slightly)
const kc = 1 - Math.exp(-6 * dt);
camX += (S.px * 0.45 - camX) * kc;
camY += (CFG.CAM_H + (S.py - 2.2) * 0.3 - camY) * kc;
camZ = S.pz - CFG.CAM_DIST;
S.shake *= Math.exp(-7 * dt);
S.flash *= Math.exp(-6 * dt);
// Dying timeline -> game over
if (dying) {
S.deadT += dt;
if (S.deadT > 1.3) {
S.mode = 'over'; S.overT = 0;
const sc = Math.floor(S.score);
if (sc > hiScore) { hiScore = sc; try { localStorage.setItem(HI_KEY, String(hiScore)); } catch (e) {} }
showOver(sc);
}
}
}
function showOver(sc) {
const reason = { SUNSET: 'THE SUN SANK', IMPACT: 'CRASHED INTO A BLOCK', BEAM: 'CLIPPED AN ARCH' }[S.cause] || S.cause;
$ostat.innerHTML =
`${reason}<br>Score <span>${sc}</span> Best <span>${hiScore}</span><br>` +
`Distance <span>${Math.floor(S.dist)} m</span> Tokens <span>${S.tokens}</span><br>` +
`Near misses <span>${S.nearMisses}</span> Max chain x<span>${S.mult}</span><br>` +
`<span style="font-size:11px;opacity:.6">seed ${S.seed.toString(36)}</span>`;
$over.classList.add('show');
}
function pruneWorld() {
S.obs = S.obs.filter(o => o.z > S.pz - 40);
S.pick = S.pick.filter(p => p.z > S.pz - 10 && !p.taken);
}
function attract(dt) {
S.time += dt;
S.pz += dt * 20; S.px = Math.sin(S.time * 0.6) * 1.6; S.py = 2.2 + Math.sin(S.time * 0.45) * 0.4;
S.roll = 0; S.pitch = 0; S.speed = 20; S.rotor += dt * 14;
ensureWorld(); pruneWorld();
const kc = 1 - Math.exp(-3 * dt);
camX += (S.px * 0.45 - camX) * kc; camY += (CFG.CAM_H - camY) * kc; camZ = S.pz - CFG.CAM_DIST;
}
// === RENDER ===
const AUX = document.createElement('canvas'), aux = AUX.getContext('2d');
const FONT = '"Trebuchet MS","Segoe UI",Verdana,sans-serif';
function poly(pts, fill) {
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath(); ctx.fillStyle = fill; ctx.fill();
}
function halo(x, y, r, col, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, `rgba(${col},${a})`); g.addColorStop(1, `rgba(${col},0)`);
ctx.fillStyle = g; ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function starPath(x, y, R, r) {
ctx.beginPath();
for (let i = 0; i < 8; i++) {
const a = i * Math.PI / 4 - Math.PI / 2, rr = i % 2 ? r : R;
const px = x + Math.cos(a) * rr, py = y + Math.sin(a) * rr;
if (i) ctx.lineTo(px, py); else ctx.moveTo(px, py);
}
ctx.closePath();
}
function sunScreen() {
const r = H * 0.11;
return { x: W * 0.5 - camX * 40, y: HZ - r * 0.55 + (1 - S.sun) * r * 1.6, r };
}
function drawSky() {
const C = CFG.COL;
const g = ctx.createLinearGradient(0, 0, 0, HZ);
g.addColorStop(0, C.skyTop); g.addColorStop(0.45, C.skyMid); g.addColorStop(0.82, C.skyLow); g.addColorStop(1, C.skyHorizon);
ctx.fillStyle = g; ctx.fillRect(0, 0, W, HZ + 2);
const s = sunScreen();
const glow = ctx.createRadialGradient(s.x, s.y, s.r * 0.5, s.x, s.y, s.r * 5);
glow.addColorStop(0, 'rgba(255,200,110,0.55)'); glow.addColorStop(0.4, 'rgba(255,110,60,0.18)'); glow.addColorStop(1, 'rgba(255,80,60,0)');
ctx.fillStyle = glow; ctx.fillRect(0, 0, W, HZ + 2);
const sg = ctx.createRadialGradient(s.x, s.y - s.r * 0.2, s.r * 0.1, s.x, s.y, s.r);
sg.addColorStop(0, C.sunCore); sg.addColorStop(0.55, C.sunMid); sg.addColorStop(1, C.sunEdge);
ctx.fillStyle = sg; ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, 6.283); ctx.fill();
// retro banding across the lower disc
ctx.save(); ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, 6.283); ctx.clip();
ctx.fillStyle = 'rgba(70,18,60,0.45)';
for (let i = 0; i < 4; i++) ctx.fillRect(s.x - s.r, s.y + s.r * (0.12 + i * 0.12), s.r * 2, s.r * (0.03 + i * 0.012));
ctx.restore();
}
function drawGround() {
const C = CFG.COL;
const g = ctx.createLinearGradient(0, HZ, 0, H);
g.addColorStop(0, C.groundTop); g.addColorStop(1, C.groundBot);
ctx.fillStyle = g; ctx.fillRect(0, HZ, W, H - HZ);
// sun reflection smear on ground
const s = sunScreen();
const rg = ctx.createRadialGradient(s.x, HZ, 0, s.x, HZ, W * 0.35);
rg.addColorStop(0, 'rgba(255,170,90,0.35)'); rg.addColorStop(1, 'rgba(255,120,60,0)');
ctx.save(); ctx.beginPath(); ctx.rect(0, HZ, W, H - HZ); ctx.clip();
ctx.translate(s.x, HZ); ctx.scale(1, 0.25); ctx.translate(-s.x, -HZ);
ctx.fillStyle = rg; ctx.fillRect(0, HZ, W, H * 4); ctx.restore();
// grid
ctx.lineWidth = 1;
const z0 = Math.ceil((camZ + 1.5) / 4) * 4, z1 = camZ + CFG.DRAW_FAR;
for (let z = z0; z < z1; z += 4) {
const a = clamp(1 - (z - camZ) / CFG.DRAW_FAR, 0, 1) * 0.24;
const l = proj(-40, 0, z), r = proj(40, 0, z);
ctx.strokeStyle = `rgba(255,160,120,${a})`;
ctx.beginPath(); ctx.moveTo(l[0], l[1]); ctx.lineTo(r[0], r[1]); ctx.stroke();
}
ctx.strokeStyle = 'rgba(255,160,120,0.14)';
for (let x = -40; x <= 40; x += 4) {
const n = proj(x, 0, camZ + 1.5), f = proj(x, 0, camZ + CFG.DRAW_FAR);
ctx.beginPath(); ctx.moveTo(n[0], n[1]); ctx.lineTo(f[0], f[1]); ctx.stroke();
}
}
// Long shadows cast toward the camera by blocks (sun sits ahead)
function drawBlockShadows() {
for (const o of S.obs) {
if (o.k !== 'block') continue;
const zb = o.z - o.hd, zs = zb - 9;
if (zb - camZ < 1 || zb - camZ > CFG.DRAW_FAR) continue;
const x0 = o.x - o.hw, x1 = o.x + o.hw;
poly([proj(x0, 0, zb), proj(x1, 0, zb), proj(x1, 0, zs), proj(x0, 0, zs)], 'rgba(8,2,16,0.5)');
}
}
function drawBox(x, y, z, hw, hh, hd, kind) {
const C = CFG.COL;
const y0 = y - hh, y1 = y + hh, zn = z - hd, zf = z + hd;
const topC = kind === 'arch' ? C.archSide : C.blockTop;
const frontC = kind === 'arch' ? C.archFront : C.blockFront;
const sideC = kind === 'arch' ? C.archSide : C.blockSide;
if (camX < x - hw) poly([proj(x - hw, y0, zn), proj(x - hw, y0, zf), proj(x - hw, y1, zf), proj(x - hw, y1, zn)], sideC);
else if (camX > x + hw) poly([proj(x + hw, y0, zn), proj(x + hw, y0, zf), proj(x + hw, y1, zf), proj(x + hw, y1, zn)], sideC);
if (camY > y1) poly([proj(x - hw, y1, zn), proj(x + hw, y1, zn), proj(x + hw, y1, zf), proj(x - hw, y1, zf)], topC);
const a = proj(x - hw, y0, zn), b = proj(x + hw, y0, zn), c = proj(x + hw, y1, zn), d = proj(x - hw, y1, zn);
poly([a, b, c, d], frontC);
// sunlit rim
ctx.strokeStyle = C.edge; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(d[0], d[1]); ctx.lineTo(c[0], c[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
function drawPickup(p) {
const bob = Math.sin(S.time * 3 + p.ph) * 0.12;
const q = proj(p.x, p.y + bob, p.z), s = F / q[2];
const x = q[0], y = q[1];
const pulse = 1 + 0.12 * Math.sin(S.time * 8 + p.ph);
const r = Math.max(4, 0.32 * s) * pulse;
if (p.type === 'token') {
halo(x, y, r * 2.4, CFG.COL.token, 0.35);
ctx.fillStyle = '#e6fdff'; starPath(x, y, r * 1.2, r * 0.36); ctx.fill();
} else if (p.type === 'cell') {
halo(x, y, r * 2.2, CFG.COL.cell, 0.4);
ctx.fillStyle = '#ffe14d'; ctx.beginPath(); ctx.arc(x, y, r * 0.85, 0, 6.283); ctx.fill();
ctx.fillStyle = '#2a1a00'; ctx.beginPath();
ctx.moveTo(x + r * 0.15, y - r * 0.6); ctx.lineTo(x - r * 0.3, y + r * 0.1); ctx.lineTo(x, y + r * 0.1);
ctx.lineTo(x - r * 0.15, y + r * 0.6); ctx.lineTo(x + r * 0.3, y - r * 0.1); ctx.lineTo(x, y - r * 0.1);
ctx.closePath(); ctx.fill();
} else {
halo(x, y, r * 2.8, CFG.COL.orb, 0.45);
ctx.strokeStyle = `rgb(${CFG.COL.orb})`; ctx.lineWidth = Math.max(2, r * 0.28);
ctx.beginPath(); ctx.arc(x, y, r * 0.9, 0, 6.283); ctx.stroke();
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(x, y, r * 0.3, 0, 6.283); ctx.fill();
}
}
function drawWorld() {
const list = [];
for (const o of S.obs) { const d = o.z - camZ; if (d >= 1 && d <= CFG.DRAW_FAR) list.push(o); }
list.sort((a, b) => b.z - a.z);
for (const o of list) drawBox(o.x, o.y, o.z, o.hw, o.hh, o.hd, o.k === 'block' ? 'block' : 'arch');
for (const p of S.pick) {
if (p.taken) continue;
const d = p.z - camZ;
if (d >= 1 && d <= CFG.DRAW_FAR) drawPickup(p);
}
}
function drawParticles() {
for (const q of S.parts) {
const p = proj(q.x, q.y, q.z);
if (p[2] < 0.6) continue;
const a = q.life / q.max;
ctx.fillStyle = `rgba(${q.col},${(a * 0.9).toFixed(3)})`;
ctx.beginPath(); ctx.arc(p[0], p[1], Math.max(0.8, q.size * F / p[2] * a), 0, 6.283); ctx.fill();
}
}
function drawPops() {
ctx.save(); ctx.textAlign = 'center'; ctx.font = `900 18px ${FONT}`;
for (const p of S.pops) {
const q = proj(p.x, p.y + (1 - p.life) * 1.2, p.z);
if (q[2] < 1) continue;
ctx.globalAlpha = Math.min(1, p.life * 2);
ctx.fillStyle = `rgb(${p.col})`;
ctx.fillText(p.text, q[0], q[1]);
}
ctx.restore();
}
// Craft: low-poly triangles, transformed by pitch/roll, projected in 3D
const CRAFT_V = [[0, 0, 1.1], [-0.85, 0, -0.55], [0.85, 0, -0.55], [0, 0.38, -0.35], [0, -0.16, -0.35], [0, 0.22, 0.35]];
const CRAFT_F = [[0, 1, 3, 0], [0, 3, 2, 0], [0, 2, 4, 1], [0, 4, 1, 1], [1, 4, 3, 2], [2, 3, 4, 2], [0, 5, 3, 3], [0, 2, 5, 3]];
function craftPt(v, roll, pitch) {
const cp = Math.cos(pitch), sp = Math.sin(pitch), cr = Math.cos(roll), sr = Math.sin(roll);
const y1 = v[1] * cp + v[2] * sp, z1 = -v[1] * sp + v[2] * cp;
const x2 = v[0] * cr - y1 * sr, y2 = v[0] * sr + y1 * cr;
return proj(S.px + x2 * 0.8, S.py + y2 * 0.8, S.pz + z1 * 0.8);
}
function drawCraft() {
const dying = S.mode === 'dying';
const roll = S.roll + (dying ? S.deadT * 7 : 0);
const pitch = S.pitch - (dying ? S.deadT * 2.2 : 0);
// ground shadow
const g = proj(S.px, 0, S.pz), rx = 0.8 * F / g[2];
const hgt = clamp(1 - (S.py - 0.5) / 5, 0.15, 1);
ctx.fillStyle = `rgba(0,0,0,${0.4 * hgt})`;
ctx.beginPath(); ctx.ellipse(g[0], g[1], rx, rx * 0.28, 0, 0, 6.283); ctx.fill();
if (S.mode === 'over') return;
const pts = CRAFT_V.map(v => craftPt(v, roll, pitch));
const cols = S.shadowed ? ['#8a7aa8', '#5c3a6a', '#40305a', '#2a9ab0'] : ['#fff4da', '#ff9a5a', '#7d5fb0', '#27e0ff'];
const order = CRAFT_F.slice().sort((a, b) => (pts[b[0]][2] + pts[b[1]][2] + pts[b[2]][2]) - (pts[a[0]][2] + pts[a[1]][2] + pts[a[2]][2]));
for (const f of order) poly([pts[f[0]], pts[f[1]], pts[f[2]]], cols[f[3]]);
// thruster glow
const e = craftPt([0, 0, -0.9], roll, pitch);
const boostK = S.boost ? 1 : 0.5;
halo(e[0], e[1], F / e[2] * (0.5 + S.speed / 80) * boostK + 6, S.boost ? '255,220,120' : '255,120,60', 0.6);
}
function drawHaze() {
const top = HZ - H * 0.08;
const g = ctx.createLinearGradient(0, top, 0, HZ + H * 0.02);
g.addColorStop(0, 'rgba(255,140,80,0)'); g.addColorStop(0.7, 'rgba(255,150,90,0.22)'); g.addColorStop(1, 'rgba(255,150,90,0)');
ctx.fillStyle = g; ctx.fillRect(0, top, W, H * 0.1);
}
function drawSpeedLines(k) {
if (k <= 0.01) return;
const cx = W * 0.5, cy = HZ;
ctx.strokeStyle = `rgba(255,225,190,${0.45 * k})`; ctx.lineWidth = 1.4;
ctx.beginPath();
for (let i = 0; i < 28; i++) {
const a = i * 2.399, p = (S.time * (1.2 + k * 1.8) + i * 0.0371) % 1;
const r0 = lerp(0.15, 0.85, p), len = 0.15 * p * k + 0.02;
const x0 = cx + Math.cos(a) * r0 * W * 0.5, y0 = cy + Math.sin(a) * r0 * H * 0.55;
ctx.moveTo(x0, y0); ctx.lineTo(x0 + Math.cos(a) * len * W * 0.5, y0 + Math.sin(a) * len * H * 0.55);
}
ctx.stroke();
}
function label(t, x, y) { ctx.fillStyle = 'rgba(255,205,150,0.8)'; ctx.font = `800 12px ${FONT}`; ctx.letterSpacing = '2px'; ctx.fillText(t, x, y); ctx.letterSpacing = '0px'; }
function value(t, x, y, size) { ctx.fillStyle = '#fff6e6'; ctx.font = `900 ${size}px ${FONT}`; ctx.fillText(t, x, y); }
function drawHUD() {
const pad = 24;
ctx.save(); ctx.textBaseline = 'top'; ctx.textAlign = 'left';
ctx.shadowColor = 'rgba(0,0,0,0.6)'; ctx.shadowBlur = 6;
label('DISTANCE', pad, pad); value(Math.floor(S.dist) + ' m', pad, pad + 14, 30);
label('SCORE', pad, pad + 66); value(String(Math.floor(S.score)), pad, pad + 80, 26);
ctx.fillStyle = S.mult > 1 ? '#ffd27a' : '#ffe9cf'; ctx.font = `900 22px ${FONT}`;
ctx.fillText('×' + S.mult, pad, pad + 116);
const frac = S.mult >= 8 ? 1 : (S.chain % 6) / 6;
ctx.fillStyle = 'rgba(255,255,255,0.15)'; ctx.fillRect(pad + 58, pad + 124, 120, 6);
ctx.fillStyle = '#7ff6ff'; ctx.fillRect(pad + 58, pad + 124, 120 * frac, 6);
// right column
ctx.textAlign = 'right';
label('SPEED', W - pad, pad); value(Math.round(S.speed * 3.6) + ' km/h', W - pad, pad + 14, 26);
label('BEST', W - pad, pad + 66); value(String(Math.max(hiScore, Math.floor(S.score))), W - pad, pad + 80, 22);
// sun meter (top center)
const bw = 260, bx = W * 0.5 - bw / 2, by = pad;
ctx.textAlign = 'center';
const flick = S.sunWarn > 0 && Math.floor(S.time * 6) % 2 === 0;
label('SUN', W * 0.5, by);
ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(bx, by + 18, bw, 8);
ctx.fillStyle = flick ? '#ff3b3b' : (S.sun < 0.25 ? '#ff6a3d' : '#ffc45c');
ctx.fillRect(bx, by + 18, bw * S.sun, 8);
// energy (bottom center)
const ew = 320, ex = W * 0.5 - ew / 2, ey = H - 52;
const en = S.energy / CFG.ENERGY_MAX;
let status = '';
if (S.shadowed) status = 'IN SHADOW · DRAINING';
if (S.boost) status = 'BOOST';
if (S.energy <= 0) status = 'DEAD BATTERY · STALLING';
label('ENERGY', W * 0.5, ey - 22);
ctx.fillStyle = 'rgba(0,0,0,0.4)'; ctx.fillRect(ex, ey, ew, 12);
const eg = ctx.createLinearGradient(ex, 0, ex + ew, 0);
if (en < 0.2 && Math.floor(S.time * 8) % 2) eg.addColorStop(0, '#ff3b3b'), eg.addColorStop(1, '#ff3b3b');
else eg.addColorStop(0, '#ffd24a'), eg.addColorStop(1, '#7ff6ff');
ctx.fillStyle = eg; ctx.fillRect(ex, ey, ew * en, 12);
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.strokeRect(ex, ey, ew, 12);
ctx.fillStyle = S.shadowed ? '#9fb8ff' : (S.boost ? '#ffe9a0' : '#ffb07a');
ctx.font = `800 13px ${FONT}`; ctx.fillText(status, W * 0.5, ey + 20);
ctx.restore();
}
function render() {
const sp = clamp((S.speed - 36) / 22, 0, 1);
const boostK = S.boost ? 0.6 : 0;
const k = Math.max(sp, boostK);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.fillStyle = '#0b0415'; ctx.fillRect(0, 0, W, H);
const sh = S.shake * S.shake * 9;
ctx.save();
if (sh > 0.05) ctx.translate((Math.random() - 0.5) * sh * 2, (Math.random() - 0.5) * sh * 2);
drawSky(); drawGround(); drawBlockShadows(); drawWorld(); drawParticles();
if (S.mode !== 'title') drawCraft();
drawPops(); drawHaze();
drawSpeedLines(k);
ctx.restore();
// screen-space flourishes
if (S.flash > 0.01) { ctx.fillStyle = `rgba(${S.flashC},${Math.min(0.6, S.flash * 0.6)})`; ctx.fillRect(0, 0, W, H); }
if (S.shadowed && S.mode === 'play') {
const vg = ctx.createRadialGradient(W / 2, H / 2, H * 0.3, W / 2, H / 2, H * 0.85);
vg.addColorStop(0, 'rgba(40,40,120,0)'); vg.addColorStop(1, 'rgba(40,40,120,0.45)');
ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H);
}
// chromatic aberration at high speed: ghost copies offset horizontally
if (k > 0.5) {
aux.canvas.width = cv.width; aux.canvas.height = cv.height;
aux.drawImage(cv, 0, 0);
ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = 0.12 * k;
const o = 3 * DPR * k;
ctx.drawImage(AUX, -o, 0); ctx.drawImage(AUX, o, 0);
ctx.restore();
}
if (S.mode === 'play' || S.mode === 'dying') drawHUD();
if (S.mode === 'over') { ctx.fillStyle = 'rgba(8,2,16,0.45)'; ctx.fillRect(0, 0, W, H); }
}
// === MAIN LOOP ===
let lastT = performance.now();
function frame(now) {
const dt = Math.min(0.05, Math.max(0, (now - lastT) / 1000));
lastT = now;
const steps = Math.max(1, Math.ceil(dt / (1 / 120)));
const h = dt / steps;
for (let i = 0; i < steps; i++) {
if (S.mode === 'play' || S.mode === 'dying') update(h);
else if (S.mode === 'title') attract(h);
else if (S.mode === 'over') { S.overT += h; S.time += h; }
}
audioFrame(S.speed, S.boost, S.mode === 'play' || S.mode === 'dying');
render();
requestAnimationFrame(frame);
}
// === INIT ===
function onAction(code) {
if (S.mode === 'title') { newRun(); return; }
if (S.mode === 'over' && S.overT > 0.6) newRun();
}
newRun();
S.mode = 'title';
$title.classList.add('show');
requestAnimationFrame(t => { lastT = t; requestAnimationFrame(frame); });
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"b2d00bd5-2cda-4b6e-991a-a60d06c23ad7","tokensIn":134664,"tokensOut":106013,"tokensTotal":240677,"cost":0.05821899000000001,"turns":6,"toolCalls":8,"failedToolCalls":0,"timestamp":"2026-10-08T05:57:49.159Z"} -->