Race the Sun
Deepseek v4.1 Flash · full

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 60,032 bytes · SHA-256 be4df7b0489f
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<title>SUNRUNNER</title>
<style>
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#07060f;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
#c{display:block;position:absolute;inset:0;cursor:crosshair;}
#boot{display:none;}
.hidden{opacity:0;}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="boot">CLICK TO LAUNCH</div>
<script>
"use strict";
// ============================================================================
// === CONSTANTS (tune everything here) ===
// ============================================================================
const CFG = {
// --- craft motion -------------------------------------------------------
strafeSpeed: 420, // max lateral units/sec
pitchSpeed: 250, // max vertical units/sec
accelTau: 0.088, // easing time-constant on input (snappy, ~90ms)
driftTau: 0.30, // gentle auto-centering toward mid
driftAmt: 0.35, // strength of the auto-centering inertia tail
playerHalf: { x: 13, y: 8, z: 15 },
// --- speed / difficulty -------------------------------------------------
baseSpeed: 200,
speedRamp: 2.7, // units/sec gained per second of play
maxSpeed: 590,
boostMult: 1.62,
boostDrain: 15,
boostMinEnergy: 6,
// --- world --------------------------------------------------------------
worldHalf: 330, // playfield half-width
yMin: 16, yMax: 230, // flight ceiling / floor for the craft
gridX: 46, gridZ: 62,
drawDist: 2500,
// --- camera -------------------------------------------------------------
camDist: 190,
camHeight: 92,
camLagX: 0.72,
camLagY: 0.45,
focal: 620,
horizonFrac: 0.455,
// --- sun & energy -------------------------------------------------------
sunStartFrac: 0.300,
sunEndFrac: 0.470,
sunSetTime: 100, // seconds until the sun is gone (hard cap on a run)
energyMax: 100,
energyStart: 100,
drainBase: 2.0, // /sec in full light
drainShadow: 12, // /sec in obstacle shadow / off-sun darkness
drainSpeed: 0.006, // extra /sec per unit of speed over base
pickupEnergy: 28,
// --- spawning -----------------------------------------------------------
gateSpacingSpeed: 0.62, // seconds of travel between gates
gateSpacingMin: 165,
pickupSpacing: 34,
// --- feel ---------------------------------------------------------------
nearMissDist: 62, // proximity that counts as a near miss
shakeNear: 5.5,
shakeCrash: 34,
shakeDecay: 5.2,
maxParticles: 900,
scorePerDist: 0.085,
tokenScore: 100,
nearMissScore: 26,
multPerOrb: 0.5,
multMax: 9,
multDecayDelay: 3.2,
multDecayRate: 0.22,
};
// Palette (rgb arrays so we can lerp / fog them)
const COL = {
skyTop: [22, 14, 54],
skyTopSet: [7, 5, 20],
skyMid: [104, 48, 118],
skyMidSet: [30, 16, 46],
horizon: [255, 152, 74],
horizonSet:[167, 62, 66],
sunCore: [255, 244, 205],
sunGlow: [255, 132, 52],
ground: [24, 18, 38],
groundLit: [64, 40, 62],
grid: [120, 96, 160],
fog: [110, 60, 96],
obs: {
pillar: [ 44, 128, 146],
wall: [136, 44, 102],
float: [166, 110, 38],
low: [ 74, 60, 130],
},
shadow: [12, 8, 26],
player: [232, 246, 255],
playerLit: [120, 240, 255],
thrust: [255, 176, 92],
energy: [ 96, 236, 255],
token: [255, 214, 96],
orb: [255, 92, 200],
danger: [255, 86, 86],
};
const HISCORE_KEY = "sunrunner.best.v1";
// ============================================================================
// === UTILITIES ===
// ============================================================================
const clamp = (v, a, b) => v < a ? a : (v > b ? b : v);
const lerp = (a, b, t) => a + (b - a) * t;
const smoothT = (r) => 1 - Math.exp(-r); // framerate-independent easing
const rnd = (a, b) => a + Math.random() * (b - a);
const TAU = Math.PI * 2;
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;
};
}
function mixCol(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 multCol(c, m) {
return [clamp(c[0] * m, 0, 255), clamp(c[1] * m, 0, 255), clamp(c[2] * m, 0, 255)];
}
function css(c, a) {
if (a === undefined) a = 1;
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
// ============================================================================
// === CANVAS / VIEWPORT ===
// ============================================================================
const canvas = document.getElementById("c");
const ctx = canvas.getContext("2d", { alpha: false });
let W = 0, H = 0, DPR = 1, SCX = 0, SCY = 0;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = window.innerWidth; H = window.innerHeight;
canvas.width = Math.floor(W * DPR);
canvas.height = Math.floor(H * DPR);
canvas.style.width = W + "px";
canvas.style.height = H + "px";
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
SCX = W * 0.5;
SCY = H * CFG.horizonFrac;
}
window.addEventListener("resize", resize);
resize();
// ============================================================================
// === AUDIO (procedural WebAudio, no assets) ===
// ============================================================================
const Sfx = {
ac: null, master: null, droneGain: null, windGain: null,
osc1: null, osc2: null, windSrc: null, lp: null, ready: false,
init() {
if (this.ac) { if (this.ac.state === "suspended") this.ac.resume(); return; }
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
const ac = this.ac = new AC();
const master = this.master = ac.createGain();
master.gain.value = 0.55;
master.connect(ac.destination);
// --- engine drone: two detuned saws through a lowpass ---
const lp = this.lp = ac.createBiquadFilter();
lp.type = "lowpass"; lp.frequency.value = 320; lp.Q.value = 3;
const dg = this.droneGain = ac.createGain();
dg.gain.value = 0.0;
lp.connect(dg); dg.connect(master);
const o1 = this.osc1 = ac.createOscillator(); o1.type = "sawtooth";
const o2 = this.osc2 = ac.createOscillator(); o2.type = "square";
o1.frequency.value = 70; o2.frequency.value = 105;
const o2g = ac.createGain(); o2g.gain.value = 0.35;
o1.connect(lp); o2.connect(o2g); o2g.connect(lp);
o1.start(); o2.start();
// --- wind: looping noise through a bandpass ---
const len = Math.floor(ac.sampleRate * 2);
const buf = ac.createBuffer(1, len, ac.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
const src = this.windSrc = ac.createBufferSource();
src.buffer = buf; src.loop = true;
const bp = ac.createBiquadFilter();
bp.type = "bandpass"; bp.frequency.value = 900; bp.Q.value = 0.7;
const wg = this.windGain = ac.createGain();
wg.gain.value = 0;
src.connect(bp); bp.connect(wg); wg.connect(master);
src.start();
this.ready = true;
},
// short synth blip
blip(f0, f1, dur, type, vol) {
if (!this.ready) return;
const ac = this.ac, t = ac.currentTime;
const o = ac.createOscillator(), g = ac.createGain();
o.type = type || "square";
o.frequency.setValueAtTime(f0, t);
o.frequency.exponentialRampToValueAtTime(Math.max(20, f1), t + dur);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(vol, t + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g); g.connect(this.master);
o.start(t); o.stop(t + dur + 0.02);
},
noiseBurst(dur, f0, f1, vol, q) {
if (!this.ready) return;
const ac = this.ac, t = ac.currentTime;
const len = Math.max(1, Math.floor(ac.sampleRate * dur));
const buf = ac.createBuffer(1, len, ac.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len);
const s = ac.createBufferSource(); s.buffer = buf;
const bp = ac.createBiquadFilter(); bp.type = "bandpass"; bp.Q.value = q || 1.2;
bp.frequency.setValueAtTime(f0, t);
bp.frequency.exponentialRampToValueAtTime(Math.max(30, f1), t + dur);
const g = ac.createGain();
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
s.connect(bp); bp.connect(g); g.connect(this.master);
s.start(t); s.stop(t + dur + 0.02);
},
pickupEnergy() { this.blip(660, 1320, 0.10, "square", 0.16); },
pickupToken() { this.blip(880, 1760, 0.11, "triangle", 0.18); this.blip(1320, 1980, 0.09, "sine", 0.09); },
pickupOrb() { this.blip(420, 1240, 0.20, "sawtooth", 0.15); },
nearMiss() { this.noiseBurst(0.20, 2600, 320, 0.22, 2.0); },
crash() {
if (!this.ready) return;
const ac = this.ac, t = ac.currentTime;
const o = ac.createOscillator(), g = ac.createGain();
o.type = "sine";
o.frequency.setValueAtTime(160, t);
o.frequency.exponentialRampToValueAtTime(34, t + 0.7);
g.gain.setValueAtTime(0.5, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.75);
o.connect(g); g.connect(this.master);
o.start(t); o.stop(t + 0.8);
this.noiseBurst(0.55, 1800, 90, 0.34, 0.9);
},
setSpeed(speed, boost) {
if (!this.ready) return;
const t = this.ac.currentTime;
const k = clamp((speed - CFG.baseSpeed) / (CFG.maxSpeed - CFG.baseSpeed), 0, 1.4);
this.osc1.frequency.setTargetAtTime(58 + k * 78, t, 0.12);
this.osc2.frequency.setTargetAtTime(88 + k * 116, t, 0.12);
this.lp.frequency.setTargetAtTime(280 + k * 900, t, 0.15);
this.droneGain.gain.setTargetAtTime(0.035 + k * 0.075, t, 0.2);
this.windGain.gain.setTargetAtTime(0.012 + k * 0.075 + (boost ? 0.05 : 0), t, 0.15);
},
silence() {
if (!this.ready) return;
const t = this.ac.currentTime;
this.droneGain.gain.setTargetAtTime(0, t, 0.1);
this.windGain.gain.setTargetAtTime(0, t, 0.1);
}
};
// ============================================================================
// === INPUT ===
// ============================================================================
const keys = Object.create(null);
function keyDown(e) {
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
keys[k] = true;
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", " ", "Spacebar"].indexOf(e.key) >= 0) e.preventDefault();
Sfx.init();
if (Sfx.ac && Sfx.ac.state === "suspended") { try { Sfx.ac.resume(); } catch (err) {} }
if (Game.state === "title") startRun();
else if ((Game.state === "over" || Game.state === "crash") && Game.overT > 0.35) startRun();
}
function keyUp(e) { keys[e.key.length === 1 ? e.key.toLowerCase() : e.key] = false; }
window.addEventListener("keydown", keyDown);
window.addEventListener("keyup", keyUp);
window.addEventListener("blur", () => { for (const k in keys) keys[k] = false; });
canvas.addEventListener("pointerdown", () => {
Sfx.init();
if (Game.state === "title") startRun();
else if ((Game.state === "over" || Game.state === "crash") && Game.overT > 0.35) startRun();
});
// ============================================================================
// === GAME STATE ===
// ============================================================================
const Game = {
state: "title", // title | play | crash | over
t: 0, // run time
overT: 0,
score: 0, dist: 0, mult: 1,
multTimer: 0, nearCombo: 0, nearComboT: 0,
energy: CFG.energyStart,
speed: CFG.baseSpeed,
seed: 0, rng: Math.random,
best: 0,
flash: 0, flashCol: [255, 255, 255],
shake: 0, shakeX: 0, shakeY: 0,
sunFrac: CFG.sunStartFrac,
light: 1,
inShadow: false,
deathReason: "",
boostOn: false,
hue: 0,
};
try { Game.best = parseInt(localStorage.getItem(HISCORE_KEY) || "0", 10) || 0; } catch (e) { Game.best = 0; }
const cam = { x: 0, y: CFG.camHeight, z: 0 };
const player = { x: 0, y: 60, z: 0, vx: 0, vy: 0, bank: 0, lean: 0 };
let obstacles = [], pickups = [], particles = [];
let spawnZ = 0, runSpeed = CFG.baseSpeed;
// ============================================================================
// === PARTICLES ===
// ============================================================================
function spawnParticle(x, y, z, vx, vy, vz, life, size, col, glow) {
if (particles.length >= CFG.maxParticles) particles.shift();
particles.push({ x, y, z, vx, vy, vz, life, max: life, size, col, glow: glow !== false });
}
function burst(x, y, z, n, spread, speed, col, life, size) {
for (let i = 0; i < n; i++) {
const a = Math.random() * TAU, b = Math.acos(rnd(-1, 1));
spawnParticle(x, y, z,
Math.sin(b) * Math.cos(a) * speed,
Math.sin(b) * Math.sin(a) * speed,
Math.cos(b) * speed,
life * rnd(0.6, 1.3), size * rnd(0.6, 1.4), col, true);
}
}
// ============================================================================
// === WORLD GENERATION (deterministic per seed) ===
// ============================================================================
function pushObstacle(x, y, z, w, h, d, kind) {
obstacles.push({
x, y, z, w, h, d, kind,
hit: false, missed: false,
spin: 0,
});
}
// Inflate a pattern: builds a chunk of obstacles at base z, returns [zEnd, safe spots]
// The course is carved as a corridor: a run of gate frames whose openings the
// craft can always physically reach. Obstacle shapes are generated around that
// corridor, so every chunk has a fair line through it by construction.
let pathX = 0, pathY = 88;
function makeChunk(z, diff, R) {
const HW = CFG.worldHalf;
const speed = Math.max(CFG.baseSpeed, runSpeed);
const step = Math.max(CFG.gateSpacingMin, speed * CFG.gateSpacingSpeed);
const tof = step / speed; // seconds per gate
// fraction of the available time the craft can spend repositioning
const reach = lerp(0.42, 0.80, diff);
const maxDX = CFG.strafeSpeed * tof * reach;
const maxDY = CFG.pitchSpeed * tof * reach;
const cw = lerp(250, 126, diff); // corridor width
const ch = lerp(220, 118, diff); // corridor height
const gates = 1 + Math.round(diff * 2.2) + Math.floor(R() * 2);
const gap = 34 + R() * 90; // breathing room before the next chunk
const safe = [];
let zz = z;
for (let i = 0; i < gates; i++) {
const limX = HW - cw / 2 - 30;
pathX = clamp(pathX + (R() * 2 - 1) * maxDX, -limX, limX);
const yLo = ch / 2 + 30, yHi = CFG.yMax - 16;
if (yHi > yLo) pathY = clamp(pathY + (R() * 2 - 1) * maxDY, yLo, yHi);
const x0 = pathX - cw / 2, x1 = pathX + cw / 2;
const y0 = pathY - ch / 2, y1 = pathY + ch / 2;
const span = HW + 150;
const topY = 290;
const style = Math.floor(R() * 5);
// side slabs: from the far edge in to the corridor mouth
if (style === 0 || style === 2 || style === 4) {
const w = x0 + span;
if (w > 16) pushObstacle(-span + w / 2, topY / 2, zz, w, topY, 58, "wall");
}
if (style === 0 || style === 3 || style === 4) {
const w = span - x1;
if (w > 16) pushObstacle(span - w / 2, topY / 2, zz, w, topY, 58, "wall");
}
// floor / ceiling bars: full width only for the slot style, otherwise they
// just frame the corridor so the gate reads as a clean rectangular hole
const barW = style === 1 ? span * 2 : cw + 190;
if (style === 1 || style === 3 || style === 4) {
const h = y0;
if (h > 16) pushObstacle(pathX, h / 2, zz + 18, barW, h, 46, "low");
}
if (style === 1 || style === 2 || style === 4) {
const h = topY - y1;
if (h > 16) pushObstacle(pathX, y1 + h / 2, zz + 18, barW, h, 46, "float");
}
// free-standing pillars just outside the corridor: pressure, near-misses
const extras = Math.round(R() * 2);
for (let e = 0; e < extras; e++) {
const side = R() < 0.5 ? -1 : 1;
const ox = pathX + side * (cw / 2 + 54 + R() * 110);
if (Math.abs(ox) > HW + 40) continue;
if (R() < 0.6) {
const h = rnd(60, 200);
pushObstacle(ox, h / 2, zz + rnd(-24, 24), rnd(46, 84), h, 52, "pillar");
} else {
const by = rnd(90, 190);
pushObstacle(ox, by, zz + rnd(-24, 24), rnd(50, 96), rnd(46, 84), 52, "float");
}
}
safe.push({ x: pathX, y: pathY, z: zz, cw: cw, ch: ch });
zz += step;
}
return { z: zz + gap, safe };
}
function genAhead() {
const limit = player.z + CFG.drawDist * 0.78;
let guard = 0;
while (spawnZ < limit && guard++ < 60) {
const diff = clamp(Game.t / 95, 0, 1);
const R = Game.rng;
// route Math.random through the seeded stream so chunk shapes are deterministic
const _mr = Math.random;
Math.random = R;
const res = makeChunk(spawnZ, diff, R);
Math.random = _mr;
for (let i = 0; i < res.safe.length; i++) {
const s = res.safe[i];
const roll = R();
const kind = roll < 0.50 ? "energy" : (roll < 0.90 ? "token" : "orb");
const count = roll < 0.50 ? 2 : 3;
for (let k = 0; k < count; k++) {
pickups.push({
x: clamp(s.x + (R() * 2 - 1) * s.cw * 0.22, -CFG.worldHalf + 30, CFG.worldHalf - 30),
y: clamp(s.y + (R() * 2 - 1) * s.ch * 0.20, 26, CFG.yMax - 10),
z: s.z + 44 + k * 56 + R() * 14,
kind, taken: false, t: R() * TAU, bob: R() * TAU,
});
}
}
spawnZ = res.z;
}
}
function cullWorld() {
const back = cam.z - 60;
for (let i = obstacles.length - 1; i >= 0; i--) {
if (obstacles[i].z + obstacles[i].d < back) obstacles.splice(i, 1);
}
for (let i = pickups.length - 1; i >= 0; i--) {
if (pickups[i].z < back) pickups.splice(i, 1);
}
}
// ============================================================================
// === RUN CONTROL ===
// ============================================================================
function startRun() {
Game.seed = (Math.random() * 0xFFFFFFFF) >>> 0;
Game.rng = mulberry32(Game.seed);
Game.state = "play";
Game.t = 0; Game.overT = 0;
Game.score = 0; Game.dist = 0; Game.mult = 1;
Game.multTimer = 0; Game.nearCombo = 0; Game.nearComboT = 0;
Game.energy = CFG.energyStart;
Game.speed = CFG.baseSpeed;
Game.flash = 0; Game.shake = 0;
Game.sunFrac = CFG.sunStartFrac;
Game.light = 1; Game.inShadow = false;
Game.deathReason = "";
Game.boostOn = false;
player.x = 0; player.y = 62; player.z = 0;
player.vx = 0; player.vy = 0; player.bank = 0; player.lean = 0;
cam.x = 0; cam.y = CFG.camHeight; cam.z = player.z - CFG.camDist;
obstacles.length = 0; pickups.length = 0; particles.length = 0;
spawnZ = 780;
pathX = 0; pathY = 92;
genAhead();
const boot = document.getElementById("boot");
if (boot) boot.classList.add("hidden");
Sfx.init();
}
function endRun(reason) {
if (Game.state !== "play") return;
Game.state = "crash";
Game.overT = 0;
Game.deathReason = reason;
Game.shake = CFG.shakeCrash;
Game.flash = 1;
Game.flashCol = reason === "crash" ? [255, 140, 90] : [255, 90, 120];
burst(player.x, player.y, player.z, 70, 1, 240, COL.thrust, 1.1, 3.4);
burst(player.x, player.y, player.z, 40, 1, 170, COL.danger, 0.9, 3.0);
burst(player.x, player.y, player.z, 30, 1, 120, COL.playerLit, 1.3, 2.4);
Sfx.crash();
Sfx.silence();
if (Game.score > Game.best) {
Game.best = Math.floor(Game.score);
try { localStorage.setItem(HISCORE_KEY, String(Game.best)); } catch (e) {}
}
}
// ============================================================================
// === UPDATE ===
// ============================================================================
function updatePlayer(dt) {
const L = keys["a"] || keys["ArrowLeft"];
const Rt = keys["d"] || keys["ArrowRight"];
const U = keys["w"] || keys["ArrowUp"];
const D = keys["s"] || keys["ArrowDown"];
let ax = (Rt ? 1 : 0) - (L ? 1 : 0);
let ay = (U ? 1 : 0) - (D ? 1 : 0);
// gentle auto-centering inertia tail (keeps it weighty, not floaty)
ax += -player.x / CFG.worldHalf * CFG.driftAmt * (ax === 0 ? 1 : 0.25);
ay += Game.inShadow ? 0 : 0;
ay += (CFG.yMax * 0.42 - player.y) / CFG.yMax * 0.10;
const tvx = clamp(ax, -1, 1) * CFG.strafeSpeed;
const tvy = clamp(ay, -1, 1) * CFG.pitchSpeed;
const k = smoothT(dt / CFG.accelTau);
player.vx = lerp(player.vx, tvx, k);
player.vy = lerp(player.vy, tvy, k);
player.x += player.vx * dt;
player.y += player.vy * dt;
// soft walls: bleed velocity, push back
if (player.x < -CFG.worldHalf) { player.x = -CFG.worldHalf; player.vx *= -0.15; }
if (player.x > CFG.worldHalf) { player.x = CFG.worldHalf; player.vx *= -0.15; }
if (player.y < CFG.yMin) { player.y = CFG.yMin; player.vy *= -0.15; }
if (player.y > CFG.yMax) { player.y = CFG.yMax; player.vy *= -0.15; }
// bank + lean cosmetics (smoothed)
const targetBank = -clamp(player.vx / CFG.strafeSpeed, -1, 1) * 0.62;
const targetLean = clamp(player.vy / CFG.pitchSpeed, -1, 1) * 0.30;
player.bank = lerp(player.bank, targetBank, smoothT(dt / 0.11));
player.lean = lerp(player.lean, targetLean, smoothT(dt / 0.14));
player.z += Game.speed * dt;
}
function updateSunAndLight(dt) {
Game.sunFrac += (CFG.sunEndFrac - CFG.sunStartFrac) / CFG.sunSetTime * dt;
// lateral light falloff: sun is centred, edges are dimmer
const edge = Math.abs(player.x) / CFG.worldHalf;
let lit = 1 - Math.pow(clamp(edge, 0, 1), 1.4) * 0.55;
// obstacle shadow: tall blocks overhead shade the craft
Game.inShadow = false;
for (let i = 0; i < obstacles.length; i++) {
const o = obstacles[i];
const dz = o.z - player.z;
if (dz < -10 || dz > 150) continue;
if (o.y + o.h / 2 < player.y + 20) continue; // not tall enough
if (Math.abs(o.x - player.x) > o.w / 2 + 12) continue;
Game.inShadow = true; break;
}
if (Game.inShadow) lit *= 0.30;
// the sky dims as the sun sinks, so late runs drain faster
const sunK = clamp((Game.sunFrac - CFG.sunStartFrac) / (CFG.sunEndFrac - CFG.sunStartFrac), 0, 1);
lit *= lerp(1, 0.72, sunK);
Game.light = lerp(Game.light, lit, smoothT(dt / 0.12));
// energy drain
let drain = CFG.drainBase + Math.max(0, Game.speed - CFG.baseSpeed) * CFG.drainSpeed;
drain = lerp(drain, CFG.drainShadow, 1 - Game.light);
if (Game.boostOn) drain += CFG.boostDrain;
Game.energy -= drain * dt;
if (Game.energy <= 0) { Game.energy = 0; endRun("power"); }
}
function updateSpeed(dt) {
const wantBoost = (keys[" "] === true) && Game.energy > CFG.boostMinEnergy;
Game.boostOn = wantBoost;
const base = Math.min(CFG.maxSpeed, CFG.baseSpeed + Game.t * CFG.speedRamp);
const target = wantBoost ? base * CFG.boostMult : base;
Game.speed = lerp(Game.speed, target, smoothT(dt / (wantBoost ? 0.16 : 0.55)));
runSpeed = Game.speed;
Sfx.setSpeed(Game.speed, wantBoost);
}
function updatePickups(dt) {
for (let i = pickups.length - 1; i >= 0; i--) {
const p = pickups[i];
p.t += dt * 3.2;
const dx = p.x - player.x, dy = p.y - player.y, dz = p.z - player.z;
const rad = 34;
if (Math.abs(dz) < 30 && dx * dx + dy * dy < rad * rad) {
pickups.splice(i, 1);
if (p.kind === "energy") {
Game.energy = Math.min(CFG.energyMax, Game.energy + CFG.pickupEnergy);
Sfx.pickupEnergy();
burst(p.x, p.y, p.z, 12, 1, 90, COL.energy, 0.45, 2.2);
Game.flash = Math.max(Game.flash, 0.14); Game.flashCol = COL.energy;
} else if (p.kind === "token") {
Game.score += CFG.tokenScore * Game.mult;
Game.multTimer = 0;
Sfx.pickupToken();
burst(p.x, p.y, p.z, 14, 1, 110, COL.token, 0.5, 2.2);
} else {
Game.mult = Math.min(CFG.multMax, Game.mult + CFG.multPerOrb);
Game.multTimer = 0;
Game.nearCombo++;
Game.nearComboT = 1.0;
Sfx.pickupOrb();
burst(p.x, p.y, p.z, 20, 1, 140, COL.orb, 0.65, 2.6);
}
continue;
}
if (p.z < cam.z - 40) pickups.splice(i, 1);
}
}
function updateObstacles(dt) {
const ph = CFG.playerHalf;
for (let i = obstacles.length - 1; i >= 0; i--) {
const o = obstacles[i];
o.spin += dt;
const dz = o.z - player.z;
if (dz > 90 || dz < -140) continue;
const ox = Math.abs(o.x - player.x), oy = Math.abs(o.y - player.y);
const hx = o.w / 2 + ph.x, hy = o.h / 2 + ph.y;
if (!o.hit && Math.abs(dz) < o.d / 2 + ph.z && ox < hx && oy < hy) {
o.hit = true;
// juicier: impact point roughly between craft and block
endRun("crash");
return;
}
if (!o.missed && !o.hit &&
Math.abs(dz) < o.d / 2 + ph.z + 16 &&
ox < hx + CFG.nearMissDist && oy < hy + CFG.nearMissDist) {
const prox = Math.max(ox - hx, oy - hy);
if (prox > 0) {
o.missed = true;
Game.shake = Math.max(Game.shake, CFG.shakeNear * (1 - prox / CFG.nearMissDist) + 2.5);
Game.score += CFG.nearMissScore * Game.mult;
Game.nearCombo++; Game.nearComboT = 0.9;
Sfx.nearMiss();
spawnParticle(player.x + (o.x - player.x) * 0.4, player.y + (o.y - player.y) * 0.4, player.z,
0, 0, -40, 0.3, 2.4, COL.playerLit, 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.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
const damp = Math.exp(-dt * 2.1);
p.vx *= damp; p.vy *= damp; p.vz *= damp;
if (p.y < 0) { p.y = 0; p.vy *= -0.35; }
}
}
function emitThrust(dt) {
const rate = Game.boostOn ? 190 : 95;
const n = Math.min(8, Math.floor(rate * dt + Math.random()));
const off = Game.boostOn ? 0.45 : 1;
for (let i = 0; i < n; i++) {
spawnParticle(
player.x + rnd(-3.5, 3.5), player.y + rnd(-3, 3), player.z - 13,
rnd(-14, 14) - player.vx * 0.12, rnd(-14, 14) - player.vy * 0.12, -rnd(110, 230) * off,
rnd(0.16, 0.34), rnd(0.75, 1.7),
Game.boostOn ? COL.playerLit : COL.thrust, true);
}
}
function updateCamera(dt) {
const tx = player.x * CFG.camLagX;
const ty = player.y * CFG.camLagY + CFG.camHeight;
cam.x = lerp(cam.x, tx, smoothT(dt / 0.13));
cam.y = Math.max(14, lerp(cam.y, ty, smoothT(dt / 0.16)));
cam.z = player.z - CFG.camDist;
// screen shake
Game.shake *= Math.exp(-dt * CFG.shakeDecay);
if (Game.shake < 0.05) Game.shake = 0;
const s = Game.shake;
Game.shakeX = rnd(-s, s);
Game.shakeY = rnd(-s, s);
}
function update(dt) {
Game.t += dt;
Game.hue += dt;
if (Game.state === "play") {
updateSpeed(dt);
updatePlayer(dt);
updateSunAndLight(dt);
updateObstacles(dt);
updatePickups(dt);
emitThrust(dt);
updateParticles(dt);
updateCamera(dt);
genAhead();
cullWorld();
// distance + score
const dz = Game.speed * dt;
Game.dist += dz * 0.1;
Game.score += dz * CFG.scorePerDist * Game.mult;
// multiplier decay after a lull
Game.multTimer += dt;
if (Game.multTimer > CFG.multDecayDelay && Game.mult > 1) {
Game.mult = Math.max(1, Game.mult - CFG.multDecayRate * dt);
}
if (Game.nearComboT > 0) { Game.nearComboT -= dt; if (Game.nearComboT <= 0) Game.nearCombo = 0; }
if (Game.state === "play" && Game.sunFrac >= CFG.sunEndFrac - 0.001) endRun("sunset");
} else if (Game.state === "crash") {
Game.overT += dt;
Game.speed = lerp(Game.speed, 0, smoothT(dt / 0.35));
updateParticles(dt);
updateCamera(dt);
Game.sunFrac += (CFG.sunEndFrac - CFG.sunStartFrac) / CFG.sunSetTime * dt;
if (Game.overT > 1.0) { Game.state = "over"; }
} else if (Game.state === "over") {
Game.overT += dt;
updateParticles(dt);
updateCamera(dt);
Game.sunFrac += (CFG.sunEndFrac - CFG.sunStartFrac) / CFG.sunSetTime * dt;
} else {
// title: idle drift so the world feels alive
Game.speed = CFG.baseSpeed * 0.62;
Game.sunFrac = CFG.sunStartFrac + 0.02 + Math.sin(Game.hue * 0.25) * 0.012;
player.z += Game.speed * dt;
player.y = 62 + Math.sin(Game.hue * 0.6) * 10;
player.x = Math.sin(Game.hue * 0.28) * 90;
player.bank = -Math.cos(Game.hue * 0.28) * 0.28;
emitTitleThrust(dt);
updateParticles(dt);
updateCamera(dt);
genAhead();
cullWorld();
Game.light = 1;
}
Game.flash *= Math.exp(-dt * 6.5);
if (Game.flash < 0.004) Game.flash = 0;
}
function emitTitleThrust(dt) {
const n = Math.min(3, Math.floor(40 * dt + Math.random()));
for (let i = 0; i < n; i++) {
spawnParticle(player.x + rnd(-5, 5), player.y + rnd(-4, 4), player.z - 16,
rnd(-10, 10), rnd(-10, 10), -rnd(70, 140), rnd(0.25, 0.5), rnd(1.5, 3), COL.thrust, true);
}
}
// ============================================================================
// === RENDER ===
// ============================================================================
let SKY = { top: COL.skyTop, mid: COL.skyMid, hor: COL.horizon };
function proj(wx, wy, wz) {
let rz = wz - cam.z;
if (rz < 9) rz = 9;
const s = CFG.focal / rz;
return { x: SCX + (wx - cam.x) * s, y: SCY - (wy - cam.y) * s, s: s, z: rz };
}
const FOG_NEAR = 620;
function fogT(rz) { return clamp((rz - FOG_NEAR) / (CFG.drawDist - FOG_NEAR), 0, 1); }
function fogged(col, rz, m) {
const f = fogT(rz) * 0.92;
const c = m === undefined ? col : multCol(col, m);
return css(mixCol(c, SKY.hor, f));
}
// Distant ridge silhouettes, generated once with a fixed seed.
const ridges = (function () {
const R = mulberry32(20240607);
const defs = [
{ amp: 24, base: 8, par: 0.018, shade: 0.74, n: 76 },
{ amp: 44, base: 15, par: 0.042, shade: 0.48, n: 58 },
{ amp: 68, base: 23, par: 0.082, shade: 0.22, n: 40 },
];
return defs.map(function (d) {
const oct = [];
for (let o = 0; o < 4; o++) oct.push({ f: 0.7 * Math.pow(2.1, o), p: R() * TAU, a: 1 / Math.pow(1.9, o) });
d.oct = oct;
return d;
});
})();
function currentSky() {
const span = CFG.sunEndFrac - CFG.sunStartFrac;
const k = clamp((Game.sunFrac - CFG.sunStartFrac) / span, 0, 1);
SKY.top = mixCol(COL.skyTop, COL.skyTopSet, k);
SKY.mid = mixCol(COL.skyMid, COL.skyMidSet, k);
SKY.hor = mixCol(COL.horizon, COL.horizonSet, k);
return k;
}
function drawSky() {
const g = ctx.createLinearGradient(0, -60, 0, SCY + 4);
g.addColorStop(0, css(SKY.top));
g.addColorStop(0.44, css(SKY.mid));
g.addColorStop(0.72, css(mixCol(SKY.mid, SKY.hor, 0.62)));
g.addColorStop(0.90, css(mixCol(SKY.mid, SKY.hor, 0.90)));
g.addColorStop(1, css(SKY.hor));
ctx.fillStyle = g;
ctx.fillRect(-80, -80, W + 160, SCY + 84);
if (Game.state === "crash" || Game.state === "over") return;
// --- the sun, clipped to above the horizon ---
const sunX = SCX - cam.x * 0.055;
const sunY = H * Game.sunFrac;
const R0 = Math.min(W, H) * 0.088;
ctx.save();
ctx.beginPath(); ctx.rect(-80, -80, W + 160, SCY + 80); ctx.clip();
const glowR = R0 * 13;
const gg = ctx.createRadialGradient(sunX, sunY, R0 * 0.4, sunX, sunY, glowR);
const kk = clamp((Game.sunFrac - CFG.sunStartFrac) / (CFG.sunEndFrac - CFG.sunStartFrac), 0, 1);
gg.addColorStop(0, css(COL.sunGlow, 0.88 - kk * 0.12));
gg.addColorStop(0.10, css(COL.sunGlow, 0.44));
gg.addColorStop(0.30, css(mixCol(COL.sunGlow, SKY.hor, 0.5), 0.20));
gg.addColorStop(0.62, css(mixCol(COL.sunGlow, SKY.hor, 0.7), 0.07));
gg.addColorStop(1, css(SKY.hor, 0));
ctx.fillStyle = gg;
ctx.fillRect(sunX - glowR, sunY - glowR, glowR * 2, glowR * 2);
const dg = ctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, R0 * 1.08);
dg.addColorStop(0, css([255, 255, 250]));
dg.addColorStop(0.55, css(COL.sunCore));
dg.addColorStop(0.86, css(mixCol(COL.sunCore, COL.sunGlow, 0.75)));
dg.addColorStop(1, css(COL.sunGlow, 0.0));
ctx.fillStyle = dg;
ctx.beginPath(); ctx.arc(sunX, sunY, R0 * 1.08, 0, TAU); ctx.fill();
// orbiting dust specs — the world "revolves"
ctx.globalCompositeOperation = "lighter";
for (let i = 0; i < 26; i++) {
const a = Game.hue * (0.06 + (i % 5) * 0.012) + i * 1.618;
const rr = R0 * (1.5 + (i % 7) * 0.42);
const px = sunX + Math.cos(a) * rr * 1.35;
const py = sunY + Math.sin(a) * rr * 0.42;
if (py > SCY) continue;
ctx.fillStyle = css(COL.sunCore, 0.16 + 0.10 * Math.sin(Game.hue * 2 + i));
ctx.fillRect(px - 1.4, py - 1.4, 2.8, 2.8);
}
ctx.globalCompositeOperation = "source-over";
ctx.restore();
// --- ridge silhouettes ---
for (let L = 0; L < ridges.length; L++) {
const d = ridges[L];
const off = -cam.x * d.par;
const span = W * 2.8;
const step = span / d.n;
ctx.beginPath();
ctx.moveTo(-W * 0.9 + off, SCY + 4);
for (let i = 0; i <= d.n; i++) {
const u = i / d.n;
let v = 0, amp = 0;
for (let o = 0; o < d.oct.length; o++) {
const oc = d.oct[o];
v += Math.sin(u * TAU * oc.f + oc.p) * oc.a;
amp += oc.a;
}
v = (v / amp) * 0.5 + 0.5;
v = Math.pow(clamp(v, 0, 1), 1.35);
ctx.lineTo(-W * 0.9 + i * step + off, SCY - d.base - v * d.amp);
}
ctx.lineTo(W * 1.9 + off, SCY + 4);
ctx.closePath();
ctx.fillStyle = css(mixCol(SKY.hor, SKY.top, 1 - d.shade * 1.35), 0.95);
ctx.fill();
}
}
function drawGround() {
const kk = clamp((Game.sunFrac - CFG.sunStartFrac) / (CFG.sunEndFrac - CFG.sunStartFrac), 0, 1);
const c0 = mixCol(SKY.hor, COL.ground, 0.50);
const c1 = mixCol(SKY.hor, COL.ground, 0.80);
const c2 = mixCol(COL.groundLit, COL.ground, 0.80 - kk * 0.58);
const g = ctx.createLinearGradient(0, SCY - 2, 0, H + 40);
g.addColorStop(0.00, css(c0));
g.addColorStop(0.05, css(c1));
g.addColorStop(0.20, css(c2));
g.addColorStop(0.58, css(mixCol(c2, COL.ground, 0.62)));
g.addColorStop(1.00, css(COL.ground));
ctx.fillStyle = g;
ctx.fillRect(-80, SCY - 2, W + 160, H - SCY + 120);
const GH = CFG.worldHalf * 2.4;
ctx.globalCompositeOperation = "lighter";
// --- sun reflection: nested bands for a soft-edged streak ---
const zN = cam.z + 90, zF = cam.z + CFG.drawDist;
const bands = [[16, 34, 0.22], [30, 64, 0.14], [52, 116, 0.085], [84, 196, 0.048], [130, 310, 0.024]];
for (let b = 0; b < bands.length; b++) {
const farW = bands[b][0], nearW = bands[b][1];
const q = [proj(-farW, 0.4, zF), proj(farW, 0.4, zF), proj(nearW, 0.4, zN), proj(-nearW, 0.4, zN)];
const sg = ctx.createLinearGradient(q[0].x, q[0].y, q[2].x, q[2].y);
sg.addColorStop(0, css(SKY.hor, bands[b][2]));
sg.addColorStop(0.4, css(SKY.hor, bands[b][2] * 0.35));
sg.addColorStop(1, css(SKY.hor, 0));
ctx.fillStyle = sg;
ctx.beginPath();
ctx.moveTo(q[0].x, q[0].y); ctx.lineTo(q[1].x, q[1].y);
ctx.lineTo(q[2].x, q[2].y); ctx.lineTo(q[3].x, q[3].y);
ctx.closePath(); ctx.fill();
}
// --- transverse grid lines ---
ctx.lineWidth = 1;
const gz = CFG.gridZ;
const startZ = Math.floor((cam.z + 40) / gz) * gz;
for (let z = startZ; z < cam.z + CFG.drawDist; z += gz) {
const rz = z - cam.z;
const f = fogT(rz);
const a = Math.pow(1 - f, 1.7) * 0.42 * (0.5 + 0.5 * Game.light);
if (a < 0.012) continue;
const p1 = proj(-GH, 0, z), p2 = proj(GH, 0, z);
ctx.strokeStyle = css(COL.grid, a);
ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke();
}
// --- longitudinal grid lines (fade with depth) ---
const zN2 = cam.z + 30, zF2 = cam.z + CFG.drawDist;
const pa = proj(0, 0, zN2), pb = proj(0, 0, zF2);
const lg = ctx.createLinearGradient(pa.x, pa.y, pb.x, pb.y);
lg.addColorStop(0, css(COL.grid, 0.38 * (0.4 + 0.6 * Game.light)));
lg.addColorStop(0.55, css(COL.grid, 0.10));
lg.addColorStop(1, css(COL.grid, 0));
ctx.strokeStyle = lg;
for (let x = -GH; x <= GH + 1; x += CFG.gridX) {
const q1 = proj(x, 0, zN2), q2 = proj(x, 0, zF2);
ctx.beginPath(); ctx.moveTo(q1.x, q1.y); ctx.lineTo(q2.x, q2.y); ctx.stroke();
}
// --- playfield boundary rails ---
for (let sgn = -1; sgn <= 1; sgn += 2) {
const bx = sgn * CFG.worldHalf;
const r1 = proj(bx, 0, zN2), r2 = proj(bx, 0, zF2);
const rg = ctx.createLinearGradient(r1.x, r1.y, r2.x, r2.y);
const edgeCol = player.x * sgn > CFG.worldHalf * 0.75 ? COL.danger : COL.playerLit;
rg.addColorStop(0, css(edgeCol, 0.55));
rg.addColorStop(0.4, css(edgeCol, 0.18));
rg.addColorStop(1, css(edgeCol, 0));
ctx.strokeStyle = rg; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(r1.x, r1.y); ctx.lineTo(r2.x, r2.y); ctx.stroke();
ctx.lineWidth = 1;
}
ctx.globalCompositeOperation = "source-over";
}
function drawShadows() {
const kk = clamp((Game.sunFrac - CFG.sunStartFrac) / (CFG.sunEndFrac - CFG.sunStartFrac), 0, 1);
const stretch = 0.55 + kk * 1.9;
ctx.globalAlpha = 1;
ctx.fillStyle = "rgba(6,4,16,0.55)";
for (let i = 0; i < obstacles.length; i++) {
const o = obstacles[i];
if (o.y - o.h / 2 > 24) continue; // floats cast no ground shadow
const rz = o.z - cam.z;
if (rz < -60 || rz > CFG.drawDist * 0.72) continue;
const len = o.h * stretch;
const hz = o.d / 2, hx = o.w / 2;
const a = proj(o.x - hx, 0.6, o.z - hz), b = proj(o.x + hx, 0.6, o.z - hz);
const c = proj(o.x + hx, 0.6, o.z - hz - len), d = proj(o.x - hx, 0.6, o.z - hz - len);
const al = (1 - fogT(rz)) * 0.5;
if (al < 0.02) continue;
ctx.fillStyle = "rgba(8,5,20," + al.toFixed(3) + ")";
ctx.beginPath();
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(c.x, c.y); ctx.lineTo(d.x, d.y);
ctx.closePath(); ctx.fill();
}
}
// --- one low-poly box, painter-sorted faces ---
const FACE_DEF = [
{ v: [0, 1, 2, 3], m: 1.00, ax: "z", dir: -1 },
{ v: [5, 4, 7, 6], m: 0.42, ax: "z", dir: 1 },
{ v: [4, 0, 3, 7], m: 0.70, ax: "x", dir: -1 },
{ v: [1, 5, 6, 2], m: 0.82, ax: "x", dir: 1 },
{ v: [4, 5, 1, 0], m: 0.34, ax: "y", dir: -1 },
{ v: [3, 2, 6, 7], m: 1.42, ax: "y", dir: 1 },
];
function drawObstacle(o) {
const hx = o.w / 2, hy = o.h / 2, hz = o.d / 2;
const X0 = o.x - hx, X1 = o.x + hx, Y0 = o.y - hy, Y1 = o.y + hy, Z0 = o.z - hz, Z1 = o.z + hz;
const P = [
proj(X0, Y0, Z0), proj(X1, Y0, Z0), proj(X1, Y1, Z0), proj(X0, Y1, Z0),
proj(X0, Y0, Z1), proj(X1, Y0, Z1), proj(X1, Y1, Z1), proj(X0, Y1, Z1),
];
const base = COL.obs[o.kind] || COL.obs.pillar;
const rz = o.z - cam.z;
const lit = 0.52 + Game.light * 0.62;
const list = [];
for (let i = 0; i < 6; i++) {
const f = FACE_DEF[i];
let vis;
if (f.ax === "z") vis = f.dir < 0 ? cam.z < Z0 : cam.z > Z1;
else if (f.ax === "x") vis = f.dir < 0 ? cam.x < X0 : cam.x > X1;
else vis = f.dir < 0 ? cam.y < Y0 : cam.y > Y1;
if (!vis) continue;
let az = 0;
for (let k = 0; k < 4; k++) az += P[f.v[k]].z;
list.push({ f: f, az: az / 4 });
}
list.sort(function (a, b) { return b.az - a.az; });
for (let i = 0; i < list.length; i++) {
const f = list[i].f;
const p = P;
const col = fogged(base, rz, f.m * lit);
ctx.beginPath();
ctx.moveTo(p[f.v[0]].x, p[f.v[0]].y);
for (let k = 1; k < 4; k++) ctx.lineTo(p[f.v[k]].x, p[f.v[k]].y);
ctx.closePath();
ctx.fillStyle = col;
ctx.fill();
if (rz < 1100) {
const edge = multCol(mixCol(COL.obs[o.kind] || COL.obs.pillar, [255, 255, 255], 0.55), 1.0);
ctx.strokeStyle = css(edge, 0.34 * (1 - fogT(rz)) * lit);
ctx.lineWidth = 1.2;
ctx.stroke();
}
}
}
function drawPickup(p) {
const pr = proj(p.x, p.y + Math.sin(p.bob + Game.hue * 2.4) * 3, p.z);
if (pr.z < 20 || pr.z > CFG.drawDist) return;
const f = 1 - fogT(pr.z);
if (f <= 0.02) return;
const pulse = 1 + Math.sin(p.t) * 0.14;
const size = (p.kind === "token" ? 15 : p.kind === "energy" ? 13 : 16) * pr.s * pulse;
if (size < 0.6) return;
const col = p.kind === "token" ? COL.token : (p.kind === "energy" ? COL.energy : COL.orb);
ctx.globalCompositeOperation = "lighter";
const gr = ctx.createRadialGradient(pr.x, pr.y, 0, pr.x, pr.y, size * 2.6);
gr.addColorStop(0, css(col, 0.42 * f));
gr.addColorStop(1, css(col, 0));
ctx.fillStyle = gr;
ctx.fillRect(pr.x - size * 2.6, pr.y - size * 2.6, size * 5.2, size * 5.2);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = css(mixCol(col, [255, 255, 255], 0.35), f);
if (p.kind === "token") {
// spinning 4-point star
const sw = Math.cos(p.t * 0.9) * size;
ctx.beginPath();
ctx.moveTo(pr.x, pr.y - size); ctx.lineTo(pr.x + sw * 0.62, pr.y);
ctx.lineTo(pr.x, pr.y + size); ctx.lineTo(pr.x - sw * 0.62, pr.y);
ctx.closePath(); ctx.fill();
ctx.fillStyle = css(col, f * 0.9);
ctx.beginPath(); ctx.arc(pr.x, pr.y, size * 0.42, 0, TAU); ctx.fill();
} else if (p.kind === "energy") {
const w = size * 0.62, h = size;
ctx.beginPath();
ctx.moveTo(pr.x - w, pr.y - h); ctx.lineTo(pr.x + w * 0.7, pr.y - h);
ctx.lineTo(pr.x + w, pr.y + h); ctx.lineTo(pr.x - w * 0.7, pr.y + h);
ctx.closePath(); ctx.fill();
ctx.fillStyle = css([20, 40, 60], f);
ctx.fillRect(pr.x - w * 0.18, pr.y - h * 0.5, w * 0.36, h);
} else {
ctx.lineWidth = Math.max(1.5, size * 0.28);
ctx.strokeStyle = css(col, f);
ctx.beginPath(); ctx.arc(pr.x, pr.y, size, p.t, p.t + Math.PI * 1.45); ctx.stroke();
ctx.beginPath(); ctx.arc(pr.x, pr.y, size * 0.5, -p.t, -p.t + Math.PI * 1.2); ctx.stroke();
ctx.fillStyle = css(mixCol(col, [255, 255, 255], 0.5), f);
ctx.beginPath(); ctx.arc(pr.x, pr.y, size * 0.26, 0, TAU); ctx.fill();
}
}
function drawParticles() {
ctx.globalCompositeOperation = "lighter";
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
const pr = proj(p.x, p.y, p.z);
if (pr.z < 12 || pr.z > CFG.drawDist) continue;
const f = 1 - fogT(pr.z);
const a = (p.life / p.max) * f;
if (a <= 0.02) continue;
const r = Math.max(0.5, p.size * pr.s * (0.4 + p.life / p.max * 0.7));
ctx.fillStyle = css(p.col, a * 0.42);
ctx.beginPath(); ctx.arc(pr.x, pr.y, r, 0, TAU); ctx.fill();
}
ctx.globalCompositeOperation = "source-over";
}
function drawPlayer() {
if (Game.state === "over") return;
const pr = proj(player.x, player.y, player.z);
const s = pr.s;
const bank = player.bank;
const squash = 1 - Math.abs(player.lean) * 0.22;
const scale = 1 - Math.abs(player.lean) * 0.1;
ctx.save();
ctx.translate(pr.x, pr.y);
ctx.rotate(bank);
ctx.scale(scale, squash * scale);
const u = s * 1.30; // px per world unit at craft depth
// --- engine flame (behind hull) ---
const flick = 0.75 + Math.random() * 0.45 + (Game.boostOn ? 0.7 : 0);
ctx.globalCompositeOperation = "lighter";
const fl = ctx.createLinearGradient(0, 6 * u, 0, (26 + 26 * flick) * u);
fl.addColorStop(0, css(COL.thrust, 0.9));
fl.addColorStop(0.4, css(COL.thrust, 0.42));
fl.addColorStop(1, css([255, 60, 40], 0));
ctx.fillStyle = fl;
ctx.beginPath();
ctx.moveTo(-4.2 * u, 5 * u);
ctx.lineTo(4.2 * u, 5 * u);
ctx.lineTo(2.2 * u, (16 + 22 * flick) * u);
ctx.lineTo(0, (24 + 30 * flick) * u);
ctx.lineTo(-2.2 * u, (16 + 22 * flick) * u);
ctx.closePath(); ctx.fill();
// --- glow halo ---
const hr = 16 * u;
const hg = ctx.createRadialGradient(0, 0, 0, 0, 0, hr);
hg.addColorStop(0, css(COL.playerLit, 0.30));
hg.addColorStop(1, css(COL.playerLit, 0));
ctx.fillStyle = hg;
ctx.fillRect(-hr, -hr, hr * 2, hr * 2);
ctx.globalCompositeOperation = "source-over";
// --- hull: swept delta wing ---
ctx.beginPath();
ctx.moveTo(0, -9.5 * u); // nose
ctx.lineTo(3.4 * u, -1.2 * u);
ctx.lineTo(11 * u, 3.6 * u); // right wingtip
ctx.lineTo(6.2 * u, 5.4 * u);
ctx.lineTo(3.0 * u, 4.2 * u);
ctx.lineTo(1.6 * u, 8.6 * u); // tail
ctx.lineTo(-1.6 * u, 8.6 * u);
ctx.lineTo(-3.0 * u, 4.2 * u);
ctx.lineTo(-6.2 * u, 5.4 * u);
ctx.lineTo(-11 * u, 3.6 * u); // left wingtip
ctx.lineTo(-3.4 * u, -1.2 * u);
ctx.closePath();
const hull = ctx.createLinearGradient(-9 * u, -9 * u, 9 * u, 9 * u);
hull.addColorStop(0, css(mixCol(COL.player, COL.playerLit, 0.25 + 0.5 * Game.light)));
hull.addColorStop(0.55, css(COL.player, 0.92));
hull.addColorStop(1, css(mixCol(COL.player, [40, 60, 120], 0.55)));
ctx.fillStyle = hull;
ctx.fill();
ctx.lineWidth = Math.max(1, 1.1 * u * 0.28);
ctx.strokeStyle = css(COL.playerLit, 0.85);
ctx.stroke();
// --- canopy ---
ctx.beginPath();
ctx.moveTo(0, -6 * u); ctx.lineTo(2.0 * u, -0.4 * u);
ctx.lineTo(0, 1.6 * u); ctx.lineTo(-2.0 * u, -0.4 * u);
ctx.closePath();
ctx.fillStyle = css([20, 26, 48], 1);
ctx.fill();
ctx.fillStyle = css(COL.playerLit, 0.5 + 0.3 * Game.light);
ctx.beginPath();
ctx.moveTo(0, -5.2 * u); ctx.lineTo(1.1 * u, -0.8 * u);
ctx.lineTo(0, 0.3 * u); ctx.lineTo(-1.1 * u, -0.8 * u);
ctx.closePath(); ctx.fill();
ctx.restore();
}
// --- speed / chromatic flourish above a velocity threshold ---
const SPEED_ANGLES = (function () {
const a = [];
for (let i = 0; i < 54; i++) a.push((i / 54) * TAU + Math.random() * 0.09);
return a;
})();
function drawSpeedLines() {
const t0 = 350;
const k = clamp((Game.speed - t0) / (CFG.maxSpeed * CFG.boostMult - t0), 0, 1);
if (k <= 0.01) return;
const cxv = SCX - cam.x * 0.30, cyv = SCY;
const baseA = k * 0.36;
ctx.globalCompositeOperation = "lighter";
ctx.lineCap = "round";
for (let i = 0; i < SPEED_ANGLES.length; i++) {
const a = SPEED_ANGLES[i];
const jitter = (1 + Math.sin(Game.hue * 9 + i * 2.3) * 0.25);
const r1 = (110 + (i % 7) * 46) * jitter;
const r2 = r1 + (120 + Game.speed * 0.42) * jitter;
const dx = Math.cos(a), dy = Math.sin(a);
const x1 = cxv + dx * r1, y1 = cyv + dy * r1;
const x2 = cxv + dx * r2, y2 = cyv + dy * r2;
const off = 1.4 + k * 4.2;
ctx.lineWidth = 1 + k * 1.6;
ctx.strokeStyle = css([255, 70, 110], baseA * 0.5);
ctx.beginPath(); ctx.moveTo(x1 - off, y1); ctx.lineTo(x2 - off, y2); ctx.stroke();
ctx.strokeStyle = css([80, 190, 255], baseA * 0.5);
ctx.beginPath(); ctx.moveTo(x1 + off, y1); ctx.lineTo(x2 + off, y2); ctx.stroke();
ctx.strokeStyle = css([255, 255, 255], baseA * 0.75);
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
}
ctx.globalCompositeOperation = "source-over";
ctx.lineCap = "butt";
}
function drawOverlays() {
// shadow / darkness grade when the craft is unlit
const dark = 1 - Game.light;
if (dark > 0.02) {
const g = ctx.createRadialGradient(SCX, SCY + H * 0.14, H * 0.05, SCX, SCY + H * 0.1, H * 0.95);
g.addColorStop(0, "rgba(6,4,18,0)");
g.addColorStop(1, "rgba(6,4,18," + (dark * 0.72).toFixed(3) + ")");
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// permanent vignette
const v = ctx.createRadialGradient(SCX, H * 0.5, H * 0.30, SCX, H * 0.5, H * 0.98);
v.addColorStop(0, "rgba(0,0,0,0)");
v.addColorStop(1, "rgba(0,0,0,0.55)");
ctx.fillStyle = v;
ctx.fillRect(0, 0, W, H);
if (Game.flash > 0.004) {
ctx.globalCompositeOperation = "lighter";
ctx.fillStyle = css(Game.flashCol, Game.flash * 0.5);
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = "source-over";
}
}
// ============================================================================
// === SCENE COMPOSITION ===
// ============================================================================
const renderList = [];
function drawScene() {
const k = currentSky();
drawSky();
drawGround();
drawShadows();
// gather everything depth-sortable
renderList.length = 0;
for (let i = 0; i < obstacles.length; i++) {
const o = obstacles[i];
const rz = o.z - cam.z;
if (rz < -40 || rz > CFG.drawDist) continue;
renderList.push(o);
}
for (let i = 0; i < pickups.length; i++) {
const p = pickups[i];
const rz = p.z - cam.z;
if (rz < -20 || rz > CFG.drawDist) continue;
renderList.push(p);
}
renderList.sort(function (a, b) { return b.z - a.z; });
for (let i = 0; i < renderList.length; i++) {
const it = renderList[i];
if (it.kind === "energy" || it.kind === "token" || it.kind === "orb") drawPickup(it);
else drawObstacle(it);
}
drawParticles();
drawPlayer();
drawSpeedLines();
}
// ============================================================================
// === HUD ===
// ============================================================================
function fmt(n) {
return Math.floor(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function panel(x, y, w, h, al) {
ctx.fillStyle = "rgba(8,6,20," + (al === undefined ? 0.42 : al) + ")";
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = "rgba(160,220,255,0.18)";
ctx.lineWidth = 1;
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
}
function drawHUD() {
if (Game.state === "title" || Game.state === "over") return;
const pad = Math.max(16, Math.min(W, H) * 0.028);
ctx.textBaseline = "top";
ctx.textAlign = "left";
// --- score (top-left) ---
const big = clamp(Math.min(W, H) * 0.045, 24, 56);
ctx.font = "700 " + big + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = "rgba(0,0,0,0.45)";
ctx.fillText(fmt(Game.score), pad + 2, pad + 2);
ctx.fillStyle = "#eaf6ff";
ctx.fillText(fmt(Game.score), pad, pad);
ctx.font = "600 " + (big * 0.30) + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = "rgba(150,205,255,0.72)";
ctx.fillText("SCORE", pad + 3, pad + big * 1.02);
// --- distance + multiplier (top-right) ---
ctx.textAlign = "right";
const mid = clamp(Math.min(W, H) * 0.030, 17, 34);
ctx.font = "700 " + mid + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = "#cfe8ff";
ctx.fillText(fmt(Game.dist) + " m", W - pad, pad);
ctx.font = "600 " + (mid * 0.66) + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = "rgba(150,205,255,0.72)";
ctx.fillText("BEST " + fmt(Game.best), W - pad, pad + mid * 1.15);
// multiplier chip
const mAl = Game.mult > 1 ? 1 : 0.45;
const mPulse = Game.multTimer < CFG.multDecayDelay && Game.mult > 1 ? 1 + Math.sin(Game.hue * 9) * 0.05 : 1;
const mcol = mixCol(COL.token, COL.orb, clamp((Game.mult - 1) / (CFG.multMax - 1), 0, 1));
const cw = mid * 3.0, ch = mid * 1.55;
const cx0 = W - pad - cw, cy0 = pad + mid * 1.95;
panel(cx0, cy0, cw, ch, 0.42);
ctx.textAlign = "center";
ctx.font = "800 " + (mid * 1.0 * mPulse) + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = css(mcol, mAl);
ctx.fillText("×" + Game.mult.toFixed(1), cx0 + cw / 2, cy0 + ch * 0.20);
ctx.font = "600 " + (mid * 0.44) + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = "rgba(255,255,255,0.5)";
ctx.fillText("MULT", cx0 + cw / 2, cy0 + ch * 0.72);
// --- speed readout (right, mid-height) ---
const kmh = Game.speed * 3.1;
ctx.textAlign = "right";
ctx.font = "700 " + (mid * 0.9) + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = Game.boostOn ? css(COL.energy) : "rgba(190,225,255,0.78)";
ctx.fillText(Math.floor(kmh) + " km/h", W - pad, H * 0.5);
// --- sun timer ---
const sunLeft = clamp(1 - (Game.sunFrac - CFG.sunStartFrac) / (CFG.sunEndFrac - CFG.sunStartFrac), 0, 1);
ctx.textAlign = "left";
ctx.font = "600 " + (mid * 0.72) + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = css(mixCol(COL.sunGlow, COL.danger, 1 - sunLeft), 0.9);
ctx.fillText("SUNLIGHT " + Math.round(sunLeft * 100) + "%", pad, H - pad - mid * 3.1);
// --- energy bar (bottom-left) ---
const bw = clamp(W * 0.30, 180, 420), bh = clamp(H * 0.022, 12, 24);
const bx = pad, by = H - pad - bh;
const eFrac = clamp(Game.energy / CFG.energyMax, 0, 1);
panel(bx, by, bw, bh, 0.5);
const eg = ctx.createLinearGradient(bx, 0, bx + bw, 0);
const lowE = eFrac < 0.25;
const ec1 = lowE ? COL.danger : COL.energy;
const ec2 = lowE ? mixCol(COL.danger, COL.token, 0.5) : mixCol(COL.energy, COL.token, 0.45);
eg.addColorStop(0, css(ec1));
eg.addColorStop(1, css(ec2));
ctx.fillStyle = eg;
const flashPulse = lowE ? 0.65 + 0.35 * Math.sin(Game.hue * 12) : 1;
ctx.globalAlpha = flashPulse;
ctx.fillRect(bx + 2, by + 2, (bw - 4) * eFrac, bh - 4);
ctx.globalAlpha = 1;
ctx.font = "700 " + (bh * 0.72) + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = "rgba(255,255,255,0.92)";
ctx.textAlign = "left";
ctx.fillText("⚡ " + Math.round(Game.energy) + "%", bx + 8, by + bh * 0.14);
ctx.font = "600 " + (bh * 0.72) + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = css(COL.energy, 0.8);
ctx.fillText("POWER", bx + bw + 12, by + bh * 0.14);
// --- near-miss / combo popups ---
if (Game.nearComboT > 0 && Game.nearCombo > 0) {
const a = clamp(Game.nearComboT / 0.9, 0, 1);
const scale = 1 + (1 - a) * 0.35;
ctx.save();
ctx.translate(SCX, H * 0.30);
ctx.scale(scale, scale);
ctx.textAlign = "center";
ctx.font = "800 " + clamp(H * 0.030, 18, 40) + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = css(Game.nearCombo > 1 ? COL.orb : COL.playerLit, a * 0.92);
ctx.fillText(Game.nearCombo > 1 ? "NEAR MISS ×" + Game.nearCombo : "NEAR MISS", 0, 0);
ctx.restore();
}
// --- boost indicator ---
if (Game.boostOn) {
ctx.textAlign = "center";
ctx.font = "800 " + (mid * 0.8) + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = css(COL.energy, 0.65 + 0.25 * Math.sin(Game.hue * 16));
ctx.fillText("BOOST", SCX, H - pad - mid * 1.2);
}
// --- off-sun warning ---
if (Game.inShadow) {
ctx.textAlign = "left";
ctx.font = "700 " + (mid * 0.66) + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = css(COL.danger, 0.55 + 0.35 * Math.sin(Game.hue * 13));
ctx.fillText("IN SHADOW — DRAINING", pad, H - pad - mid * 2.0);
}
}
// ============================================================================
// === TITLE & GAME OVER SCREENS ===
// ============================================================================
function translucentWash(a) {
ctx.fillStyle = "rgba(4,3,14," + a + ")";
ctx.fillRect(0, 0, W, H);
}
function drawTitle() {
const s = clamp(Math.min(W, H) * 0.00105, 0.55, 1.7);
const bob = Math.sin(Game.hue * 1.1) * 4;
translucentWash(0.12);
ctx.shadowColor = "rgba(0,0,0,0.85)";
ctx.shadowBlur = 18;
ctx.textAlign = "center";
const ts = clamp(Math.min(W * 0.115, H * 0.155), 40, 170);
ctx.font = "900 " + ts + "px ui-monospace,Menlo,monospace";
const tg = ctx.createLinearGradient(0, H * 0.20, 0, H * 0.20 + ts);
tg.addColorStop(0, "#fff4d6");
tg.addColorStop(0.55, "#ffb457");
tg.addColorStop(1, "#ff5f8d");
ctx.fillStyle = tg;
ctx.fillText("SUNRUNNER", SCX, H * 0.235 + bob);
ctx.shadowBlur = 10;
ctx.font = "600 " + (ts * 0.135) + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = "rgba(224,238,255,0.86)";
ctx.fillText("stay in the light · chase the sun", SCX, H * 0.235 + ts * 1.12 + bob);
// controls block
ctx.shadowBlur = 0; ctx.shadowColor = "transparent";
const bw = clamp(W * 0.62, 300, 660), bh = 118 * s;
const bx = SCX - bw / 2, by = H * 0.46;
panel(bx, by, bw, bh, 0.58);
ctx.shadowBlur = 8; ctx.shadowColor = "rgba(0,0,0,0.9)";
const lines = [
["W A S D / ARROWS", "strafe + pitch"],
["SPACE", "boost (burns power)"],
["R", "restart"],
];
ctx.font = "600 " + (13 * s + 4) + "px ui-monospace,Menlo,monospace";
for (let i = 0; i < lines.length; i++) {
const ly = by + 22 * s + i * (26 * s + 4);
ctx.textAlign = "left";
ctx.fillStyle = css(COL.energy, 0.95);
ctx.fillText(lines[i][0], bx + 22 * s, ly);
ctx.textAlign = "right";
ctx.fillStyle = "rgba(220,236,255,0.78)";
ctx.fillText(lines[i][1], bx + bw - 22 * s, ly);
}
const pulse = 0.55 + 0.45 * Math.sin(Game.hue * 3.4);
ctx.textAlign = "center";
ctx.font = "800 " + (ts * 0.175) + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = css(COL.token, pulse);
ctx.fillText("PRESS ANY KEY / CLICK", SCX, by + bh + ts * 0.42);
if (Game.best > 0) {
ctx.font = "600 " + (ts * 0.13) + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = "rgba(196,220,255,0.75)";
ctx.fillText("BEST " + fmt(Game.best), SCX, H - 46);
}
ctx.shadowBlur = 0; ctx.shadowColor = "transparent";
}
function drawOver() {
const a = clamp((Game.overT - 0.5) / 0.7, 0, 1);
translucentWash(0.55 * a);
const ts = clamp(Math.min(W * 0.10, H * 0.13), 34, 120);
ctx.textAlign = "center";
const title = Game.deathReason === "sunset" ? "THE SUN SET" :
Game.deathReason === "power" ? "POWER LOST" : "WRECKED";
const tcol = Game.deathReason === "crash" ? COL.danger : COL.sunGlow;
ctx.font = "900 " + ts + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = "rgba(0,0,0,0.5)";
ctx.fillText(title, SCX + 3, H * 0.24 + 3);
ctx.fillStyle = css(tcol, a);
ctx.fillText(title, SCX, H * 0.24);
const isBest = Math.floor(Game.score) >= Game.best && Game.score > 0;
const ss = clamp(Math.min(W * 0.075, H * 0.10), 28, 88);
ctx.font = "900 " + ss + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = "rgba(0,0,0,0.5)";
ctx.fillText(fmt(Game.score), SCX + 2, H * 0.40 + 2);
ctx.fillStyle = "#eaf6ff";
ctx.fillText(fmt(Game.score), SCX, H * 0.40);
ctx.font = "600 " + (ss * 0.30) + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = "rgba(160,205,255,0.72)";
ctx.fillText("SCORE", SCX, H * 0.40 + ss * 1.05);
const mid = clamp(Math.min(W, H) * 0.030, 15, 30);
ctx.font = "700 " + mid + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = "rgba(215,232,255,0.85)";
const y2 = H * 0.40 + ss * 2.0;
ctx.fillText(fmt(Game.dist) + " m · ×" + Game.mult.toFixed(1) + " mult · seed " + Game.seed.toString(16), SCX, y2);
if (isBest) {
const p = 0.7 + 0.3 * Math.sin(Game.hue * 6);
ctx.fillStyle = css(COL.token, p);
ctx.fillText("★ NEW BEST ★", SCX, y2 + mid * 1.6);
} else {
ctx.fillStyle = "rgba(170,205,255,0.62)";
ctx.fillText("BEST " + fmt(Game.best), SCX, y2 + mid * 1.6);
}
if (a > 0.6) {
const pulse = 0.5 + 0.5 * Math.sin(Game.hue * 3.6);
ctx.font = "800 " + (mid * 1.1) + "px ui-monospace,Menlo,monospace";
ctx.fillStyle = css(COL.energy, pulse);
ctx.fillText("PRESS R OR CLICK TO FLY AGAIN", SCX, y2 + mid * 3.4);
}
}
// ============================================================================
// === MAIN LOOP ===
// ============================================================================
let last = 0;
function frame(now) {
requestAnimationFrame(frame);
if (!last) last = now;
let dt = (now - last) / 1000;
last = now;
if (dt > 0.05) dt = 0.05; // clamp long frames
if (dt <= 0) dt = 0.0001;
update(dt);
// --- draw ---
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = css(COL.ground);
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.translate(Game.shakeX, Game.shakeY);
drawScene();
ctx.restore();
drawOverlays();
drawHUD();
if (Game.state === "title") drawTitle();
else if (Game.state === "over") drawOver();
else if (Game.state === "crash") {
const a = clamp(Game.overT / 0.9, 0, 1);
translucentWash(0.35 * a);
}
}
// global R: restart at any time
window.addEventListener("keydown", function (e) {
if ((e.key === "r" || e.key === "R") && Game.state !== "title") {
if (Game.overT > 0.25) startRun();
}
});
// boot
spawnZ = 900; pathX = 0; pathY = 92; genAhead();
requestAnimationFrame(frame);
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"233b40b9-45d5-449b-89bf-18a31e804846","tokensIn":20868189,"tokensOut":118417,"tokensTotal":20986606,"cost":0.167217366,"turns":102,"toolCalls":101,"failedToolCalls":0,"timestamp":"2026-09-11T00:25:17.976Z"} -->