← Race the Sun results

Race the Sun

muse spark 1.1 · composer

Initial view of Race the Sun

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"} -->