Race the Sun
muse spark 1.2 contributor · full

Browser check: Artifact runtime error. This is a technical check, not a quality review.
Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 40,332 bytes · SHA-256 794dc1792924
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RACE THE SUN — Endless Solar Run</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&family=JetBrains+Mono:wght@700&display=swap');
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#0a0a14;font-family:'Space Grotesk',system-ui,sans-serif}
canvas{display:block;width:100%;height:100%}
#hud{position:absolute;inset:0;pointer-events:none;display:flex;flex-direction:column;justify-content:space-between;padding:14px 18px}
.topbar{display:flex;gap:12px;align-items:stretch;flex-wrap:wrap}
.pill{background:rgba(10,12,28,0.72);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.12);border-radius:14px;padding:10px 14px;display:flex;flex-direction:column;justify-content:center;min-width:110px;flex:1}
.pill label{font-size:9px;letter-spacing:0.18em;color:rgba(255,255,255,0.55);font-weight:700}
.pill strong{font-family:'JetBrains Mono',monospace;font-size:18px;color:#fff;line-height:1}
.pill small{font-family:'JetBrains Mono',monospace;font-size:10px;color:rgba(255,255,255,0.6)}
.energy-wrap{flex:1.6;min-width:200px}
.bar{height:10px;background:rgba(255,255,255,0.12);border-radius:999px;overflow:hidden;margin-top:6px;position:relative}
.bar i{position:absolute;inset:0;background:linear-gradient(90deg,#ff3b30,#ffcc00);width:100%;transform-origin:left;transition:width 0.12s linear}
.bar b{position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,0.5));opacity:0}
.boosting .bar b{opacity:1;animation:shine 0.3s linear infinite}
@keyframes shine{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}
.bottombar{display:flex;justify-content:space-between;align-items:end;gap:12px}
.speedo{background:rgba(10,12,28,0.72);border:1px solid rgba(255,255,255,0.12);border-radius:14px;padding:10px 14px;min-width:140px}
.speedo label{font-size:9px;letter-spacing:0.18em;color:rgba(255,255,255,0.55);font-weight:700}
.speedo strong{font-family:'JetBrains Mono',monospace;color:#fff;font-size:20px}
.controls-hint{color:rgba(255,255,255,0.55);font-size:11px;line-height:1.4;text-align:right}
.controls-hint kbd{background:rgba(255,255,255,0.12);border:1px solid rgba(255,255,255,0.2);border-bottom-width:2px;border-radius:4px;padding:1px 5px;font-family:'JetBrains Mono',monospace;font-size:10px;color:#fff}
#overlay{position:absolute;inset:0;display:grid;place-items:center;background:radial-gradient(800px 600px at 50% 30%, rgba(255,120,40,0.18), transparent 70%), linear-gradient(180deg, rgba(10,10,20,0.2), rgba(5,5,15,0.85));backdrop-filter:blur(2px)}
.card{width:min(560px,92vw);background:rgba(14,16,32,0.88);border:1px solid rgba(255,255,255,0.14);border-radius:22px;padding:28px;box-shadow:0 20px 60px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.1);text-align:center;pointer-events:auto}
.badge{display:inline-flex;align-items:center;gap:8px;background:linear-gradient(90deg,#ff3b30,#ff8a00);color:#fff;font-weight:700;font-size:11px;letter-spacing:0.14em;padding:6px 12px;border-radius:999px}
h1{font-size:42px;line-height:0.9;letter-spacing:-0.04em;color:#fff;margin:14px 0 8px;font-weight:800}
h1 span{background:linear-gradient(90deg,#ffcc00,#ff3b30);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.subtitle{color:rgba(255,255,255,0.65);font-size:13px;line-height:1.5}
.cta{margin-top:18px;display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
.btn{appearance:none;border:0;border-radius:999px;padding:13px 22px;font-weight:800;letter-spacing:0.06em;font-size:13px;cursor:pointer;transition:transform 0.08s, box-shadow 0.08s}
.btn-primary{background:#fff;color:#111;box-shadow:0 8px 24px rgba(255,255,255,0.18)}
.btn-primary:hover{transform:translateY(-1px)}
.btn-ghost{background:rgba(255,255,255,0.08);color:#fff;border:1px solid rgba(255,255,255,0.14)}
.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:18px}
.stat{background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:14px;padding:12px}
.stat label{font-size:9px;letter-spacing:0.14em;color:rgba(255,255,255,0.5);font-weight:700}
.stat strong{font-family:'JetBrains Mono',monospace;color:#fff;font-size:16px;display:block;margin-top:4px}
.hidden{display:none !important}
#flash{position:absolute;inset:0;background:#fff;opacity:0;pointer-events:none;transition:opacity 0.12s}
.shake{animation:shake 0.18s}
@keyframes shake{0%,100%{transform:translate(0,0)}25%{transform:translate(-6px,2px)}50%{transform:translate(6px,-2px)}75%{transform:translate(-3px,-4px)}}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="flash"></div>
<div id="hud">
<div class="topbar">
<div class="pill"><label>DISTANCE</label><strong id="hudDist">0 m</strong><small id="hudBest">BEST 0 m</small></div>
<div class="pill"><label>SCORE</label><strong id="hudScore">0</strong><small id="hudMult">×1.0 MULTIPLIER</small></div>
<div class="pill energy-wrap" id="energyPill"><label>ENERGY — STAY IN LIGHT <span id="sunPct" style="float:right;color:#ffcc00">☀ 100%</span></label><div class="bar"><i id="energyBar"></i><b></b></div><small id="energyHint" style="display:flex;justify-content:space-between;margin-top:4px"><span>SHADOW DRAINS ×3</span><span style="color:#ffcc00">SPACE = BOOST</span></small></div>
</div>
<div class="bottombar">
<div class="speedo"><label>SPEED</label><div><strong id="hudSpeed">0</strong> <small style="color:rgba(255,255,255,0.6);font-family:'JetBrains Mono',monospace">KM/H</small></div><div class="bar" style="height:4px;margin-top:6px"><i id="speedBar" style="background:linear-gradient(90deg,#00e5ff,#7c4dff)"></i></div></div>
<div class="controls-hint">
<div><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> or <kbd>ARROWS</kbd> — strafe & pitch</div>
<div><kbd>SPACE</kbd> boost <kbd>R</kbd> restart <kbd>P</kbd> pause</div>
</div>
</div>
</div>
<div id="overlay">
<div class="card" id="titleCard">
<div class="badge">◉ SOLAR ENDLESS RUNNER — 60 FPS</div>
<h1>RACE THE <span>SUN</span></h1>
<p class="subtitle">The sun is sinking. Your craft lives on light. Weave through the geometric wasteland, skim energy cells, chain multipliers — and outrun the dusk. Speed builds every second.</p>
<div class="cta">
<button class="btn btn-primary" id="playBtn">▶ IGNITE ENGINES — SPACE</button>
<button class="btn btn-ghost" id="howBtn">HOW TO PLAY</button>
</div>
<div class="stats">
<div class="stat"><label>HIGH SCORE</label><strong id="statHigh">0</strong></div>
<div class="stat"><label>BEST DISTANCE</label><strong id="statDist">0 m</strong></div>
<div class="stat"><label>LAST RUN</label><strong id="statLast">—</strong></div>
</div>
<p class="subtitle" style="margin-top:14px;font-size:11px;opacity:0.7">Tip: Near-miss obstacles for bonus score + screen shake. Tall shadows drain energy — stay sunlit. Boost sparingly.</p>
</div>
<div class="card hidden" id="gameOverCard">
<div class="badge" style="background:linear-gradient(90deg,#1a1a2e,#ff3b30)">☠ SIGNAL LOST</div>
<h1 id="goTitle" style="font-size:34px">ECLIPSED</h1>
<p class="subtitle" id="goReason">You ran out of light.</p>
<div class="stats">
<div class="stat"><label>SCORE</label><strong id="goScore">0</strong></div>
<div class="stat"><label>DISTANCE</label><strong id="goDist">0 m</strong></div>
<div class="stat"><label>MULTIPLIER</label><strong id="goMult">×1.0</strong></div>
</div>
<div class="cta">
<button class="btn btn-primary" id="retryBtn">↻ RETRY — R</button>
<button class="btn btn-ghost" id="menuBtn">MENU</button>
</div>
</div>
<div class="card hidden" id="howCard">
<h1 style="font-size:28px">FLIGHT MANUAL</h1>
<div style="text-align:left;color:rgba(255,255,255,0.8);font-size:13px;line-height:1.6;margin-top:12px;display:grid;gap:10px">
<div><b style="color:#ffcc00">MOVE</b> — A/D or ←/→ strafe, W/S or ↑/↓ pitch. Craft banks and leans — snappy 100ms easing with inertia.</div>
<div><b style="color:#00e5ff">BOOST</b> — Hold SPACE to surge. Costs energy, triggers speed-lines & chromatic burst.</div>
<div><b style="color:#ff3b30">SUN</b> — Sinks over ~90s. Energy drains slowly in light, 3× in shadow. Pick up ⚡ to recharge.</div>
<div><b style="color:#7cff7c">PICKUPS</b> — ⚡ Energy (cyan), ✦ Score (gold +100×mult), ◈ Multiplier orb (magenta, +0.2×, max 5×).</div>
<div><b>NEAR MISS</b> — Skim obstacles for +50 and juicy shake. Crash = instant game over.</div>
</div>
<div class="cta"><button class="btn btn-primary" id="backBtn">BACK</button></div>
</div>
</div>
<script>
// === TUNABLES ===
const C = {
// movement
baseSpeed: 38, // world units per second at start
maxSpeed: 165, // cap
accel: 0.62, // speed increase per second
boostMult: 1.55, // speed multiplier while boosting
boostDrain: 22, // energy per second while boosting
strafeSpeed: 14, // max lateral speed
pitchSpeed: 9,
easing: 0.18, // lerp factor per frame (~100ms)
inertia: 0.92, // velocity damping
boundsX: 26, // horizontal limits
boundsY: 14, // vertical limits (pitch)
// energy / sun
energyMax: 100,
drainLight: 2.2, // per second in light
drainShadow: 7.5, // per second in shadow
sunDuration: 92, // seconds until sunset (game over if not extended by pickups? we just drain)
// generation
spawnInterval: 0.42, // seconds between waves (decreases with speed)
pickupChance: 0.62,
// scoring
scorePerSec: 10,
tokenScore: 100,
nearMissScore: 50,
// visuals
fov: 420,
camZ: -28,
camY: 9,
horizonY: 0.38, // 0-1 screen height
// colors
skyTop: '#0b0b1e',
skyMid: '#3a1a6a',
skyHorizon: '#ff6a2a',
ground: '#0f0f1a',
gridColor: 'rgba(255,120,40,0.18)',
// difficulty
obstacleDensityRamp: 0.018, // increase per second
};
// === SETUP ===
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let W=0,H=0,DPR=1;
function resize(){
DPR = Math.min(2, devicePixelRatio||1);
W = innerWidth; H = innerHeight;
canvas.width = W*DPR; canvas.height = H*DPR;
canvas.style.width = W+'px'; canvas.style.height = H+'px';
ctx.setTransform(DPR,0,0,DPR,0,0);
}
addEventListener('resize', resize); resize();
// === STATE ===
let seed = (Math.random()*1e9)|0;
function rnd(){ seed = (seed*1664525+1013904223)>>>0; return seed/4294967296; }
function rand(a,b){ return a + rnd()*(b-a); }
const state = {
mode: 'title', // title | playing | paused | gameover
t: 0, distance: 0, score: 0, mult: 1, speed: C.baseSpeed,
energy: C.energyMax, sunT: 1, // 1 = high noon, 0 = set
x: 0, y: 0, vx: 0, vy: 0, targetX:0, targetY:0, roll:0, pitch:0,
shake: 0, flash: 0, boosting: false,
nearMissCooldown: 0,
spawnTimer: 0,
objects: [], particles: [], speedLines: [],
keys: {},
highScore: parseInt(localStorage.getItem('rts_high')||'0'),
bestDist: parseInt(localStorage.getItem('rts_dist')||'0'),
lastScore: 0, lastDist:0,
};
const hudDist = document.getElementById('hudDist');
const hudBest = document.getElementById('hudBest');
const hudScore = document.getElementById('hudScore');
const hudMult = document.getElementById('hudMult');
const hudSpeed = document.getElementById('hudSpeed');
const energyBar = document.getElementById('energyBar');
const speedBar = document.getElementById('speedBar');
const sunPct = document.getElementById('sunPct');
const energyPill = document.getElementById('energyPill');
const overlay = document.getElementById('overlay');
const titleCard = document.getElementById('titleCard');
const gameOverCard = document.getElementById('gameOverCard');
const howCard = document.getElementById('howCard');
const flashEl = document.getElementById('flash');
// === AUDIO (WebAudio) ===
let actx=null;
function ensureAudio(){ if(!actx) actx = new (window.AudioContext||window.webkitAudioContext)(); if(actx.state==='suspended') actx.resume(); }
function tone(freq, dur, type='sine', gain=0.18, slideTo){
if(!actx) return;
const o=actx.createOscillator(), g=actx.createGain();
o.type=type; o.frequency.value=freq; o.connect(g); g.connect(actx.destination);
g.gain.value=gain; g.gain.exponentialRampToValueAtTime(0.001, actx.currentTime+dur);
if(slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, actx.currentTime+dur);
o.start(); o.stop(actx.currentTime+dur);
}
function sfxPickup(kind){
ensureAudio();
if(kind==='energy') { tone(520,0.12,'sine',0.22,880); setTimeout(()=>tone(880,0.12,'sine',0.14,1040),80); }
else if(kind==='score') { tone(700,0.09,'square',0.12,900); }
else if(kind==='mult') { tone(300,0.15,'sawtooth',0.14,600); setTimeout(()=>tone(600,0.18,'sine',0.16,900),120); }
else if(kind==='near') { tone(180,0.08,'square',0.18,90); }
else if(kind==='crash') { tone(120,0.4,'sawtooth',0.35,30); tone(60,0.5,'square',0.2); }
else if(kind==='boost') { tone(80,0.2,'sawtooth',0.08,160); }
}
let windNode=null, windGain=null;
function startWind(){
ensureAudio();
if(windNode) return;
const bufSize = actx.sampleRate*1;
const buf = actx.createBuffer(1, bufSize, actx.sampleRate);
const ch = buf.getChannelData(0);
for(let i=0;i<bufSize;i++) ch[i]=(Math.random()*2-1)*0.5;
windNode = actx.createBufferSource(); windNode.buffer=buf; windNode.loop=true;
const filter = actx.createBiquadFilter(); filter.type='bandpass'; filter.frequency.value=900; filter.Q.value=0.7;
windGain = actx.createGain(); windGain.gain.value=0;
windNode.connect(filter); filter.connect(windGain); windGain.connect(actx.destination);
windNode.start();
}
function updateWind(){
if(!windGain) return;
const s = state.speed / C.maxSpeed;
windGain.gain.value = state.mode==='playing' ? s*0.07 : 0;
if(windGain && actx){
// slight pitch with speed
}
}
// === INPUT ===
addEventListener('keydown', e=>{
const k = e.key.toLowerCase();
state.keys[k]=true;
if(k===' '){ e.preventDefault(); if(state.mode==='title') startGame(); else if(state.mode==='gameover') startGame(); else if(state.mode==='playing') state.boosting=true; }
if(k==='r'){ if(state.mode==='gameover' || state.mode==='playing') startGame(); }
if(k==='p'){ if(state.mode==='playing') setPaused(true); else if(state.mode==='paused') setPaused(false); }
if(k==='arrowleft' || k==='arrowright' || k==='arrowup' || k==='arrowdown') e.preventDefault();
if(k==='escape' && state.mode==='paused') setPaused(false);
});
addEventListener('keyup', e=>{
const k=e.key.toLowerCase();
state.keys[k]=false;
if(k===' ') state.boosting=false;
});
['playBtn','retryBtn'].forEach(id=> document.getElementById(id).addEventListener('click', startGame));
document.getElementById('howBtn').addEventListener('click', ()=> showCard('how'));
document.getElementById('backBtn').addEventListener('click', ()=> showCard('title'));
document.getElementById('menuBtn').addEventListener('click', ()=> showCard('title'));
canvas.addEventListener('click', ()=>{
if(state.mode==='title' || state.mode==='gameover') startGame();
});
function showCard(which){
titleCard.classList.toggle('hidden', which!=='title');
gameOverCard.classList.toggle('hidden', which!=='gameover');
howCard.classList.toggle('hidden', which!=='how');
overlay.classList.toggle('hidden', which==='none');
if(which==='none') overlay.style.display='none'; else overlay.style.display='grid';
if(which==='title') updateTitleStats();
}
function setPaused(p){
state.mode = p ? 'paused' : 'playing';
overlay.classList.toggle('hidden', !p);
if(p){
overlay.style.display='grid';
titleCard.classList.add('hidden'); gameOverCard.classList.add('hidden'); howCard.classList.add('hidden');
// show pause card inline
titleCard.classList.remove('hidden');
titleCard.querySelector('h1').innerHTML='PAUSED';
titleCard.querySelector('.subtitle').textContent='Press P or ESC to resume';
titleCard.querySelector('#playBtn').textContent='RESUME — P';
titleCard.querySelector('#playBtn').onclick=()=>setPaused(false);
} else {
overlay.style.display='none';
titleCard.querySelector('h1').innerHTML='RACE THE <span>SUN</span>';
titleCard.querySelector('.subtitle').textContent='The sun is sinking. Your craft lives on light. Weave through the geometric wasteland, skim energy cells, chain multipliers — and outrun the dusk. Speed builds every second.';
document.getElementById('playBtn').textContent='▶ IGNITE ENGINES — SPACE';
document.getElementById('playBtn').onclick=startGame;
}
}
function updateTitleStats(){
document.getElementById('statHigh').textContent = state.highScore.toLocaleString();
document.getElementById('statDist').textContent = state.bestDist.toLocaleString()+' m';
document.getElementById('statLast').textContent = state.lastScore ? state.lastScore.toLocaleString() : '—';
}
// === GENERATION ===
function spawnWave(){
const speedFactor = Math.min(1, state.t/90);
const count = 1 + (rnd()<0.5+speedFactor*0.3 ? 1 : 0) + (rnd()<speedFactor*0.6 ? 1 : 0);
const z = 220 + rand(0,40); // ahead
// choose pattern
const pattern = Math.floor(rand(0,4));
const spots = [];
if(pattern===0){ // random scattered
for(let i=0;i<count;i++) spots.push({x: rand(-C.boundsX+4, C.boundsX-4), y: rand(-2,6)});
} else if(pattern===1){ // gate with gap
const gap = Math.floor(rand(0,3))-1; // -1,0,1
for(let lane=-1; lane<=1; lane++) if(lane!==gap) spots.push({x: lane*13, y: 0});
if(rnd()<0.5) spots.push({x: gap*13, y: 8}); // overhead
} else if(pattern===2){ // wall
const x = rand(-10,10);
spots.push({x, y:0, w: rand(14,22), h: rand(8,14)});
} else { // staggered pillars
for(let i=0;i<count;i++) spots.push({x: (i-1)*12 + rand(-4,4), y: rand(-1,2)});
}
spots.forEach(s=>{
const isObstacle = rnd()<0.72;
if(isObstacle){
const type = rnd()<0.33 ? 'pyramid' : rnd()<0.5 ? 'box' : 'arch';
state.objects.push({
kind:'obstacle', type, x:s.x, y:s.y||0, z,
w: s.w|| rand(6,10), h: s.h|| rand(7,13), d: rand(6,10),
hue: 8 + rand(-6,18), // reddish
shadowLen: rand(18,34),
});
} else {
// pickup
const r = rnd();
let pkind='score';
if(r<0.45) pkind='energy';
else if(r<0.75) pkind='score';
else pkind='mult';
state.objects.push({kind:'pickup', pkind, x:s.x, y: rand(2,9), z, r:3.2, bob: rand(0,Math.PI*2)});
}
});
// extra pickups in gaps
if(rnd()<C.pickupChance){
state.objects.push({kind:'pickup', pkind: rnd()<0.5?'score':'energy', x: rand(-C.boundsX+3, C.boundsX-3), y: rand(3,10), z: z+ rand(12,28), r:3.2, bob: rand(0,Math.PI*2)});
}
}
// === PARTICLES ===
function emit(x,y,z, n, color, speed=1){
for(let i=0;i<n;i++){
state.particles.push({
x: x + rand(-1,1), y: y+rand(-1,1), z: z+rand(-2,2),
vx: rand(-6,6)*speed, vy: rand(-4,8)*speed, vz: rand(-8,6)*speed,
life: rand(0.3,0.7), t:0, color, size: rand(1.5,4.5)
});
}
}
function emitSpeedLines(n=1){
for(let i=0;i<n;i++) state.speedLines.push({x: rand(-W/2,W/2), y: rand(-H*0.2, H*0.6), len: rand(40,120), alpha: rand(0.3,0.7), speed: rand(900,1600)});
}
// === COLLISION ===
function aabbCollide(ax,ay,aw,ah, bx,by,bw,bh){
return Math.abs(ax-bx) < (aw+bw)/2 && Math.abs(ay-by) < (ah+bh)/2;
}
// === GAME FLOW ===
function resetRun(){
seed = (Math.random()*1e9)|0;
state.t=0; state.distance=0; state.score=0; state.mult=1; state.energy=C.energyMax; state.sunT=1;
state.x=0; state.y=4; state.vx=0; state.vy=0; state.targetX=0; state.targetY=4; state.roll=0; state.pitch=0;
state.speed=C.baseSpeed; state.objects=[]; state.particles=[]; state.speedLines=[]; state.spawnTimer=0; state.shake=0; state.flash=0; state.boosting=false;
for(let i=0;i<6;i++) spawnWaveAt(rand(40,200));
}
function spawnWaveAt(z){
// helper for initial spread
const x = rand(-C.boundsX+4, C.boundsX-4);
if(rnd()<0.7) state.objects.push({kind:'obstacle', type:'box', x, y:0, z, w:rand(7,10), h:rand(8,12), d:8, hue:12, shadowLen:24});
else state.objects.push({kind:'pickup', pkind:'score', x, y:rand(3,8), z, r:3.2, bob:0});
}
function startGame(){
ensureAudio(); startWind();
resetRun();
state.mode='playing';
showCard('none');
sfxPickup('boost');
}
function gameOver(reason){
state.mode='gameover';
state.lastScore = Math.floor(state.score);
state.lastDist = Math.floor(state.distance);
if(state.lastScore > state.highScore){ state.highScore=state.lastScore; localStorage.setItem('rts_high', state.highScore); }
if(state.lastDist > state.bestDist){ state.bestDist=state.lastDist; localStorage.setItem('rts_dist', state.bestDist); }
document.getElementById('goScore').textContent = state.lastScore.toLocaleString();
document.getElementById('goDist').textContent = state.lastDist.toLocaleString()+' m';
document.getElementById('goMult').textContent = '×'+state.mult.toFixed(1);
document.getElementById('goReason').textContent = reason;
document.getElementById('goTitle').textContent = reason.includes('Sun') ? 'SUNSET' : reason.includes('Energy') ? 'POWER LOST' : 'CRITICAL IMPACT';
showCard('gameover');
sfxPickup('crash');
state.shake=14;
flashEl.style.opacity='0.55'; setTimeout(()=>flashEl.style.opacity='0',180);
}
// === UPDATE ===
let lastTime = performance.now();
function update(dt){
if(state.mode!=='playing') return;
state.t += dt;
// sun sinks
state.sunT = Math.max(0, 1 - state.t / C.sunDuration);
// speed ramp
const targetSpeed = Math.min(C.maxSpeed, C.baseSpeed + state.t * C.accel);
state.speed += (targetSpeed - state.speed) * 0.02;
const curSpeed = state.boosting && state.energy>0 ? state.speed * C.boostMult : state.speed;
state.distance += curSpeed * dt * 0.6; // scale to meters
state.score += C.scorePerSec * dt * state.mult;
// input
const left = state.keys['a']||state.keys['arrowleft'];
const right = state.keys['d']||state.keys['arrowright'];
const up = state.keys['w']||state.keys['arrowup'];
const down = state.keys['s']||state.keys['arrowdown'];
state.targetX += ( (right?1:0) - (left?1:0) ) * C.strafeSpeed * dt * 3.2;
state.targetY += ( (up?1:0) - (down?1:0) ) * C.pitchSpeed * dt * 3.2;
state.targetX = Math.max(-C.boundsX, Math.min(C.boundsX, state.targetX));
state.targetY = Math.max(-2, Math.min(12, state.targetY));
// auto-center slightly
state.targetX *= 0.999;
// easing with inertia
const dx = state.targetX - state.x;
const dy = state.targetY - state.y;
state.vx = state.vx * C.inertia + dx * C.easing * 60 * dt;
state.vy = state.vy * C.inertia + dy * C.easing * 60 * dt;
state.x += state.vx;
state.y += state.vy;
state.x = Math.max(-C.boundsX, Math.min(C.boundsX, state.x));
state.y = Math.max(-1, Math.min(12, state.y));
state.roll = -state.vx * 0.045;
state.pitch = -state.vy * 0.04;
// boost energy
if(state.boosting && state.energy>0){
state.energy -= C.boostDrain * dt;
if(state.energy<=0){ state.energy=0; state.boosting=false; }
if(Math.random()<0.5) emit(state.x, state.y-1, -6, 2, '#ffcc00', 0.8);
}
// energy drain (shadow check: if near tall obstacle ahead, consider shadow)
let inShadow = false;
for(const o of state.objects){
if(o.kind!=='obstacle') continue;
if(Math.abs(o.z - 12) < 18 && Math.abs(o.x - state.x) < o.w*0.6 && o.h>9){
// if sun low, shadow longer to one side
const shadowDir = 1; // sun to right, shadow to left
const shadowX = o.x - (1-state.sunT)*18;
if(Math.abs(shadowX - state.x) < 10) inShadow = true;
}
}
// also random shadow when off-center away from sun
if(state.x < -8 && state.sunT < 0.6) inShadow = true;
const drain = inShadow ? C.drainShadow : C.drainLight;
state.energy -= drain * dt;
if(state.energy<=0){ state.energy=0; gameOver('Energy depleted — you faded into shadow.'); return; }
if(state.sunT<=0){ gameOver('The sun has set. Darkness claims the dunes.'); return; }
// spawn
const interval = Math.max(0.18, C.spawnInterval - state.t*0.003 - (curSpeed-C.baseSpeed)*0.002);
state.spawnTimer += dt;
if(state.spawnTimer > interval){ state.spawnTimer=0; spawnWave(); }
// move world
for(let i=state.objects.length-1;i>=0;i--){
const o = state.objects[i];
o.z -= curSpeed * dt;
if(o.kind==='pickup') o.bob += dt*4;
// cull behind
if(o.z < C.camZ+2){
state.objects.splice(i,1);
continue;
}
// collision / pickup
const dz = Math.abs(o.z); // player at z=0
if(dz < 6){
if(o.kind==='pickup'){
const dist = Math.hypot(o.x - state.x, o.y - state.y);
if(dist < 4.2){
// collect
if(o.pkind==='energy'){ state.energy = Math.min(C.energyMax, state.energy+28); state.flash=0.22; }
else if(o.pkind==='score'){ state.score += C.tokenScore * state.mult; }
else if(o.pkind==='mult'){ state.mult = Math.min(5, state.mult+0.2); }
sfxPickup(o.pkind==='energy'?'energy': o.pkind==='mult'?'mult':'score');
emit(o.x,o.y,o.z, 14, o.pkind==='energy'?'#00e5ff': o.pkind==='mult'?'#ff00ff':'#ffcc00', 1.2);
state.objects.splice(i,1);
state.shake = Math.max(state.shake, 3);
continue;
}
} else if(o.kind==='obstacle'){
const pw=3.2, ph=2.2;
const ow=o.w, oh=o.h;
const collide = aabbCollide(state.x, state.y, pw, ph, o.x, o.y+oh/2, ow, oh);
if(collide){
emit(state.x, state.y, 0, 28, '#ff3b30', 1.6);
gameOver('Hull breach — you clipped a monolith at '+Math.floor(curSpeed)+' km/h.');
return;
} else {
// near miss
const nearDist = Math.hypot(o.x - state.x, (o.y+oh/2)-state.y);
const threshold = (ow+pw)/2 + 1.2;
if(nearDist < threshold+1.8 && nearDist > threshold-0.2 && state.nearMissCooldown<=0 && dz<4){
state.score += C.nearMissScore * state.mult;
state.nearMissCooldown=0.35;
state.shake = Math.max(state.shake, 5);
sfxPickup('near');
emit(o.x, o.y+oh/2, o.z, 6, '#ffffff', 0.9);
// flash
flashEl.style.background='rgba(255,255,255,0.35)'; flashEl.style.opacity='0.35';
setTimeout(()=>{ flashEl.style.opacity='0'; flashEl.style.background='#fff'; },90);
}
}
}
}
}
if(state.nearMissCooldown>0) state.nearMissCooldown-=dt;
// particles
for(let i=state.particles.length-1;i>=0;i--){
const p=state.particles[i];
p.t+=dt; p.x+=p.vx*dt; p.y+=p.vy*dt; p.z+=p.vz*dt - curSpeed*dt*0.1;
p.vy -= 6*dt;
if(p.t>p.life) state.particles.splice(i,1);
}
// speed lines
if(curSpeed > 85){
if(Math.random()< (curSpeed-80)/140) emitSpeedLines(1);
}
for(let i=state.speedLines.length-1;i>=0;i--){
const s=state.speedLines[i];
s.y += s.speed*dt*0.02;
s.alpha -= dt*0.9;
if(s.alpha<=0) state.speedLines.splice(i,1);
}
// shake decay
state.shake *= Math.pow(0.82, dt*60);
state.flash *= Math.pow(0.85, dt*60);
}
// === RENDER ===
function project(x,y,z){
const dz = z - C.camZ;
const scale = C.fov / (dz);
const sx = W/2 + (x - 0) * scale;
const sy = H * C.horizonY + (C.camY - y) * scale * 0.92;
return {x:sx, y:sy, s:scale, dz};
}
function draw(){
// shake offset
const shakeX = (Math.random()-0.5)*state.shake;
const shakeY = (Math.random()-0.5)*state.shake;
ctx.save();
ctx.translate(shakeX, shakeY);
// sky gradient
const sunY = H*C.horizonY - state.sunT * H*0.62;
const sunX = W*0.72;
const grad = ctx.createLinearGradient(0,0,0,H*C.horizonY+80);
grad.addColorStop(0, C.skyTop);
grad.addColorStop(0.45, C.skyMid);
grad.addColorStop(0.78, '#ff7a2e');
grad.addColorStop(1, '#ffcc6a');
ctx.fillStyle=grad;
ctx.fillRect(0,0,W,H);
// sun
const sunR = 44 + (1-state.sunT)*18;
// glow
const glow = ctx.createRadialGradient(sunX,sunY,sunR*0.6, sunX,sunY,sunR*5);
glow.addColorStop(0,'rgba(255,220,120,0.9)');
glow.addColorStop(0.25,'rgba(255,120,40,0.35)');
glow.addColorStop(1,'rgba(255,80,20,0)');
ctx.fillStyle=glow;
ctx.beginPath(); ctx.arc(sunX,sunY,sunR*5,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#fff8d0';
ctx.shadowColor='rgba(255,200,80,0.9)'; ctx.shadowBlur=24;
ctx.beginPath(); ctx.arc(sunX,sunY,sunR,0,Math.PI*2); ctx.fill();
ctx.shadowBlur=0;
// sun bands
ctx.strokeStyle='rgba(255,255,255,0.18)'; ctx.lineWidth=1;
for(let i=1;i<=3;i++){ ctx.beginPath(); ctx.arc(sunX,sunY,sunR+i*7, -0.2, Math.PI+0.2); ctx.stroke(); }
// horizon haze
ctx.fillStyle='rgba(255,180,120,0.18)';
ctx.fillRect(0, H*C.horizonY-18, W, 36);
// ground
const groundY = H*C.horizonY;
ctx.fillStyle=C.ground;
ctx.fillRect(0, groundY, W, H-groundY+20);
// grid
ctx.strokeStyle=C.gridColor; ctx.lineWidth=1;
const gridOffset = (state.distance*2.2) % 40;
// horizontal lines perspective
for(let i=0;i<22;i++){
const z = i*18 - gridOffset;
const p = project(0,0,z);
if(p.y < groundY) continue;
const w = (C.fov/(z - C.camZ))*C.boundsX*2.2;
ctx.beginPath();
ctx.moveTo(W/2 - w, p.y);
ctx.lineTo(W/2 + w, p.y);
ctx.globalAlpha = Math.max(0, 1 - i*0.07);
ctx.stroke();
}
ctx.globalAlpha=1;
// vertical lines
for(let lane=-2; lane<=2; lane++){
const x = lane*13;
const p1 = project(x,0, -10);
const p2 = project(x,0, 320);
ctx.beginPath(); ctx.moveTo(p1.x,p1.y); ctx.lineTo(p2.x,p2.y); ctx.stroke();
}
// distant dunes silhouette
ctx.fillStyle='rgba(20,14,30,0.9)';
ctx.beginPath();
ctx.moveTo(0, groundY);
for(let x=0;x<=W;x+=18){
const h = Math.sin(x*0.008 + state.distance*0.01)*18 + Math.cos(x*0.015)*12;
ctx.lineTo(x, groundY - h - 6);
}
ctx.lineTo(W, groundY); ctx.closePath(); ctx.fill();
// secondary dunes
ctx.fillStyle='rgba(40,22,50,0.55)';
ctx.beginPath(); ctx.moveTo(0, groundY);
for(let x=0;x<=W;x+=22){
const h = Math.sin(x*0.012 - state.distance*0.008)*10 + 4;
ctx.lineTo(x, groundY - h);
}
ctx.lineTo(W, groundY); ctx.closePath(); ctx.fill();
// sort objects back to front
const sorted = [...state.objects].sort((a,b)=> b.z - a.z);
for(const o of sorted){
if(o.z < 0) continue;
if(o.kind==='obstacle'){
drawObstacle(o);
} else {
drawPickup(o);
}
}
// particles
for(const p of state.particles){
const pr = project(p.x,p.y,p.z);
if(pr.dz<=0) continue;
const a = 1 - p.t/p.life;
ctx.globalAlpha = a;
ctx.fillStyle=p.color;
ctx.beginPath(); ctx.arc(pr.x, pr.y, p.size*pr.s*0.08, 0, Math.PI*2); ctx.fill();
}
ctx.globalAlpha=1;
// player craft
drawCraft();
// speed lines overlay
if(state.speed>90 || state.boosting){
ctx.save();
ctx.globalAlpha = Math.min(0.55, (state.speed-70)/120 + (state.boosting?0.2:0));
ctx.strokeStyle='#ffffff';
ctx.lineWidth=1.5;
for(const s of state.speedLines){
ctx.globalAlpha = s.alpha*0.7;
ctx.beginPath(); ctx.moveTo(W/2 + s.x*0.6, s.y); ctx.lineTo(W/2 + s.x*0.6, s.y + s.len*0.12); ctx.stroke();
}
ctx.restore();
// chromatic aberration when boosting
if(state.boosting){
ctx.save(); ctx.globalAlpha=0.08; ctx.fillStyle='#ff0040';
ctx.fillRect(0,0,W,H); ctx.globalCompositeOperation='screen';
ctx.fillStyle='#00ffff'; ctx.fillRect(2,0,W,H); ctx.restore();
}
// vignette
const vig = ctx.createRadialGradient(W/2,H/2, Math.min(W,H)*0.3, W/2,H/2, Math.max(W,H)*0.8);
vig.addColorStop(0,'transparent'); vig.addColorStop(1,'rgba(0,0,0,0.45)');
ctx.fillStyle=vig; ctx.fillRect(0,0,W,H);
}
// flash
if(state.flash>0){
ctx.fillStyle=`rgba(255,255,255,${state.flash*0.22})`;
ctx.fillRect(0,0,W,H);
}
ctx.restore();
// HUD update
hudDist.textContent = Math.floor(state.distance).toLocaleString()+' m';
hudBest.textContent = 'BEST '+state.bestDist.toLocaleString()+' m';
hudScore.textContent = Math.floor(state.score).toLocaleString();
hudMult.textContent = '×'+state.mult.toFixed(1)+' MULTIPLIER';
hudMult.style.color = state.mult>1.4 ? '#ffcc00' : 'rgba(255,255,255,0.6)';
hudSpeed.textContent = Math.floor((state.boosting? state.speed*C.boostMult : state.speed)*3.1);
energyBar.style.width = (state.energy/C.energyMax*100)+'%';
energyBar.style.background = state.energy<25 ? 'linear-gradient(90deg,#ff0040,#ff6a00)' : state.energy<50 ? 'linear-gradient(90deg,#ff8a00,#ffcc00)' : 'linear-gradient(90deg,#00e5ff,#7cff7c)';
sunPct.textContent = '☀ '+(state.sunT*100|0)+'%';
sunPct.style.color = state.sunT<0.25 ? '#ff3b30' : '#ffcc00';
speedBar.style.width = ((state.speed/C.maxSpeed)*100)+'%';
energyPill.classList.toggle('boosting', state.boosting);
// low energy pulse
if(state.energy<20 && state.mode==='playing'){
energyPill.style.boxShadow = (Math.sin(state.t*8)>0) ? '0 0 18px rgba(255,60,60,0.9)' : 'none';
} else energyPill.style.boxShadow='none';
}
function drawObstacle(o){
const base = project(o.x, 0, o.z);
const top = project(o.x, o.h, o.z);
const scale = base.s;
const w = o.w * scale * 0.55;
const h = (top.y - base.y);
// shadow on ground
const shadowW = (o.w + o.shadowLen*(1-state.sunT)) * scale *0.55;
ctx.fillStyle='rgba(0,0,0,0.35)';
ctx.beginPath();
ctx.ellipse(base.x, base.y+2, shadowW, 8*scale*0.08, 0,0,Math.PI*2); ctx.fill();
// body
const light = state.sunT>0.5 ? 1 : 0.6 + state.sunT*0.8;
const hue = o.hue;
if(o.type==='box'){
// front face
ctx.fillStyle=`hsl(${hue} 78% ${Math.floor(52*light)}%)`;
ctx.fillRect(base.x - w/2, base.y - Math.abs(h), w, Math.abs(h));
// top face
ctx.fillStyle=`hsl(${hue} 85% ${Math.floor(68*light)}%)`;
ctx.beginPath();
ctx.moveTo(base.x - w/2, base.y - Math.abs(h));
ctx.lineTo(base.x - w/2 + 10*scale*0.08, top.y);
ctx.lineTo(base.x + w/2 + 10*scale*0.08, top.y);
ctx.lineTo(base.x + w/2, base.y - Math.abs(h));
ctx.closePath(); ctx.fill();
// side
ctx.fillStyle=`hsl(${hue} 70% ${Math.floor(32*light)}%)`;
ctx.beginPath();
ctx.moveTo(base.x + w/2, base.y - Math.abs(h));
ctx.lineTo(base.x + w/2 + 10*scale*0.08, top.y);
ctx.lineTo(base.x + w/2 + 10*scale*0.08, base.y);
ctx.lineTo(base.x + w/2, base.y);
ctx.closePath(); ctx.fill();
// edge highlight
ctx.strokeStyle='rgba(255,255,255,0.18)'; ctx.lineWidth=1; ctx.strokeRect(base.x - w/2, base.y - Math.abs(h), w, Math.abs(h));
} else if(o.type==='pyramid'){
ctx.fillStyle=`hsl(${hue} 80% ${Math.floor(55*light)}%)`;
ctx.beginPath();
ctx.moveTo(base.x, top.y);
ctx.lineTo(base.x - w/2, base.y);
ctx.lineTo(base.x + w/2, base.y);
ctx.closePath(); ctx.fill();
// shading
ctx.fillStyle=`hsl(${hue} 70% ${Math.floor(35*light)}%)`;
ctx.beginPath(); ctx.moveTo(base.x, top.y); ctx.lineTo(base.x + w/2, base.y); ctx.lineTo(base.x, base.y); ctx.closePath(); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,0.15)'; ctx.beginPath(); ctx.moveTo(base.x, top.y); ctx.lineTo(base.x, base.y); ctx.stroke();
} else { // arch
const archW = w;
const archH = Math.abs(h);
ctx.fillStyle=`hsl(${hue} 75% ${Math.floor(50*light)}%)`;
ctx.fillRect(base.x - archW/2, base.y - archH, 4*scale*0.18, archH);
ctx.fillRect(base.x + archW/2 - 4*scale*0.18, base.y - archH, 4*scale*0.18, archH);
ctx.fillRect(base.x - archW/2, base.y - archH, archW, 4*scale*0.18);
// inner shadow
ctx.fillStyle='rgba(0,0,0,0.25)';
ctx.fillRect(base.x - archW/2 + 4*scale*0.18, base.y - archH + 4*scale*0.18, archW - 8*scale*0.18, archH - 4*scale*0.18);
}
}
function drawPickup(o){
const p = project(o.x, o.y + Math.sin(o.bob)*0.6, o.z);
const s = p.s;
const r = o.r * s * 0.22;
// glow
ctx.shadowColor = o.pkind==='energy' ? '#00e5ff' : o.pkind==='mult' ? '#ff00ff' : '#ffcc00';
ctx.shadowBlur = 14;
if(o.pkind==='energy'){
// diamond
ctx.fillStyle='#7af2ff';
ctx.beginPath();
ctx.moveTo(p.x, p.y - r*1.2);
ctx.lineTo(p.x + r, p.y);
ctx.lineTo(p.x, p.y + r*1.2);
ctx.lineTo(p.x - r, p.y);
ctx.closePath(); ctx.fill();
ctx.fillStyle='#fff'; ctx.font=`${Math.max(10, r*0.9)}px JetBrains Mono`; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('⚡', p.x, p.y+0.5);
} else if(o.pkind==='score'){
ctx.fillStyle='#ffcc00';
ctx.beginPath(); ctx.arc(p.x,p.y,r,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#1a1a1a'; ctx.font=`bold ${Math.max(9, r*0.85)}px Space Grotesk`; ctx.textAlign='center'; ctx.fillText('✦', p.x, p.y+3);
// orbit ring
ctx.shadowBlur=0;
ctx.strokeStyle='rgba(255,204,0,0.45)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.ellipse(p.x,p.y,r*1.6,r*0.9, Math.sin(o.bob)*0.3,0,Math.PI*2); ctx.stroke();
ctx.shadowBlur=14;
} else {
// mult orb
const grad = ctx.createRadialGradient(p.x-r*0.3,p.y-r*0.3, r*0.2, p.x,p.y,r*1.2);
grad.addColorStop(0,'#ffb3ff'); grad.addColorStop(1,'#9a00ff');
ctx.fillStyle=grad;
ctx.beginPath(); ctx.arc(p.x,p.y,r,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#fff'; ctx.font=`bold ${Math.max(8,r*0.7)}px JetBrains Mono`; ctx.textAlign='center'; ctx.fillText('×', p.x, p.y+2);
}
ctx.shadowBlur=0;
// tether line to ground
const gp = project(o.x, 0, o.z);
ctx.strokeStyle='rgba(255,255,255,0.12)'; ctx.setLineDash([3,4]); ctx.beginPath(); ctx.moveTo(p.x,p.y+r); ctx.lineTo(gp.x,gp.y); ctx.stroke(); ctx.setLineDash([]);
}
function drawCraft(){
// craft screen position fixed near bottom center, but offset by x/y
const cx = W/2 + state.x * 14; // screen offset
const cy = H*0.78 - state.y * 10;
const roll = state.roll;
const pitch = state.pitch;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(roll);
// shadow on ground
const gp = project(state.x,0,0);
ctx.fillStyle='rgba(0,0,0,0.28)';
ctx.beginPath(); ctx.ellipse((gp.x - cx)*0.35, (H*C.horizonY+28 - cy), 26, 8, 0,0,Math.PI*2); ctx.fill();
// thrust
const thrust = 1 + (state.boosting? 1.6 : state.speed/C.maxSpeed);
ctx.save();
ctx.translate(0, 14 + pitch*6);
const flameGrad = ctx.createLinearGradient(0,0,0, 18*thrust);
flameGrad.addColorStop(0,'#fff7a0'); flameGrad.addColorStop(0.3,'#ff8a00'); flameGrad.addColorStop(1,'transparent');
ctx.fillStyle=flameGrad;
ctx.beginPath();
ctx.moveTo(-7,0); ctx.lineTo(0, 16*thrust); ctx.lineTo(7,0); ctx.closePath(); ctx.fill();
// side thrusters
ctx.fillStyle='rgba(0,229,255,0.9)';
ctx.beginPath(); ctx.ellipse(-10, -2, 3, 6*thrust*0.6, 0,0,Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.ellipse(10, -2, 3, 6*thrust*0.6, 0,0,Math.PI*2); ctx.fill();
ctx.restore();
// body
ctx.fillStyle='#eef2ff';
ctx.strokeStyle='rgba(0,0,0,0.25)'; ctx.lineWidth=1.2;
ctx.beginPath();
// sleek dart shape
ctx.moveTo(0, -22 + pitch*4);
ctx.lineTo(-13, 10);
ctx.lineTo(-6, 14);
ctx.lineTo(6, 14);
ctx.lineTo(13, 10);
ctx.closePath();
ctx.fill(); ctx.stroke();
// cockpit
ctx.fillStyle='#0a0a1a';
ctx.beginPath(); ctx.ellipse(0, -6 + pitch*2, 5, 7, 0,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#00e5ff'; ctx.globalAlpha=0.9;
ctx.beginPath(); ctx.ellipse(0, -7 + pitch*2, 3.2, 4.5, 0,0,Math.PI*2); ctx.fill(); ctx.globalAlpha=1;
// wings detail
ctx.fillStyle='#7c4dff';
ctx.fillRect(-13, 2, 6, 2);
ctx.fillRect(7, 2, 6, 2);
// highlight
ctx.strokeStyle='rgba(255,255,255,0.7)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(0,-20); ctx.lineTo(-6,6); ctx.stroke();
ctx.restore();
}
// === LOOP ===
function loop(now){
const dt = Math.min(0.033, (now - lastTime)/1000);
lastTime = now;
update(dt);
draw();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
updateTitleStats();
showCard('title');
// subtle auto-demo drift in title to show movement
setInterval(()=>{
if(state.mode==='title'){
state.t+=0.016;
state.sunT = 0.65 + Math.sin(state.t*0.3)*0.08;
}
},16);
</script>
</body>
</html>