Race the Sun
muse spark 1.1 · composer

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 47,572 bytes · SHA-256 40874604a84c
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RACE THE SUN — Solar Runner</title>
<style>
/* ── css: base ── */
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#05070d;font-family: 'Geist', system-ui, sans-serif;user-select:none}
#wrap{position:absolute;inset:0;overflow:hidden;background:#0b0f1d}
#game{display:block;width:100%;height:100%}
#fx-vignette{position:absolute;inset:0;pointer-events:none;background:radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.55) 100%);z-index:3}
#fx-flash{position:absolute;inset:0;pointer-events:none;opacity:0;background:white;z-index:6;transition:opacity .15s}
#fx-speed{position:absolute;inset:0;pointer-events:none;z-index:4;opacity:0}
#fx-speed::before{content:'';position:absolute;inset:-20%;background:
repeating-linear-gradient(90deg, transparent 0 98%, rgba(255,240,200,.25) 98% 100%);
background-size:120px 100%; animation:speedLines .15s linear infinite; opacity:0; transition:opacity .3s}
#fx-speed.boost::before{opacity:1}
@keyframes speedLines{from{transform:translateX(0)}to{transform:translateX(-120px)}}
/* HUD */
#hud{position:absolute;top:0;left:0;right:0;display:flex;justify-content:space-between;padding:18px 24px;z-index:10;pointer-events:none;color:#fff;font-variant-numeric:tabular-nums}
.hud-left{display:flex;gap:22px}
.stat{display:flex;flex-direction:column;gap:2px}
.stat span{font-size:10px;letter-spacing:.25em;opacity:.5;font-weight:800}
.stat b{font-size:20px;letter-spacing:-.02em;text-shadow:0 2px 12px rgba(255,200,80,.6)}
.hud-center{display:flex;flex-direction:column;align-items:center;gap:6px;min-width:160px}
#hudSpeedBar{width:160px;height:4px;background:rgba(255,255,255,.15);border-radius:99px;overflow:hidden;position:relative}
#hudSpeedBar i{display:block;height:100%;width:0;background:linear-gradient(90deg,#ffe78a,#ff7a3d);box-shadow:0 0 12px #ff9a3d;transition:width .15s}
#hudSpeedLabel{font-size:10px;letter-spacing:.25em;opacity:.6;font-weight:800}
.hud-right{display:flex;flex-direction:column;align-items:flex-end}
.energy-wrap{display:flex;flex-direction:column;gap:6px;align-items:flex-end}
#energyBar{width:180px;height:10px;background:rgba(0,0,0,.5);border:1px solid rgba(255,255,255,.2);border-radius:99px;position:relative;overflow:hidden}
#energyBar i{display:block;position:absolute;inset:1px;height:calc(100% - 2px);width:60%;background:linear-gradient(90deg,#7af7ff,#ffe65a);border-radius:99px;box-shadow:0 0 10px #ffe65a inset;transition:width .2s}
#energyBar em{position:absolute;inset:0;background:linear-gradient(90deg, transparent, rgba(255,255,255,.35), transparent);background-size:200% 100%;animation:shimmer 1.2s linear infinite;mix-blend-mode:overlay}
@keyframes shimmer{from{background-position:-100% 0}to{background-position:200% 0}}
.energy-wrap label{font-size:9px;letter-spacing:.28em;opacity:.7;font-weight:800}
/* Screens */
.screen{position:absolute;inset:0;z-index:20;display:flex;flex-direction:column;align-items:center;justify-content:center;background:radial-gradient(1100px 700px at 50% 120%, rgba(255,120,40,.25), transparent 60%), radial-gradient(900px 600px at 15% -10%, rgba(90,200,255,.22), transparent 70%), rgba(5,8,14,.88);backdrop-filter:blur(10px);color:#fff;transition:opacity .35s, transform .35s, visibility .35s;text-align:center;padding:24px}
.screen.hidden{opacity:0;pointer-events:none;visibility:hidden;transform:scale(1.03)}
#titleScreen h1{font-size:clamp(48px, 10vw, 118px);line-height:.85;letter-spacing:-.05em;font-weight:900;display:flex;align-items:baseline;gap:.12em;filter:drop-shadow(0 10px 40px rgba(255,120,20,.45))}
#titleScreen h1 span{font-size:.28em;letter-spacing:.4em;opacity:.5;font-weight:800;margin:0 .1em}
.tag{margin-top:14px;font-size:14px;letter-spacing:.18em;opacity:.7;font-weight:600;text-transform:uppercase;max-width:620px}
.keys{margin-top:26px;display:flex;flex-direction:column;gap:8px;font-size:12px;letter-spacing:.06em;opacity:.75;line-height:1.6}
.keys b{color:#ffe78a;text-shadow:0 0 10px #ff9a3d}
button{margin-top:28px;padding:14px 28px;border-radius:99px;border:1px solid rgba(255,230,120,.6);background:linear-gradient(180deg,#fff6c9,#ffae3d);color:#1a1000;font-weight:900;letter-spacing:.16em;font-size:13px;cursor:pointer;box-shadow:0 10px 30px rgba(255,140,20,.5), 0 0 0 1px rgba(255,255,255,.3) inset;transition:transform .12s, box-shadow .12s, filter .12s}
button:hover{transform:translateY(-2px) scale(1.02);box-shadow:0 16px 40px rgba(255,140,20,.65)}
button:active{transform:translateY(1px) scale(.98)}
.best{margin-top:22px;font-size:10px;letter-spacing:.28em;opacity:.5;font-weight:800}
#gameOverScreen h2{font-size:clamp(36px, 8vw, 72px);letter-spacing:-.04em;font-weight:900;text-shadow:0 0 40px rgba(255,80,80,.6)}
.go-stats{display:flex;gap:32px;margin-top:18px}
.go-stats div{display:flex;flex-direction:column;gap:4px}
.go-stats span{font-size:10px;letter-spacing:.3em;opacity:.5;font-weight:800}
.go-stats b{font-size:28px}
#goReason{margin-top:16px;font-size:12px;opacity:.6;letter-spacing:.12em;max-width:460px}
/* Touch */
#touchControls{position:absolute;inset:0;z-index:15;pointer-events:none;display:none}
#joyArea{position:absolute;left:18px;bottom:18px;width:130px;height:130px;border-radius:50%;background:radial-gradient(circle, rgba(255,255,255,.12), rgba(255,255,255,.04));border:1px solid rgba(255,255,255,.15);backdrop-filter:blur(6px);pointer-events:auto}
#joyStick{position:absolute;left:50%;top:50%;width:56px;height:56px;margin:-28px;border-radius:50%;background:radial-gradient(circle at 30% 30%, #fff, #ffcb5a);box-shadow:0 8px 18px rgba(0,0,0,.5), 0 0 20px rgba(255,200,70,.6);transform:translate(0,0)}
#boostBtn{position:absolute;right:18px;bottom:18px;width:92px;height:92px;border-radius:50%;display:grid;place-items:center;background:radial-gradient(circle at 30% 30%, #fff5cc, #ff7a3d);border:2px solid rgba(255,255,255,.7);font-weight:900;font-size:12px;letter-spacing:.12em;color:#2a1500;box-shadow:0 10px 24px rgba(255,120,20,.5);pointer-events:auto}
@media (max-width:900px){#touchControls{display:block} .keys{font-size:11px} #hud{padding:12px 14px} #energyBar{width:120px} #hudSpeedBar{width:100px}}
</style>
</head>
<body>
<div id="wrap">
<canvas id="game"></canvas>
<div id="fx-vignette"></div>
<div id="fx-flash"></div>
<div id="fx-speed"></div>
<div id="hud">
<div class="hud-left">
<div class="stat"><span>DIST</span><b id="hudDist">0m</b></div>
<div class="stat"><span>SCORE</span><b id="hudScore">0</b></div>
<div class="stat"><span>x</span><b id="hudMult">1.0</b></div>
</div>
<div class="hud-center">
<div id="hudSpeedBar"><i></i></div>
<div id="hudSpeedLabel">SPEED 0</div>
</div>
<div class="hud-right">
<div class="energy-wrap">
<div id="energyBar"><i></i><em></em></div>
<label>SOLAR ENERGY</label>
</div>
</div>
</div>
<div id="titleScreen" class="screen">
<h1>RACE<span>THE</span>SUN</h1>
<p class="tag">Stay in light. Don't hit anything. Don't let the sun set.</p>
<div class="keys">
<div><b>WASD</b> / <b>ARROWS</b> steer • <b>SPACE</b> boost</div>
<div>⚡ energy • ✦ score • ◍ multiplier — graze obstacles for bonus</div>
</div>
<button id="btnPlay">IGNITE ENGINES — [SPACE]</button>
<div class="best">BEST <b id="bestScore">0</b> • PRESS ANY KEY TO BE A SPECK OF LIGHT AGAIN</div>
</div>
<div id="gameOverScreen" class="screen hidden">
<h2 id="goTitle">SHADOW CLAIMED</h2>
<div class="go-stats">
<div><span>DISTANCE</span><b id="goDist">0</b></div>
<div><span>SCORE</span><b id="goScore">0</b></div>
<div><span>BEST</span><b id="goBest">0</b></div>
</div>
<button id="btnRestart">REBIRTH [R]</button>
<p id="goReason"></p>
</div>
<div id="touchControls">
<div id="joyArea"><div id="joyStick"></div></div>
<div id="boostBtn">BOOST</div>
</div>
</div>
<script>
'use strict';
// ── const: T ──
// === CONSTANTS ===
const T = {
BASE_SPEED: 36,
MAX_SPEED: 132,
ACCEL: 1.75,
STRAFE_SPEED: 30,
PITCH_SPEED: 18,
EASE: 9.5,
INERTIA: 2.2,
ROLL_MAX: 0.85,
PITCH_MAX: 0.55,
WORLD_W: 64,
WORLD_Y_MIN: -14,
WORLD_Y_MAX: 18,
ENERGY_MAX: 100,
ENERGY_DRAIN: 1.15,
ENERGY_DRAIN_SHADOW: 7.0,
ENERGY_PICKUP: 24,
BOOST_COST_PER_SEC: 34,
BOOST_ACCEL: 52,
BOOST_MIN_ENERGY: 10,
SUN_SINK_SPEED: 0.011,
SUN_GAMEOVER: -0.38,
SPAWN_GAP_MIN: 13,
SPAWN_GAP_MAX: 24,
VIEW_DIST: 300,
COLLIDE_RAD: 1.7,
NEAR_MISS: 4.2,
FOV: 0.95,
};
// ── const: lerp ──
// === UTILS ===
const lerp = (a,b,t)=>a+(b-a)*t;
// ── const: clamp ──
const clamp = (v,a,b)=>v<a?a:v>b?b:v;
// ── const: rand ──
const rand = (a=0,b=1)=>a+Math.random()*(b-a);
// ── const: lerpColor ──
const lerpColor = (c1,c2,t)=>{
const r1=(c1>>16)&255,g1=(c1>>8)&255,b1=c1&255;
const r2=(c2>>16)&255,g2=(c2>>8)&255,b2=c2&255;
return `rgb(${ (r1+(r2-r1)*t)|0 },${ (g1+(g2-g1)*t)|0 },${ (b1+(b2-b1)*t)|0 })`;
};
// ── function: mulberry32 ──
// === SEEDED RNG ===
function mulberry32(a){return function(){let t=a+=0x6D2B79F5; t=Math.imul(t^t>>>15,t|1); t^=t+Math.imul(t^t>>>7,t|61); return ((t^t>>>14)>>>0)/4294967296;}}
// ── class: RNG ──
class RNG{
constructor(seed){this.seed=seed||(Math.random()*1e9)|0; this.r=mulberry32(this.seed);}
next(){return this.r();}
range(a,b){return a+this.next()*(b-a);}
pick(arr){return arr[(this.next()*arr.length)|0];}
chance(p){return this.next()<p;}
}
// ── class: AudioMgr ──
// === AUDIO (WebAudio bleeps) ===
class AudioMgr{
constructor(){this.ctx=null; this.enabled=true; this.windGain=null; this.windOsc=null;}
init(){
try{
this.ctx = new (window.AudioContext||window.webkitAudioContext)();
const g=this.ctx.createGain(); g.gain.value=0; g.connect(this.ctx.destination);
this.master=g;
// wind drone
const osc=this.ctx.createOscillator(); osc.type='sawtooth'; osc.frequency.value=35;
const wg=this.ctx.createGain(); wg.gain.value=0; osc.connect(wg); wg.connect(g); osc.start();
this.windOsc=osc; this.windGain=wg;
}catch(e){this.enabled=false;}
}
resume(){if(this.ctx&&this.ctx.state==='suspended') this.ctx.resume();}
setWind(speedNorm){
if(!this.enabled||!this.windGain) return;
this.windGain.gain.value = 0.015 + speedNorm*0.045;
this.windOsc.frequency.value = 28 + speedNorm*90;
}
blip(freq=600, dur=0.12, type='sine', vol=0.35, slide=0){
if(!this.enabled||!this.ctx) return;
const o=this.ctx.createOscillator(), g=this.ctx.createGain();
o.type=type; o.frequency.value=freq;
if(slide) { o.frequency.linearRampToValueAtTime(freq+slide, this.ctx.currentTime+dur); }
g.gain.setValueAtTime(vol, this.ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime+dur);
o.connect(g); g.connect(this.master); o.start(); o.stop(this.ctx.currentTime+dur);
}
pickup(type){
if(type==='energy'){ this.blip(420,0.18,'sine',0.45,180); setTimeout(()=>this.blip(840,0.18,'sine',0.32),60); }
else if(type==='score'){ this.blip(660,0.15,'triangle',0.4,240); }
else { this.blip(300,0.22,'sawtooth',0.4,600); this.blip(900,0.22,'sine',0.35); }
}
crash(){ this.blip(120,0.5,'sawtooth',0.6,-80); this.blip(60,0.6,'square',0.5); }
nearMiss(){ this.blip(1200,0.08,'square',0.18); }
boost(){ this.blip(180,0.25,'sawtooth',0.3,120); }
gameOver(){ this.blip(220,0.6,'sawtooth',0.5,-100); setTimeout(()=>this.blip(110,0.8,'triangle',0.4,-50),120); }
}
// ── class: InputMgr ──
// === INPUT ===
class InputMgr{
constructor(){
this.x=0; this.y=0; // -1..1
this.keys={};
this.boost=false;
this.joyActive=false; this.joyX=0; this.joyY=0;
window.addEventListener('keydown',e=>{
this.keys[e.code]=true;
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) e.preventDefault();
});
window.addEventListener('keyup',e=>{ this.keys[e.code]=false; this.boost=false; });
}
update(){
let hx=0, hy=0;
if(this.keys['KeyA']||this.keys['ArrowLeft']) hx-=1;
if(this.keys['KeyD']||this.keys['ArrowRight']) hx+=1;
if(this.keys['KeyW']||this.keys['ArrowUp']) hy+=1;
if(this.keys['KeyS']||this.keys['ArrowDown']) hy-=1;
if(this.keys['Space']) this.boost=true;
// joystick overrides
if(this.joyActive){ hx=this.joyX; hy=this.joyY; }
this.x = clamp(hx,-1,1);
this.y = clamp(hy,-1,1);
}
}
// ── class: ParticleSystem ──
// === PARTICLES ===
class ParticleSystem{
constructor(){ this.p=[]; }
spawn(x,y,opt={}){ // screen space
this.p.push({x,y,
vx:(opt.vx??rand(-1,1))* (opt.speed??1),
vy:(opt.vy??rand(-1,1))* (opt.speed??1),
life:opt.life??rand(0.4,0.9),
maxLife:opt.life??1,
size:opt.size??rand(2,6),
color:opt.color??'#fff',
drag:opt.drag??0.98,
grav:opt.grav??0,
type:opt.type||'circle'
});
}
burst(x,y,count,color){
for(let i=0;i<count;i++) this.spawn(x,y,{vx:rand(-1,1), vy:rand(-1.5,0.5), life:rand(0.3,0.9), size:rand(2,8), color, speed:rand(60,220), drag:0.96, grav:rand(10,60)});
}
update(dt){
for(let i=this.p.length-1;i>=0;i--){
const q=this.p[i];
q.life-=dt;
if(q.life<=0){ this.p.splice(i,1); continue; }
q.vx*=q.drag; q.vy*=q.drag; q.vy+=q.grav*dt;
q.x+=q.vx*dt; q.y+=q.vy*dt;
}
}
draw(ctx){
for(const q of this.p){
const a=q.life/q.maxLife;
ctx.globalAlpha=a;
ctx.fillStyle=q.color;
if(q.type==='rect'){
ctx.fillRect(q.x - q.size*a/2, q.y - q.size*a/2, q.size*a, q.size*a*0.6);
}else{
ctx.beginPath(); ctx.arc(q.x,q.y,q.size*a,0,Math.PI*2); ctx.fill();
}
}
ctx.globalAlpha=1;
}
}
// ── class: World ──
// === WORLD GEN ===
class World {
constructor(rng){
this.rng=rng;
this.obstacles=[];
this.pickups=[];
this.nextZ=40;
this.lanePatterns=['single','double','triple','corridor','gate','slant','tower'];
this.difficulty=0;
}
reset(){ this.obstacles=[]; this.pickups=[]; this.nextZ=40; this.difficulty=0; }
update(distance, speed){
this.difficulty = clamp(distance/1200,0,1);
const cullZ = distance - 60;
this.obstacles = this.obstacles.filter(o=>o.z > cullZ);
this.pickups = this.pickups.filter(p=>!p.collected && p.z > cullZ);
while(this.nextZ < distance + T.VIEW_DIST){
this.generateWave(this.nextZ);
this.nextZ += lerp(T.SPAWN_GAP_MAX, T.SPAWN_GAP_MIN, this.difficulty) + this.rng.range(-2,3);
}
}
generateWave(z){
const r=this.rng;
const pattern = r.pick(this.lanePatterns);
const addBox = (x,y,w,h,l, tall=false) =>{
this.obstacles.push({x,y,w,h,l,z:z+r.range(-2,4),tall, id:Math.random()});
};
const addPickup = (x,y,type) =>{
if(r.next()>T.PICKUP_SPAWN && type!=='mult') return;
const tp = type|| r.pick(['score','score','score','energy','mult']);
this.pickups.push({x,y,z:z+r.range(-3,6),w:1.8,h:1.8,l:1.8,type:tp,collected:false,phase:r.range(0,Math.PI*2)});
};
if(pattern==='single'){
const x=r.range(-T.WORLD_W*0.35, T.WORLD_W*0.35);
const tall=r.chance(0.35+this.difficulty*0.25);
addBox(x, tall?6:0, r.range(7,14), tall?18: r.range(4,9), r.range(6,12), tall);
addPickup(x+ r.range(-12,-7), r.range(-4,8), 'score');
addPickup(x+ r.range(7,12), r.range(-4,8), 'score');
} else if(pattern==='double'){
const gap=lerp(18,12,this.difficulty);
const x0=r.range(-10,10);
addBox(x0-gap, r.range(-2,4), r.range(6,11), r.range(4,10), r.range(6,10), r.chance(0.3));
addBox(x0+gap, r.range(-2,4), r.range(6,11), r.range(4,12), r.range(6,10), r.chance(0.4));
addPickup(x0, r.range(2,9), r.pick(['mult','score']));
} else if(pattern==='triple'){
for(let i=-1;i<=1;i++){
if(r.chance(0.2)) continue;
addBox(i*14 + r.range(-3,3), r.range(-3,3), r.range(5,9), r.range(3,8), r.range(5,9), false);
}
if(r.chance(0.6)) addPickup(0,6,'mult');
} else if(pattern==='corridor'){
const leftX = -T.WORLD_W*0.5 + 4;
const rightX = T.WORLD_W*0.5 - 4;
addBox(leftX, 8, 8, 22, 40, true);
addBox(rightX, 8, 8, 22, 40, true);
for(let k=0;k<3;k++) addPickup((r.range(-1,1))*6, r.range(0,10), 'score');
} else if(pattern==='gate'){
const gap=lerp(20,11,this.difficulty);
addBox(-gap/2 -6, 7, 8, 20, 6, true);
addBox(gap/2 +6, 7, 8, 20, 6, true);
if(r.chance(0.4)) addBox(0,-2, gap*0.6, 3, 6, false);
addPickup(0, 8, 'mult');
} else if(pattern==='slant'){
const n=4+ (r.next()* this.difficulty*2 |0);
for(let i=0;i<n;i++){
addBox(lerp(-20,20,i/(n-1))+r.range(-2,2), r.range(-2,6), r.range(4,7), r.range(3,7), r.range(4,7), false);
}
} else {
addBox(r.range(-12,12), 9, r.range(8,13), r.range(16,26), r.range(8,13), true);
for(let i=0;i<2;i++) addBox(r.range(-22,22), r.range(-2,4), r.range(5,9), r.range(4,8), r.range(5,9), false);
if(r.chance(0.7)) addPickup(r.range(-8,8), r.range(8,14), 'energy');
}
if(r.chance(0.22)) addPickup(r.range(-8,8), r.range(1,9), 'energy');
if(r.chance(0.15+this.difficulty*0.2)) addPickup(r.range(-18,18), r.range(2,12), 'mult');
}
}
// ── function: checkAABB ──
// === COLLISION & SHADOW ===
function checkAABB(ax,ay,az, ahx,ahy,ahz, bx,by,bz, bhx,bhy,bhz){
return Math.abs(ax-bx) < (ahx+bhx) && Math.abs(ay-by) < (ahy+bhy) && Math.abs(az-bz) < (ahz+bhz);
}
// ── function: inShadow ──
function inShadow(player, obstacles, sunDir){
// sunDir approx {x:-0.6,y:-0.2} from right shallow. shadow is behind obstacle opposite sun
// simplified: if player is within thin projected shadow quad of tall obstacles ahead-behind?
// We'll test if any tall obstacle is between sun and player: if obstacle is up-sun side and close.
for(const o of obstacles){
if(!o.tall) continue;
const dz = player.z - o.z; // positive if player behind obstacle (obstacle ahead)
if(dz < -2 || dz > 18) continue; // only shadow extends behind
// sun comes from +X? Assume sun at right => obstacles cast shadow to left
const shadowShift = dz * 0.6; // sun angle
const shadowX = o.x - shadowShift * 0.7; // shift left as behind
if(Math.abs(player.x - shadowX) < o.w*0.85 && Math.abs(player.y - o.y) < 6){
// also check ray height: if player y lower than obstacle top
if(player.y < o.h*0.6) return true;
}
}
return false;
}
// ── class: Renderer ──
// === RENDERER ===
class Renderer{
constructor(canvas){
this.canvas=canvas;
this.ctx=canvas.getContext('2d');
this.W=0; this.H=0; this.dpr=1;
this.horizon=0;
this.shake=0; this.shakeT=0;
this.speedFx=0;
this.particles=new ParticleSystem();
this.resize();
window.addEventListener('resize',()=>this.resize());
}
resize(){
this.dpr=Math.min(devicePixelRatio||1,2);
this.W=this.canvas.clientWidth; this.H=this.canvas.clientHeight;
this.canvas.width=this.W*this.dpr; this.canvas.height=this.H*this.dpr;
this.ctx.setTransform(this.dpr,0,0,this.dpr,0,0);
this.horizon = this.H*0.54;
}
project(x,y,z,cam){
const dz=z - cam.z;
if(dz < 0.5) return null;
const dx=x - cam.x, dy=y - cam.y;
const focal = this.H * T.FOV;
const sx = this.W*0.5 + dx / dz * focal;
const sy = this.horizon - dy / dz * focal;
const scale = focal / dz;
return {x:sx,y:sy,scale,dz};
}
addShake(v){ this.shake = Math.max(this.shake, v); }
updateShake(dt){
if(this.shake>0){
this.shakeT+=dt* (20 + this.shake*30);
this.shake = Math.max(0, this.shake - dt* (this.shake*1.2 + 0.8));
}
}
drawSky(sunT){
const ctx=this.ctx;
// sunT 0.7 high -> -0.4 low
const sunProgress = clamp((sunT - T.SUN_GAMEOVER)/(0.7 - T.SUN_GAMEOVER),0,1);
// colors shift from bright to dusk
const top = lerpColor(0x0a1028, 0x180a1a, 1-sunProgress);
// Actually we need nicer gradient via canvas grad
const grad = ctx.createLinearGradient(0,0,0,this.H);
if(sunProgress>0.55){
grad.addColorStop(0,'#0b122e');
grad.addColorStop(0.22,'#162457');
grad.addColorStop(0.45,'#27428a');
grad.addColorStop(0.62,'#ff7a3d');
grad.addColorStop(0.75,'#ffbe6b');
grad.addColorStop(1,'#1a0e22');
}else if(sunProgress>0.25){
grad.addColorStop(0,'#0d0d22');
grad.addColorStop(0.35,'#3a1840');
grad.addColorStop(0.6,'#7a2a3a');
grad.addColorStop(0.75,'#ff5533');
grad.addColorStop(0.85,'#ffae4d');
grad.addColorStop(1,'#120a14');
}else{
grad.addColorStop(0,'#070712');
grad.addColorStop(0.5,'#1a0e1e');
grad.addColorStop(0.78,'#3a1020');
grad.addColorStop(0.9,'#5a1a0a');
grad.addColorStop(1,'#0a0505');
}
ctx.fillStyle=grad;
ctx.fillRect(0,0,this.W,this.H);
// distant mountains silhouette
ctx.fillStyle='rgba(0,0,0,0.35)';
ctx.beginPath();
ctx.moveTo(0,this.horizon+12);
for(let x=0;x<=this.W;x+=22){
const n=Math.sin(x*0.008 + sunT*2)*(12+Math.sin(x*0.015)*8);
ctx.lineTo(x, this.horizon+8 + n - (1-sunProgress)*8);
}
ctx.lineTo(this.W,this.H); ctx.lineTo(0,this.H); ctx.closePath(); ctx.fill();
// sun
const sunX = this.W*0.5 + Math.sin(-sunT*0.9)* this.W*0.18;
const sunBaseY = this.horizon - 12;
const sunY = sunBaseY - (sunT* this.H*0.72);
const sunR = lerp(56, 84, sunProgress)* (this.W/1280 +0.6);
// glow
const glowGrad = ctx.createRadialGradient(sunX,sunY,0,sunX,sunY,sunR*3.2);
glowGrad.addColorStop(0,'rgba(255,230,120,0.85)');
glowGrad.addColorStop(0.25,'rgba(255,140,50,0.45)');
glowGrad.addColorStop(0.55,'rgba(255,80,30,0.15)');
glowGrad.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=glowGrad;
ctx.beginPath(); ctx.arc(sunX,sunY,sunR*3.2,0,Math.PI*2); ctx.fill();
// core
const sunGrad = ctx.createRadialGradient(sunX-sunR*0.25,sunY-sunR*0.25,0,sunX,sunY,sunR);
sunGrad.addColorStop(0,'#ffffff');
sunGrad.addColorStop(0.18,'#fff6a8');
sunGrad.addColorStop(0.45,'#ffbe3d');
sunGrad.addColorStop(1,'#ff4a18');
ctx.fillStyle=sunGrad;
ctx.beginPath(); ctx.arc(sunX,sunY,sunR,0,Math.PI*2); ctx.fill();
// horizon haze line
ctx.fillStyle=`rgba(255,${180+sunProgress*40|0},90,0.25)`;
ctx.fillRect(0,this.horizon-2,this.W,4);
return {sunX,sunY,sunR,sunProgress};
}
drawGround(cam,sunInfo,distance,speed){
const ctx=this.ctx;
// ground plane below horizon - draw stripes
const groundY0=this.horizon;
// checker-like fading grid
const focal=this.H*T.FOV;
const camZ=cam.z;
// draw far plane as dark
ctx.fillStyle='#0a0f1f';
ctx.fillRect(0,groundY0,this.W,this.H-groundY0);
// perspective grid lines longitudinal
ctx.strokeStyle='rgba(255,255,255,0.06)';
ctx.lineWidth=1;
// lateral lines moving with distance
const offset = distance % 16;
for(let z=0; z<T.VIEW_DIST; z+=8){
const dz = z + offset;
const proj = this.project(0,0,camZ+dz,cam);
if(!proj) continue;
const y=proj.y;
if(y<groundY0 || y>this.H) continue;
const alpha = clamp(1 - dz / T.VIEW_DIST,0,1);
ctx.globalAlpha=alpha*0.35;
ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(this.W,y); ctx.stroke();
}
ctx.globalAlpha=1;
// radial guides from vanishing
ctx.strokeStyle='rgba(255,200,120,0.07)';
const vanX=this.W*0.5;
const vanY=this.horizon;
for(let gx=-32; gx<=32; gx+=4){
const wx=gx*3;
const pNear=this.project(wx,0,camZ+5,cam);
const pFar=this.project(wx,0,camZ+T.VIEW_DIST,cam);
if(!pNear||!pFar) continue;
ctx.beginPath(); ctx.moveTo(vanX+(wx*0.02)*200, vanY); ctx.lineTo(pNear.x, pNear.y);
ctx.stroke();
}
// speed streaks on ground
const speedNorm = clamp((speed - T.BASE_SPEED)/(T.MAX_SPEED- T.BASE_SPEED),0,1);
if(speedNorm>0.3){
ctx.fillStyle=`rgba(255,240,180,${(speedNorm-0.3)*0.18})`;
for(let i=0;i< (speedNorm*12|0); i++){
const rx=rand(0,this.W);
const ry=rand(groundY0+10, this.H-10);
const w=rand(30,120)*speedNorm;
ctx.fillRect(rx,ry,w,1);
}
}
}
drawWorldObstacles(obstacles, cam, sunProgress){
const ctx=this.ctx;
// sort far to near for painter
const list=[];
for(const o of obstacles){
const p=this.project(o.x,o.y,o.z,cam);
if(!p) continue;
if(p.dz<1 || p.dz> T.VIEW_DIST+50) continue;
// frustum cull x
if(p.x < -200 || p.x > this.W+200) continue;
list.push({o,p});
}
list.sort((a,b)=>b.p.dz - a.p.dz);
for(const it of list){
const {o,p}=it;
const w=p.scale * o.w;
const h=p.scale * o.h;
if(w<0.5||h<0.5) continue;
const x=p.x - w*0.5;
const y=p.y - h;
const dark = o.tall? 0.18:0.35;
// shadow factor based on sunProgress
const shade = clamp(sunProgress*0.7 + (o.tall?0:0.2),0,1);
// box faces
// front
ctx.globalAlpha=0.95;
let colFront, colTop, colSide;
if(o.tall){
colFront = `rgb(${ (40+shade*70)|0 },${ (50+shade*90)|0 },${ (80+shade*60)|0 })`;
colTop = `rgb(${ (70+shade*90)|0 },${ (90+shade*110)|0 },${ (130+shade*80)|0 })`;
colSide = `rgb(${ (25+shade*40)|0 },${ (30+shade*50)|0 },${ (55+shade*40)|0 })`;
}else{
colFront = `rgb(${ (85+shade*90)|0 },${ (75+shade*60)|0 },${ (70+shade*40)|0 })`;
colTop = `rgb(${ (130+shade*80)|0 },${ (115+shade*70)|0 },${ (105+shade*50)|0 })`;
colSide = `rgb(${ (45+shade*50)|0 },${ (40+shade*40)|0 },${ (38+shade*30)|0 })`;
}
// project back top edge for depth
const pBack = this.project(o.x, o.y+o.h, o.z+o.l, cam);
const pFrontTop = this.project(o.x, o.y+o.h, o.z, cam);
const pFront = p;
const wBack = pBack? pBack.scale * o.w : w*0.78;
// side polygon connecting front to back (perspective shrink)
if(pBack && pFrontTop){
// top face
ctx.fillStyle=colTop;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x+w, y);
ctx.lineTo(pBack.x + wBack*0.5, pBack.y);
ctx.lineTo(pBack.x - wBack*0.5, pBack.y);
ctx.closePath(); ctx.fill();
// side face (right)
ctx.fillStyle=colSide;
ctx.beginPath();
ctx.moveTo(x+w, y);
ctx.lineTo(x+w, y+h);
ctx.lineTo(pBack.x + wBack*0.5, pBack.y + (wBack/w)*h);
ctx.lineTo(pBack.x + wBack*0.5, pBack.y);
ctx.closePath(); ctx.fill();
}
// front face
ctx.fillStyle=colFront;
ctx.fillRect(x, y, w, h);
// edge highlight top
ctx.fillStyle=`rgba(255,220,150,${0.12+shade*0.22})`;
ctx.fillRect(x, y, w, Math.max(1, h*0.08));
// ground shadow blob
const pGround = this.project(o.x,0,o.z,cam);
if(pGround){
const sw = pGround.scale* o.w *1.3;
ctx.fillStyle=`rgba(0,0,0,${clamp(0.35 - pGround.dz/400,0,0.35)})`;
ctx.beginPath(); ctx.ellipse(pGround.x, pGround.y, sw*0.5, sw*0.12,0,0,Math.PI*2); ctx.fill();
}
// wireframe subtle for far
if(p.dz>120){
ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.strokeRect(x,y,w,h);
}
}
}
drawPickups(pickups,cam,time){
const ctx=this.ctx;
for(const pu of pickups){
if(pu.collected) continue;
const pr=this.project(pu.x, pu.y, pu.z, cam);
if(!pr||pr.dz<1||pr.dz> T.VIEW_DIST) continue;
const s=pr.scale;
const sz = Math.max(6, s*2.2);
const bob = Math.sin(time*2.2 + pu.phase)* 6 * s;
const y = pr.y + bob - sz;
// glow
let color='#fff', glow='#fff';
if(pu.type==='energy'){ color='#7affff'; glow='rgba(120,255,255,0.7)'; }
else if(pu.type==='score'){ color='#ffe66a'; glow='rgba(255,230,100,0.8)'; }
else { color='#ff7aff'; glow='rgba(255,120,255,0.8)'; }
ctx.shadowColor=glow; ctx.shadowBlur=sz*1.6;
ctx.fillStyle=color;
ctx.beginPath();
if(pu.type==='energy'){
// bolt shape
ctx.save(); ctx.translate(pr.x, y); ctx.rotate(Math.sin(time*3+pu.phase)*0.2);
ctx.beginPath();
ctx.moveTo(-sz*0.2, -sz*0.6); ctx.lineTo(sz*0.3, -sz*0.1); ctx.lineTo(sz*0.05,0); ctx.lineTo(sz*0.4, sz*0.6); ctx.lineTo(-sz*0.1, sz*0.1); ctx.lineTo(sz*0.05,0); ctx.lineTo(-sz*0.4, -sz*0.2);
ctx.closePath(); ctx.fill(); ctx.restore();
}else if(pu.type==='score'){
// diamond
ctx.beginPath();
ctx.moveTo(pr.x, y-sz); ctx.lineTo(pr.x+sz*0.7, y); ctx.lineTo(pr.x, y+sz); ctx.lineTo(pr.x-sz*0.7, y); ctx.closePath(); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.8)'; ctx.beginPath(); ctx.arc(pr.x - sz*0.2, y - sz*0.2, sz*0.18,0,Math.PI*2); ctx.fill();
}else{
// orb with ring
ctx.beginPath(); ctx.arc(pr.x, y, sz*0.6,0,Math.PI*2); ctx.fill();
ctx.strokeStyle=glow; ctx.lineWidth=2; ctx.shadowBlur=0;
ctx.beginPath(); ctx.ellipse(pr.x, y, sz*1.0, sz*0.35, Math.sin(time)*0.8,0,Math.PI*2); ctx.stroke();
ctx.beginPath(); ctx.ellipse(pr.x, y, sz*0.9, sz*0.6, Math.cos(time)*0.6,0,Math.PI*2); ctx.stroke();
}
ctx.shadowBlur=0;
// ground dot
const pg=this.project(pu.x,0,pu.z,cam);
if(pg){ ctx.fillStyle='rgba(255,255,255,0.08)'; ctx.beginPath(); ctx.arc(pg.x, pg.y, Math.max(1, s*0.4),0,Math.PI*2); ctx.fill(); }
}
}
drawPlayer(x,y,roll,pitch,boosting,time,speedNorm){
const ctx=this.ctx;
const cx = this.W*0.5 + x*11 + roll*18;
const cy = this.H*0.78 - y*9 + pitch*12;
ctx.save();
ctx.translate(cx,cy);
ctx.rotate(roll*0.55);
const thrust = 1 + speedNorm*0.8 + (boosting?1.2:0);
// shadow under ship
ctx.fillStyle='rgba(0,0,0,0.35)';
ctx.beginPath(); ctx.ellipse(0, 22, 36 + Math.abs(roll)*12, 9, 0,0,Math.PI*2); ctx.fill();
// thrust flame
const flameH = 18 + thrust*18 + Math.sin(time*20)*3;
const flameGrad = ctx.createLinearGradient(0,12,0,12+flameH);
flameGrad.addColorStop(0,'rgba(255,255,255,0.9)');
flameGrad.addColorStop(0.2, boosting?'#7af7ff':'#ffbe4d');
flameGrad.addColorStop(0.6, boosting?'#3ac6ff':'#ff6a2d');
flameGrad.addColorStop(1,'rgba(255,40,10,0)');
ctx.fillStyle=flameGrad;
ctx.beginPath();
ctx.moveTo(-10,12); ctx.lineTo(10,12); ctx.lineTo(0,12+flameH); ctx.closePath(); ctx.fill();
// second flame inner
ctx.fillStyle='rgba(255,255,255,0.9)';
ctx.beginPath(); ctx.moveTo(-4,12); ctx.lineTo(4,12); ctx.lineTo(0,12+flameH*0.55); ctx.closePath(); ctx.fill();
// hull - low poly arrow
const scale=1;
// main body
ctx.fillStyle='#e8f0ff';
ctx.strokeStyle='rgba(0,0,0,0.5)'; ctx.lineWidth=1.2;
ctx.beginPath();
ctx.moveTo(0,-34*scale); // nose
ctx.lineTo(18*scale, 8*scale);
ctx.lineTo(8*scale, 14*scale);
ctx.lineTo(-8*scale,14*scale);
ctx.lineTo(-18*scale,8*scale);
ctx.closePath(); ctx.fill(); ctx.stroke();
// cockpit
ctx.fillStyle='#1a2338';
ctx.beginPath(); ctx.moveTo(0,-18); ctx.lineTo(6,-2); ctx.lineTo(-6,-2); ctx.closePath(); ctx.fill();
ctx.fillStyle='rgba(122,247,255,0.9)'; ctx.beginPath(); ctx.moveTo(0,-15); ctx.lineTo(3,-4); ctx.lineTo(-3,-4); ctx.closePath(); ctx.fill();
// wings shading
ctx.fillStyle='rgba(0,0,0,0.15)';
ctx.beginPath(); ctx.moveTo(0,-34); ctx.lineTo(18,8); ctx.lineTo(8,14); ctx.lineTo(0,-10); ctx.closePath(); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.6)';
ctx.beginPath(); ctx.moveTo(0,-34); ctx.lineTo(-18,8); ctx.lineTo(-8,14); ctx.lineTo(0,-10); ctx.closePath(); ctx.fill();
// edge lines
ctx.strokeStyle=boosting?'#7af7ff':'#ffae4d'; ctx.lineWidth=1.5; ctx.shadowColor=boosting?'#5ae6ff':'#ff9a3d'; ctx.shadowBlur=10;
ctx.beginPath(); ctx.moveTo(-18,8); ctx.lineTo(0,-34); ctx.lineTo(18,8); ctx.stroke();
ctx.shadowBlur=0;
ctx.restore();
// speed halo when fast
if(speedNorm>0.6){
this.ctx.save();
this.ctx.globalAlpha=(speedNorm-0.6)*0.7;
this.ctx.strokeStyle='#ffffff';
this.ctx.lineWidth=1;
this.ctx.beginPath();
this.ctx.arc(cx,cy, 30 + speedNorm*20, 0,Math.PI*2); this.ctx.stroke();
this.ctx.restore();
}
}
}
// ── class: Game ──
// === GAME CORE ===
class Game{
constructor(canvas){
this.canvas=canvas;
this.renderer=new Renderer(canvas);
this.input=new InputMgr();
this.audio=new AudioMgr();
this.rng=new RNG(((Math.random()*1e9)|0));
this.world=new World(this.rng);
this.state='title';
this.time=0;
this.speed=T.BASE_SPEED;
this.distance=0;
this.score=0;
this.mult=1;
this.multTimer=0;
this.energy=T.ENERGY_MAX;
this.sun=0.68;
this.player={x:0,y:0, vx:0,vy:0, roll:0,pitch:0, tx:0,ty:0};
this.isBoosting=false;
this.flash=0;
this.lastNearMiss=0;
this.best = +(localStorage.getItem('race_sun_best')||0);
this.setupJoy();
this.bindUI();
this.lastT=0;
this.updateHUD();
}
bindUI(){
const $=s=>document.getElementById(s);
$('btnPlay').addEventListener('click',()=>this.start());
$('btnRestart').addEventListener('click',()=>this.start());
window.addEventListener('keydown',e=>{
if(e.code==='Space'){
if(this.state==='title') this.start();
else if(this.state==='gameover') this.start();
}
if(e.code==='KeyR' && this.state==='gameover') this.start();
});
document.addEventListener('keydown',e=>{ if(e.code==='Space') e.preventDefault(); });
}
setupJoy(){
const area=document.getElementById('joyArea');
const stick=document.getElementById('joyStick');
const boostBtn=document.getElementById('boostBtn');
let touchId=null;
const reset=()=>{
this.input.joyActive=false; this.input.joyX=0; this.input.joyY=0;
stick.style.transform='translate(0,0)';
};
const handle=(clientX,clientY)=>{
const rect=area.getBoundingClientRect();
const cx=rect.left+rect.width/2, cy=rect.top+rect.height/2;
let dx=clientX-cx, dy=clientY-cy;
const dist=Math.hypot(dx,dy);
const max=rect.width*0.38;
if(dist>max){ dx=dx/dist*max; dy=dy/dist*max; }
stick.style.transform=`translate(${dx}px,${dy}px)`;
this.input.joyActive=true;
this.input.joyX=clamp(dx/max,-1,1);
this.input.joyY=clamp(-dy/max,-1,1);
};
area.addEventListener('touchstart',e=>{
e.preventDefault();
touchId=e.changedTouches[0].identifier;
handle(e.changedTouches[0].clientX, e.changedTouches[0].clientY);
},{passive:false});
area.addEventListener('touchmove',e=>{
e.preventDefault();
for(const t of e.changedTouches) if(t.identifier===touchId) handle(t.clientX,t.clientY);
},{passive:false});
area.addEventListener('touchend',()=>{ touchId=null; reset(); });
area.addEventListener('mousedown',e=>{
const move=(ev)=>handle(ev.clientX,ev.clientY);
const up=()=>{reset(); window.removeEventListener('mousemove',move); window.removeEventListener('mouseup',up);};
window.addEventListener('mousemove',move); window.addEventListener('mouseup',up);
handle(e.clientX,e.clientY);
});
boostBtn.addEventListener('touchstart',e=>{e.preventDefault(); this.input.boost=true;});
boostBtn.addEventListener('mousedown',()=>{this.input.boost=true;});
boostBtn.addEventListener('touchend',()=>{this.input.boost=false;});
boostBtn.addEventListener('mouseup',()=>{this.input.boost=false;});
boostBtn.addEventListener('mouseleave',()=>{this.input.boost=false;});
}
start(){
if(!this.audio.ctx) this.audio.init();
this.audio.resume();
this.rng=new RNG(((Math.random()*1e9)|0) ^ (Date.now()&0xffff));
this.world=new World(this.rng);
this.world.update(0, T.BASE_SPEED);
this.time=0; this.speed=T.BASE_SPEED; this.distance=0; this.score=0; this.mult=1; this.multTimer=0;
this.energy=T.ENERGY_MAX; this.sun=0.68;
this.player={x:0,y:0, vx:0,vy:0, roll:0,pitch:0, tx:0,ty:0};
this.isBoosting=false;
this.lastNearMiss=0;
this.flash=0;
document.getElementById('fx-flash').style.opacity=0;
this.state='playing';
document.getElementById('titleScreen').classList.add('hidden');
document.getElementById('gameOverScreen').classList.add('hidden');
this.renderer.particles.p.length=0;
}
triggerGameOver(reason='SHADOW CLAIMED', title='SHADOW CLAIMED'){
if(this.state!=='playing') return;
this.state='gameover';
this.audio.gameOver();
this.renderer.addShake(14);
this.flash=1;
const bestPrev=this.best;
if(this.score>bestPrev){ this.best=this.score|0; localStorage.setItem('race_sun_best', this.best); }
document.getElementById('goTitle').textContent=title;
document.getElementById('goDist').textContent=(this.distance|0)+'m';
document.getElementById('goScore').textContent=(this.score|0).toLocaleString();
document.getElementById('goBest').textContent=this.best.toLocaleString();
document.getElementById('goReason').textContent=reason;
document.getElementById('gameOverScreen').classList.remove('hidden');
this.renderer.particles.burst(this.renderer.W*0.5, this.renderer.H*0.78, 42, '#ffae6a');
}
update(dt){
this.time+=dt;
if(this.state!=='playing'){ this.renderer.updateShake(dt); this.renderer.particles.update(dt); return; }
this.input.update();
const dtC=Math.min(dt,0.033);
this.player.tx = this.input.x * (T.WORLD_W*0.42);
this.player.ty = this.input.y * (T.WORLD_Y_MAX*0.6) + (this.input.y>0 ? 2 : 0);
const ease = T.EASE;
this.player.vx += (this.player.tx - this.player.x)* ease * dtC * 1.2 - this.player.vx * T.INERTIA * dtC;
this.player.vy += (this.player.ty - this.player.y)* ease * dtC * 1.1 - this.player.vy * T.INERTIA * dtC;
this.player.x += this.player.vx * dtC;
this.player.y += this.player.vy * dtC;
this.player.x = clamp(this.player.x, -T.WORLD_W*0.46, T.WORLD_W*0.46);
this.player.y = clamp(this.player.y, T.WORLD_Y_MIN+1, T.WORLD_Y_MAX);
let targetRoll = -this.input.x * T.ROLL_MAX - this.player.vx*0.012;
let targetPitch = -this.input.y * T.PITCH_MAX - this.player.vy*0.015;
targetRoll = clamp(targetRoll, -T.ROLL_MAX*1.35, T.ROLL_MAX*1.35);
targetPitch = clamp(targetPitch, -T.PITCH_MAX, T.PITCH_MAX);
this.player.roll = lerp(this.player.roll, targetRoll, 1 - Math.exp(-12*dtC));
this.player.pitch = lerp(this.player.pitch, targetPitch, 1 - Math.exp(-10*dtC));
let speedBoost=0;
if(this.input.boost && this.energy > 2){
if(!this.isBoosting) this.audio.boost();
this.isBoosting=true;
speedBoost = T.BOOST_ACCEL;
this.energy -= T.BOOST_COST_PER_SEC * dtC;
if(this.energy<=0){ this.energy=0; this.isBoosting=false; }
}else{
this.isBoosting=false;
}
const acc = T.ACCEL * (1 + this.world.difficulty*0.6);
this.speed = clamp(this.speed + (acc + (this.isBoosting?12:0))* dtC, T.BASE_SPEED, T.MAX_SPEED + (this.isBoosting?T.BOOST_ACCEL:0));
const effSpeed = this.speed + speedBoost;
this.distance += effSpeed * dtC;
this.sun -= T.SUN_SINK_SPEED * dtC * (1 + this.world.difficulty*0.6);
if(this.sun < T.SUN_GAMEOVER){
this.triggerGameOver('The sun set. You ran out of light to chase.', 'NIGHT FALLS');
}
this.world.update(this.distance, effSpeed);
const playerZ = this.distance;
const playerPos = {x:this.player.x, y:this.player.y, z:playerZ};
const inShade = inShadow(playerPos, this.world.obstacles);
let drain = T.ENERGY_DRAIN + (inShade? T.ENERGY_DRAIN_SHADOW:0) + (this.isBoosting? 6:0) - (this.world.difficulty*0.2);
if(!inShade && this.sun>0.2 && !this.isBoosting) drain -= 0.6;
this.energy -= drain * dtC;
if(this.energy<=0){
this.energy=0;
this.triggerGameOver('Solar batteries depleted in the long shadow.', 'POWER LOST');
}
let nearMissCount=0;
for(const ob of this.world.obstacles){
const dz = Math.abs(ob.z - playerZ);
if(dz > 10) continue;
const halfW=ob.w*0.5 + T.COLLIDE_RAD, halfH=ob.h*0.5, halfL=ob.l*0.5 + 1.2;
const ocy = ob.h*0.5;
if(Math.abs(this.player.x - ob.x) < halfW && Math.abs(this.player.y - ocy) < (halfH+1.0) && dz < halfL){
this.audio.crash();
this.renderer.addShake(22);
this.flash=0.9;
this.renderer.particles.burst(this.renderer.W*0.5 + this.player.x*11, this.renderer.H*0.78 - this.player.y*9, 64, '#ff8a5a');
this.renderer.particles.burst(this.renderer.W*0.5 + this.player.x*11, this.renderer.H*0.78 - this.player.y*9, 28, '#ffffff');
document.getElementById('fx-flash').style.background='#ff4a18';
this.triggerGameOver('Direct impact at '+ (this.speed|0)+' km/s. Sleek craft, brutal stop.', 'HULL BREACH');
break;
}
const nearDist = Math.hypot(this.player.x - ob.x, (this.player.y - ocy)*0.6);
const thresh = halfW + T.NEAR_MISS * (1 - clamp(dz/10,0,1)*0.5);
if(dz<6 && nearDist < thresh && nearDist > halfW*0.9) nearMissCount++;
}
if(nearMissCount>0 && this.time - this.lastNearMiss > 0.25){
const bonus = nearMissCount* (15 + this.speed*0.12) * this.mult;
this.score+=bonus;
this.multTimer=3.5;
this.mult = clamp(this.mult + nearMissCount*0.12,1,8);
this.lastNearMiss=this.time;
this.renderer.addShake(4 + nearMissCount*2);
this.audio.nearMiss();
this.renderer.particles.burst(this.renderer.W*0.5 + this.player.x*14, this.renderer.H*0.78 - this.player.y*8, 12, '#ffe78a');
const fx=document.getElementById('fx-speed'); fx.style.opacity='0.9'; setTimeout(()=>fx.style.opacity='0',120);
}
for(const pu of this.world.pickups){
if(pu.collected) continue;
const dz=Math.abs(pu.z - playerZ);
if(dz>4) continue;
if(Math.abs(pu.x - this.player.x) < 3.2 && Math.abs(pu.y - this.player.y) < 4.5 && dz<3){
pu.collected=true;
if(pu.type==='energy'){
this.energy = clamp(this.energy + T.ENERGY_PICKUP,0,T.ENERGY_MAX);
this.score+= 20*this.mult;
this.renderer.addShake(2);
}else if(pu.type==='score'){
this.score+= (120 + this.speed*0.8)* this.mult;
this.multTimer=4.0;
this.mult = clamp(this.mult + 0.06,1,8);
}else{
this.mult = clamp(this.mult + 0.35,1,8);
this.multTimer=6.0;
this.score+= 250*this.mult;
this.renderer.addShake(5);
}
this.audio.pickup(pu.type);
const proj=this.renderer.project(pu.x,pu.y,pu.z,{x:this.player.x*0.35,y:this.player.y*0.25+6,z:playerZ-16});
if(proj) this.renderer.particles.burst(proj.x, proj.y, pu.type==='mult'?18:12, pu.type==='energy'?'#7af7ff':pu.type==='score'?'#ffe78a':'#ff7aff');
}
}
if(this.multTimer>0){
this.multTimer-=dtC;
if(this.multTimer<=0){ this.mult = Math.max(1, this.mult - 0.4); if(this.mult>1) this.multTimer=1.0; }
}
const sunBonus = clamp((this.sun - T.SUN_GAMEOVER)/0.9,0,1)*0.5+0.5;
this.score += effSpeed * dtC * 1.2 * this.mult * sunBonus;
this.renderer.particles.update(dtC);
this.renderer.updateShake(dtC);
if(this.state==='playing' && Math.random()<0.7){
const cx=this.renderer.W*0.5 + this.player.x*11 + rand(-2,2);
const cy=this.renderer.H*0.78 - this.player.y*9 + 14 + rand(-1,2);
this.renderer.particles.spawn(cx,cy,{vx:rand(-8,8),vy:rand(18,50), life:rand(0.2,0.5), size:rand(2,5), color:this.isBoosting?'#7af7ff':rand(0,1)>0.5?'#ffbe4d':'#ff7a3d', speed:1, drag:0.94});
}
const speedNorm=clamp((effSpeed - T.BASE_SPEED)/(T.MAX_SPEED - T.BASE_SPEED),0,1);
this.audio.setWind(speedNorm + (this.isBoosting?0.25:0));
if(this.flash>0){
this.flash-=dtC*2.2;
const el=document.getElementById('fx-flash');
el.style.opacity=this.flash;
el.style.background=this.flash>0.5?'#ff4a18':'#ffffff';
}
this.updateHUD(inShade, effSpeed);
}
updateHUD(inShade, effSpeed){
const $=id=>document.getElementById(id);
if(this.state==='playing'){
$('hudDist').textContent=(this.distance|0)+'m';
$('hudScore').textContent=(this.score|0).toLocaleString();
$('hudMult').textContent='x'+this.mult.toFixed(1);
const speedNorm=clamp(( (effSpeed||this.speed) - T.BASE_SPEED)/(T.MAX_SPEED - T.BASE_SPEED),0,1);
$('hudSpeedBar').firstElementChild.style.width=(speedNorm*100)+'%';
$('hudSpeedLabel').textContent='SPEED '+ ((effSpeed||this.speed)|0) + (inShade?' — SHADOW':'') + (this.isBoosting?' — BOOST':'');
const ePerc=clamp(this.energy / T.ENERGY_MAX,0,1);
$('energyBar').firstElementChild.style.width=(ePerc*100)+'%';
$('energyBar').firstElementChild.style.filter=ePerc<0.25?'brightness(1.2) hue-rotate(-40deg)':'';
const vig=document.getElementById('fx-vignette');
vig.style.boxShadow= inShade? 'inset 0 0 120px rgba(0,0,0,0.85), inset 0 0 30px rgba(0,0,0,0.9)' : '';
const sfx=document.getElementById('fx-speed');
sfx.classList.toggle('boost', speedNorm>0.65);
sfx.style.opacity=speedNorm>0.65? (speedNorm-0.65)*1.2 : '0';
}
document.getElementById('bestScore').textContent=this.best.toLocaleString();
}
render(){
const cam={x:this.player.x*0.35, y:this.player.y*0.25 + 6, z:this.distance - 16};
let shakeX=0,shakeY=0;
if(this.renderer.shake>0.1){
shakeX= (Math.sin(this.renderer.shakeT*1.7)* this.renderer.shake*2.1);
shakeY= (Math.cos(this.renderer.shakeT*2.3)* this.renderer.shake*1.4);
}
const ctx=this.renderer.ctx;
ctx.save();
ctx.translate(shakeX,shakeY);
const sunInfo=this.renderer.drawSky(this.sun);
this.renderer.drawGround(cam,sunInfo,this.distance, this.speed);
this.renderer.drawWorldObstacles(this.world.obstacles, cam, sunInfo.sunProgress);
this.renderer.drawPickups(this.world.pickups, cam, this.time);
const speedNorm=clamp(((this.speed + (this.isBoosting?T.BOOST_ACCEL:0)) - T.BASE_SPEED)/(T.MAX_SPEED - T.BASE_SPEED),0,1);
this.renderer.drawPlayer(this.player.x, this.player.y, this.player.roll, this.player.pitch, this.isBoosting, this.time, speedNorm);
this.renderer.particles.draw(ctx);
ctx.restore();
}
loop(now){
if(!this.lastT) this.lastT=now;
const dt=(now - this.lastT)/1000; this.lastT=now;
this.update(Math.min(dt,0.05));
this.render();
requestAnimationFrame(t=>this.loop(t));
}
}
// ── main ──
// === SETUP & RUN ===
const canvas = document.getElementById('game');
function fit(){
canvas.style.width=window.innerWidth+'px';
canvas.style.height=window.innerHeight+'px';
}
fit();
window.addEventListener('resize', fit);
const game = new Game(canvas);
game.loop(performance.now());
// expose for probes
window.game = game;
window.T = T;
// quality of life: focus
window.addEventListener('click',()=>{ if(game.audio.ctx && game.audio.ctx.state==='suspended') game.audio.resume(); });
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.1","provider":"openrouter","persona":"composer","sessionId":"d7c9a8e9-0a91-47ad-9496-6782d9831100","tokensIn":570872,"tokensOut":31249,"tokensTotal":602121,"turns":24,"toolCalls":23,"failedToolCalls":1,"timestamp":"2026-07-17T04:32:04.338Z"} -->