Race the Sun
Grok 4.5 · legacy

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 48,769 bytes · SHA-256 1a8ff736209e
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Solar Dash — Endless Runner</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
overflow: hidden;
background: #0a0612;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
}
canvas {
display: block;
width: 100%;
height: 100%;
cursor: none;
}
#hint {
position: fixed;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
color: rgba(255,220,180,0.45);
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
pointer-events: none;
user-select: none;
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hint">WASD / Arrows · Space boost · R restart</div>
<script>
// =============================================================================
// === TUNABLE CONSTANTS =======================================================
// =============================================================================
const C = {
// World & physics
BASE_SPEED: 42,
MAX_SPEED: 140,
ACCEL: 0.65, // units/s²
LANE_HALF: 18, // half-width of playable plane
STRAFE_SPEED: 38,
STRAFE_EASE: 0.18, // lerp factor per frame-ish (scaled by dt)
PITCH_SPEED: 16,
PITCH_RANGE: 5.5,
BOOST_MULT: 1.55,
BOOST_DRAIN: 28, // energy/s while boosting
INERTIA: 0.88,
// Energy / sun
ENERGY_MAX: 100,
ENERGY_DRAIN: 3.2, // base drain /s
ENERGY_SHADOW: 11, // extra drain in shadow
ENERGY_PICKUP: 22,
SUN_START: 1.0, // 1 = high, 0 = set
SUN_SINK_RATE: 0.0065, // per second (tunes ~150s theoretical max)
SUN_SINK_SPEED: 0.0008, // extra sink scaled by speed
// Spawning
SPAWN_DIST_BASE: 28,
SPAWN_DIST_MIN: 11,
OBSTACLE_CHANCE: 0.55,
PICKUP_CHANCE: 0.38,
RAMP_CHANCE: 0.12,
WAVE_SIZE: [2, 5],
// Scoring
DIST_SCORE_RATE: 1.2,
TOKEN_SCORE: 50,
NEAR_MISS_SCORE: 35,
NEAR_MISS_DIST: 2.4,
// Visual
FOV: 1.15,
CAM_DIST: 14,
CAM_HEIGHT: 6.5,
CAM_LOOK: 28,
SHAKE_DECAY: 0.88,
PARTICLE_MAX: 420,
// Colors (sunset palette)
SKY_TOP: [28, 12, 48],
SKY_MID: [210, 70, 60],
SKY_HORIZON: [255, 170, 80],
SUN_CORE: [255, 250, 220],
SUN_GLOW: [255, 160, 40],
GROUND: [18, 10, 28],
GROUND_LIT: [55, 30, 45],
CRAFT: [255, 230, 120],
CRAFT_ACCENT: [80, 220, 255],
OBSTACLE: [40, 20, 55],
OBSTACLE_LIT: [120, 50, 80],
SHADOW: [8, 4, 16],
ENERGY_COL: [80, 255, 200],
TOKEN_COL: [255, 220, 80],
MULTI_COL: [255, 100, 220],
};
// =============================================================================
// === SETUP ===================================================================
// =============================================================================
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let W = 0, H = 0, DPR = 1;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = window.innerWidth;
H = window.innerHeight;
canvas.width = (W * DPR) | 0;
canvas.height = (H * DPR) | 0;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
window.addEventListener('resize', resize);
resize();
// RNG (mulberry32) — deterministic per run
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function randRange(rng, a, b) { return a + rng() * (b - a); }
function randInt(rng, a, b) { return (a + rng() * (b - a + 1)) | 0; }
function pick(rng, arr) { return arr[(rng() * arr.length) | 0]; }
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
function lerp(a, b, t) { return a + (b - a) * t; }
function smoothstep(t) { return t * t * (3 - 2 * t); }
function dist2(ax, az, bx, bz) { const dx = ax - bx, dz = az - bz; return dx * dx + dz * dz; }
// =============================================================================
// === INPUT ===================================================================
// =============================================================================
const keys = Object.create(null);
const JUST = Object.create(null);
window.addEventListener('keydown', e => {
const k = e.key.toLowerCase();
if (!keys[k]) JUST[k] = true;
keys[k] = true;
if (['arrowup','arrowdown','arrowleft','arrowright',' ','w','a','s','d'].includes(k) || e.code === 'Space') {
e.preventDefault();
}
if (k === 'r' && (state.mode === 'playing' || state.mode === 'gameover')) {
startRun();
}
if ((k === ' ' || k === 'enter') && state.mode === 'title') {
startRun();
}
if ((k === ' ' || k === 'enter' || k === 'r') && state.mode === 'gameover') {
startRun();
}
});
window.addEventListener('keyup', e => {
keys[e.key.toLowerCase()] = false;
});
canvas.addEventListener('click', () => {
if (state.mode === 'title' || state.mode === 'gameover') startRun();
tryUnlockAudio();
});
window.addEventListener('mousedown', tryUnlockAudio);
window.addEventListener('keydown', tryUnlockAudio, { once: false });
function inputAxis() {
let x = 0, y = 0;
if (keys['a'] || keys['arrowleft']) x -= 1;
if (keys['d'] || keys['arrowright']) x += 1;
if (keys['w'] || keys['arrowup']) y += 1;
if (keys['s'] || keys['arrowdown']) y -= 1;
return { x, y };
}
function consumeJust(k) {
if (JUST[k]) { JUST[k] = false; return true; }
return false;
}
function clearJust() {
for (const k in JUST) JUST[k] = false;
}
// =============================================================================
// === AUDIO (WebAudio procedural) =============================================
// =============================================================================
let audioCtx = null;
let masterGain = null;
let droneOsc = null, droneGain = null, windFilter = null;
function tryUnlockAudio() {
if (audioCtx) return;
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = audioCtx.createGain();
masterGain.gain.value = 0.35;
masterGain.connect(audioCtx.destination);
setupDrone();
} catch (_) {}
}
function setupDrone() {
if (!audioCtx) return;
droneOsc = audioCtx.createOscillator();
droneOsc.type = 'sawtooth';
droneOsc.frequency.value = 55;
windFilter = audioCtx.createBiquadFilter();
windFilter.type = 'lowpass';
windFilter.frequency.value = 200;
windFilter.Q.value = 0.7;
droneGain = audioCtx.createGain();
droneGain.gain.value = 0;
droneOsc.connect(windFilter);
windFilter.connect(droneGain);
droneGain.connect(masterGain);
droneOsc.start();
}
function setDrone(speedNorm, playing) {
if (!droneGain || !audioCtx) return;
const t = audioCtx.currentTime;
const target = playing ? 0.04 + speedNorm * 0.12 : 0;
droneGain.gain.setTargetAtTime(target, t, 0.15);
if (droneOsc) droneOsc.frequency.setTargetAtTime(48 + speedNorm * 80, t, 0.2);
if (windFilter) windFilter.frequency.setTargetAtTime(180 + speedNorm * 1200, t, 0.2);
}
function beep(freq, dur, type, vol, slide) {
if (!audioCtx || !masterGain) return;
const t0 = audioCtx.currentTime;
const o = audioCtx.createOscillator();
const g = audioCtx.createGain();
o.type = type || 'square';
o.frequency.setValueAtTime(freq, t0);
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, freq * slide), t0 + dur);
g.gain.setValueAtTime(vol || 0.15, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
o.connect(g); g.connect(masterGain);
o.start(t0); o.stop(t0 + dur + 0.02);
}
function noiseBurst(dur, vol, hp) {
if (!audioCtx || !masterGain) return;
const n = (audioCtx.sampleRate * dur) | 0;
const buf = audioCtx.createBuffer(1, n, audioCtx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / n);
const src = audioCtx.createBufferSource();
src.buffer = buf;
const f = audioCtx.createBiquadFilter();
f.type = hp ? 'highpass' : 'lowpass';
f.frequency.value = hp || 400;
const g = audioCtx.createGain();
g.gain.value = vol || 0.2;
src.connect(f); f.connect(g); g.connect(masterGain);
src.start();
}
function sfxPickup(kind) {
if (kind === 'energy') { beep(520, 0.08, 'sine', 0.18, 1.6); beep(780, 0.1, 'sine', 0.12, 1.3); }
else if (kind === 'token') { beep(660, 0.06, 'triangle', 0.16, 1.4); beep(990, 0.09, 'triangle', 0.1); }
else { beep(440, 0.05, 'square', 0.12); beep(660, 0.05, 'square', 0.1); beep(880, 0.12, 'square', 0.14, 1.5); }
}
function sfxNearMiss() { beep(180, 0.05, 'sawtooth', 0.08, 0.5); noiseBurst(0.06, 0.08, 800); }
function sfxCrash() { noiseBurst(0.35, 0.45, 120); beep(90, 0.4, 'sawtooth', 0.25, 0.3); beep(60, 0.5, 'sine', 0.2, 0.4); }
function sfxBoost() { beep(220, 0.15, 'sawtooth', 0.1, 2.2); }
function sfxUI() { beep(400, 0.05, 'sine', 0.1); beep(600, 0.08, 'sine', 0.08); }
// =============================================================================
// === PARTICLES ===============================================================
// =============================================================================
const particles = [];
function spawnParticle(x, y, z, opts) {
if (particles.length >= C.PARTICLE_MAX) particles.splice(0, 40);
particles.push({
x, y, z,
vx: opts.vx || 0,
vy: opts.vy || 0,
vz: opts.vz || 0,
life: opts.life || 0.6,
maxLife: opts.life || 0.6,
size: opts.size || 0.3,
color: opts.color || [255, 255, 255],
drag: opts.drag != null ? opts.drag : 0.98,
gravity: opts.gravity || 0,
glow: !!opts.glow,
});
}
function burst(x, y, z, n, color, speed, life) {
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const elev = (Math.random() - 0.3) * Math.PI * 0.6;
const sp = speed * (0.4 + Math.random() * 0.8);
spawnParticle(x, y, z, {
vx: Math.cos(a) * Math.cos(elev) * sp,
vy: Math.sin(elev) * sp + 2,
vz: Math.sin(a) * Math.cos(elev) * sp,
life: life * (0.5 + Math.random() * 0.6),
size: 0.15 + Math.random() * 0.45,
color,
gravity: -8,
glow: true,
});
}
}
function updateParticles(dt) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life -= dt;
if (p.life <= 0) { particles.splice(i, 1); continue; }
p.vx *= p.drag; p.vy *= p.drag; p.vz *= p.drag;
p.vy += p.gravity * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
}
}
// =============================================================================
// === PROCEDURAL WORLD ========================================================
// =============================================================================
// Stream of segments along +Z (forward). Player starts at z=0 heading +Z?
// Convention: craft moves +Z, camera looks +Z, sun is far +Z on horizon-ish
// Actually classic race-the-sun: forward is into the distance toward the sun.
// We'll use +Z as forward (increasing z = further ahead into the world).
function createWorld(seed) {
const rng = makeRng(seed);
return {
seed,
rng,
nextZ: 20,
entities: [], // obstacles, pickups, ramps
decor: [], // distant pyramids / pillars for depth
groundStripes: [], // minor ground detail
shadowRects: [], // dynamic: tall obstacle shadows (approx other entities)
};
}
function spawnWave(world, playerZ, speed) {
// Tighten spacing with speed
const speedT = clamp((speed - C.BASE_SPEED) / (C.MAX_SPEED - C.BASE_SPEED), 0, 1);
const gap = lerp(C.SPAWN_DIST_BASE, C.SPAWN_DIST_MIN, speedT);
const count = randInt(world.rng, C.WAVE_SIZE[0], C.WAVE_SIZE[1]);
const baseZ = world.nextZ;
world.nextZ += gap + count * 3;
// Occasionally leave a rest zone early on
if (playerZ < 80 && world.rng() < 0.15) {
world.nextZ += 15;
return;
}
for (let i = 0; i < count; i++) {
const z = baseZ + i * randRange(world.rng, 2.5, 5.5);
const roll = world.rng();
if (roll < C.OBSTACLE_CHANCE) {
spawnObstacle(world, z, speedT);
} else if (roll < C.OBSTACLE_CHANCE + C.PICKUP_CHANCE) {
spawnPickup(world, z);
} else if (roll < C.OBSTACLE_CHANCE + C.PICKUP_CHANCE + C.RAMP_CHANCE) {
spawnRamp(world, z);
} else {
spawnObstacle(world, z, speedT);
if (world.rng() < 0.4) spawnPickup(world, z + 2);
}
}
// Occasional wall formation / gate
if (world.rng() < 0.18 + speedT * 0.15) {
spawnGate(world, baseZ + gap * 0.5, speedT);
}
// Decor pillars far off sides
if (world.rng() < 0.5) {
for (let k = 0; k < 3; k++) {
world.decor.push({
x: (world.rng() < 0.5 ? -1 : 1) * randRange(world.rng, C.LANE_HALF + 4, C.LANE_HALF + 40),
z: baseZ + randRange(world.rng, 0, gap),
h: randRange(world.rng, 4, 22),
w: randRange(world.rng, 1.5, 4),
d: randRange(world.rng, 1.5, 4),
});
}
}
}
function spawnObstacle(world, z, speedT) {
const styles = ['block', 'pillar', 'wedge', 'thin'];
const style = pick(world.rng, styles);
let w, h, d, x;
if (style === 'pillar') {
w = randRange(world.rng, 1.2, 2.2);
d = w;
h = randRange(world.rng, 3, 10 + speedT * 4);
x = randRange(world.rng, -C.LANE_HALF + 1, C.LANE_HALF - 1);
} else if (style === 'thin') {
w = randRange(world.rng, 0.8, 1.4);
d = randRange(world.rng, 3, 8);
h = randRange(world.rng, 2, 5);
x = randRange(world.rng, -C.LANE_HALF + 2, C.LANE_HALF - 2);
} else if (style === 'wedge') {
w = randRange(world.rng, 3, 7);
d = randRange(world.rng, 2, 4);
h = randRange(world.rng, 1.5, 4);
x = randRange(world.rng, -C.LANE_HALF + 2, C.LANE_HALF - 2);
} else {
w = randRange(world.rng, 2, 5.5);
d = randRange(world.rng, 2, 4.5);
h = randRange(world.rng, 1.8, 6 + speedT * 2);
x = randRange(world.rng, -C.LANE_HALF + w * 0.5, C.LANE_HALF - w * 0.5);
}
// Sometimes low blockers you must pitch over mentally with height feeling
world.entities.push({
type: 'obstacle', style, x, z, w, h, d,
y: 0,
alive: true,
nearMissed: false,
});
}
function spawnGate(world, z, speedT) {
const gap = randRange(world.rng, 4.5, 8 - speedT * 1.5);
const gapX = randRange(world.rng, -C.LANE_HALF + gap, C.LANE_HALF - gap);
const h = randRange(world.rng, 3, 7);
// left wall
const leftW = (gapX - gap * 0.5) - (-C.LANE_HALF);
if (leftW > 1.5) {
world.entities.push({
type: 'obstacle', style: 'block',
x: -C.LANE_HALF + leftW * 0.5,
z, w: leftW, h, d: 2.5, y: 0, alive: true, nearMissed: false,
});
}
// right wall
const rightStart = gapX + gap * 0.5;
const rightW = C.LANE_HALF - rightStart;
if (rightW > 1.5) {
world.entities.push({
type: 'obstacle', style: 'block',
x: rightStart + rightW * 0.5,
z, w: rightW, h, d: 2.5, y: 0, alive: true, nearMissed: false,
});
}
// hanging top sometimes
if (world.rng() < 0.35 + speedT * 0.2) {
world.entities.push({
type: 'obstacle', style: 'block',
x: gapX, z,
w: gap + 1, h: 2 + speedT * 2, d: 2,
y: h + 1.5, alive: true, nearMissed: false, hanging: true,
});
}
}
function spawnPickup(world, z) {
const r = world.rng();
let kind = 'token';
if (r < 0.45) kind = 'energy';
else if (r < 0.6) kind = 'multi';
const x = randRange(world.rng, -C.LANE_HALF + 1.5, C.LANE_HALF - 1.5);
const y = randRange(world.rng, 0.6, 3.2);
world.entities.push({
type: 'pickup', kind, x, y, z,
r: kind === 'multi' ? 0.7 : 0.55,
spin: world.rng() * Math.PI * 2,
alive: true,
});
}
function spawnRamp(world, z) {
const x = randRange(world.rng, -C.LANE_HALF + 4, C.LANE_HALF - 4);
world.entities.push({
type: 'ramp', x, z,
w: randRange(world.rng, 4, 8),
d: randRange(world.rng, 6, 12),
h: randRange(world.rng, 2, 4),
alive: true,
});
}
function ensureWorld(world, playerZ, speed) {
while (world.nextZ < playerZ + 180) {
spawnWave(world, playerZ, speed);
}
// cull behind
const minZ = playerZ - 30;
world.entities = world.entities.filter(e => e.z > minZ && e.alive !== false);
world.decor = world.decor.filter(d => d.z > minZ - 20);
}
// =============================================================================
// === GAME STATE ==============================================================
// =============================================================================
const state = {
mode: 'title', // title | playing | gameover
t: 0,
seed: 1,
world: null,
player: null,
score: 0,
distance: 0,
multiplier: 1,
multiTimer: 0,
highScore: 0,
shake: 0,
flash: 0,
speedLines: 0,
messages: [],
titlePulse: 0,
deathReason: '',
};
try {
state.highScore = parseInt(localStorage.getItem('solarDashHS') || '0', 10) || 0;
} catch (_) {}
function makePlayer() {
return {
x: 0, y: 1.2, z: 0,
vx: 0, vy: 0,
targetX: 0, targetY: 1.2,
roll: 0, pitch: 0,
speed: C.BASE_SPEED,
energy: C.ENERGY_MAX,
boosting: false,
alive: true,
invuln: 0,
trail: [],
};
}
function startRun() {
tryUnlockAudio();
sfxUI();
state.mode = 'playing';
state.t = 0;
state.seed = (Date.now() ^ (Math.random() * 1e9)) >>> 0;
state.world = createWorld(state.seed);
state.player = makePlayer();
state.score = 0;
state.distance = 0;
state.multiplier = 1;
state.multiTimer = 0;
state.shake = 0;
state.flash = 0;
state.speedLines = 0;
state.messages = [];
state.deathReason = '';
particles.length = 0;
// seed some early empty runway
state.world.nextZ = 40;
ensureWorld(state.world, 0, C.BASE_SPEED);
}
function gameOver(reason) {
if (state.mode !== 'playing') return;
state.mode = 'gameover';
state.deathReason = reason || 'Destroyed';
state.flash = 0.8;
state.shake = 18;
sfxCrash();
const p = state.player;
burst(p.x, p.y, p.z, 60, [255, 180, 80], 22, 1.1);
burst(p.x, p.y, p.z, 30, [255, 80, 60], 14, 0.8);
burst(p.x, p.y, p.z, 20, [255, 255, 200], 8, 0.5);
if (state.score > state.highScore) {
state.highScore = state.score | 0;
try { localStorage.setItem('solarDashHS', String(state.highScore)); } catch (_) {}
}
}
function addMessage(text, color) {
state.messages.push({ text, color, life: 1.2 });
}
// =============================================================================
// === UPDATE ==================================================================
// =============================================================================
function update(dt) {
state.t += dt;
state.titlePulse += dt;
if (state.shake > 0) state.shake *= Math.pow(C.SHAKE_DECAY, dt * 60);
if (state.shake < 0.05) state.shake = 0;
if (state.flash > 0) state.flash = Math.max(0, state.flash - dt * 2.5);
for (let i = state.messages.length - 1; i >= 0; i--) {
state.messages[i].life -= dt;
if (state.messages[i].life <= 0) state.messages.splice(i, 1);
}
updateParticles(dt);
if (state.mode !== 'playing') {
setDrone(0, false);
// gently animate title particles
if (Math.random() < 0.3) {
spawnParticle((Math.random() - 0.5) * 30, Math.random() * 4, state.t * 20 + Math.random() * 40, {
vx: (Math.random() - 0.5) * 2, vy: Math.random() * 2, vz: -5 - Math.random() * 10,
life: 1.5, size: 0.2, color: [255, 180, 100], glow: true,
});
}
return;
}
const p = state.player;
const axis = inputAxis();
// Speed ramp
p.speed = Math.min(C.MAX_SPEED, p.speed + C.ACCEL * dt);
const speedNorm = clamp((p.speed - C.BASE_SPEED) / (C.MAX_SPEED - C.BASE_SPEED), 0, 1);
// Boost
p.boosting = false;
if ((keys[' '] || keys['spacebar']) && p.energy > 5) {
if (!p._boosted) { sfxBoost(); p._boosted = true; }
p.boosting = true;
p.energy -= C.BOOST_DRAIN * dt;
if (p.energy < 0) p.energy = 0;
} else {
p._boosted = false;
}
const boostMul = p.boosting ? C.BOOST_MULT : 1;
const forwardSpeed = p.speed * boostMul;
// Strafe / pitch with snappy ease + inertia tail
p.targetX = clamp(p.targetX + axis.x * C.STRAFE_SPEED * dt, -C.LANE_HALF + 0.8, C.LANE_HALF - 0.8);
// Micro-correct toward target with ease; velocity for inertia feel
const ease = 1 - Math.pow(1 - C.STRAFE_EASE, dt * 60);
const desiredVx = (p.targetX - p.x) * (1 / 0.09);
p.vx = lerp(p.vx, desiredVx, ease);
p.vx *= Math.pow(C.INERTIA, dt * 60 * 0.15);
p.x += p.vx * dt;
p.x = clamp(p.x, -C.LANE_HALF + 0.5, C.LANE_HALF - 0.5);
p.targetY = clamp(p.targetY + axis.y * C.PITCH_SPEED * dt, 0.6, C.PITCH_RANGE);
const desiredVy = (p.targetY - p.y) * (1 / 0.1);
p.vy = lerp(p.vy, desiredVy, ease);
p.y += p.vy * dt;
p.y = clamp(p.y, 0.5, C.PITCH_RANGE + 0.5);
// Visual bank / pitch
const rollTarget = clamp(-axis.x * 0.55 - p.vx * 0.015, -0.7, 0.7);
p.roll = lerp(p.roll, rollTarget, 1 - Math.pow(0.001, dt));
const pitchTarget = clamp((p.targetY - 1.2) * 0.08 - axis.y * 0.15, -0.35, 0.4);
p.pitch = lerp(p.pitch, pitchTarget, 1 - Math.pow(0.001, dt));
// Forward
p.z += forwardSpeed * dt;
state.distance = p.z;
state.score += forwardSpeed * dt * C.DIST_SCORE_RATE * state.multiplier;
// Sun sink
const sun = getSunHeight();
if (sun <= 0.02) {
gameOver('The sun has set');
return;
}
// Energy drain (+ shadow)
const inShadow = isInShadow(p.x, p.y, p.z);
let drain = C.ENERGY_DRAIN * (0.7 + speedNorm * 0.5);
if (inShadow) drain += C.ENERGY_SHADOW;
// Near edges feel cooler / more drain slightly
drain += Math.abs(p.x) / C.LANE_HALF * 1.2;
p.energy -= drain * dt;
if (p.energy <= 0) {
p.energy = 0;
gameOver('Energy depleted');
return;
}
// Multiplier timer
if (state.multiTimer > 0) {
state.multiTimer -= dt;
if (state.multiTimer <= 0) {
state.multiplier = Math.max(1, state.multiplier - 1);
if (state.multiplier > 1) state.multiTimer = 4;
}
}
// Trail
p.trail.push({ x: p.x, y: p.y, z: p.z, life: 0.35 });
if (p.trail.length > 28) p.trail.shift();
for (const t of p.trail) t.life -= dt;
// Thrust particles
if (Math.random() < 0.9 + speedNorm) {
const side = (Math.random() - 0.5) * 0.6;
spawnParticle(p.x + side, p.y - 0.15, p.z - 0.8, {
vx: -p.vx * 0.1 + (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 1.5,
vz: -forwardSpeed * 0.15 - Math.random() * 8,
life: 0.25 + Math.random() * 0.2,
size: 0.12 + Math.random() * 0.2,
color: p.boosting ? [120, 240, 255] : [255, 160 + Math.random() * 60, 60],
glow: true,
drag: 0.96,
});
}
if (p.boosting && Math.random() < 0.7) {
burst(p.x, p.y, p.z - 1, 2, [100, 220, 255], 6, 0.3);
}
ensureWorld(state.world, p.z, p.speed);
setDrone(speedNorm + (p.boosting ? 0.25 : 0), true);
state.speedLines = smoothstep(clamp((speedNorm - 0.35) / 0.5, 0, 1));
// Entity interactions
const craftHalfW = 0.7, craftHalfH = 0.35, craftHalfD = 1.0;
for (const e of state.world.entities) {
if (!e.alive) continue;
if (e.type === 'pickup') {
e.spin += dt * 4;
e.yHover = Math.sin(state.t * 4 + e.spin) * 0.25;
const dy = (e.y + (e.yHover || 0)) - p.y;
const dsq = dist2(e.x, e.z, p.x, p.z) + dy * dy;
if (dsq < (e.r + 1.1) * (e.r + 1.1)) {
e.alive = false;
collectPickup(e);
}
continue;
}
if (e.type === 'ramp') {
// Soft vertical assist when over ramp footprint
if (Math.abs(p.x - e.x) < e.w * 0.5 && p.z > e.z - e.d * 0.5 && p.z < e.z + e.d * 0.5) {
const local = (p.z - (e.z - e.d * 0.5)) / e.d;
const rampY = local * e.h + 0.8;
if (p.y < rampY && p.y > rampY - 2) {
p.y = lerp(p.y, rampY + 0.3, 0.2);
p.targetY = Math.max(p.targetY, rampY);
}
}
continue;
}
if (e.type === 'obstacle') {
// AABB vs craft
const ey = e.y || 0;
const closestX = clamp(p.x, e.x - e.w * 0.5, e.x + e.w * 0.5);
const closestY = clamp(p.y, ey, ey + e.h);
const closestZ = clamp(p.z, e.z - e.d * 0.5, e.z + e.d * 0.5);
const dx = p.x - closestX;
const dy = p.y - closestY;
const dz = p.z - closestZ;
const d2 = dx * dx + dy * dy + dz * dz;
const rad = 0.55;
if (d2 < rad * rad) {
gameOver('Crashed');
return;
}
// Near miss: lateral proximity while passing in z
if (!e.nearMissed && Math.abs(p.z - e.z) < e.d * 0.5 + 1.5) {
const lateral = Math.sqrt(dx * dx + dy * dy);
if (lateral < C.NEAR_MISS_DIST && lateral > rad) {
e.nearMissed = true;
state.score += C.NEAR_MISS_SCORE * state.multiplier;
state.shake = Math.max(state.shake, 5);
sfxNearMiss();
addMessage('NEAR MISS +' + (C.NEAR_MISS_SCORE * state.multiplier | 0), C.TOKEN_COL);
burst(p.x, p.y, p.z, 8, [255, 255, 255], 10, 0.35);
}
}
}
}
}
function getSunHeight() {
if (!state.player) return 1;
const t = state.t;
const speedBoost = Math.max(0, state.player.speed - C.BASE_SPEED) * C.SUN_SINK_SPEED;
return clamp(C.SUN_START - t * C.SUN_SINK_RATE - speedBoost * t * 0.15, 0, 1);
}
function isInShadow(x, y, z) {
// Sun is high on +Z horizon; shadows cast roughly toward -Z and slightly biased by sun height
// Approximate: if a taller obstacle is ahead (+z) within shadow length covering us
const sun = getSunHeight();
const shadowScale = lerp(18, 6, sun); // longer shadows when sun low
for (const e of state.world.entities) {
if (e.type !== 'obstacle' || !e.alive) continue;
const top = (e.y || 0) + e.h;
if (top < y + 0.5) continue;
// shadow projects backward (-Z) from obstacle
const shadowLen = top * shadowScale * 0.15 + 3;
if (z < e.z && z > e.z - shadowLen) {
// expand shadow slightly in x
const expand = (e.z - z) * 0.08;
if (Math.abs(x - e.x) < e.w * 0.5 + expand + 0.4) {
// y: full height shadow column (simplified)
if (y < top) return true;
}
}
}
return false;
}
function collectPickup(e) {
const p = state.player;
if (e.kind === 'energy') {
p.energy = Math.min(C.ENERGY_MAX, p.energy + C.ENERGY_PICKUP);
sfxPickup('energy');
burst(e.x, e.y, e.z, 14, C.ENERGY_COL, 10, 0.5);
addMessage('+' + C.ENERGY_PICKUP + ' ENERGY', C.ENERGY_COL);
} else if (e.kind === 'token') {
const pts = (C.TOKEN_SCORE * state.multiplier) | 0;
state.score += pts;
sfxPickup('token');
burst(e.x, e.y, e.z, 12, C.TOKEN_COL, 9, 0.45);
addMessage('+' + pts, C.TOKEN_COL);
} else {
state.multiplier = Math.min(9, state.multiplier + 1);
state.multiTimer = 6;
sfxPickup('multi');
burst(e.x, e.y, e.z, 20, C.MULTI_COL, 12, 0.6);
addMessage('x' + state.multiplier + ' MULTIPLIER', C.MULTI_COL);
state.shake = Math.max(state.shake, 3);
}
}
// =============================================================================
// === RENDER / CAMERA =========================================================
// =============================================================================
function project(x, y, z, cam) {
// Camera space
const dx = x - cam.x;
const dy = y - cam.y;
const dz = z - cam.z;
// Look slightly down the path; simple yaw 0
if (dz <= 0.15) return null;
const scale = (H * 0.75) / (dz * C.FOV);
const sx = W * 0.5 + dx * scale + cam.shakeX;
const sy = H * 0.52 - dy * scale + cam.shakeY;
return { sx, sy, scale, dz };
}
function rgb(c, a) {
if (a == null) return `rgb(${c[0]|0},${c[1]|0},${c[2]|0})`;
return `rgba(${c[0]|0},${c[1]|0},${c[2]|0},${a})`;
}
function mixColor(a, b, t) {
return [
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t,
];
}
function getCamera() {
const p = state.player || { x: 0, y: 1.2, z: 0, roll: 0 };
const sh = state.shake;
const ang = state.t * 40;
return {
x: p.x * 0.85,
y: p.y + C.CAM_HEIGHT,
z: p.z - C.CAM_DIST,
shakeX: sh ? (Math.random() - 0.5) * sh * 2 : 0,
shakeY: sh ? (Math.random() - 0.5) * sh * 2 : 0,
roll: p.roll * 0.25,
};
}
function drawSky(cam, sunH) {
// Vertical gradient backdrop
const g = ctx.createLinearGradient(0, 0, 0, H);
const dusk = 1 - sunH;
const top = mixColor(C.SKY_TOP, [8, 4, 18], dusk * 0.7);
const mid = mixColor(C.SKY_MID, [60, 20, 50], dusk * 0.5);
const hor = mixColor(C.SKY_HORIZON, [120, 40, 40], dusk * 0.6);
g.addColorStop(0, rgb(top));
g.addColorStop(0.45, rgb(mid));
g.addColorStop(0.72, rgb(hor));
g.addColorStop(1, rgb(mixColor(hor, C.GROUND, 0.5)));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
// Sun
const sunZ = (state.player ? state.player.z : 0) + 220;
const sunY = lerp(-2, 28, sunH);
const sp = project(0, sunY, sunZ, cam);
if (sp) {
const R = 40 + sunH * 50;
// glow layers
for (let i = 5; i >= 0; i--) {
const rr = R * (1 + i * 0.55);
const alpha = 0.07 * (1 - i / 6) * (0.5 + sunH * 0.5);
const grd = ctx.createRadialGradient(sp.sx, sp.sy, 0, sp.sx, sp.sy, rr);
grd.addColorStop(0, rgb(C.SUN_GLOW, alpha * 2));
grd.addColorStop(1, rgb(C.SUN_GLOW, 0));
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(sp.sx, sp.sy, rr, 0, Math.PI * 2);
ctx.fill();
}
const core = ctx.createRadialGradient(sp.sx, sp.sy, 0, sp.sx, sp.sy, R);
core.addColorStop(0, rgb(C.SUN_CORE, 0.95));
core.addColorStop(0.5, rgb(C.SUN_GLOW, 0.9));
core.addColorStop(1, rgb(C.SUN_GLOW, 0));
ctx.fillStyle = core;
ctx.beginPath();
ctx.arc(sp.sx, sp.sy, R, 0, Math.PI * 2);
ctx.fill();
}
// Horizon haze line
const hz = project(0, 0, (state.player ? state.player.z : 0) + 200, cam);
if (hz) {
ctx.fillStyle = rgb(hor, 0.15);
ctx.fillRect(0, hz.sy - 2, W, 8);
}
}
function drawGround(cam) {
const p = state.player || { z: 0, x: 0 };
const sunH = getSunHeight();
// Perspective ground grid / chevrons toward sun
ctx.lineWidth = 1;
// solid ground plane fill via trapezoid bands
for (let i = 40; i >= 0; i--) {
const z0 = p.z - 10 + i * 6;
const z1 = z0 + 6;
const a = project(-C.LANE_HALF - 30, 0, z0, cam);
const b = project(C.LANE_HALF + 30, 0, z0, cam);
const c = project(C.LANE_HALF + 30, 0, z1, cam);
const d = project(-C.LANE_HALF - 30, 0, z1, cam);
if (!a || !b || !c || !d) continue;
const lit = 0.3 + sunH * 0.4 + Math.sin(z0 * 0.05) * 0.03;
const col = mixColor(C.GROUND, C.GROUND_LIT, lit * (i / 40));
ctx.fillStyle = rgb(col);
ctx.beginPath();
ctx.moveTo(a.sx, a.sy); ctx.lineTo(b.sx, b.sy); ctx.lineTo(c.sx, c.sy); ctx.lineTo(d.sx, d.sy);
ctx.closePath();
ctx.fill();
}
// Center path strip
for (let i = 0; i < 50; i++) {
const z0 = p.z + i * 5;
const fade = 1 - i / 50;
const a = project(-C.LANE_HALF, 0.02, z0, cam);
const b = project(C.LANE_HALF, 0.02, z0, cam);
if (!a || !b) continue;
ctx.strokeStyle = rgb([80, 50, 70], 0.12 * fade);
ctx.beginPath();
ctx.moveTo(a.sx, a.sy); ctx.lineTo(b.sx, b.sy);
ctx.stroke();
}
// Lane edge rails
const edges = [-C.LANE_HALF, C.LANE_HALF];
for (const ex of edges) {
ctx.beginPath();
let started = false;
for (let i = 0; i < 60; i++) {
const z = p.z - 8 + i * 5;
const pt = project(ex, 0.05, z, cam);
if (!pt) continue;
if (!started) { ctx.moveTo(pt.sx, pt.sy); started = true; }
else ctx.lineTo(pt.sx, pt.sy);
}
ctx.strokeStyle = rgb([255, 140, 80], 0.35);
ctx.lineWidth = 2;
ctx.stroke();
}
// Grid ticks
ctx.lineWidth = 1;
for (let i = 0; i < 40; i++) {
const z = Math.floor((p.z - 5) / 8) * 8 + i * 8;
const fade = clamp(1 - (z - p.z) / 160, 0, 1);
const l = project(-C.LANE_HALF, 0.03, z, cam);
const r = project(C.LANE_HALF, 0.03, z, cam);
if (!l || !r) continue;
ctx.strokeStyle = rgb([255, 180, 100], 0.07 * fade);
ctx.beginPath(); ctx.moveTo(l.sx, l.sy); ctx.lineTo(r.sx, r.sy); ctx.stroke();
}
}
function drawBox(e, cam, sunH) {
const x = e.x, z = e.z, y = e.y || 0;
const hw = e.w * 0.5, hd = e.d * 0.5, h = e.h;
// 8 corners → project visible faces roughly as 2.5D extruded poly
const corners = [
[x - hw, y, z - hd],
[x + hw, y, z - hd],
[x + hw, y, z + hd],
[x - hw, y, z + hd],
[x - hw, y + h, z - hd],
[x + hw, y + h, z - hd],
[x + hw, y + h, z + hd],
[x - hw, y + h, z + hd],
];
const pts = corners.map(c => project(c[0], c[1], c[2], cam));
if (pts.some(p => !p)) return;
const depth = pts.reduce((s, p) => s + p.dz, 0) / 8;
// Shadow blob on ground (toward -z)
const shadowLen = h * lerp(1.8, 0.5, sunH);
const sa = project(x - hw * 1.05, 0.02, z - hd, cam);
const sb = project(x + hw * 1.05, 0.02, z - hd, cam);
const sc = project(x + hw * 1.2, 0.02, z - hd - shadowLen, cam);
const sd = project(x - hw * 1.2, 0.02, z - hd - shadowLen, cam);
if (sa && sb && sc && sd) {
ctx.fillStyle = rgb(C.SHADOW, 0.45);
ctx.beginPath();
ctx.moveTo(sa.sx, sa.sy); ctx.lineTo(sb.sx, sb.sy); ctx.lineTo(sc.sx, sc.sy); ctx.lineTo(sd.sx, sd.sy);
ctx.closePath();
ctx.fill();
}
const litAmt = 0.35 + sunH * 0.5;
const base = mixColor(C.OBSTACLE, C.OBSTACLE_LIT, litAmt * clamp(1.2 - depth / 120, 0.2, 1));
const topCol = mixColor(base, C.SUN_GLOW, 0.15 + sunH * 0.2);
const sideCol = mixColor(base, [0, 0, 0], 0.25);
// painter-ish: back face, sides, top, front
function face(idx, col, alpha) {
ctx.fillStyle = rgb(col, alpha == null ? 1 : alpha);
ctx.beginPath();
ctx.moveTo(pts[idx[0]].sx, pts[idx[0]].sy);
for (let i = 1; i < idx.length; i++) ctx.lineTo(pts[idx[i]].sx, pts[idx[i]].sy);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = rgb([255, 200, 150], 0.08);
ctx.lineWidth = 1;
ctx.stroke();
}
face([3, 2, 6, 7], sideCol); // back-ish +z top edges
face([0, 3, 7, 4], mixColor(sideCol, [0,0,0], 0.15));
face([1, 2, 6, 5], mixColor(base, C.SUN_GLOW, 0.08));
face([4, 5, 6, 7], topCol);
face([0, 1, 5, 4], mixColor(base, [255,255,255], 0.05)); // front
return depth;
}
function drawRamp(e, cam) {
const x = e.x, z = e.z, hw = e.w * 0.5, hd = e.d * 0.5, h = e.h;
const pts = [
project(x - hw, 0, z - hd, cam),
project(x + hw, 0, z - hd, cam),
project(x + hw, h, z + hd, cam),
project(x - hw, h, z + hd, cam),
];
if (pts.some(p => !p)) return;
ctx.fillStyle = rgb(mixColor(C.OBSTACLE_LIT, C.SUN_GLOW, 0.2), 0.85);
ctx.beginPath();
ctx.moveTo(pts[0].sx, pts[0].sy);
for (let i = 1; i < 4; i++) ctx.lineTo(pts[i].sx, pts[i].sy);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = rgb([255, 200, 120], 0.4);
ctx.stroke();
}
function drawPickup(e, cam) {
const y = e.y + (e.yHover || 0);
const p = project(e.x, y, e.z, cam);
if (!p) return;
const col = e.kind === 'energy' ? C.ENERGY_COL : e.kind === 'multi' ? C.MULTI_COL : C.TOKEN_COL;
const r = Math.max(3, e.r * p.scale);
// glow
const g = ctx.createRadialGradient(p.sx, p.sy, 0, p.sx, p.sy, r * 3);
g.addColorStop(0, rgb(col, 0.55));
g.addColorStop(1, rgb(col, 0));
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(p.sx, p.sy, r * 3, 0, Math.PI * 2); ctx.fill();
ctx.save();
ctx.translate(p.sx, p.sy);
ctx.rotate(e.spin);
ctx.fillStyle = rgb(col);
ctx.strokeStyle = rgb([255, 255, 255], 0.7);
ctx.lineWidth = 1.5;
if (e.kind === 'energy') {
// bolt-like hex
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
const rr = r * (i % 2 ? 0.6 : 1);
i === 0 ? ctx.moveTo(Math.cos(a) * rr, Math.sin(a) * rr) : ctx.lineTo(Math.cos(a) * rr, Math.sin(a) * rr);
}
ctx.closePath(); ctx.fill(); ctx.stroke();
} else if (e.kind === 'multi') {
ctx.beginPath();
for (let i = 0; i < 5; i++) {
const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
const a2 = a + Math.PI / 5;
i === 0 ? ctx.moveTo(Math.cos(a) * r, Math.sin(a) * r) : ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r);
ctx.lineTo(Math.cos(a2) * r * 0.45, Math.sin(a2) * r * 0.45);
}
ctx.closePath(); ctx.fill(); ctx.stroke();
} else {
ctx.beginPath();
ctx.moveTo(0, -r); ctx.lineTo(r * 0.7, 0); ctx.lineTo(0, r); ctx.lineTo(-r * 0.7, 0);
ctx.closePath(); ctx.fill(); ctx.stroke();
}
ctx.restore();
}
function drawDecor(d, cam, sunH) {
drawBox({ x: d.x, y: 0, z: d.z, w: d.w, h: d.h, d: d.d }, cam, sunH);
}
function drawCraft(cam) {
const p = state.player;
if (!p) return;
// Build a sleek delta craft as projected polygons with roll/pitch
const cosR = Math.cos(p.roll), sinR = Math.sin(p.roll);
const cosP = Math.cos(p.pitch), sinP = Math.sin(p.pitch);
function local(lx, ly, lz) {
// pitch then roll
let y1 = ly * cosP - lz * sinP;
let z1 = ly * sinP + lz * cosP;
let x1 = lx * cosR - y1 * sinR;
let y2 = lx * sinR + y1 * cosR;
return project(p.x + x1, p.y + y2, p.z + z1, cam);
}
const nose = local(0, 0, 1.6);
const left = local(-1.3, 0, -1.1);
const right = local(1.3, 0, -1.1);
const tail = local(0, 0.15, -1.3);
const canopy = local(0, 0.35, 0.2);
const lWing = local(-1.8, -0.1, -0.3);
const rWing = local(1.8, -0.1, -0.3);
if (!nose || !left || !right) return;
// Trail ribbon
if (p.trail.length > 2) {
ctx.beginPath();
let first = true;
for (let i = 0; i < p.trail.length; i++) {
const t = p.trail[i];
if (t.life <= 0) continue;
const tp = project(t.x, t.y, t.z, cam);
if (!tp) continue;
if (first) { ctx.moveTo(tp.sx, tp.sy); first = false; }
else ctx.lineTo(tp.sx, tp.sy);
}
ctx.strokeStyle = rgb(p.boosting ? C.CRAFT_ACCENT : C.CRAFT, 0.35);
ctx.lineWidth = 2;
ctx.stroke();
}
// Wing body
ctx.fillStyle = rgb(C.CRAFT);
ctx.strokeStyle = rgb([255, 255, 255], 0.5);
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(nose.sx, nose.sy);
if (rWing) ctx.lineTo(rWing.sx, rWing.sy);
if (right) ctx.lineTo(right.sx, right.sy);
if (tail) ctx.lineTo(tail.sx, tail.sy);
if (left) ctx.lineTo(left.sx, left.sy);
if (lWing) ctx.lineTo(lWing.sx, lWing.sy);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Canopy
if (canopy && tail) {
ctx.fillStyle = rgb(C.CRAFT_ACCENT, 0.85);
ctx.beginPath();
ctx.moveTo(lerp(nose.sx, canopy.sx, 0.3), lerp(nose.sy, canopy.sy, 0.3));
ctx.lineTo(canopy.sx, canopy.sy);
ctx.lineTo(lerp(tail.sx, canopy.sx, 0.4), lerp(tail.sy, canopy.sy, 0.4));
ctx.closePath();
ctx.fill();
}
// Engine glow
const eng = local(0, 0, -1.4);
if (eng) {
const R = (p.boosting ? 14 : 8) + Math.sin(state.t * 30) * 2;
const eg = ctx.createRadialGradient(eng.sx, eng.sy, 0, eng.sx, eng.sy, R);
eg.addColorStop(0, rgb(p.boosting ? [180, 255, 255] : [255, 240, 180], 0.9));
eg.addColorStop(0.4, rgb(p.boosting ? C.CRAFT_ACCENT : C.SUN_GLOW, 0.5));
eg.addColorStop(1, rgb(C.SUN_GLOW, 0));
ctx.fillStyle = eg;
ctx.beginPath(); ctx.arc(eng.sx, eng.sy, R, 0, Math.PI * 2); ctx.fill();
}
}
function drawParticles(cam) {
for (const p of particles) {
const pr = project(p.x, p.y, p.z, cam);
if (!pr) continue;
const a = clamp(p.life / p.maxLife, 0, 1);
const r = Math.max(0.8, p.size * pr.scale);
if (p.glow) {
const g = ctx.createRadialGradient(pr.sx, pr.sy, 0, pr.sx, pr.sy, r * 2.5);
g.addColorStop(0, rgb(p.color, a * 0.8));
g.addColorStop(1, rgb(p.color, 0));
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(pr.sx, pr.sy, r * 2.5, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = rgb(p.color, a);
ctx.fillRect(pr.sx - r * 0.5, pr.sy - r * 0.5, r, r);
}
}
function drawSpeedLines(cam) {
if (state.speedLines <= 0.01 || state.mode !== 'playing') return;
const p = state.player;
const n = (30 * state.speedLines) | 0;
ctx.save();
for (let i = 0; i < n; i++) {
const x = (Math.random() - 0.5) * C.LANE_HALF * 2.5;
const y = Math.random() * 8;
const z = p.z + 2 + Math.random() * 40;
const a = project(x, y, z, cam);
const b = project(x, y, z + 2 + state.speedLines * 4, cam);
if (!a || !b) continue;
ctx.strokeStyle = rgb([255, 230, 200], 0.08 + state.speedLines * 0.12);
ctx.lineWidth = 1 + state.speedLines;
ctx.beginPath(); ctx.moveTo(a.sx, a.sy); ctx.lineTo(b.sx, b.sy); ctx.stroke();
}
ctx.restore();
// Subtle chromatic edge vignette at high speed
if (state.speedLines > 0.4) {
const a = (state.speedLines - 0.4) * 0.15;
const g = ctx.createRadialGradient(W/2, H/2, H * 0.3, W/2, H/2, H * 0.75);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, `rgba(40,0,20,${a})`);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
}
function drawHUD() {
const pad = 22;
ctx.save();
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
if (state.mode === 'playing' || state.mode === 'gameover') {
const p = state.player;
// Score block
ctx.fillStyle = rgb([255, 230, 200], 0.9);
ctx.font = '600 13px system-ui,sans-serif';
ctx.fillText('SCORE', pad, pad);
ctx.font = '700 32px system-ui,sans-serif';
ctx.fillText(String(state.score | 0), pad, pad + 16);
ctx.font = '600 12px system-ui,sans-serif';
ctx.fillStyle = rgb([255, 200, 140], 0.7);
ctx.fillText('DIST ' + (state.distance | 0) + ' m', pad, pad + 52);
ctx.fillText('SPD ' + (p.speed | 0), pad, pad + 68);
// Multiplier
if (state.multiplier > 1) {
ctx.fillStyle = rgb(C.MULTI_COL, 0.9);
ctx.font = '700 22px system-ui,sans-serif';
ctx.fillText('x' + state.multiplier, pad, pad + 88);
}
// High score
ctx.textAlign = 'right';
ctx.fillStyle = rgb([255, 220, 180], 0.55);
ctx.font = '600 12px system-ui,sans-serif';
ctx.fillText('BEST ' + state.highScore, W - pad, pad);
ctx.fillText('SEED ' + (state.seed >>> 0).toString(16).toUpperCase(), W - pad, pad + 18);
// Energy bar
const bw = 220, bh = 10;
const bx = (W - bw) / 2, by = H - 36;
ctx.textAlign = 'center';
ctx.fillStyle = rgb([255, 220, 180], 0.5);
ctx.font = '600 10px system-ui,sans-serif';
ctx.fillText('ENERGY', W / 2, by - 16);
ctx.fillStyle = rgb([30, 15, 30], 0.7);
ctx.fillRect(bx - 2, by - 2, bw + 4, bh + 4);
const er = p.energy / C.ENERGY_MAX;
const ecol = er < 0.25 ? [255, 60, 80] : er < 0.5 ? [255, 180, 60] : C.ENERGY_COL;
ctx.fillStyle = rgb(ecol, 0.9);
ctx.fillRect(bx, by, bw * er, bh);
if (p.boosting) {
ctx.strokeStyle = rgb(C.CRAFT_ACCENT, 0.8);
ctx.strokeRect(bx - 2, by - 2, bw + 4, bh + 4);
}
// Sun meter
const sunH = getSunHeight();
const sw = 6, sh = 80;
const sx = W - pad - sw, sy = H * 0.5 - sh * 0.5;
ctx.fillStyle = rgb([30, 15, 30], 0.6);
ctx.fillRect(sx - 2, sy - 2, sw + 4, sh + 4);
const sunCol = mixColor(C.SUN_GLOW, [255, 60, 40], 1 - sunH);
ctx.fillStyle = rgb(sunCol);
const sunFill = sh * sunH;
ctx.fillRect(sx, sy + sh - sunFill, sw, sunFill);
ctx.fillStyle = rgb([255, 220, 180], 0.45);
ctx.font = '600 9px system-ui,sans-serif';
ctx.textAlign = 'center';
ctx.fillText('SUN', sx + sw / 2, sy + sh + 10);
// Floating messages
ctx.textAlign = 'center';
ctx.font = '700 16px system-ui,sans-serif';
for (let i = 0; i < state.messages.length; i++) {
const m = state.messages[i];
const a = clamp(m.life, 0, 1);
ctx.fillStyle = rgb(m.color, a);
ctx.fillText(m.text, W / 2, H * 0.35 - i * 22 - (1 - a) * 16);
}
}
// Title screen
if (state.mode === 'title') {
drawCenterPanel(true);
}
if (state.mode === 'gameover') {
drawCenterPanel(false);
}
// Flash
if (state.flash > 0) {
ctx.fillStyle = `rgba(255,200,150,${state.flash * 0.55})`;
ctx.fillRect(0, 0, W, H);
}
ctx.restore();
}
function drawCenterPanel(isTitle) {
const pulse = 0.7 + Math.sin(state.titlePulse * 3) * 0.3;
ctx.save();
// dim
ctx.fillStyle = 'rgba(8,4,16,0.45)';
ctx.fillRect(0, 0, W, H);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (isTitle) {
ctx.fillStyle = rgb(C.SUN_GLOW, 0.95);
ctx.font = '700 56px system-ui,sans-serif';
ctx.fillText('SOLAR DASH', W / 2, H * 0.36);
ctx.fillStyle = rgb([255, 220, 180], 0.55);
ctx.font = '400 15px system-ui,sans-serif';
ctx.fillText('Outrun the dusk. Stay in the light.', W / 2, H * 0.36 + 42);
ctx.fillStyle = rgb([255, 240, 200], pulse);
ctx.font = '600 18px system-ui,sans-serif';
ctx.fillText('CLICK / SPACE / ENTER · PLAY', W / 2, H * 0.55);
ctx.fillStyle = rgb([255, 200, 150], 0.4);
ctx.font = '400 13px system-ui,sans-serif';
ctx.fillText('WASD or Arrows to strafe & pitch · Hold Space to boost', W / 2, H * 0.55 + 32);
if (state.highScore > 0) {
ctx.fillStyle = rgb(C.TOKEN_COL, 0.7);
ctx.font = '600 14px system-ui,sans-serif';
ctx.fillText('BEST SCORE ' + state.highScore, W / 2, H * 0.55 + 60);
}
} else {
ctx.fillStyle = rgb([255, 100, 90], 0.95);
ctx.font = '700 42px system-ui,sans-serif';
ctx.fillText('RUN ENDED', W / 2, H * 0.32);
ctx.fillStyle = rgb([255, 200, 160], 0.7);
ctx.font = '400 15px system-ui,sans-serif';
ctx.fillText(state.deathReason, W / 2, H * 0.32 + 36);
ctx.fillStyle = rgb(C.CRAFT, 0.95);
ctx.font = '700 48px system-ui,sans-serif';
ctx.fillText(String(state.score | 0), W / 2, H * 0.48);
ctx.fillStyle = rgb([255, 220, 180], 0.55);
ctx.font = '600 13px system-ui,sans-serif';
ctx.fillText('SCORE', W / 2, H * 0.48 + 32);
ctx.fillText('DISTANCE ' + (state.distance | 0) + ' m', W / 2, H * 0.48 + 54);
if (state.score >= state.highScore && state.score > 0) {
ctx.fillStyle = rgb(C.TOKEN_COL, pulse);
ctx.font = '700 16px system-ui,sans-serif';
ctx.fillText('★ NEW HIGH SCORE ★', W / 2, H * 0.48 + 82);
} else {
ctx.fillStyle = rgb([255, 200, 150], 0.45);
ctx.font = '600 13px system-ui,sans-serif';
ctx.fillText('BEST ' + state.highScore, W / 2, H * 0.48 + 82);
}
ctx.fillStyle = rgb([255, 240, 200], pulse);
ctx.font = '600 18px system-ui,sans-serif';
ctx.fillText('R / SPACE / CLICK · RETRY', W / 2, H * 0.72);
}
ctx.restore();
}
function render() {
const cam = getCamera();
const sunH = state.mode === 'title' ? 0.85 : getSunHeight();
const p = state.player;
// slight camera roll
ctx.save();
if (p && state.mode === 'playing') {
ctx.translate(W / 2, H / 2);
ctx.rotate(cam.roll * 0.15);
ctx.translate(-W / 2, -H / 2);
}
drawSky(cam, sunH);
drawGround(cam);
// Collect draw list sorted by depth
const drawList = [];
if (state.world) {
for (const d of state.world.decor) {
const pr = project(d.x, d.h * 0.5, d.z, cam);
if (pr) drawList.push({ z: pr.dz, fn: () => drawDecor(d, cam, sunH) });
}
for (const e of state.world.entities) {
if (e.alive === false) continue;
const pr = project(e.x, (e.y || 0) + (e.h || e.r || 1) * 0.5, e.z, cam);
if (!pr) continue;
if (e.type === 'obstacle') drawList.push({ z: pr.dz, fn: () => drawBox(e, cam, sunH) });
else if (e.type === 'ramp') drawList.push({ z: pr.dz, fn: () => drawRamp(e, cam) });
else if (e.type === 'pickup') drawList.push({ z: pr.dz, fn: () => drawPickup(e, cam) });
}
}
drawList.sort((a, b) => b.z - a.z);
for (const d of drawList) d.fn();
drawParticles(cam);
if (state.mode === 'playing' || (state.mode === 'gameover' && p)) drawCraft(cam);
drawSpeedLines(cam);
ctx.restore();
// Vignette
const vig = ctx.createRadialGradient(W/2, H/2, H * 0.25, W/2, H/2, H * 0.75);
vig.addColorStop(0, 'rgba(0,0,0,0)');
vig.addColorStop(1, 'rgba(6,2,12,0.55)');
ctx.fillStyle = vig;
ctx.fillRect(0, 0, W, H);
drawHUD();
}
// =============================================================================
// === MAIN LOOP ===============================================================
// =============================================================================
let last = performance.now();
let acc = 0;
function frame(now) {
let dt = (now - last) / 1000;
last = now;
// clamp huge tabs stalls
dt = Math.min(dt, 0.05);
update(dt);
render();
clearJust();
requestAnimationFrame(frame);
}
// Fake player for title cam drift
state.player = makePlayer();
state.world = createWorld(42);
ensureWorld(state.world, 0, C.BASE_SPEED);
requestAnimationFrame(frame);
</script>
</body>
</html>