<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title><![CDATA[Learn Technical Independence]]></title><description><![CDATA[Intelligence is more than knowing what to say when prompted]]></description><link>https://learn.piescale.com/</link><image><url>https://learn.piescale.com/favicon.png</url><title>Learn Technical Independence</title><link>https://learn.piescale.com/</link></image><generator>Ghost 4.48</generator><lastBuildDate>Sat, 10 Oct 2026 09:57:57 GMT</lastBuildDate><atom:link href="https://learn.piescale.com/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[Scorched Earth by GLM and Qwen]]></title><description><![CDATA[<p>GLM 5.2 Max vs Qwen 3.8 Max.<br>Prompt 1:<br>Make a single HTML file video game like the original turn-based artillery game Scorched Earth. &#xA0;Try to make it look like the original MS-DOS version from 1991, and make sure to have both a player vs player mode,</p>]]></description><link>https://learn.piescale.com/scorched-earth-by-glm-and-qwen/</link><guid isPermaLink="false">6ac59833445f5f00017660a3</guid><dc:creator><![CDATA[Josh]]></dc:creator><pubDate>Wed, 07 Oct 2026 01:02:21 GMT</pubDate><content:encoded><![CDATA[<p>GLM 5.2 Max vs Qwen 3.8 Max.<br>Prompt 1:<br>Make a single HTML file video game like the original turn-based artillery game Scorched Earth. &#xA0;Try to make it look like the original MS-DOS version from 1991, and make sure to have both a player vs player mode, and a player vs computer mode.<br><br>Result:<br>GLM won round 1. &#xA0;Qwen graphics were out of focus, and the animation felt a little more modern, which here is not good. &#xA0;GLM much was more clear, and period correct. &#xA0;Qwen also goes to the shop screen after every exchange which is unwanted.<br><br>Within arena.ai&apos;s web display, GLM was selected as the winner, and from there the harness feeds that as the correct base to build from to both models for subsequent prompts.<br><br>Prompt 2:<br><br>This is really good, but can you change four things:</p><ol><li>Make it so in Play vs Computer, the computer buys its own weapons.</li><li>Make the computer player a little less accurate in easy mode. &#xA0;Seems to be hitting at almost 100%, dial it down to about 50.</li><li>Make the differences between the different sized weapons a little larger.</li><li>In the scorched earth shop screen, there is flashing white text at the bottom that I cannot read because it is a few pixels below the bottom cutoff. &#xA0;Can you fix only that. &#xA0;The rest of the game, and menus display perfectly.</li></ol><p>Result:</p><p>Almost identical, but the GLM version feels a little more balanced, and fonts look a little clearer. &#xA0;Tie goes to GLM for continuity, and cohesion as the base author.</p><p>Prompt 3:</p><p>This is almost perfect in a desktop browser, but it needs a full screen option. &#xA0;It also needs to work in a mobile browser. &#xA0;You should be able to start the game by tapping, or clicking a start button. &#xA0;The game controls should appear as icons you can tap when in a mobile browser.</p><p>Result:</p><p>GLM wins with a bigger screen, and clearer controls.<br></p><p>Prompt 4:</p><p>Very good. &#xA0;The desktop view is now perfect. &#xA0;The only change I request is to slow down the angle, and power adjustment by about 2x. &#xA0;Right now even very small taps move the number by more than one, so dialing in an exact number is difficult.<br>In mobile only, make the shop text twice as large, and add scrolling to fit all weapons. &#xA0;Also in mobile only make the angle, and power display twice as large.</p><p>GLM wins, store menue better, but new display box did block the main game. &#xA0;Qwen just made existing field bigger, which is exactly what was asked for, but probably not as good. &#xA0;But if I had to pick a V4 to play vs a V4 to turn into V5, Qwen would win here.</p><p>Prompt 5:</p><p>Desktop version is now perfect.<br>Mobile version is almost perfect.<br>In the mobile version only, move the new ANG and PWR display box to the left outside the main playing screen like the rest of the mobile only controls are. &#xA0;It currently blocks the main playing display, and that&apos;s sometimes a big problem.</p><p>GLM perfect. &#xA0;Qwen vertically expanded the mobile control display grid, and shrank the main playing field.</p><p>Prompt 6:</p><p>Almost there.<br>Desktop is still perfect.<br>Mobile shrank the main playing canvas, which should never happen. &#xA0;In mobile only, move the ANG and PWR numbers to the top left next to the FULL SCREEN button, and don&apos;t use anymore vertical pixels than are currently used by those above canvas buttons. &#xA0;And move the POWER METER to the left of the canvas displayed vertically, making certain not to shrink the playing canvas.</p><p>GLM numbers more clear, power meter tighter to canvas, should maximize canvas on any mobile screen.</p><!--kg-card-begin: html--><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>SCORCHED EARTH - DOS Artillery Tribute</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=VT323&amp;display=swap" rel="stylesheet">
<style>
  *{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
  html,body{margin:0;padding:0;height:100%;background:#0b0b0b;overflow:hidden;
    font-family:'VT323','Courier New',monospace;-webkit-text-size-adjust:100%;
    -webkit-user-select:none;user-select:none;overscroll-behavior:none;}
  #wrap{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
    background:radial-gradient(ellipse at center,#151515 0%,#050505 80%);gap:5px;
    padding:5px;padding-bottom:calc(5px + env(safe-area-inset-bottom,0px));}
  /* ---- DOS style buttons ---- */
  button{font-family:inherit;font-weight:normal;letter-spacing:1px;line-height:1;
    background:#000;color:#AAAAAA;border:2px solid #555555;padding:6px 10px;
    font-size:17px;cursor:pointer;touch-action:manipulation;outline:none;border-radius:0}
  button:hover{border-color:#AAAAAA;color:#FFFF55}
  button:active,.on{background:#552200;color:#FFFF55;border-color:#AA5500}
  #topbar{display:flex;flex-wrap:wrap;justify-content:center;gap:5px;max-width:100%;flex:0 0 auto}
  #topbar button{font-size:15px;padding:5px 8px}
  #screen{position:relative;line-height:0;flex:0 0 auto;max-width:100%;
    box-shadow:0 0 0 2px #2a2a2a,0 0 0 4px #101010,0 14px 40px #000;}
  canvas{display:block;image-rendering:pixelated;image-rendering:-moz-crisp-edges;image-rendering:crisp-edges;
    background:#000;cursor:crosshair;touch-action:none;}
  #screen.crt::after{content:'';position:absolute;inset:0;pointer-events:none;
    background:
      repeating-linear-gradient(to bottom,rgba(0,0,0,0) 0 2px,rgba(0,0,0,.30) 2px 3px),
      radial-gradient(ellipse at center,rgba(0,0,0,0) 55%,rgba(0,0,0,.45) 100%);
    mix-blend-mode:multiply;}
  #startwrap{position:absolute;left:50%;top:71%;transform:translateX(-50%);
    display:none;gap:8px;flex-wrap:wrap;justify-content:center;line-height:normal;z-index:5}
  #startwrap button{font-size:22px;padding:9px 14px;border-width:3px;background:#000;
    color:#FFFF55;border-color:#AA5500;box-shadow:3px 3px 0 #000}
  #startwrap button:active{background:#AA0000;color:#FFFFFF;border-color:#FF5555}
  #tip{color:#555;font-size:15px;letter-spacing:1px;margin-top:6px;text-align:center;padding:0 8px;
    flex:0 0 auto;max-width:100%}
  #tip b{color:#8a8a8a;font-weight:normal}
  /* ---- touch pad ---- */
  #pad{display:none;flex-wrap:wrap;justify-content:center;gap:5px;max-width:100%;flex:0 0 auto;line-height:normal}
  #pad .cluster{display:flex;gap:5px}
  .pbtn{min-width:52px;min-height:50px;font-size:19px;padding:4px 9px;background:#111;color:#AAAAAA}
  .pbtn.big{min-width:96px;font-size:24px;color:#FFFF55;border-color:#AA5500}
  .pbtn.fire{background:#550000;color:#FFFF55;border-color:#AA0000;font-size:24px;min-width:104px}
  .pbtn.fire:active{background:#AA0000;color:#FFFFFF;border-color:#FF5555}
  .pbtn.buy{background:#003300;color:#55FF55;border-color:#00AA00}
  /* ---- big angle / power readout, outside the play field ---- */
  /* ---- ANG / PWR readouts live in the top bar, same height as the buttons ---- */
  #gaugeTop{display:flex;gap:3px;align-items:flex-start;flex:0 0 auto}
  #gaugeTop .gx{background:#000;border:2px solid #AA5500;color:#FFFF55;font-size:15px;line-height:1;
    padding:0 5px;height:24px;white-space:nowrap;display:flex;align-items:center;gap:3px;box-sizing:border-box}
  #gaugeTop .gx b{font-weight:normal;color:#FFFFFF;font-size:18px;min-width:2.1em;text-align:right}
  /* ---- canvas row with the vertical power meter beside it ---- */
  #mainrow{display:flex;align-items:stretch;justify-content:center;gap:2px;flex:0 0 auto;max-width:100%}
  #meterV{display:none;width:8px;border:1px solid #AA5500;padding:1px;background:#000;flex:0 0 auto}
  #mvBar{display:flex;flex-direction:column-reverse;gap:1px;height:100%}
  #mvBar i{flex:1 1 auto;background:#242424}
  @media (max-width:760px){
    #tip{display:none}
    .pbtn{min-width:48px;min-height:46px;font-size:17px}
    #topbar button{font-size:13px;padding:4px 6px}
  }
  /* ---- mobile mode: the meter + gaps total exactly 14px, the same horizontal
     budget the canvas already had, so the play field does not shrink at all ---- */
  body.mob #wrap{padding-left:0;padding-right:0}
  body.mob #topbar{gap:3px}
  body.mob #topbar button{font-size:12px;padding:4px 5px;height:24px;line-height:1}
  body.mob #topbar #bStart1,body.mob #topbar #bStart2{display:none!important}
  body.pseudo #gaugeTop .gx,body.pseudo #meterV{border-color:#AA5500}
  /* pseudo fullscreen (iOS) - drop the chrome, keep the pad */
  body.pseudo #tip{display:none!important}
  body.pseudo #wrap{padding:2px;gap:3px}
  body.pseudo #topbar button{font-size:12px;padding:3px 5px}
  body.pseudo .pbtn{min-height:42px}
</style>
</head>
<body>
<div id="wrap">
  <div id="topbar">
    <div id="gaugeTop" style="display:none">
      <span class="gx">ANG <b id="gvA">60</b></span>
      <span class="gx">PWR <b id="gvP">100</b></span>
    </div>
    <button id="bStart1" type="button" style="display:none">&#x25B6; START: VS COMPUTER</button>
    <button id="bStart2" type="button" style="display:none">&#x25B6; START: VS PLAYER</button>
    <button id="bFS" type="button">FULL SCREEN</button>
    <button id="bHelp" type="button">HELP</button>
    <button id="bSnd" type="button">SOUND ON</button>
    <button id="bPad" type="button">PAD ON</button>
    <button id="bMenu" type="button">MENU</button>
  </div>
  <div id="mainrow">
    <div id="meterV"><div id="mvBar"></div></div>
    <div id="screen" class="crt">
      <canvas id="game" width="640" height="400"></canvas>
      <div id="startwrap">
        <button id="s1" type="button">&#x25B6; VS COMPUTER</button>
        <button id="s2" type="button">&#x25B6; VS PLAYER</button>
      </div>
    </div>
  </div>
  <div id="pad">
    <div class="cluster" data-show="play">
      <button class="pbtn" data-key="ArrowLeft" type="button">&#x25C0; AIM</button>
      <button class="pbtn" data-key="ArrowUp" type="button">&#x25B2; PWR</button>
      <button class="pbtn" data-key="ArrowDown" type="button">&#x25BC; PWR</button>
      <button class="pbtn" data-key="ArrowRight" type="button">AIM &#x25B6;</button>
    </div>
    <div class="cluster" data-show="play">
      <button class="pbtn" data-key="KeyQ" type="button">WPN -</button>
      <button class="pbtn" data-key="KeyW" type="button">WPN +</button>
      <button class="pbtn" data-key="Tab" type="button">LIST</button>
    </div>
    <div class="cluster" data-show="play">
      <button class="pbtn" data-key="Comma" type="button">&#x25C0; DRIVE</button>
      <button class="pbtn" data-key="Period" type="button">DRIVE &#x25B6;</button>
      <button class="pbtn fire" data-key="Space" type="button">FIRE!</button>
    </div>
    <div class="cluster" data-show="menu">
      <button class="pbtn" data-key="ArrowUp" type="button">&#x25B2;</button>
      <button class="pbtn" data-key="ArrowDown" type="button">&#x25BC;</button>
      <button class="pbtn big" data-key="Enter" type="button">OK</button>
      <button class="pbtn" data-key="Escape" type="button">BACK</button>
    </div>
    <div class="cluster" data-show="shop">
      <button class="pbtn" data-key="ArrowUp" type="button">ITEM &#x25B2;</button>
      <button class="pbtn" data-key="ArrowDown" type="button">ITEM &#x25BC;</button>
      <button class="pbtn buy" data-key="Enter" type="button">BUY</button>
      <button class="pbtn" data-key="Escape" type="button">DONE</button>
    </div>
  </div>
  <div id="tip">F1 HELP &#xA0;&#xB7;&#xA0; F9 SCANLINES &#xA0;&#xB7;&#xA0; <b>&#x2190;&#x2192;</b> ANGLE &#xA0;&#xB7;&#xA0; <b>&#x2191;&#x2193;</b> POWER &#xA0;&#xB7;&#xA0; <b>Q/W</b> WEAPON &#xA0;&#xB7;&#xA0; <b>, .</b> DRIVE &#xA0;&#xB7;&#xA0; <b>SPACE</b> FIRE &#xA0;&#xB7;&#xA0; MOUSE-DRAG TO AIM</div>
</div>
<script>
'use strict';
/* ============================================================
   SCORCHED EARTH  --  HTML tribute to the 1991 DOS classic
   640x400 canvas == 80x25 character grid (like mode 13h/EGA)
   ============================================================ */
const W=640, H=400;
const cv=document.getElementById('game'), ctx=cv.getContext('2d');
const screenEl=document.getElementById('screen');
ctx.imageSmoothingEnabled=false;

/* ---------- EGA / VGA 16 colour palette ---------- */
const PAL=['#000000','#0000AA','#00AA00','#00AAAA','#AA0000','#AA00AA','#AA5500','#AAAAAA',
           '#555555','#5555FF','#55FF55','#55FFFF','#FF5555','#FF55FF','#FFFF55','#FFFFFF'];

/* ---------- text grid ---------- */
let CHW=8, CHH=16, COLS=80, RS=1;   /* RS = device pixel ratio multiplier */
let MOB=false;                      /* true on phones / tablets / narrow windows */
function fnt(px){ ctx.font=px+"px 'VT323','Courier New',monospace"; }
function txt(s,x,y,col,px){ fnt(px||16); ctx.fillStyle=col||PAL[15]; ctx.textBaseline='top'; ctx.fillText(s,Math.round(x),Math.round(y)); }
function cell(s,c,r,col,px){ txt(s,Math.round(c*CHW),Math.round(r*CHH),col,px||16); }
function ctext(s,r,col,px){ const p=px||16; fnt(p); const w=ctx.measureText(s).width; txt(s,Math.round((W-w)/2),Math.round(r*CHH),col,p); }
function ctextS(s,r,col,px){ const p=px||16; fnt(p); const w=ctx.measureText(s).width; txt(s,Math.round((W-w)/2)+2,Math.round(r*CHH)+2,'#000',p); ctext(s,r,col,p); }
function box(c0,r0,c1,r1,col,dbl){
  const x0=Math.round(c0*CHW), y0=Math.round(r0*CHH), x1=Math.round(c1*CHW), y1=Math.round(r1*CHH);
  ctx.fillStyle=col||PAL[7];
  ctx.fillRect(x0,y0,x1-x0,1); ctx.fillRect(x0,y1-1,x1-x0,1);
  ctx.fillRect(x0,y0,1,y1-y0); ctx.fillRect(x1-1,y0,1,y1-y0);
  if(dbl){ const c=[[x0,y0],[x1-3,y0],[x0,y1-3],[x1-3,y1-3]];
    for(const k of c) ctx.fillRect(k[0],k[1],3,3);
    ctx.fillRect(x0+3,y0+3,x1-x0-6,1); ctx.fillRect(x0+3,y1-4,x1-x0-6,1);
    ctx.fillRect(x0+3,y0+3,1,y1-y0-6); ctx.fillRect(x1-4,y0+3,1,y1-y0-6);
  }
}
/* segmented DOS style bar, no exotic glyphs needed */
function barCells(x,y,n,frac,colOn,colOff){
  const cw=Math.max(3,CHW-2);
  for(let i=0;i<n;i++){
    ctx.fillStyle=(i<Math.round(frac*n))?(colOn||PAL[10]):(colOff||'#242424');
    ctx.fillRect(Math.round(x)+i*(cw+1),Math.round(y),cw,CHH-7);
  }
}
function pad(n,w){ let s=String(n); return s.length>=w?s:' '.repeat(w-s.length)+s; }
function rint(a,b){ return a+Math.floor(Math.random()*(b-a+1)); }
function rnum(a,b){ return a+Math.random()*(b-a); }
function clamp(v,a,b){ return v<a?a:(v>b?b:v); }

/* ============================================================
   AUDIO  (PC-speaker style beeps + noise bursts)
   ============================================================ */
let AC=null, master=null;
function audioInit(){
  if(AC) return;
  try{ AC=new (window.AudioContext||window.webkitAudioContext)();
    master=AC.createGain(); master.gain.value=0.55; master.connect(AC.destination);
  }catch(e){ AC=null; }
}
function beep(f1,dur,type,vol,f2){
  if(!AC||!opts.sound) return;
  const o=AC.createOscillator(), g=AC.createGain();
  o.type=type||'square'; o.frequency.setValueAtTime(f1,AC.currentTime);
  if(f2) o.frequency.exponentialRampToValueAtTime(Math.max(20,f2),AC.currentTime+dur);
  g.gain.setValueAtTime(vol||0.05,AC.currentTime);
  g.gain.exponentialRampToValueAtTime(0.0008,AC.currentTime+dur);
  o.connect(g); g.connect(master); o.start(); o.stop(AC.currentTime+dur+0.02);
}
function noise(dur,vol,cut){
  if(!AC||!opts.sound) return;
  const n=Math.floor(AC.sampleRate*dur), buf=AC.createBuffer(1,n,AC.sampleRate), d=buf.getChannelData(0);
  for(let i=0;i<n;i++) d[i]=(Math.random()*2-1)*(1-i/n);
  const s=AC.createBufferSource(); s.buffer=buf;
  const f=AC.createBiquadFilter(); f.type='lowpass'; f.frequency.value=cut||900;
  const g=AC.createGain(); g.gain.value=vol||0.2;
  s.connect(f); f.connect(g); g.connect(master); s.start();
}
const SFX={
  blip(){ beep(880,0.05,'square',0.04); },
  pick(){ beep(1200,0.05,'square',0.045); beep(1600,0.05,'square',0.03); },
  move(){ beep(180,0.03,'square',0.02); },
  fire(){ beep(560,0.13,'square',0.05,120); },
  boom(r){ noise(0.18+r*0.006,0.16+r*0.003,1100-r*8); if(r>28){ beep(70,0.5,'sine',0.10,28); noise(0.9,0.12,300);} },
  die(){ beep(400,0.5,'square',0.06,60); noise(0.5,0.14,500); },
  cash(){ beep(1400,0.05,'square',0.04); setTimeout(()=>beep(1800,0.06,'square',0.04),60); },
  err(){ beep(120,0.12,'square',0.05); },
  win(){ [523,659,784,1046].forEach((f,i)=>setTimeout(()=>beep(f,0.12,'square',0.05),i*110)); }
};

/* ============================================================
   OPTIONS
   ============================================================ */
const opts={ rounds:4, wind:'variable', gravity:'normal', terrain:'random', sound:true, scan:true, ai:1 };
const WINDS=['calm','breeze','variable','gale'];
const GRAVS=['low','normal','high'];
const TERRS=['random','hills','mountains','plains','canyon','islands'];
const AILV=['EASY','NORMAL','HARD','DEADLY'];

/* ============================================================
   WEAPONS
   ============================================================ */
/* NOTE: damage / blast radius are deliberately spread FAR apart so that
   stepping up a size class really changes the shape of the battlefield. */
const WEAPONS=[
 {id:0, name:'BABY MISSLE', short:'BABY MS', price:0,   dmg:10,rad:10,kind:'single'},
 {id:1, name:'MISSILE',     short:'MISSILE', price:30,  dmg:26,rad:19,kind:'single'},
 {id:2, name:'BIG MISSILE', short:'BIG MSL', price:110, dmg:70,rad:38,kind:'single'},
 {id:3, name:'BABY NUKE',   short:'B-NUKE',  price:330, dmg:120,rad:62,kind:'single',nuke:true},
 {id:4, name:'NUKE',        short:'NUKE',    price:900, dmg:210,rad:95,kind:'single',nuke:true},
 {id:5, name:'FUNKY BOMB',  short:'FUNKY',   price:190, dmg:26,rad:21,kind:'funky',count:10,spread:46},
 {id:6, name:'MIRV',        short:'MIRV',    price:520, dmg:30,rad:24,kind:'mirv',count:5,child:{dmg:32,rad:24}},
 {id:7, name:'DEATH HEAD',  short:'D-HEAD',  price:1800,dmg:150,rad:70,kind:'mirv',count:5,nuke:true,child:{dmg:150,rad:70,nuke:true}},
 {id:8, name:'DIGGER',      short:'DIGGER',  price:150, dmg:34,rad:17,kind:'digger'},
 {id:9, name:'DIRT BALL',   short:'DIRT',    price:60,  dmg:6, rad:30,kind:'dirt'},
 {id:10,name:'ROLLER',      short:'ROLLER',  price:150, dmg:55,rad:28,kind:'roller'},
 {id:11,name:'NAPALM',      short:'NAPALM',  price:210, dmg:14,rad:17,kind:'napalm'},
 {id:12,name:'TRACER',      short:'TRACER',  price:15,  dmg:0, rad:0, kind:'tracer'}
];
const SHOP=[];
WEAPONS.forEach(w=>{ if(w.price>0) SHOP.push({t:'w',id:w.id,name:w.name,price:w.price,unit:'EACH'}); });
SHOP.push({t:'fuel', name:'FUEL (50 UNITS)',       price:60,  unit:'LOT'});
SHOP.push({t:'para', name:'PARACHUTE',             price:120, unit:'EACH'});
SHOP.push({t:'batt', name:'BATTERY (+25 ARMOR)',   price:250, unit:'EACH'});
SHOP.push({t:'rep',  name:'ARMOR REPAIR (FULL)',   price:220, unit:'FIX'});
SHOP.push({t:'sh1',  name:'SHIELD #1 (ABSORB 60)', price:300, unit:'UNIT'});
SHOP.push({t:'sh2',  name:'SHIELD #2 (ABSORB 150)',price:650, unit:'UNIT'});
SHOP.push({t:'sh3',  name:'SHIELD #3 (ABSORB 320)',price:1400,unit:'UNIT'});

const TANKCOL=[PAL[14],PAL[12],PAL[11],PAL[10],PAL[13],PAL[9]];

/* ============================================================
   TERRAIN
   ============================================================ */
const AIR=0,DIRT=1,GRASS=2,LOOSE=3,ROCK=4,TRUNK=5,LEAF=6;
let terr=new Uint8Array(W*H);
let terrCv=document.createElement('canvas'); terrCv.width=W; terrCv.height=H;
let tctx=terrCv.getContext('2d');
let dirty=null;
let skyCv=document.createElement('canvas'); skyCv.width=W; skyCv.height=H;

function solid(x,y){ x|=0; y|=0; if(x<0||x>=W||y<0||y>=H) return 0; return terr[y*W+x]!==0; }
function matCol(m,x,y){
  switch(m){
    case DIRT:  return ((x+y)&1)?0x7A3C00:0xAA5500;
    case GRASS: return ((x*3+y)&3)===0?0x55FF55:0x00AA00;
    case LOOSE: return ((x+y)&1)?0x5A3000:0x8A4500;
    case ROCK:  return ((x*5+y*3)&7)===0?0xAAAAAA:0x555555;
    case TRUNK: return 0x7A3C00;
    case LEAF:  return ((x+y)&1)?0x008800:0x00AA00;
  }
  return 0;
}
function markDirty(x0,y0,x1,y1){
  x0=Math.max(0,Math.floor(x0)-1); y0=Math.max(0,Math.floor(y0)-1);
  x1=Math.min(W-1,Math.ceil(x1)+1);  y1=Math.min(H-1,Math.ceil(y1)+1);
  if(x1<x0||y1<y0) return;
  if(!dirty) dirty=[x0,y0,x1,y1];
  else{ dirty[0]=Math.min(dirty[0],x0); dirty[1]=Math.min(dirty[1],y0);
        dirty[2]=Math.max(dirty[2],x1); dirty[3]=Math.max(dirty[3],y1); }
}
function flushTerrain(){
  if(!dirty) return;
  const x0=dirty[0],y0=dirty[1],x1=dirty[2],y1=dirty[3]; dirty=null;
  const w=x1-x0+1,h=y1-y0+1; if(w<=0||h<=0) return;
  const img=tctx.createImageData(w,h), d=img.data;
  for(let y=0;y<h;y++){ const py=y0+y;
    for(let x=0;x<w;x++){ const px=x0+x; const m=terr[py*W+px];
      const i=(y*w+x)*4;
      if(m){ const c=matCol(m,px,py); d[i]=(c>>16)&255; d[i+1]=(c>>8)&255; d[i+2]=c&255; d[i+3]=255; }
      else d[i+3]=0;
    }
  }
  tctx.putImageData(img,x0,y0);
}
function groundAt(x,fromY){
  x|=0; if(x<0||x>=W) return null;
  let y=Math.max(0,Math.floor(fromY));
  for(;y<H;y++) if(terr[y*W+x]!==0) return y;
  return null;
}
function surfaceTop(x){ return groundAt(x,0); }

function carve(cx,cy,r){
  const x0=Math.max(0,Math.floor(cx-r)), x1=Math.min(W-1,Math.ceil(cx+r));
  const y0=Math.max(0,Math.floor(cy-r)), y1=Math.min(H-1,Math.ceil(cy+r));
  for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++){
    const dx=x-cx, dy=y-cy;
    if(dx*dx+dy*dy<=r*r) terr[y*W+x]=AIR;
  }
  markDirty(x0,y0,x1,y1);
}
function addDirt(cx,cy,r){
  const x0=Math.max(0,Math.floor(cx-r)), x1=Math.min(W-1,Math.ceil(cx+r));
  const y0=Math.max(0,Math.floor(cy-r)), y1=Math.min(H-1,Math.ceil(cy+r));
  for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++){
    const dx=x-cx, dy=y-cy;
    if(dx*dx+dy*dy<=r*r && terr[y*W+x]===AIR) terr[y*W+x]=LOOSE;
  }
  markDirty(x0,y0,x1,y1);
}
function flatten(cx,half){
  const yc=surfaceTop(cx); if(yc===null) return;
  for(let dx=-half-4;dx<=half+4;dx++){
    const x=cx+dx; if(x<0||x>=W) continue;
    const t=Math.abs(dx);
    let ytop;
    if(t<=half) ytop=yc;
    else { const o=surfaceTop(x); ytop=Math.round(yc+(t-half)/4*((o===null?yc:o)-yc)); }
    if(ytop<0) ytop=0; if(ytop>=H) continue;
    for(let y=0;y<ytop;y++) terr[y*W+x]=AIR;
    for(let y=ytop;y<H;y++){ if(terr[y*W+x]===AIR) terr[y*W+x]=(y-ytop<3)?GRASS:DIRT; }
  }
  markDirty(cx-half-5,0,cx+half+5,H-1);
}
function genTerrain(kind){
  terr.fill(0); dirty=null;
  if(kind==='random') kind=TERRS[rint(1,TERRS.length-1)];
  const hgt=new Float32Array(W);
  const oct=5; let amp=1, base=H*0.62;
  const ph=[],fr=[];
  for(let o=0;o<oct;o++){ ph.push(rnum(0,6.28)); fr.push(rnum(1,3.2)*Math.pow(2,o)); amp*=0.55; }
  amp=1;
  for(let o=0;o<oct;o++){
    const f=rnum(0.4,1.1)*Math.pow(1.9,o), p=ph[o], a=(1/Math.pow(1.85,o));
    for(let x=0;x<W;x++) hgt[x]+=Math.sin(x/W*Math.PI*2*f+p)*a;
    amp=a;
  }
  // normalise to -1..1
  let mn=1e9,mx=-1e9; for(let x=0;x<W;x++){ if(hgt[x]<mn)mn=hgt[x]; if(hgt[x]>mx)mx=hgt[x]; }
  const span=Math.max(0.001,mx-mn);
  for(let x=0;x<W;x++) hgt[x]=((hgt[x]-mn)/span)*2-1;
  let lo=90, hi=H-34;
  if(kind==='mountains'){ lo=76; hi=H-40; }
  if(kind==='plains'){ lo=H-90; hi=H-26; }
  if(kind==='canyon'){ lo=H-100; hi=H-26; }
  if(kind==='islands'){ lo=H-110; hi=H-24; }
  const hmap=new Int16Array(W);
  for(let x=0;x<W;x++){
    let v=hgt[x];
    let y;
    if(kind==='mountains') y=lo+(hi-lo)*Math.pow((v+1)/2,1.5);
    else if(kind==='plains') y=lo+(hi-lo)*(0.5+v*0.22);
    else if(kind==='canyon'){
      const c=Math.abs(x-W/2)/(W/2);
      const notch=Math.max(0,1-c*2.4);
      y=lo+(hi-lo)*(0.45+v*0.2)+notch*90;
    } else if(kind==='islands'){
      const b=Math.sin(x/W*Math.PI*3.0+0.6);
      const m=(Math.sin(x/W*Math.PI*3.0+0.6)>-0.25)?1:0;
      y=lo+(hi-lo)*m*(0.45+0.3*b+v*0.12);
      if(!m) y=H+40;
    } else y=lo+(hi-lo)*(0.5+v*0.42);
    hmap[x]=clamp(Math.round(y),40,H+40);
  }
  // smooth
  for(let k=0;k<3;k++){ const cp=Int16Array.from(hmap);
    for(let x=1;x<W-1;x++) hmap[x]=Math.round((cp[x-1]+cp[x]*2+cp[x+1])/4); }
  for(let x=0;x<W;x++){
    const y=hmap[x];
    for(let yy=y;yy<H;yy++){
      if(yy<0||yy>=H) continue;
      terr[yy*W+x]= (yy-y<3)?GRASS:DIRT;
    }
  }
  // rock blobs
  const nb=rint(3,7);
  for(let b=0;b<nb;b++){
    const bx=rint(20,W-20), by=rint(60,H-20), br=rint(12,34);
    for(let y=by-br;y<=by+br;y++) for(let x=bx-br;x<=bx+br;x++){
      if(x<0||x>=W||y<0||y>=H) continue;
      const dx=x-bx,dy=(y-by)*1.6;
      if(dx*dx+dy*dy<=br*br && terr[y*W+x]===DIRT) terr[y*W+x]=ROCK;
    }
  }
  // trees
  for(let i=0;i<14;i++){
    const x=rint(15,W-15), y=surfaceTop(x); if(y===null||y>H-24) continue;
    const yl=surfaceTop(x-4), yr=surfaceTop(x+4);
    if(yl===null||yr===null||Math.abs(yl-yr)>4) continue;
    const th=rint(6,11);
    for(let k=0;k<th;k++){ const yy=y-k-1; if(yy>0) terr[yy*W+x]=TRUNK; }
    const cy=y-th-3, cr=rint(3,5);
    for(let yy=cy-cr;yy<=cy+cr;yy++) for(let xx=x-cr-1;xx<=x+cr+1;xx++){
      if(xx<1||xx>=W-1||yy<1) continue;
      const dx=xx-x, dy=yy-cy;
      if(dx*dx+dy*dy*1.5<=cr*cr && terr[yy*W+xx]===AIR) terr[yy*W+xx]=LEAF;
    }
  }
  dirty=null; markDirty(0,0,W-1,H-1); flushTerrain();
}
function drawSkyInit(){
  const c=skyCv.getContext('2d');
  c.fillStyle='#000'; c.fillRect(0,0,W,H);
  for(let i=0;i<260;i++){
    const x=rint(0,W-1), y=rint(0,H*0.75);
    c.fillStyle= Math.random()<0.3?PAL[8]:'#202020';
    c.fillRect(x,y,1,1);
  }
  // a sun / moon disc, DOS style
  const sx=W-70, sy=44;
  c.fillStyle='#555555'; c.beginPath(); c.arc(sx,sy,16,0,7); c.fill();
  c.fillStyle='#000'; c.beginPath(); c.arc(sx-6,sy-4,14,0,7); c.fill();
}

/* ============================================================
   TANK
   ============================================================ */
function mkTank(i,name,isAI){
  return {
    idx:i, name:name, isAI:isAI, color:TANKCOL[i%6],
    x:0,y:0,vx:0,vy:0,angle:60,power:60,weapon:0,
    health:100, maxHealth:100, shieldType:0, shieldHP:0, shieldMax:0,
    fuel:60, paras:0, money:800, alive:true, falling:false,
    inv:WEAPONS.map(w=>w.id===0?Infinity:0),
    kills:0, deaths:0, won:0, dmgDealt:0, drive:0, flash:0
  };
}
function turretY(t){ return t.y-9; }
function muzzle(t){ const a=t.angle*Math.PI/180; return {x:t.x+Math.cos(a)*12, y:t.y-9-Math.sin(a)*12}; }

/* ============================================================
   GAME STATE
   ============================================================ */
let game=null, G=0.12, windA=0;
const GRAVVAL={low:0.085,normal:0.12,high:0.175};
function blank(){
  return {state:'title',demo:true,players:[],cur:0,round:1,mode:'pvc',
    phase:'aim',pt:0,time:0,shake:0,wind:0,log:[],projs:[],parts:[],booms:[],fires:[],
    timers:[],dots:[],showW:false,paused:false,shopIdx:0,shopWho:0,shopT:0,shopScroll:0,aiPlan:null,aiTimer:0,
    winner:null,flashT:0,menuIdx:0,setupStep:0,nPlayers:2,demoT:0};
}
function msg(text,col){ game.log.push({t:text,c:col||PAL[15],n:340}); if(game.log.length>7) game.log.shift(); }
function newWind(){
  const m={calm:0,breeze:()=>rnum(-5,5),variable:()=>rnum(-13,13),gale:()=>rnum(-24,24)}[opts.wind];
  game.wind= m===0?0:Math.round(m());
  windA=game.wind*0.0045;
}
function setGrav(){ G=GRAVVAL[opts.gravity]; }

function startRound(){
  genTerrain(opts.terrain);
  const n=game.players.length;
  const spots=[];
  const margin=52, span=W-margin*2;
  for(let i=0;i<n;i++){
    let x=margin+ (n===1?span/2: span*i/(n-1));
    x+=rnum(-14,14);
    spots.push(clamp(Math.round(x),24,W-24));
  }
  // enforce separation
  for(let pass=0;pass<40;pass++){
    for(let i=0;i<n;i++) for(let j=i+1;j<n;j++){
      if(Math.abs(spots[i]-spots[j])<58){
        const d=spots[i]<spots[j]?-1:1;
        spots[i]=clamp(spots[i]+d*3,24,W-24); spots[j]=clamp(spots[j]-d*3,24,W-24);
      }
    }
  }
  game.players.forEach((t,i)=>{
    t.alive=true; t.health=t.maxHealth; t.shieldHP=t.shieldMax*(t.shieldType?1:0);
    t.vy=0; t.vx=0; t.falling=false; t.drive=0;
    let x=spots[i];
    if(surfaceTop(x)===null||surfaceTop(x)>H-24){        // nudge off a gap / cliff edge
      for(let d=1;d<90;d++){
        const a=surfaceTop(clamp(x-d,24,W-24)), b=surfaceTop(clamp(x+d,24,W-24));
        if(a!==null&&a<H-24){ x=clamp(x-d,24,W-24); break; }
        if(b!==null&&b<H-24){ x=clamp(x+d,24,W-24); break; }
      }
    }
    t.x=x;
    flatten(t.x,9);
    const g=surfaceTop(t.x); t.y=(g===null)?H-40:g;
    t.angle= t.x<W/2?55:125; t.power=60;
  });
  newWind(); setGrav();
  game.projs=[]; game.parts=[]; game.booms=[]; game.fires=[]; game.timers=[]; game.dots=[]; game.log=[];
  game.cur=(game.round-1)%game.players.length;
  if(!game.players[game.cur].alive) nextTurn();
  else beginTurn();
  game.state='play';
}
function beginTurn(){
  const t=game.players[game.cur];
  game.phase= t.isAI?'ai':'aim';
  game.pt=75; game.aiPlan=null; game.aiTimer=40+rint(0,30); game.showW=false;
  if(t.weapon>0 && t.inv[t.weapon]<=0) cycleWeapon(t,1);
}
function nextTurn(){
  const n=game.players.length;
  for(let k=1;k<=n;k++){
    const i=(game.cur+k)%n;
    if(game.players[i].alive){ game.cur=i; break; }
  }
  beginTurn();
}
function aliveCount(){ return game.players.filter(p=>p.alive).length; }

/* ============================================================
   EXPLOSIONS / DAMAGE
   ============================================================ */
function boomVis(x,y,r,nuke){
  game.booms.push({x:x,y:y,r:r,age:0,max:nuke?26:16,nuke:!!nuke});
  const n=Math.min(90,Math.round(r*1.6));
  for(let i=0;i<n;i++){
    const a=rnum(0,6.283), s=rnum(0.6,3.4)*(0.6+r/34);
    game.parts.push({x:x,y:y,vx:Math.cos(a)*s,vy:Math.sin(a)*s-rnum(0,1.2),
      life:rint(30,90),c:Math.random()<0.5?PAL[6]:PAL[4],b:2});
  }
  if(nuke){ // mushroom column
    for(let i=0;i<44;i++){
      game.parts.push({x:x+rnum(-6,6),y:y-rnum(0,6),vx:rnum(-0.5,0.5),vy:-rnum(1.2,3.6),
        life:rint(60,140),c:Math.random()<0.4?PAL[8]:PAL[6],b:3,soft:true});
    }
  }
}
function explode(x,y,spec,owner){
  if(spec.rad>0) carve(x,y,spec.rad);
  boomVis(x,y,spec.rad||8,spec.nuke);
  SFX.boom(spec.rad||8);
  game.shake=Math.min(15,game.shake+(spec.rad||8)*0.2);
  if(spec.dmg>0){
    const R=(spec.rad||8)+8;
    for(const t of game.players){
      if(!t.alive) continue;
      const d=Math.hypot(t.x-x,(t.y-5)-y);
      if(d<R){
        let dmg=spec.dmg*(1-d/R);
        if(t.shieldHP>0){
          const abs=Math.min(t.shieldHP,dmg);
          t.shieldHP-=abs; dmg-=abs; t.flash=6;
          if(t.shieldHP<=0){ t.shieldHP=0; t.shieldType=0; msg(t.name+" SHIELD DOWN!",PAL[13]); beep(300,0.2,'square',0.05,90); }
        }
        const k=1-d/R;
        t.vy-=k*2.0; t.vx+=(t.x-x)/(d||1)*k*2.6; t.falling=true;
        if(dmg>=1) applyDamage(t,dmg,owner);
      }
    }
  }
}
function applyDamage(t,dmg,owner){
  if(game.demo) return;
  t.health-=dmg; t.flash=6;
  if(owner&&owner!==t){ owner.money+=Math.round(dmg*3); owner.dmgDealt+=Math.round(dmg); }
  if(t.health<=0) killTank(t,owner); else msg(t.name+" -"+Math.round(dmg)+" HP",t.color);
}
function killTank(t,killer,reason){
  if(!t.alive||game.demo) return;
  t.alive=false; t.deaths++;
  msg((reason? t.name+" "+reason : t.name+" DESTROYED!"),PAL[12]);
  SFX.die();
  explode(t.x,t.y-5,{dmg:26,rad:26},null);
  game.parts.push({x:t.x,y:t.y-6,vx:0,vy:-2,life:80,c:PAL[12],b:4,soft:true});
  if(killer&&killer!==t){ killer.kills++; killer.money+=500; msg(killer.name+" EARNS $500",PAL[14]); }
  game.flashT=6;
}

/* ============================================================
   PROJECTILES
   ============================================================ */
function spawnProj(x,y,vx,vy,spec,owner,extra){
  const p={x:x,y:y,vx:vx,vy:vy,spec:spec,kind:spec.kind,owner:owner,
    state:'fly',life:0,rest:0,dist:0,split:false};
  if(extra) Object.assign(p,extra);
  game.projs.push(p);
  return p;
}
function fire(t){
  const w=WEAPONS[t.weapon];
  if(t.weapon>0&&t.inv[t.weapon]<=0){ msg("OUT OF AMMO",PAL[12]); SFX.err(); return; }
  if(t.weapon>0) t.inv[t.weapon]--;
  const a=t.angle*Math.PI/180, v=2+t.power/100*8;
  const m=muzzle(t);
  spawnProj(m.x,m.y,Math.cos(a)*v,-Math.sin(a)*v,
    {name:w.name,dmg:w.dmg,rad:w.rad,nuke:!!w.nuke,kind:w.kind,count:w.count,spread:w.spread,child:w.child},
    t,{skip:14});
  SFX.fire();
  game.shake=Math.min(8,game.shake+2);
  game.phase='shot'; game.shotFrames=0;
}
function onImpact(p){
  const s=p.spec;
  if(p.kind!=='digger'&&p.kind!=='roller') p.dead=true;
  switch(p.kind){
    case 'tracer':
      msg("TRACER COMPLETE",PAL[8]); break;
    case 'dirt':
      addDirt(p.x,p.y,s.rad); SFX.boom(10); boomVis(p.x,p.y,10,false);
      explode(p.x,p.y,{dmg:s.dmg,rad:0},p.owner); break;
    case 'napalm': {
      explode(p.x,p.y,{dmg:s.dmg,rad:s.rad*0.6},p.owner);
      for(let i=0;i<46;i++){
        const a=rnum(0,6.283);
        game.fires.push({x:p.x+Math.cos(a)*rnum(0,10),y:p.y-Math.abs(Math.sin(a))*rnum(0,8),
          vx:Math.cos(a)*rnum(0.3,1.6),vy:-Math.abs(Math.sin(a))*rnum(0.2,1.2),life:rint(120,260),tick:0,owner:p.owner});
      }
      break; }
    case 'funky': {
      SFX.boom(14); boomVis(p.x,p.y,16,false); carve(p.x,p.y,10);
      const n=s.count||9;
      for(let i=0;i<n;i++){
        const dx=rnum(-1,1)*(s.spread||40), dy=rnum(-0.8,0.4)*(s.spread||40)*0.7;
        game.timers.push({n:2+i*2+rint(0,4),fn:()=>{
          const bx=clamp(p.x+dx,2,W-2), by=clamp(p.y+dy,4,H-2);
          explode(bx,by,{dmg:s.dmg,rad:s.rad},p.owner);
        }});
      }
      break; }
    case 'digger': p.state='dig'; p.digLeft=48; p.vy=0; p.vx=0; break;
    case 'roller': p.state='roll'; p.vy=0; break;
    default:
      explode(p.x,p.y,s,p.owner);
  }
}
function updateProjectiles(){
  const SUB=8, DT=1/SUB;
  for(let i=game.projs.length-1;i>=0;i--){
    const p=game.projs[i];
    if(p.state==='dig'){
      p.digLeft--; p.y+=1.5;
      carve(p.x,p.y+4,p.spec.rad*0.95);
      if(game.time%2===0) game.parts.push({x:p.x+rnum(-6,6),y:p.y-6,vx:rnum(-1,1),vy:-rnum(0.5,2.5),
        life:rint(20,50),c:PAL[6],b:2});
      if(game.time%9===0) beep(150,0.05,'square',0.03,90);
      if(p.digLeft<=0||p.y>H-8){
        explode(p.x,p.y,p.spec,p.owner);
        game.projs.splice(i,1);
      }
      continue;
    }
    if(p.state==='roll'){
      const g=groundAt(p.x,p.y-1);
      if(g===null){ explode(p.x,p.y,p.spec,p.owner); game.projs.splice(i,1); continue; }
      p.y=g-1;
      const l=groundAt(p.x-5,p.y-6), r=groundAt(p.x+5,p.y-6);
      let dir=0;
      if(l!==null&&r!==null){ if(l>r) dir=-1; else if(r>l) dir=1; }
      if(dir===0){ p.rest++; if(p.rest>22){ explode(p.x,p.y,p.spec,p.owner); game.projs.splice(i,1); continue; } }
      else { p.rest=0; p.x+=dir*1.3; if(p.x<3||p.x>W-3){ explode(p.x,p.y,p.spec,p.owner); game.projs.splice(i,1); continue; } }
      let hit=false;
      for(const t of game.players){ if(t.alive&&t!==p.owner&&Math.abs(t.x-p.x)<10&&Math.abs((t.y-5)-p.y)<9) hit=true; }
      if(hit){ explode(p.x,p.y,p.spec,p.owner); game.projs.splice(i,1); continue; }
      if(game.time%3===0) game.dots.push({x:p.x,y:p.y,a:0.5});
      continue;
    }
    // flying
    for(let s=0;s<SUB;s++){
      p.vy+=G*DT; p.vx+=windA*DT;
      p.x+=p.vx*DT; p.y+=p.vy*DT;
      if(p.skip>0) p.skip--;
      p.dist+=Math.hypot(p.vx*DT,p.vy*DT);
      if(p.dist>4){ p.dist=0; if(game.dots.length<620) game.dots.push({x:p.x,y:p.y,a:0.85}); }
      // MIRV splits at the top of its arc
      if(!p.split&&p.spec.kind==='mirv'&&p.vy>-0.2&&p.life>6){
        p.split=true;
        const n=p.spec.count||5;
        for(let k=0;k<n;k++){
          spawnProj(p.x,p.y,p.vx+(k-(n-1)/2)*0.8,p.vy*0.75-0.6,
            {name:p.spec.name,dmg:p.spec.child.dmg,rad:p.spec.child.rad,nuke:!!p.spec.child.nuke,kind:'single'},
            p.owner,{skip:8});
        }
        beep(900,0.08,'square',0.04,400);
        p.dead=true; break;
      }
      if(p.y>H+60||p.x<-80||p.x>W+80){ p.dead=true; break; }
      if(p.y>0&&solid(p.x,p.y)){ onImpact(p); break; }
      let hitTank=null;
      for(const t of game.players){
        if(!t.alive) continue;
        if(t===p.owner&&p.life<12) continue;
        if(Math.abs(t.x-p.x)<9&&Math.abs((t.y-5)-p.y)<9){ hitTank=t; break; }
      }
      if(hitTank){ onImpact(p); break; }
      p.life++;
    }
    p.life++;
    if(p.dead) game.projs.splice(i,1);
  }
}
function updateParticles(){
  for(let i=game.parts.length-1;i>=0;i--){
    const p=game.parts[i];
    p.vy+= p.soft? 0.012 : 0.16;
    p.x+=p.vx; p.y+=p.vy;
    p.life--;
    if(!p.soft&&solid(p.x,p.y)){ game.parts.splice(i,1); continue; }
    if(p.life<=0||p.y>H+20) game.parts.splice(i,1);
  }
  if(game.parts.length>900) game.parts.splice(0,game.parts.length-900);
}
function updateFires(){
  for(let i=game.fires.length-1;i>=0;i--){
    const f=game.fires[i];
    f.life--; f.tick++;
    f.vy+=0.09;
    f.x+=f.vx; f.y+=f.vy;
    if(solid(f.x,f.y)){
      f.y=Math.floor(f.y)-1; f.vy=0;
      const l=groundAt(f.x-3,f.y-2), r=groundAt(f.x+3,f.y-2);
      let dir=0; if(l!==null&&r!==null){ if(l>r)dir=-1; else if(r>l)dir=1; }
      f.x+=dir*0.9; f.vx=dir*0.9;
      if(dir===0&&f.tick%20===0) f.vx=rnum(-0.4,0.4);
    }
    if(f.tick%8===0){
      for(const t of game.players){
        if(t.alive&&Math.abs(t.x-f.x)<9&&Math.abs((t.y-4)-f.y)<8){
          applyDamage(t,4,f.owner||null);
        }
      }
    }
    if(f.life<=0||f.x<0||f.x>W||f.y>H) game.fires.splice(i,1);
  }
}
function updateTanks(){
  for(const t of game.players){
    if(!t.alive) continue;
    if(t.flash>0) t.flash--;
    // driving
    if(t.drive!==0&&t.fuel>0){
      const nx=t.x+t.drive*0.8;
      if(nx>12&&nx<W-12){
        const g=groundAt(nx,t.y-3);            /* can climb 3px at a time */
        if(g!==null&&g>=t.y-3){
          t.x=nx; t.y=g; t.fuel-=0.32;
          if(game.time%6===0) SFX.move();
        }
      }
      t.drive=0;
    }
    const g=groundAt(t.x,t.y-1);
    if(g===null){ // nothing below: fall
      t.falling=true; t.vy+=G*0.5; t.y+=t.vy;
      if(t.y>H+26){
        if(game.demo){ respawnDemoTank(t); }
        else { killTank(t,null,"FELL INTO THE VOID!"); t.falling=false; }
      }
    } else if(g>t.y+0.5){
      t.falling=true; t.vy+=G*0.5; t.y+=t.vy;
      if(t.y>=g){
        const imp=t.vy; t.y=g; t.vy=0; t.falling=false;
        if(imp>3.2){
          if(t.paras>0){ t.paras--; msg(t.name+" PARACHUTE USED",PAL[11]); }
          else { applyDamage(t,(imp-3.2)*14,null); msg(t.name+" HARD LANDING",PAL[12]); }
        }
      }
    } else {
      if(g<t.y){ t.y=g; }   // pushed up by dirt
      t.vy=0; t.falling=false;
    }
  }
}
function allSettled(){
  if(game.projs.length||game.fires.length||game.booms.length) return false;
  for(const t of game.players) if(t.alive&&t.falling) return false;
  return true;
}

/* ============================================================
   AI
   ============================================================ */
function simShot(me,ang,pw){
  const a=ang*Math.PI/180, v=2+pw/100*8;
  let x=me.x+Math.cos(a)*12, y=me.y-9-Math.sin(a)*12;
  let vx=Math.cos(a)*v, vy=-Math.sin(a)*v;
  for(let i=0;i<520;i++){
    for(let s=0;s<4;s++){ vy+=G/4; vx+=windA/4; x+=vx/4; y+=vy/4; }
    if(y>H+20||x<-60||x>W+60) return {x:x,y:y,hit:null};
    if(y>0&&solid(x,y)) return {x:x,y:y,hit:'terrain'};
    for(const t of game.players){
      if(!t.alive) continue;
      if(Math.abs(t.x-x)<9&&Math.abs((t.y-5)-y)<9){
        if(t===me&&i<26) return {x:x,y:y,hit:'self'};
        return {x:x,y:y,hit:t};
      }
    }
  }
  return {x:x,y:y,hit:null};
}
function aiPickWeapon(me,target,dist){
  const inv=me.inv;
  const has=id=>inv[id]>0;
  const skill=opts.ai;
  // finisher
  if(target.health<=45&&skill>=2){
    if(dist<170&&has(1)) return 1;
    if(dist<120&&has(0)) return 0;
  }
  if(skill===3&&has(7)&&dist>110) return 7;
  if(skill>=2&&has(6)&&dist>90) return 6;
  if(has(5)&&dist>70&&Math.random()<0.6) return 5;
  if(has(2)&&dist>120) return 2;
  if(has(3)&&dist>150&&skill>=1) return 3;
  if(has(1)) return 1;
  if(has(0)) return 0;
  for(let k=WEAPONS.length-1;k>0;k--) if(has(k)) return k;
  return 0;
}
function aiPlan(me){
  const foes=game.players.filter(p=>p.alive&&p!==me);
  if(!foes.length) return {angle:me.angle,power:me.power,weapon:me.weapon};
  let target=foes[0], bd=1e9;
  for(const f of foes){ const d=Math.abs(f.x-me.x)+f.health*0.6; if(d<bd){ bd=d; target=f; } }
  const dist=Math.abs(target.x-me.x);
  const wpn=aiPickWeapon(me,target,dist);
  const skill=opts.ai;
  const astep=[11,6,3,1.6][skill], pstep=[11,6,3,1.5][skill];
  const right=target.x>me.x;
  const a0=right?4:96, a1=right?88:176;
  let best=null;
  for(let a=a0;a<=a1;a+=astep){
    for(let pw=18;pw<=100;pw+=pstep){
      const r=simShot(me,a,pw);
      let score;
      if(r.hit==='self') score=-5000;
      else if(r.hit&&r.hit!==me&&r.hit!=='terrain') score=3000-dist*0.2;
      else if(r.hit==='terrain'){
        const d=Math.abs(r.x-target.x)+Math.abs(r.y-(target.y-5))*0.8;
        score=-d;
      } else score=-2000;
      if(!best||score>best.score) best={score:score,angle:a,power:pw};
      if(best.score>2500) break;
    }
    if(best.score>2500) break;
  }
  /* Deliberate aiming error, tuned so the hit rate per skill level is roughly
     EASY ~50%, NORMAL ~70%, HARD ~85%, DEADLY ~95%. */
  const errA=[15,7,3,1.4][skill];
  const errP=[17,8,3.5,1.4][skill];
  let ang=best.angle, pw=best.power;
  if(skill===0&&Math.random()<0.20){          // rookie occasionally misreads the range
    ang+=rnum(-30,30); pw+=rnum(-26,26);
  } else if(skill===1&&Math.random()<0.08){
    ang+=rnum(-9,9);  pw+=rnum(-10,10);
  } else {
    ang+=rnum(-errA,errA); pw+=rnum(-errP,errP);
  }
  ang=clamp(ang,1,179); pw=clamp(pw,10,100);
  return {angle:ang,power:pw,weapon:wpn};
}
function aiStep(){
  const me=game.players[game.cur];
  if(!me.alive){ nextTurn(); return; }
  if(game.aiTimer>0){ game.aiTimer--; return; }
  if(!game.aiPlan){ game.aiPlan=aiPlan(me); game.aiTimer=18; return; }
  const p=game.aiPlan;
  me.weapon=p.weapon;
  let done=true;
  if(Math.abs(me.angle-p.angle)>0.6){ me.angle=clamp(me.angle+clamp(p.angle-me.angle,-1.7,1.7),0,180); done=false; }
  if(Math.abs(me.power-p.power)>0.6){ me.power=clamp(me.power+clamp(p.power-me.power,-1.7,1.7),0,100); done=false; }
  if(!done){ game.aiTimer=1; return; }
  me.angle=p.angle; me.power=p.power;
  game.aiPlan=null;
  fire(me);
}
/* ---- the computer does its own shopping, no human help required ---- */
function shopFind(f){ for(let i=0;i<SHOP.length;i++) if(f(SHOP[i])) return i; return -1; }
function aiBuy(t,f,n){
  const id=shopFind(f); if(id<0) return 0;
  const it=SHOP[id]; let k=0;
  while(k<n&&t.money>=it.price){ t.money-=it.price; applyShop(t,it); k++; }
  return k;
}
function aiShop(t){
  const skill=opts.ai, keep= skill>=2?120: skill>=1?80:45;   // cash reserve the CPU tries to keep
  const rich=t.money;
  // survival gear first
  if(t.fuel<50) aiBuy(t,s=>s.t==='fuel',1);
  if(t.paras<1&&t.money>keep+260) aiBuy(t,s=>s.t==='para',1);
  if(t.health<t.maxHealth*0.8&&t.money>keep+220) aiBuy(t,s=>s.t==='rep',1);
  if(t.shieldType===0&&t.money>keep+900) aiBuy(t,s=>s.t==='sh2',1);
  else if(t.shieldType===0&&t.money>keep+320) aiBuy(t,s=>s.t==='sh1',1);
  // then ordnance - better computers buy bigger guns
  const wish= skill===3?[6,4,3,1,1,10,5]: skill===2?[5,4,1,1,10,1]: skill===1?[1,1,4,0,0]:[1,0,0];
  for(const wid of wish){
    if(t.money<=keep+30) break;
    aiBuy(t,s=>s.t==='w'&&s.id===wid, t.money>keep+1200?3:1);
  }
  // spend whatever is left over on plain missiles rather than sit on cash
  let guard=0;
  while(t.money>keep+SHOP[0].price && guard++<40) aiBuy(t,s=>s.t==='w'&&s.id===1,1);
  t.money=Math.round(t.money);
  if(t.money!==rich) SFX.cash();
}

/* ============================================================
   SHOP HELPERS
   ============================================================ */
function applyShop(t,it){
  switch(it.t){
    case 'w': t.inv[it.id]=(t.inv[it.id]===Infinity?Infinity:t.inv[it.id]+1); break;
    case 'fuel': t.fuel=Math.min(400,t.fuel+50); break;
    case 'para': t.paras++; break;
    case 'batt': t.health=Math.min(300,t.health+25); t.maxHealth=Math.max(t.maxHealth,t.health); break;
    case 'rep': t.health=t.maxHealth; break;
    case 'sh1': t.shieldType=1; t.shieldMax=60; t.shieldHP=60; break;
    case 'sh2': t.shieldType=2; t.shieldMax=150; t.shieldHP=150; break;
    case 'sh3': t.shieldType=3; t.shieldMax=320; t.shieldHP=320; break;
  }
}
function invLabel(t,it){
  switch(it.t){
    case 'w': return t.inv[it.id]===Infinity?'INF':String(t.inv[it.id]);
    case 'fuel': return String(Math.round(t.fuel));
    case 'para': return String(t.paras);
    case 'batt': case 'rep': return String(Math.round(t.health))+'HP';
    case 'sh1': case 'sh2': case 'sh3': return t.shieldType?t.shieldHP+'/'+t.shieldMax:'0';
  }
  return '';
}

/* ============================================================
   INPUT
   ============================================================ */
const keys={};
const mouse={x:0,y:0,down:false,drag:false,hoverId:null,sx:0,sy:0};
let hotspots=[];
function hspot(id,x,y,w,h,act,defer){ hotspots.push({id:id,x:x,y:y,w:w,h:h,act:act,defer:!!defer}); }
function viewH(){
  let h=window.innerHeight;
  if(window.visualViewport) h=Math.min(h,window.visualViewport.height);
  const tb=document.getElementById('topbar'), pd=document.getElementById('pad');
  h-=(tb?tb.offsetHeight:0)+(pd?pd.offsetHeight:0);
  h-=18;
  return h;
}
function fitCanvas(){
  const wasMob=MOB;
  MOB = (window.innerWidth<=760) || (isTouch && window.innerWidth<=1180);
  if(MOB!==wasMob) document.body.classList.toggle('mob',MOB);
  const nd=Math.min(2,window.devicePixelRatio||1);
  if(nd!==RS){ RS=nd||1; cv.width=Math.round(W*RS); cv.height=Math.round(H*RS);
    ctx.imageSmoothingEnabled=false; ctx.textBaseline='top'; }
  let s=Math.min((window.innerWidth-14)/W, viewH()/H);
  if(s>=1) s=Math.floor(s*2)/2;
  s=Math.max(0.28,s);
  cv.style.width=Math.round(W*s)+'px'; cv.style.height=Math.round(H*s)+'px';
  const sw=document.getElementById('startwrap');
  if(sw){ const hh=cv.getBoundingClientRect().height; sw.style.top=Math.round(hh*0.71)+'px'; }
}
let rszT=null;
function fitSoon(){ if(rszT) clearTimeout(rszT); rszT=setTimeout(()=>{rszT=null;fitCanvas();},120); }
window.addEventListener('resize',fitSoon);
window.addEventListener('orientationchange',()=>{ setTimeout(fitCanvas,320); });
if(window.visualViewport) window.visualViewport.addEventListener('resize',fitSoon);
document.addEventListener('fullscreenchange',fitSoon);
document.addEventListener('webkitfullscreenchange',fitSoon);
function canvasPos(e){
  const r=cv.getBoundingClientRect();
  return {x:(e.clientX-r.left)*W/r.width, y:(e.clientY-r.top)*H/r.height};
}
cv.addEventListener('mousemove',e=>{
  const p=canvasPos(e); mouse.x=p.x; mouse.y=p.y;
  mouse.hoverId=null; let cur=false;
  for(let i=hotspots.length-1;i>=0;i--){ const h=hotspots[i];
    if(p.x>=h.x&&p.x<=h.x+h.w&&p.y>=h.y&&p.y<=h.y+h.h){ mouse.hoverId=h.id; cur=true; break; } }
  cv.style.cursor=cur?'pointer':'crosshair';
  if(mouse.down&&game&&game.state==='play'&&!game.paused&&game.phase==='aim'&&!curPlayer().isAI){
    const t=curPlayer();
    const dx=mouse.x-t.x, dy=(t.y-9)-mouse.y;
    if(Math.hypot(dx,dy)>6){
      mouse.drag=true;
      let a=Math.atan2(dy,dx)*180/Math.PI;
      if(a<0) a=(dx<0?180:0);
      t.angle=clamp(Math.round(a),0,180);
      t.power=clamp(Math.round(Math.hypot(dx,dy)/3.2),5,100);
    }
  }
});
cv.addEventListener('mousedown',e=>{
  audioInit(); if(AC&&AC.state==='suspended') AC.resume();
  const p=canvasPos(e); mouse.x=p.x; mouse.y=p.y; mouse.down=true; mouse.sx=p.x; mouse.sy=p.y;
  for(let i=hotspots.length-1;i>=0;i--){ const h=hotspots[i];
    if(p.x>=h.x&&p.x<=h.x+h.w&&p.y>=h.y&&p.y<=h.y+h.h){ h.act(); return; } }
});
window.addEventListener('mouseup',e=>{
  if(mouse.down&&mouse.drag&&game&&game.state==='play'&&game.phase==='aim'&&!curPlayer().isAI){
    const t=curPlayer();
    const d=Math.hypot(mouse.x-mouse.sx,mouse.y-mouse.sy);
    if(d>8&&Math.hypot(mouse.x-t.x,(t.y-9)-mouse.y)>6) fire(t);
  }
  mouse.down=false; mouse.drag=false;
});
function curPlayer(){ return game.players[game.cur]; }
function cycleWeapon(t,dir){
  for(let k=1;k<=WEAPONS.length;k++){
    const i=(t.weapon+dir*k+WEAPONS.length*4)%WEAPONS.length;
    if(t.inv[i]>0){ t.weapon=i; SFX.blip(); return; }
  }
}
window.addEventListener('keydown',e=>{
  audioInit(); if(AC&&AC.state==='suspended') AC.resume();
  const c=e.code;
  if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Space','Tab','F1','F9','Enter'].includes(c)) e.preventDefault();
  if(keys[c] && !(game && game.state==='shop' && (c==='Enter'||c==='Space'))) return;  /* no auto-repeat */
  keys[c]=true;
  if(c==='F9'){ opts.scan=!opts.scan; screenEl.classList.toggle('crt',opts.scan); return; }
  onKey(c,e);
});
window.addEventListener('keyup',e=>{ keys[e.code]=false; });

function onKey(c,e){
  const st=game.state;
  if(c==='F1'){ if(st==='help'){ game.state=game.prevState||'title'; } else { game.prevState=st; game.state='help'; } return; }
  if(st==='help'){ if(c==='Escape'||c==='Enter'||c==='Space'){ game.state=game.prevState||'title'; } return; }

  if(st==='title'){
    const n=4;
    if(c==='ArrowUp'){ game.menuIdx=(game.menuIdx+n-1)%n; SFX.blip(); }
    if(c==='ArrowDown'){ game.menuIdx=(game.menuIdx+1)%n; SFX.blip(); }
    if(c==='Digit1') pickMenu(0); if(c==='Digit2') pickMenu(1);
    if(c==='Digit3') pickMenu(2); if(c==='Digit4') pickMenu(3);
    if(c==='Enter'||c==='Space') pickMenu(game.menuIdx);
    return;
  }
  if(st==='setup'){
    const n=game.setupStep===0?3:4;
    game.setupIdx=((game.setupIdx%n)+n)%n;
    if(c==='ArrowUp'||c==='ArrowLeft'||c==='KeyQ'){ game.setupIdx=(game.setupIdx+n-1)%n; SFX.blip(); }
    if(c==='ArrowDown'||c==='ArrowRight'||c==='KeyW'){ game.setupIdx=(game.setupIdx+1)%n; SFX.blip(); }
    if(c==='Enter'||c==='Space') setupNext();
    if(c==='Escape'){ game.state='title'; }
    return;
  }
  if(st==='options'){
    const n=6;
    if(c==='ArrowUp'){ game.optIdx=(game.optIdx+n-1)%n; SFX.blip(); }
    if(c==='ArrowDown'){ game.optIdx=(game.optIdx+1)%n; SFX.blip(); }
    if(c==='ArrowLeft'||c==='ArrowRight'||c==='Enter'||c==='Space'){
      const d=(c==='ArrowLeft')?-1:1; cycleOpt(game.optIdx,d);
    }
    if(c==='Escape'){ game.state='title'; }
    return;
  }
  if(st==='shop'){
    const n=SHOP.length;
    if(c==='ArrowUp'){ game.shopIdx=(game.shopIdx+n-1)%n; SFX.blip(); shopFollow(); }
    if(c==='ArrowDown'){ game.shopIdx=(game.shopIdx+1)%n; SFX.blip(); shopFollow(); }
    if(c==='Home') game.shopIdx=0;
    if(c==='End') game.shopIdx=n-1;
    if(/^Digit[0-9]$/.test(c)){ const d=+c.slice(5); if(d>0&&d<=Math.min(9,n)){ game.shopIdx=d-1; shopBuy(1);} return; }
    if(c==='Enter'||c==='Space') shopBuy(e&&e.shiftKey?10:1);
    if(c==='Escape'){ shopDone(); }
    return;
  }
  if(st==='roundend'){
    if(c==='Enter'||c==='Space'||c==='Escape'){ afterRound(); }
    return;
  }
  if(st==='over'){
    if(c==='Enter'||c==='Space'){ game.state='title'; game.menuIdx=0; newDemo(); }
    return;
  }
  if(st!=='play') return;

  // ---- in game ----
  const t=curPlayer();
  if(c==='Escape'){
    if(game.showW) game.showW=false;
    else game.paused=!game.paused;
    SFX.blip(); return;
  }
  if(game.paused){
    if(c==='KeyM'){ game.state='title'; game.menuIdx=0; newDemo(); }
    if(c==='KeyR'){ game.round--; if(game.round<1) game.round=1; startRound(); }
    return;
  }
  if(game.showW){
    if(c==='Tab'||c==='Escape'||c==='KeyI'){ game.showW=false; return; }
    if(/^Digit[0-9]$/.test(c)){ const d=+c.slice(5); if(d>0&&d<=WEAPONS.length){ if(t.inv[d-1]>0){t.weapon=d-1;SFX.pick();} } return; }
    if(c==='ArrowDown'||c==='KeyW'){ cycleWeapon(t,1); return; }
    if(c==='ArrowUp'||c==='KeyQ'){ cycleWeapon(t,-1); return; }
    return;
  }
  if(game.phase!=='aim'||t.isAI) return;
  if(c==='KeyQ'||c==='PageUp') cycleWeapon(t,-1);
  if(c==='KeyW'||c==='PageDown'||c==='KeyE') cycleWeapon(t,1);
  if(c==='Tab'||c==='KeyI'){ game.showW=true; SFX.blip(); }
  if(c==='Space'||c==='Enter') fire(t);
  if(c==='KeyK'){ msg(t.name+" SELF DESTRUCTS",PAL[12]); killTank(t,null,"SELF-DESTRUCTED!"); game.phase='shot'; }
  if(/^Digit[0-9]$/.test(c)){ const d=+c.slice(5); if(d>0&&d<=WEAPONS.length&&t.inv[d-1]>0){ t.weapon=d-1; SFX.pick(); } }
}
function cycleOpt(i,d){
  switch(i){
    case 0: opts.rounds=clamp(opts.rounds+d,1,15); break;
    case 1: opts.wind=WINDS[(WINDS.indexOf(opts.wind)+d+WINDS.length)%WINDS.length]; break;
    case 2: opts.gravity=GRAVS[(GRAVS.indexOf(opts.gravity)+d+GRAVS.length)%GRAVS.length]; break;
    case 3: opts.terrain=TERRS[(TERRS.indexOf(opts.terrain)+d+TERRS.length)%TERRS.length]; break;
    case 4: opts.ai=(opts.ai+d+4)%4; break;
    case 5: opts.sound=!opts.sound; break;
  }
  SFX.blip();
}
function pickMenu(i){
  SFX.pick();
  if(i===0){ game.mode='pvc'; game.setupStep=0; game.setupIdx=0; game.nPlayers=2; game.state='setup'; }
  else if(i===1){ game.mode='pvp'; game.setupStep=0; game.setupIdx=0; game.nPlayers=2; game.state='setup'; }
  else if(i===2){ game.optIdx=0; game.state='options'; }
  else if(i===3){ game.prevState='title'; game.state='help'; }
}
function setupNext(){
  SFX.pick();
  if(game.setupStep===0){
    game.nPlayers=2+game.setupIdx;      // 2..4
    if(game.mode==='pvp'){ startMatch(); }
    else { game.setupStep=1; game.setupIdx=opts.ai; }
  } else {
    opts.ai=game.setupIdx; startMatch();
  }
}
function startMatch(){
  const n=game.nPlayers;
  const g=blank();
  g.demo=false; g.state='shop'; g.mode=game.mode; g.round=1; g.shopWho=0; g.shopIdx=0; g.shopT=0; g.shopScroll=0; g.firstShop=true;
  for(let i=0;i<n;i++){
    const nm= game.mode==='pvp'? 'PLAYER '+(i+1) : (i===0?'YOU':'CPU '+i);
    const ai= game.mode==='pvp'? false : (i>0);
    g.players.push(mkTank(i,nm,ai));
  }
  game=g; game.state='shop';
  msg('WELCOME TO SCORCHED EARTH',PAL[14]);
}
function respawnDemoTank(t){
  let x=rint(30,W-30);
  for(let k=0;k<80;k++){
    const tx=clamp(x+rint(-70,70),24,W-24);
    const y=surfaceTop(tx);
    if(y!==null&&y>H*0.35&&y<H-26){ x=tx; break; }
  }
  flatten(x,9);
  const y=surfaceTop(x);
  t.x=x; t.y=(y===null)?H-40:y; t.vy=0; t.vx=0; t.falling=false;
  t.angle=t.x<W/2?rint(40,70):rint(110,140); t.power=rint(45,78);
  t.health=99999;
}
function newDemo(){
  const g=blank(); g.demo=true; g.state='title'; g.menuIdx=0;
  const names=['SCORCH','MELTDOWN','WARHEAD'];
  for(let i=0;i<3;i++) g.players.push(mkTank(i,names[i],true));
  game=g;
  genTerrain(opts.terrain);
  const sp=[110,W/2,W-110];
  game.players.forEach((t,i)=>{ t.x=sp[i]; flatten(t.x,9); const g2=surfaceTop(t.x); t.y=g2===null?H-40:g2;
    t.angle=t.x<W/2?rint(40,70):rint(110,140); t.power=rint(45,75); t.health=99999; });
  newWind(); setGrav();
  game.demoT=90;
}
function shopBuy(n){
  const t=game.players[game.shopWho];
  if(t.isAI) return;                       /* the computer shops for itself */
  const it=SHOP[game.shopIdx];
  let k=0;
  while(k<n&&t.money>=it.price){ t.money-=it.price; applyShop(t,it); k++; }
  if(k>0){ SFX.cash(); } else SFX.err();
}
function shopAdvance(){
  SFX.blip();
  game.shopWho++;
  game.shopIdx=0;
  game.shopT=0;
  game.shopScroll=0;
  if(game.shopWho>=game.players.length){
    if(game.firstShop){ game.firstShop=false; startRound(); }
    else { game.round++; startRound(); }
  }
}
function shopDone(){ shopAdvance(); }
/* the CPU shopper: thinks for a moment, buys, shows you what it got, moves on */
function shopAIStep(){
  const t=game.players[game.shopWho];
  game.shopT=(game.shopT||0)+1;
  if(game.shopT===22){ aiShop(t); }
  if(game.shopT>=92){ shopAdvance(); }
}
function afterRound(){
  if(game.round>=opts.rounds){ game.state='over'; SFX.win(); }
  else { game.state='shop'; game.shopWho=0; game.shopIdx=0; game.shopT=0; game.shopScroll=0; }
}

/* ============================================================
   UPDATE
   ============================================================ */
function step(){
  game.time++;
  if(game.shake>0) game.shake*=0.88;
  if(game.flashT>0) game.flashT--;
  for(let i=game.log.length-1;i>=0;i--){ game.log[i].n--; if(game.log[i].n<=0) game.log.splice(i,1); }
  for(let i=game.dots.length-1;i>=0;i--){ game.dots[i].a-=0.0022; if(game.dots[i].a<=0) game.dots.splice(i,1); }
  if(game.timers.length){ for(let i=game.timers.length-1;i>=0;i--){ const t=game.timers[i]; t.n--; if(t.n<=0){ game.timers.splice(i,1); t.fn(); } } }
  for(let i=game.booms.length-1;i>=0;i--){ game.booms[i].age++; if(game.booms[i].age>=game.booms[i].max) game.booms.splice(i,1); }

  if(game.state==='title'){
    // demo fireworks
    game.demoT--;
    if(game.demoT<=0){
      const t=game.players[rint(0,game.players.length-1)];
      const a=rnum(25,155), v=rnum(4,9.6);
      spawnProj(t.x+Math.cos(a*Math.PI/180)*12,t.y-9-Math.sin(a*Math.PI/180)*12,
        Math.cos(a*Math.PI/180)*v,-Math.sin(a*Math.PI/180)*v,
        {name:'MISSILE',dmg:22,rad:19,kind:'single'},t,{skip:14});
      game.demoT=rint(70,150);
    }
    updateProjectiles(); updateParticles(); updateTanks(); updateFires();
    flushTerrain();
    if(Math.random()<0.004) newWind();
    return;
  }
  if(game.state==='shop'){
    const t=game.players[game.shopWho];
    if(t&&t.isAI) shopAIStep();
    return;
  }
  if(game.state!=='play') return;
  if(game.paused) return;

  if(game.phase==='aim'){
    if(game.pt>0) game.pt--;
    const t=curPlayer();
    if(!t.isAI){
      /* Dialling: exactly +/-1 per tick, but only every Nth frame so the rate is
         roughly half of what it was. Every value you land on is a whole number. */
      const fine=keys['ShiftLeft']||keys['ShiftRight'];
      if(game.time% (fine?8:2) ===0){
        if(keys['ArrowLeft'])  t.angle=clamp(Math.round(t.angle)+1,0,180);
        if(keys['ArrowRight']) t.angle=clamp(Math.round(t.angle)-1,0,180);
        if(keys['ArrowUp'])    t.power=clamp(Math.round(t.power)+1,0,100);
        if(keys['ArrowDown'])  t.power=clamp(Math.round(t.power)-1,0,100);
      }
      if(keys['Comma']&&t.fuel>0) t.drive=-1;
      if(keys['Period']&&t.fuel>0) t.drive=1;
    }
  } else if(game.phase==='ai'){
    if(game.pt>0) game.pt--;
    else aiStep();
  } else if(game.phase==='shot'){
    game.shotFrames++;
    if(allSettled()&&game.shotFrames>25){ game.phase='resolve'; game.pt=34; }
  } else if(game.phase==='resolve'){
    game.pt--;
    if(game.pt<=0){
      if(aliveCount()<=1){ endRound(); }
      else { nextTurn(); }
    }
  }
  updateProjectiles(); updateParticles(); updateTanks(); updateFires();
  flushTerrain();
}
function endRound(){
  const alive=game.players.filter(p=>p.alive);
  game.winner=alive.length?alive[0]:null;
  if(game.winner){ game.winner.won++; game.winner.money+=1000; }
  game.state='roundend';
  SFX.win();
}

/* ============================================================
   RENDER
   ============================================================ */
function drawTank(t){
  if(!t.alive) return;
  const x=Math.round(t.x), y=Math.round(t.y);
  const active=game.players[game.cur]===t&&game.state==='play';
  if(t.shieldHP>0){
    const col= t.shieldType===1?PAL[11]: t.shieldType===2?PAL[9]:PAL[15];
    ctx.globalAlpha=0.16+ (t.flash>0?0.2:0);
    ctx.fillStyle=col; ctx.beginPath(); ctx.arc(x,y-4,16,Math.PI,Math.PI*2); ctx.fill();
    ctx.globalAlpha=0.75; ctx.strokeStyle=col; ctx.lineWidth=1;
    ctx.beginPath(); ctx.arc(x,y-4,16,Math.PI,Math.PI*2); ctx.stroke();
    ctx.globalAlpha=1;
  }
  // treads
  ctx.fillStyle='#555555'; ctx.fillRect(x-9,y-4,18,4);
  ctx.fillStyle='#000000';
  for(let i=-8;i<9;i+=3) ctx.fillRect(x+i,y-3,1,2);
  // hull
  ctx.fillStyle=t.flash>0?'#FFFFFF':t.color; ctx.fillRect(x-7,y-8,14,4);
  ctx.fillStyle='#000000'; ctx.fillRect(x-7,y-5,14,1);
  // turret
  ctx.fillStyle= t.flash>0?'#FFFFFF':'#AAAAAA'; ctx.fillRect(x-3,y-11,6,3);
  // barrel
  const a=t.angle*Math.PI/180;
  ctx.strokeStyle=t.flash>0?'#FFFFFF':'#AAAAAA'; ctx.lineWidth=2;
  ctx.beginPath(); ctx.moveTo(x,y-10);
  ctx.lineTo(x+Math.cos(a)*12,y-10-Math.sin(a)*12); ctx.stroke();
  // label
  const lbl=t.name+' '+Math.max(0,Math.round(t.health));
  fnt(12); const lw=ctx.measureText(lbl).width;
  txt(lbl,x-lw/2,y-30,t.color,12);
  if(active&&Math.floor(game.time/16)%2===0){
    ctx.fillStyle=PAL[14];
    ctx.fillRect(x-5,y-44,11,2); ctx.fillRect(x-4,y-42,9,2);
    ctx.fillRect(x-3,y-40,7,2); ctx.fillRect(x-2,y-38,5,2);
  }
}
function drawProj(p){
  const x=Math.round(p.x), y=Math.round(p.y);
  ctx.fillStyle=PAL[14];
  const s= p.spec&&p.spec.nuke?4:3;
  ctx.fillRect(x-((s/2)|0),y-((s/2)|0),s,s);
  ctx.fillStyle='#FFFFFF'; ctx.fillRect(x-1,y-1,1,1);
}
function drawPlay(){
  const shx=game.shake>0.4?Math.round(rnum(-game.shake,game.shake)):0;
  const shy=game.shake>0.4?Math.round(rnum(-game.shake,game.shake)):0;
  ctx.drawImage(skyCv,0,0);
  ctx.save(); ctx.translate(shx,shy);
  ctx.drawImage(terrCv,0,0);
  // trajectory dots
  for(const d of game.dots){
    ctx.globalAlpha=Math.min(1,d.a);
    ctx.fillStyle=PAL[7]; ctx.fillRect(d.x|0,d.y|0,1,1);
  }
  ctx.globalAlpha=1;
  // fires
  for(const f of game.fires){
    const c=[PAL[4],PAL[6],PAL[14],PAL[15]][(f.tick+f.life)%4];
    ctx.fillStyle=c; ctx.fillRect(f.x|0,f.y|0,3,3);
    if((f.tick&3)===0) ctx.fillStyle=PAL[12], ctx.fillRect((f.x|0)+1,(f.y|0)-1,1,1);
  }
  for(const t of game.players) drawTank(t);
  for(const p of game.projs) drawProj(p);
  // particles
  for(const p of game.parts){ ctx.fillStyle=p.c; ctx.globalAlpha=clamp(p.life/40,0.15,1); ctx.fillRect(p.x|0,p.y|0,p.b,p.b); }
  ctx.globalAlpha=1;
  // explosions
  for(const b of game.booms){
    const ph=b.age/b.max, r=b.r*(0.4+0.8*ph);
    ctx.globalAlpha=clamp(1-ph*0.85,0,1);
    ctx.fillStyle= b.nuke?PAL[14]:PAL[12]; ctx.beginPath(); ctx.arc(b.x,b.y,r,0,7); ctx.fill();
    ctx.fillStyle=PAL[15]; ctx.beginPath(); ctx.arc(b.x,b.y,r*0.6,0,7); ctx.fill();
    ctx.globalAlpha=clamp(1-ph,0,1)*0.5; ctx.strokeStyle=PAL[4]; ctx.lineWidth=1;
    ctx.beginPath(); ctx.arc(b.x,b.y,r*1.2,0,7); ctx.stroke();
    ctx.globalAlpha=1;
  }
  ctx.restore();
  drawHUD();
  drawLog();
  if(game.showW) drawWeaponPanel();
  if(game.pt>0&&game.phase!=='shot'&&game.phase!=='resolve') drawTurnBanner();
  if(game.paused) drawPause();
}
/* ---- ANG/PWR numbers in the top bar, power meter beside the canvas ---- */
const gvA=document.getElementById('gvA'), gvP=document.getElementById('gvP'),
      gaugeTop=document.getElementById('gaugeTop'), meterV=document.getElementById('meterV'),
      mvBar=document.getElementById('mvBar');
const GSEG=26;
for(let i=0;i<GSEG;i++) mvBar.appendChild(document.createElement('i'));
const mvCells=Array.prototype.slice.call(mvBar.children);
let gShown='', gVisible=null;
function updateGauge(){
  const on = MOB && padWanted && game.state==='play' && !game.paused;
  if(on!==gVisible){
    gVisible=on;
    gaugeTop.style.display = on?'flex':'none';
    meterV.style.display   = on?'block':'none';
    if(on) fitSoon();
  }
  if(!on){ gShown=''; return; }
  const t=curPlayer(); if(!t) return;
  const key=Math.round(t.angle)+':'+Math.round(t.power)+':'+t.idx;
  if(key!==gShown){
    gShown=key;
    gvA.textContent=pad(Math.round(t.angle),3);
    gvP.textContent=pad(Math.round(t.power),3);
    const lit=Math.round(t.power/100*GSEG);
    for(let i=0;i<GSEG;i++){
      const c = i<lit ? (i>=GSEG-3?'#FF5555': i>=GSEG*0.55?'#FFFF55':'#55FF55') : '#242424';
      if(mvCells[i].style.background!==c) mvCells[i].style.background=c;
    }
  }
}
function drawHUD(){
  ctx.fillStyle='#000'; ctx.fillRect(0,0,W,36);
  ctx.fillStyle=PAL[8]; ctx.fillRect(0,36,W,1);
  const t=game.players[game.cur];
  txt(t.name+(t.isAI?' [CPU]':''),4,2,t.color,16);
  txt('ANG '+pad(Math.round(t.angle),3)+'  PWR '+pad(Math.round(t.power),3),150,2,PAL[15]);
  const wl=Math.abs(game.wind);
  const wc= wl>14?PAL[12]: wl>7?PAL[14]:PAL[10];
  txt('WIND '+ (game.wind<0?'<':'>')+' '+pad(Math.round(game.wind),3),330,2,wc);
  txt('$'+pad(t.money,5),462,2,PAL[14]);
  txt('RD '+game.round+'/'+opts.rounds,570,2,PAL[8]);
  const w=WEAPONS[t.weapon];
  const cnt=t.inv[t.weapon]===Infinity?'INF':t.inv[t.weapon];
  txt('WEAPON: '+w.name+(cnt!=='INF'?' x'+cnt:''),4,20,PAL[14]);
  txt('FUEL '+pad(Math.round(t.fuel),3),336,20,PAL[11]);
  if(t.shieldType) txt('SHLD '+t.shieldHP,410,20,PAL[9]);
  if(t.paras) txt('PARA '+t.paras,488,20,PAL[10]);
  // player list
  let bx=4;
  for(const p of game.players){
    const nm=(p.idx+1)+':'+p.name.slice(0,7);
    txt(nm,bx,H-15,p.alive?p.color:PAL[8],16);
    barCells(bx+66,H-13,7,p.health/100,p.alive?PAL[10]:'#242424');
    if(!p.alive) txt('DEAD',bx+66,H-15,PAL[4],16);
    bx+=152;
  }
}
function drawLog(){
  let y=H-40;
  for(let i=game.log.length-1;i>=0&&y>44;i--){
    const m=game.log[i];
    ctx.globalAlpha=clamp(m.n/90,0.25,1);
    txt(m.t,4,y,m.c,16);
    ctx.globalAlpha=1; y-=16;
  }
}
function drawTurnBanner(){
  const t=game.players[game.cur];
  const s1=(t.isAI? t.name+' (COMPUTER) IS TAKING A SHOT':'YOUR TURN, '+t.name);
  const s2= game.mode==='pvp'&&game.cur>0? 'PASS THE KEYBOARD TO '+t.name : (t.isAI?'':'FIRE WITH SPACE');
  fnt(16);
  const w=Math.max(ctx.measureText(s1).width,ctx.measureText(s2).width)+40;
  const x=Math.round((W-w)/2), y=58;
  ctx.fillStyle='#000'; ctx.fillRect(x,y,w,42);
  ctx.strokeStyle=PAL[8]; ctx.strokeRect(x+0.5,y+0.5,w-1,41);
  ctext(s1,4.2,t.color);
  ctext(s2,6.6,PAL[7],14);
}
function drawPause(){
  ctx.fillStyle='rgba(0,0,0,0.72)'; ctx.fillRect(0,0,W,H);
  ctextS('PAUSED',6,PAL[14],40);
  ctext('ESC  RESUME',10,PAL[15]);
  ctext('R    RESTART ROUND',11.4,PAL[15]);
  ctext('M    MAIN MENU',12.8,PAL[15]);
}
function drawWeaponPanel(){
  const c0=6, r0=3, rows=WEAPONS.length+3;
  const c1=30;
  box(c0,r0,c1,r0+rows+1,PAL[7],true);
  cell(' WEAPON SELECT        AMMO',c0+1,r0+1,PAL[14]);
  WEAPONS.forEach((w,i)=>{
    const r=r0+2+i;
    const sel=curPlayer().weapon===i;
    const have=curPlayer().inv[i]>0;
    const col= sel?PAL[14]: have?PAL[15]:PAL[8];
    const line=' '+(i+1<10?' ':'')+(i+1)+' '+w.name;
    if(sel){ ctx.fillStyle='#552200'; ctx.fillRect(c0*CHW+2,(r*CHH)+1,(c1-c0)*CHW-4,CHH-1); }
    cell(line,c0+1,r,col);
    const q=curPlayer().inv[i]===Infinity?'INF':String(curPlayer().inv[i]);
    cell(have?q:'--',c1-5,r,have?PAL[11]:PAL[8]);
    hspot('wp'+i,c0*CHW+2,r*CHH+1,(c1-c0)*CHW-4,CHH-1,()=>{ if(curPlayer().inv[i]>0){curPlayer().weapon=i;game.showW=false;SFX.pick();} else SFX.err(); });
  });
  cell(' CLICK OR PRESS 1-9 · TAB CLOSES',c0+1,r0+rows,PAL[8],14);
}
/* ---------- title ---------- */
function drawTitle(){
  drawPlayLikeDemo();
  // title
  fnt(58); const tw=ctx.measureText('SCORCHED EARTH').width;
  txt('SCORCHED EARTH',Math.round((W-tw)/2)+4,26,'#AA0000',58);
  txt('SCORCHED EARTH',Math.round((W-tw)/2),22,PAL[14],58);
  ctext('THE ULTIMATE ARTILLERY GAME  ·  DOS TRIBUTE EDITION',7.2,PAL[11]);
  box(22,9.6,58,17.4,PAL[7],true);
  const items=['PLAY VS. COMPUTER','PLAY VS. PLAYER','OPTIONS','HOW TO PLAY'];
  items.forEach((s,i)=>{
    const sel=game.menuIdx===i;
    const r=10.8+i*1.5;
    if(sel){ ctx.fillStyle='#552200'; ctx.fillRect(23*CHW,r*CHH+1,34*CHW,CHH-1); }
    cell((i+1)+'.  '+s,25,r, sel?PAL[14]:PAL[15]);
    hspot('m'+i,23*CHW,r*CHH+1,34*CHW,CHH-1,()=>{ game.menuIdx=i; pickMenu(i); });
    if(sel&&Math.floor(game.time/18)%2===0) cell('█',24,r,PAL[10]);
  });
  if(Math.floor(game.time/22)%2===0) ctext('TAP A MENU ITEM OR PRESS A START BUTTON BELOW',21.1,PAL[15],14);
  ctext('© 1991 ORIGINAL GAME BY WENDELL HICKEN  ·  FREE BROWSER TRIBUTE',22.5,PAL[8],14);
  ctext('ARROWS + ENTER · CLICK OR TAP ITEMS · DOUBLE-TAP SCREEN FOR FULL SCREEN',23.8,PAL[8],13);
}
function drawPlayLikeDemo(){
  ctx.drawImage(skyCv,0,0);
  ctx.drawImage(terrCv,0,0);
  for(const d of game.dots){ ctx.globalAlpha=Math.min(1,d.a); ctx.fillStyle=PAL[7]; ctx.fillRect(d.x|0,d.y|0,1,1); }
  ctx.globalAlpha=1;
  for(const t of game.players) drawTank(t);
  for(const p of game.projs) drawProj(p);
  for(const p of game.parts){ ctx.fillStyle=p.c; ctx.globalAlpha=clamp(p.life/40,0.15,1); ctx.fillRect(p.x|0,p.y|0,p.b,p.b); }
  ctx.globalAlpha=1;
  for(const b of game.booms){
    const ph=b.age/b.max, r=b.r*(0.4+0.8*ph);
    ctx.globalAlpha=clamp(1-ph*0.85,0,1);
    ctx.fillStyle=b.nuke?PAL[14]:PAL[12]; ctx.beginPath(); ctx.arc(b.x,b.y,r,0,7); ctx.fill();
    ctx.fillStyle=PAL[15]; ctx.beginPath(); ctx.arc(b.x,b.y,r*0.6,0,7); ctx.fill();
    ctx.globalAlpha=1;
  }
}
/* ---------- setup ---------- */
function drawSetup(){
  ctx.fillStyle='#000'; ctx.fillRect(0,0,W,H);
  ctextS('GAME SETUP',1.5,PAL[14],34);
  if(game.setupStep===0){
    ctext('HOW MANY TANKS WILL DO BATTLE?',4.5,PAL[15]);
    const items=['2 TANKS','3 TANKS','4 TANKS'];
    items.forEach((s,i)=>{
      const sel=game.setupIdx===i, r=6.5+i*1.6;
      if(sel){ ctx.fillStyle='#552200'; ctx.fillRect(30*CHW,r*CHH+1,20*CHW,CHH-1); }
      cell(s,33,r,sel?PAL[14]:PAL[15]);
      hspot('s'+i,30*CHW,r*CHH+1,20*CHW,CHH-1,()=>{game.setupIdx=i;setupNext();});
    });
    ctext(game.mode==='pvp'?'( HOT-SEAT: EVERY TANK IS HUMAN )':'( TANK 1 IS YOU, THE REST ARE COMPUTERS )',13,PAL[8],14);
  } else {
    ctext('HOW TOUGH IS THE COMPUTER?',4.5,PAL[15]);
    AILV.forEach((s,i)=>{
      const sel=game.setupIdx===i, r=6.5+i*1.6;
      if(sel){ ctx.fillStyle='#552200'; ctx.fillRect(28*CHW,r*CHH+1,24*CHW,CHH-1); }
      cell(s,33,r,sel?PAL[14]:PAL[15]);
      hspot('a'+i,28*CHW,r*CHH+1,24*CHW,CHH-1,()=>{game.setupIdx=i;setupNext();});
    });
    ctext('DEADLY AI SEARCHES FINER ANGLES AND BUYS BIGGER GUNS',13,PAL[8],14);
  }
  ctext('ENTER = NEXT     ESC = BACK',16,PAL[7]);
}
/* ---------- options ---------- */
function drawOptions(){
  ctx.fillStyle='#000'; ctx.fillRect(0,0,W,H);
  ctextS('OPTIONS',1.2,PAL[14],34);
  const rows=[
    ['ROUNDS IN A MATCH', String(opts.rounds)],
    ['WIND', opts.wind.toUpperCase()],
    ['GRAVITY', opts.gravity.toUpperCase()],
    ['TERRAIN TYPE', opts.terrain.toUpperCase()],
    ['COMPUTER SKILL', AILV[opts.ai]],
    ['SOUND', opts.sound?'ON':'OFF']
  ];
  rows.forEach((r,i)=>{
    const sel=game.optIdx===i, y=(5+i*1.7)*CHH;
    if(sel){ ctx.fillStyle='#552200'; ctx.fillRect(6*CHW,y+1,68*CHW,CHH-1); }
    cell(r[0],8,5+i*1.7,sel?PAL[14]:PAL[15]);
    cell('< '+r[1]+' >',56,5+i*1.7,sel?PAL[11]:PAL[7]);
    hspot('o'+i,6*CHW,y+1,68*CHW,CHH-1,()=>{ if(i===5) cycleOpt(i,1); else cycleOpt(i,1); });
  });
  ctext('LEFT / RIGHT TO CHANGE     ESC = BACK',17.5,PAL[7]);
  ctext('WIND CHANGES EVERY TURN AND PUSHES YOUR SHELLS',19.5,PAL[8],14);
}
/* ---------- help ---------- */
function drawHelp(){
  ctx.fillStyle='#000'; ctx.fillRect(0,0,W,H);
  ctextS('HOW TO PLAY',0.8,PAL[14],30);
  box(1,2.4,79,24.4,PAL[8],false);
  const L=[
    ['CONTROLS',PAL[14]],
    ['  LEFT / RIGHT ........... AIM THE BARREL',PAL[15]],
    ['  UP / DOWN .............. POWER (SHIFT = FINE)',PAL[15]],
    ['  Q / W or PAGE UP/DN .... PREVIOUS / NEXT WEAPON',PAL[15]],
    ['  TAB .................... WEAPON LIST (CLICK OR 1-9)',PAL[15]],
    ['  COMMA / PERIOD ......... DRIVE TANK (BURNS FUEL)',PAL[15]],
    ['  SPACE or ENTER ......... FIRE!',PAL[15]],
    ['  MOUSE .................. DRAG FROM TANK TO AIM, RELEASE TO FIRE',PAL[15]],
    ['  K ...................... SELF DESTRUCT   ESC = PAUSE',PAL[15]],
    ['',PAL[15]],
    ['TIPS',PAL[14]],
    ['  * TERRAIN IS DESTRUCTIBLE - DIG, BURY AND BLAST YOUR ENEMIES.',PAL[15]],
    ['  * THE DOTTED LINE SHOWS WHERE YOUR LAST SHOTS WENT.',PAL[15]],
    ['  * WIND CHANGES EACH TURN. HIGH SHOTS DRIFT MORE THAN LOW ONES.',PAL[15]],
    ['  * BUY FUEL, PARACHUTES, SHIELDS AND BIG GUNS IN THE SHOP.',PAL[15]],
    ['  * YOU EARN $3 PER POINT OF DAMAGE, $500 PER KILL, $1000 PER ROUND.',PAL[15]],
    ['',PAL[15]],
    ['WEAPONS  (BUY THEM IN THE SHOP BETWEEN ROUNDS)',PAL[14]],
    ['  BABY MISSILE FREE   MISSILE $30    BIG MISSILE $110   BABY NUKE $330',PAL[10]],
    ['  NUKE $900   FUNKY BOMB $190   MIRV $520   DEATH HEAD $1800',PAL[10]],
    ['  DIGGER $150   DIRT BALL $60   ROLLER $150   NAPALM $210   TRACER $15',PAL[10]]
  ];
  L.forEach((l,i)=>cell(l[0],2,3+i,l[1],i===0||l[0]==='TIPS'||l[0]==='WEAPONS'?16:16));
  ctext('TAP ANYWHERE OR PRESS ANY KEY TO RETURN',23.2,PAL[14]);
  hspot('back',0,0,W,H,()=>{ game.state=game.prevState||'title'; SFX.blip(); });
}
/* ---------- shop ---------- */
/* mobile layout: 2x text, one scrolling column, big tappable buttons */
const SHOP_ROWH=28, SHOP_TOP=118, SHOP_BOT=318,
      SHOP_VIS=Math.floor((SHOP_BOT-SHOP_TOP)/SHOP_ROWH);
function shopRows(){
  const r=[{h:'FOR SALE - WEAPONS AND GEAR',c:PAL[14]}];
  SHOP.forEach((it,i)=>r.push({it:it,idx:i}));
  r.push({h:'AMMO IN YOUR TANK',c:PAL[10]});
  WEAPONS.forEach((w,i)=>r.push({w:w,wid:i}));
  return r;
}
function shopMaxScroll(){ return Math.max(0,shopRows().length-SHOP_VIS); }
function shopFollow(){
  const ri=1+game.shopIdx; let s=game.shopScroll;
  if(ri<s) s=ri;
  if(ri>s+SHOP_VIS-1) s=ri-SHOP_VIS+1;
  game.shopScroll=clamp(s,0,shopMaxScroll());
}
function bigBtn(id,x,y,w,h,label,act,bg,fg,fs){
  ctx.fillStyle=bg||'#000'; ctx.fillRect(x,y,w,h);
  ctx.strokeStyle=PAL[7]; ctx.lineWidth=2; ctx.strokeRect(x+1,y+1,w-2,h-2);
  const p=fs||28; fnt(p);
  txt(label,x+(w-ctx.measureText(label).width)/2,y+(h-p*0.82)/2,fg||PAL[14],p);
  hspot(id,x,y,w,h,act);
}
function drawShopMobile(){
  const t=game.players[game.shopWho];
  game.shopScroll=clamp(game.shopScroll,0,shopMaxScroll());
  const rows=shopRows();
  ctx.fillStyle='#000'; ctx.fillRect(0,0,W,H);
  ctextS('THE SCORCHED EARTH SHOP',0.25,PAL[14],28);
  txt('BUYING FOR: '+t.name,8,42,t.color,20);
  fnt(20); const cs='CASH $'+t.money;
  txt(cs,W-8-ctx.measureText(cs).width,42,PAL[14],20);
  txt('RD '+Math.min(game.round,opts.rounds)+'/'+opts.rounds+'   ARMOR '+Math.round(t.health)+
      '   SHIELD '+(t.shieldType?t.shieldHP+'/'+t.shieldMax:'NONE')+
      '   FUEL '+Math.round(t.fuel)+'   PARA '+t.paras,8,68,PAL[7],18);
  box(0.2,5.4,79.8,21.4,PAL[7],true);
  txt('#  ITEM                        PRICE   HAVE',16,96,PAL[14],20);
  for(let v=0;v<SHOP_VIS;v++){
    const ri=game.shopScroll+v;
    if(ri>=rows.length) break;
    const row=rows[ri], y=SHOP_TOP+v*SHOP_ROWH;
    if(row.h){ txt(row.h,20,y,row.c,24); continue; }
    if(row.it){
      const it=row.it, sel=game.shopIdx===row.idx&&!t.isAI, afford=t.money>=it.price;
      if(sel){ ctx.fillStyle='#552200'; ctx.fillRect(10,y-3,606,SHOP_ROWH-1); }
      const col= sel?PAL[14]: afford?PAL[15]:PAL[8];
      txt(pad(row.idx+1,2),20,y,col,24);
      txt(it.name.slice(0,26),60,y,col,24);
      txt('$'+it.price,396,y,afford?PAL[10]:PAL[8],24);
      txt(invLabel(t,it),540,y,PAL[11],24);
      hspot('sr'+row.idx,10,y-3,606,SHOP_ROWH-1,()=>{
        if(game.players[game.shopWho].isAI){ SFX.err(); return; }
        game.shopIdx=row.idx; shopBuy(1);
      },true);
    } else {
      const q=t.inv[row.wid], col=q>0?PAL[15]:PAL[8];
      txt(pad(row.wid+1,2),20,y,col,24);
      txt(row.w.name.slice(0,26),60,y,col,24);
      txt('AMMO',396,y,PAL[8],24);
      txt(q===Infinity?'INF':(q>0?String(q):'-'),540,y,q>0?PAL[11]:PAL[8],24);
      hspot('sw'+row.wid,10,y-3,606,SHOP_ROWH-1,()=>{
        const tt=game.players[game.shopWho];
        if(tt.inv[row.wid]>0){ tt.weapon=row.wid; SFX.pick(); } else SFX.err();
      },true);
    }
  }
  /* scroll rail */
  const tot=rows.length, ms=Math.max(1,tot-SHOP_VIS);
  ctx.fillStyle='#333333'; ctx.fillRect(622,SHOP_TOP,8,SHOP_BOT-SHOP_TOP);
  const th=Math.max(20,(SHOP_BOT-SHOP_TOP)*SHOP_VIS/tot);
  const ty=SHOP_TOP+(SHOP_BOT-SHOP_TOP-th)*(game.shopScroll/ms);
  ctx.fillStyle=PAL[14]; ctx.fillRect(622,ty,8,th);
  txt((game.shopScroll+1)+'-'+Math.min(tot,game.shopScroll+SHOP_VIS)+' OF '+tot+'   DRAG LIST TO SCROLL',
      16,326,PAL[8],18);
  /* action buttons */
  if(t.isAI){
    const dots='.'.repeat(1+(Math.floor(game.time/8)%3));
    const done=(game.shopT||0)>=22;
    txt('COMPUTER IS SHOPPING'+dots,16,352,PAL[11],26);
    txt(done?'ORDER FILLED - $'+t.money:'BROWSING THE CATALOG...',16,382,done?PAL[10]:PAL[8],22);
  } else {
    bigBtn('mb1',8,346,198,46,'BUY ONE',()=>{ game.players[game.shopWho].isAI?SFX.err():shopBuy(1); },'#003300',PAL[10]);
    bigBtn('mb10',214,346,198,46,'BUY TEN',()=>{ game.players[game.shopWho].isAI?SFX.err():shopBuy(10); },'#003300',PAL[10]);
    bigBtn('mbdone',420,346,212,46,'DONE / NEXT',()=>{ shopAdvance(); },'#552200',PAL[14]);
  }
}
function drawShop(){
  if(MOB){ drawShopMobile(); return; }
  ctx.fillStyle='#000'; ctx.fillRect(0,0,W,H);
  const t=game.players[game.shopWho];
  ctextS('THE SCORCHED EARTH SHOP',0.6,PAL[14],30);
  txt('BUYING FOR: '+t.name,8,3.2*CHH,t.color);
  txt('CASH: $'+t.money,54*CHW,3.2*CHH,PAL[14]);
  txt('ROUND '+Math.min(game.round,opts.rounds)+' OF '+opts.rounds+'   ARMOR '+Math.round(t.health)+
      '   SHIELD '+(t.shieldType?t.shieldHP+'/'+t.shieldMax:'NONE')+'   FUEL '+Math.round(t.fuel)+'   PARACHUTES '+t.paras,
      8,4.1*CHH,PAL[7],14);
  box(1,4.8,50,24.2,PAL[7],true);
  cell(' #  ITEM                      PRICE   HAVE',2,5.4,PAL[14],14);
  const rows=SHOP.length;          /* all 19 items fit now */
  for(let i=0;i<rows;i++){
    const it=SHOP[i], r=6.1+i*0.88;
    const sel=game.shopIdx===i&&!t.isAI;
    const afford=t.money>=it.price;
    if(sel){ ctx.fillStyle='#552200'; ctx.fillRect(2*CHW,r*CHH+1,47*CHW,CHH-2); }
    const col= sel?PAL[14]: afford?PAL[15]:PAL[8];
    const line=' '+pad(i+1,2)+'  '+it.name+ ' '.repeat(Math.max(1,25-it.name.length))+'$'+pad(it.price,5)+'   '+invLabel(t,it);
    cell(line,2,r,col,14);
    hspot('b'+i,2*CHW,r*CHH+1,47*CHW,CHH-2,()=>{ game.shopIdx=i; shopBuy(1); });
  }
  cell('ENTER=BUY  SHIFT+ENTER=BUY 10  1-9=QUICK',2,22.95,PAL[8],14);
  box(52,4.8,79,24.2,PAL[7],true);
  cell('INVENTORY',53,5.4,PAL[14],14);
  WEAPONS.forEach((w,i)=>{
    const q=t.inv[i]===Infinity?'INF':t.inv[i];
    cell(' '+w.short,53,6.2+i*0.62,q>0?PAL[15]:PAL[8],14);
    cell(q>0?String(q):'-',62,6.2+i*0.62,q>0?PAL[11]:PAL[8],14);
  });
  if(t.isAI){
    const dots='.'.repeat(1+(Math.floor(game.time/8)%3));
    const bought=(game.shopT||0)>=22;
    cell('COMPUTER IS SHOPPING'+dots,53,21.6,PAL[11],14);
    cell(bought?'ORDER FILLED - $'+t.money:'BROWSING THE CATALOG...',53,22.4,bought?PAL[10]:PAL[8],14);
    barCells(53*CHW,23.55*CHH,24,Math.min(1,(game.shopT||0)/92),PAL[11],'#242424');
    if(Math.floor(game.time/18)%2===0) cell('IT BUYS ITS OWN GUNS',53,21.6-1.0,PAL[14],14);
  } else {
    cell('ENTER = BUY ONE',53,21.6,PAL[14],14);
    cell('SHIFT+ENTER = BUY TEN',53,22.4,PAL[14],14);
    if(Math.floor(game.time/20)%2===0) cell('PRESS ESC WHEN DONE',53,23.2,PAL[15],14);
  }
}
/* ---------- round end ---------- */
function drawRoundEnd(){
  drawPlayLikeDemo();
  ctx.fillStyle='rgba(0,0,0,0.75)'; ctx.fillRect(0,0,W,H);
  ctextS('ROUND '+game.round+' COMPLETE',2.2,PAL[14],34);
  if(game.winner) ctext(game.winner.name+' WINS THE ROUND!  (+$1000)',4.6,game.winner.color);
  else ctext('EVERYBODY LOST. THE MOUNTAINS ARE SILENT.',4.6,PAL[12]);
  cell(' TANK              ROUNDS   KILLS   ARMOR   CASH',2,6.4,PAL[14]);
  game.players.forEach((p,i)=>{
    const r=7.6+i*1.3;
    cell(' '+p.name+' '.repeat(Math.max(1,17-p.name.length))+pad(p.won,6)+'  '+
         pad(p.kills,6)+'  '+pad(Math.max(0,Math.round(p.health)),6)+'  $'+pad(p.money,6),
         2,r,p.color);
  });
  ctext(game.round>=opts.rounds?'TAP SCREEN OR PRESS ENTER FOR THE FINAL RESULT':'TAP SCREEN OR PRESS ENTER TO VISIT THE SHOP',19,PAL[15]);
  hspot('cont',0,0,W,H,()=>pressKey('Enter'));
}
/* ---------- game over ---------- */
function drawOver(){
  ctx.fillStyle='#000'; ctx.fillRect(0,0,W,H);
  for(let i=0;i<120;i++){ ctx.fillStyle=Math.random()<0.5?PAL[8]:'#202020'; ctx.fillRect(rint(0,W),rint(0,H),1,1); }
  ctextS('GAME OVER',2,PAL[12],48);
  const sorted=game.players.slice().sort((a,b)=> (b.won-a.won)||(b.money-a.money));
  ctext(sorted[0].name+' IS THE SCORCHED EARTH CHAMPION!',6,sorted[0].color);
  cell(' TANK              ROUNDS   KILLS   DEATHS  DAMAGE  CASH',2,8.2,PAL[14]);
  sorted.forEach((p,i)=>{
    cell(' '+(i+1)+'. '+p.name+' '.repeat(Math.max(1,14-p.name.length))+
      pad(p.won,6)+'  '+pad(p.kills,6)+'  '+pad(p.deaths,6)+'  '+pad(p.dmgDealt,7)+'  $'+pad(p.money,6),
      2,9.6+i*1.2,i===0?PAL[14]:p.color);
    hspot('g'+i,2*CHW,(9.6+i*1.2)*CHH,70*CHW,CHH,()=>{});
  });
  ctext('TAP SCREEN OR PRESS ENTER FOR THE MAIN MENU',20,PAL[15]);
  hspot('cont2',0,0,W,H,()=>pressKey('Enter'));
}
function render(){
  hotspots=[];
  ctx.setTransform(RS,0,0,RS,0,0);
  ctx.fillStyle='#000'; ctx.fillRect(0,0,W,H);
  switch(game.state){
    case 'title': drawTitle(); break;
    case 'setup': drawSetup(); break;
    case 'options': drawOptions(); break;
    case 'help': drawHelp(); break;
    case 'play': drawPlay(); break;
    case 'shop': drawShop(); break;
    case 'roundend': drawRoundEnd(); break;
    case 'over': drawOver(); break;
  }
  if(game.flashT>0){ ctx.fillStyle='rgba(255,255,255,'+(game.flashT/12)+')'; ctx.fillRect(0,0,W,H); }
}

/* ============================================================
   MOBILE / TOUCH / FULLSCREEN  UI LAYER
   ============================================================ */
const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints>0);
let padWanted = isTouch;            /* touch devices get the on-screen pad */
const padEl=document.getElementById('pad'), tipEl=document.getElementById('tip'),
      startWrap=document.getElementById('startwrap'),
      bFS=document.getElementById('bFS'), bSnd=document.getElementById('bSnd'),
      bPad=document.getElementById('bPad'), bMenu=document.getElementById('bMenu'),
      bHelp=document.getElementById('bHelp'),
      bStart1=document.getElementById('bStart1'), bStart2=document.getElementById('bStart2'),
      s1=document.getElementById('s1'), s2=document.getElementById('s2');
const padClusters=Array.prototype.slice.call(document.querySelectorAll('#pad .cluster'));
[bFS,bSnd,bPad,bMenu,bHelp,bStart1,bStart2,s1,s2].forEach(b=>b.setAttribute('tabindex','-1'));

/* --- feed the pad straight into the existing keyboard handler --- */
function pressKey(code,shift){
  audioInit(); if(AC&&AC.state==='suspended') AC.resume();
  keys[code]=true;
  onKey(code,{shiftKey:!!shift});
}
function releaseKey(code){ keys[code]=false; }
function bindPad(){
  document.querySelectorAll('#pad button[data-key]').forEach(b=>{
    const code=b.dataset.key; let held=false;
    const dn=e=>{ e.preventDefault(); e.stopPropagation(); held=true; b.classList.add('on'); pressKey(code); };
    const up=e=>{ if(e.cancelable) e.preventDefault(); held=false; b.classList.remove('on'); releaseKey(code); };
    b.addEventListener('touchstart',dn,{passive:false});
    b.addEventListener('touchend',up,{passive:false});
    b.addEventListener('touchcancel',up,{passive:false});
    b.addEventListener('mousedown',dn);
    b.addEventListener('mouseup',up);
    b.addEventListener('mouseleave',()=>{ if(held){ held=false; b.classList.remove('on'); releaseKey(code);} });
    b.addEventListener('contextmenu',e=>e.preventDefault());
  });
}
function fsActive(){ return !!(document.fullscreenElement||document.webkitFullscreenElement); }
function fsSupported(){
  const el=document.documentElement;
  return !!(el.requestFullscreen||el.webkitRequestFullscreen||el.msRequestFullscreen);
}
function toggleFS(){
  const el=document.documentElement;
  try{
    if(!fsActive()){
      if(fsSupported()){
        const rq=el.requestFullscreen||el.webkitRequestFullscreen||el.msRequestFullscreen;
        const p=rq.call(el);
        if(p&&p.then) p.then(()=>{ try{ screen.orientation.lock('landscape'); }catch(e){} }).catch(()=>{});
      } else {
        pseudoFull=!pseudoFull;                       /* fallback: strip the chrome */
        document.body.classList.toggle('pseudo',pseudoFull);
        SFX.blip();
      }
    } else {
      const xf=document.exitFullscreen||document.webkitExitFullscreen||document.msExitFullscreen;
      if(xf) xf.call(document);
    }
  }catch(e){}
  setTimeout(fitCanvas,180);
}
function doStart(mode){
  audioInit(); if(AC&&AC.state==='suspended') AC.resume();
  SFX.pick();
  game.mode=mode; game.setupStep=0; game.setupIdx=0; game.nPlayers=2; game.state='setup';
}
function menuAction(){
  audioInit();
  if(game.state==='play'){ game.paused=!game.paused; game.showW=false; SFX.blip(); }
  else if(game.state!=='title'){ game.state='title'; game.menuIdx=0; newDemo(); SFX.blip(); }
  else { game.optIdx=0; game.state='options'; SFX.blip(); }
}
function helpAction(){
  if(game.state==='help'){ game.state=game.prevState||'title'; }
  else { game.prevState=game.state; game.state='help'; }
  SFX.blip();
}
bFS.addEventListener('click',e=>{ e.preventDefault(); toggleFS(); });
bSnd.addEventListener('click',e=>{ e.preventDefault(); audioInit(); opts.sound=!opts.sound; SFX.blip(); });
bPad.addEventListener('click',e=>{ e.preventDefault(); padWanted=!padWanted; fitSoon(); SFX.blip(); });
bMenu.addEventListener('click',e=>{ e.preventDefault(); menuAction(); });
bHelp.addEventListener('click',e=>{ e.preventDefault(); helpAction(); });
bStart1.addEventListener('click',e=>{ e.preventDefault(); doStart('pvc'); });
bStart2.addEventListener('click',e=>{ e.preventDefault(); doStart('pvp'); });
s1.addEventListener('click',e=>{ e.preventDefault(); doStart('pvc'); });
s2.addEventListener('click',e=>{ e.preventDefault(); doStart('pvp'); });
document.addEventListener('dblclick',e=>{ if(e.target===cv&&game.state!=='play') toggleFS(); });
let pseudoFull=false;   /* iOS Safari has no real Fullscreen API */
bindPad();

/* --- tap / drag aiming on the canvas --- */
function applyAim(p){
  const t=curPlayer();
  const dx=p.x-t.x, dy=(t.y-9)-p.y;
  if(Math.hypot(dx,dy)<10) return false;
  let a=Math.atan2(dy,dx)*180/Math.PI;
  if(a<0) a=(dx<0?180:0);
  t.angle=clamp(Math.round(a),0,180);
  t.power=clamp(Math.round(Math.hypot(dx,dy)/3.2),5,100);
  return true;
}
let touchAim=null, touchMoved=0, lastTap=0;
let shopTouch=null;   /* drag-to-scroll in the mobile shop */
cv.addEventListener('wheel',e=>{
  if(game.state==='shop'&&MOB){
    e.preventDefault();
    game.shopScroll=clamp(game.shopScroll+(e.deltaY>0?1:-1),0,shopMaxScroll());
  }
},{passive:false});
cv.addEventListener('touchstart',e=>{
  e.preventDefault(); audioInit(); if(AC&&AC.state==='suspended') AC.resume();
  const t=e.changedTouches[0], p=canvasPos(t);
  let defer=null;
  for(let i=hotspots.length-1;i>=0;i--){ const h=hotspots[i];
    if(p.x>=h.x&&p.x<=h.x+h.w&&p.y>=h.y&&p.y<=h.y+h.h){
      if(h.defer&&game.state==='shop'){ defer=h; break; }   /* wait for release: it may be a scroll */
      h.act(); return;
    } }
  if(defer){ shopTouch={id:t.identifier,y0:p.y,s0:game.shopScroll,h:defer,moved:0}; return; }
  if(game.state==='shop'&&MOB&&p.y>80&&p.y<340){
    shopTouch={id:t.identifier,y0:p.y,s0:game.shopScroll,h:null,moved:0}; return;
  }
  if(game.state==='play'&&!game.paused&&game.phase==='aim'&&!curPlayer().isAI){
    touchAim=t.identifier; touchMoved=0; applyAim(p);
  }
},{passive:false});
cv.addEventListener('touchmove',e=>{
  e.preventDefault();
  if(shopTouch){
    for(const t of e.changedTouches) if(t.identifier===shopTouch.id){
      const p=canvasPos(t), dy=p.y-shopTouch.y0;
      if(Math.abs(dy)>12) shopTouch.moved=1;
      game.shopScroll=clamp(Math.round(shopTouch.s0-dy/SHOP_ROWH),0,shopMaxScroll());
    }
    return;
  }
  if(touchAim===null) return;
  for(const t of e.changedTouches) if(t.identifier===touchAim){
    const p=canvasPos(t); const pr=curPlayer();
    const before=pr.angle+':'+pr.power;
    applyAim(p);
    if((pr.angle+':'+pr.power)!==before) touchMoved++;
  }
},{passive:false});
function touchEnd(e){
  e.preventDefault();
  if(shopTouch){
    for(const t of e.changedTouches) if(t.identifier===shopTouch.id){
      const st=shopTouch; shopTouch=null;
      if(!st.moved&&st.h) st.h.act();     /* a real tap, not a flick */
    }
    return;
  }
  if(touchAim===null) return;
  for(const t of e.changedTouches) if(t.identifier===touchAim){
    touchAim=null;
    if(touchMoved>2){ fire(curPlayer()); }        /* a real drag releases the shell */
  }
}
cv.addEventListener('touchend',touchEnd,{passive:false});
cv.addEventListener('touchcancel',e=>{ touchAim=null; shopTouch=null; },{passive:false});

/* --- keep the HTML overlay in step with the game state --- */
let uiState='', uiPad=null;
function syncUI(){
  const st=game.state;
  const shopHuman=(st==='shop'&&game.players[game.shopWho]&&!game.players[game.shopWho].isAI);
  const menuish=(st!=='play'&&st!=='shop');
  let any=false;
  for(const c of padClusters){
    const s=c.dataset.show; let on=false;
    if(padWanted){ on = s==='play'? (st==='play') : s==='shop'? shopHuman : menuish; }
    if(on) any=true;
    if(c.style.display!==(on?'flex':'none')) c.style.display=on?'flex':'none';
  }
  if(padEl.style.display!==(any?'flex':'none')) padEl.style.display=any?'flex':'none';
  tipEl.style.display=(padWanted||pseudoFull||window.innerWidth<=760)?'none':'block';
  if(startWrap.style.display!==(st==='title'?'flex':'none')) startWrap.style.display=(st==='title'?'flex':'none');
  /* keep the top bar to a single row on phones: short labels there */
  const L=(s,l)=>MOB?s:l;
  bSnd.textContent=L(opts.sound?'SND':'MUTE','SOUND '+(opts.sound?'ON':'OFF'));
  bPad.textContent=L('PAD',padWanted?'PAD OFF':'PAD ON');
  bFS.textContent=L((fsActive()||pseudoFull)?'EXIT':'FULL SCREEN',(fsActive()||pseudoFull)?'EXIT FULL':'FULL SCREEN');
  bMenu.textContent=(st==='play')?L(game.paused?'GO':'MENU',game.paused?'RESUME':'MENU')
                                 :L('OPT',st==='title'?'OPTIONS':'MENU');
  bHelp.textContent=L('?','HELP');
  updateGauge();
  if(st!==uiState||any!==uiPad){ uiState=st; uiPad=any; fitCanvas(); }
}

/* ============================================================
   MAIN LOOP
   ============================================================ */
let last=0, acc=0;
function loop(t){
  requestAnimationFrame(loop);
  if(!last) last=t;
  let dt=(t-last)/1000; last=t;
  if(dt>0.25) dt=0.25;
  acc+=dt;
  let guard=0;
  while(acc>=1/60&&guard<6){ step(); acc-=1/60; guard++; }
  if(guard>=6) acc=0;
  render();
  syncUI();
}
function boot(){
  RS=Math.min(2,window.devicePixelRatio||1)||1;
  cv.width=Math.round(W*RS); cv.height=Math.round(H*RS);
  ctx.imageSmoothingEnabled=false; ctx.textBaseline='top';
  fnt(16);
  CHW=ctx.measureText('M').width||8;
  if(CHW<4||CHW>20) CHW=8;
  COLS=Math.floor(W/CHW);
  drawSkyInit();
  newDemo();
  fitCanvas(); syncUI();
  setTimeout(fitCanvas,400);
  requestAnimationFrame(loop);
}
if(document.fonts&&document.fonts.load){
  Promise.race([
    document.fonts.load("16px 'VT323'").then(()=>document.fonts.load("58px 'VT323'")),
    new Promise(r=>setTimeout(r,1200))
  ]).then(boot).catch(boot);
} else boot();
</script>
</body>
</html><!--kg-card-end: html-->]]></content:encoded></item><item><title><![CDATA[GML 5.2 and Qwen 3.8 Max Failure Points]]></title><description><![CDATA[<p>How far will a paired team of GLM 5.2 and Qwen 3.8 Max go?<br></p><p>I wanted the car to look like it was turning, just a subtle 3D effect of showing a side fender, and a front tire like Rad Racer did. &#xA0;Neither could do it, Qwen</p>]]></description><link>https://learn.piescale.com/ai-failure/</link><guid isPermaLink="false">6ab982a0445f5f000176603d</guid><dc:creator><![CDATA[Josh]]></dc:creator><pubDate>Sun, 27 Sep 2026 21:21:16 GMT</pubDate><content:encoded><![CDATA[<p>How far will a paired team of GLM 5.2 and Qwen 3.8 Max go?<br></p><p>I wanted the car to look like it was turning, just a subtle 3D effect of showing a side fender, and a front tire like Rad Racer did. &#xA0;Neither could do it, Qwen was closer, but both lost the rear tires, and the sides looked wrong to the point of breaking the basic box geometry of the main car.</p><p>Small details of world model understanding that a human could do in assembly language on the original 8 bit Nintendo, but the two smartest AI&apos;s on the planet in 2026 can&apos;t crack the code.</p><p>But they could add an overhead map, a turbo boost that feels like an addition more than a distraction, new obstacle vehicles, and a level 7 that&apos;s interestingly different.</p><p>Qwen 3.6 35b was unable to add a level 7 to a working 6 level game.</p><!--kg-card-begin: html--><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RAD HORIZON &#x2014; Turbo Grand Prix</title>
<link rel="icon" href="data:image/svg+xml,&lt;svg xmlns=&apos;http://www.w3.org/2000/svg&apos; viewBox=&apos;0 0 100 100&apos;&gt;&lt;text y=&apos;.9em&apos; font-size=&apos;90&apos;&gt;&#x1F3C1;&lt;/text&gt;&lt;/svg&gt;">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&amp;family=Chakra+Petch:wght@400;600;700&amp;display=swap" rel="stylesheet">
<style>
  :root{
    --bg:#0b0d10; --panel:#14171c; --panel2:#1a1e25; --line:#2b313c;
    --ink:#f3ead9; --dim:#8b93a1; --cyan:#3fe0d0; --orange:#ff8a3d;
    --red:#ff4b4b; --yellow:#ffd23f;
  }
  *{ margin:0; padding:0; box-sizing:border-box; }
  html,body{ height:100%; }
  body{
    background:var(--bg);
    color:var(--ink);
    font-family:"Chakra Petch", sans-serif;
    display:flex; flex-direction:column; align-items:center;
    min-height:100vh; padding:26px 14px 34px;
    overflow-x:hidden;
    -webkit-user-select:none; user-select:none;
  }
  /* layered ambient page texture */
  body::before{
    content:""; position:fixed; inset:0; z-index:0; pointer-events:none;
    background:
      repeating-linear-gradient(115deg, rgba(255,255,255,.016) 0 2px, transparent 2px 16px),
      radial-gradient(120% 70% at 50% 118%, rgba(255,138,61,.10), transparent 60%),
      radial-gradient(90% 60% at 50% -18%, rgba(63,224,208,.07), transparent 60%);
  }
  body::after{
    content:""; position:fixed; inset:0; z-index:0; pointer-events:none; opacity:.5;
    background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E");
  }
  .cabinet{ position:relative; z-index:1; width:min(1060px, 96vw); }

  /* ---------- MARQUEE ---------- */
  .marquee{
    background:linear-gradient(180deg,#171b22,#0d0f14 70%);
    border:1px solid #262c36; border-bottom:none;
    border-radius:16px 16px 0 0;
    padding:18px 26px 14px;
    display:flex; align-items:center; justify-content:space-between; gap:18px;
    position:relative; overflow:hidden;
    box-shadow:0 -1px 0 rgba(255,255,255,.06) inset;
  }
  .marquee::after{ /* shine sweep */
    content:""; position:absolute; top:0; bottom:0; width:38%;
    background:linear-gradient(105deg, transparent, rgba(255,255,255,.05), transparent);
    transform:translateX(-160%);
    animation:shine 7s ease-in-out infinite;
  }
  @keyframes shine{ 0%,55%{transform:translateX(-160%)} 80%,100%{transform:translateX(320%)} }
  .logo-block{ line-height:1; }
  .logo{
    font-family:"Press Start 2P", monospace;
    font-size:clamp(19px, 3.4vw, 34px);
    color:var(--yellow); letter-spacing:.02em;
    text-shadow:3px 3px 0 #b3402a, 6px 6px 0 #14171c, 0 0 26px rgba(255,210,63,.25);
    white-space:nowrap;
  }
  .logo em{ font-style:normal; color:var(--orange); }
  .tag{
    margin-top:9px; font-weight:600; font-size:11px; letter-spacing:.34em;
    color:var(--cyan); text-transform:uppercase; opacity:.9;
  }
  .marquee-side{
    display:flex; flex-direction:column; align-items:flex-end; gap:8px;
    font-size:10px; letter-spacing:.22em; color:var(--dim); text-transform:uppercase; font-weight:600;
  }
  .led{
    font-family:"Press Start 2P", monospace; font-size:10px;
    background:#0a0c0f; border:1px solid #23293388; border-radius:6px;
    padding:8px 12px; color:#ff6a4d; text-shadow:0 0 8px rgba(255,106,77,.7);
    display:flex; gap:10px; align-items:center;
  }
  .led .pwr{ width:7px; height:7px; border-radius:50%; background:#3dff8b; box-shadow:0 0 8px #3dff8b; animation:pulse 2.2s infinite; }
  @keyframes pulse{ 0%,100%{opacity:1} 50%{opacity:.35} }
  .stripes{
    height:9px; border-radius:0;
    background:repeating-linear-gradient(115deg, var(--orange) 0 16px, #171a20 16px 30px, var(--cyan) 30px 34px, #171a20 34px 48px);
    background-size:96px 100%;
    animation:stripeMove 3.2s linear infinite;
    border-left:1px solid #262c36; border-right:1px solid #262c36;
  }
  @keyframes stripeMove{ to{ background-position:96px 0; } }

  /* ---------- CRT BEZEL ---------- */
  .bezel{
    background:#0d0f13;
    border:1px solid #262c36;
    padding:clamp(10px, 2.2vw, 22px);
    box-shadow:0 34px 90px rgba(0,0,0,.6), 0 2px 0 rgba(255,255,255,.04) inset;
  }
  .crt{
    position:relative; border-radius:10px; overflow:hidden;
    background:#000; aspect-ratio:16/9;
    box-shadow:0 0 0 3px #05070a, 0 0 0 4px #23293388, 0 0 60px rgba(63,224,208,.06) inset;
  }
  canvas#game{ display:block; width:100%; height:100%; image-rendering:auto; }
  .crt::before{ /* scanlines */
    content:""; position:absolute; inset:0; z-index:5; pointer-events:none;
    background:repeating-linear-gradient(0deg, rgba(0,0,0,.17) 0 1px, transparent 1px 3px);
    animation:flicker 4s steps(2) infinite;
  }
  @keyframes flicker{ 0%,100%{opacity:.9} 50%{opacity:1} }
  .crt::after{ /* vignette + tube curvature */
    content:""; position:absolute; inset:0; z-index:6; pointer-events:none;
    background:radial-gradient(115% 90% at 50% 45%, transparent 55%, rgba(0,0,0,.45) 100%);
    box-shadow:inset 0 0 90px rgba(0,0,0,.55);
    border-radius:10px;
  }
  .rollbar{
    position:absolute; left:0; right:0; top:-140px; height:130px; z-index:4; pointer-events:none;
    background:linear-gradient(180deg, transparent, rgba(255,255,255,.028), transparent);
    animation:roll 8s linear infinite;
  }
  @keyframes roll{ to{ transform:translateY(820px); } }

  /* touch pads */
  .touch{ display:none; position:absolute; inset:0; z-index:7; pointer-events:none; }
  .pad{
    position:absolute; pointer-events:auto; touch-action:none;
    width:64px; height:64px; border-radius:50%;
    background:rgba(10,14,18,.55); border:1px solid rgba(63,224,208,.4);
    color:var(--ink); font-family:"Press Start 2P",monospace; font-size:16px;
    display:flex; align-items:center; justify-content:center;
  }
  .pad:active{ background:rgba(63,224,208,.35); }
  #padL{ left:14px; bottom:16px; } #padR{ left:92px; bottom:16px; }
  #padB{ right:92px; bottom:16px; font-size:9px; } #padN{ right:14px; bottom:16px; font-size:9px; color:var(--yellow); border-color:rgba(255,210,63,.5); }
  @media (pointer:coarse){ .touch{ display:block; } }

  /* ---------- CONTROL DECK ---------- */
  .deck{
    background:linear-gradient(180deg,#171b22,#101318);
    border:1px solid #262c36; border-top:1px solid #1f242d;
    border-radius:0 0 16px 16px;
    padding:16px 22px 18px;
    display:flex; flex-wrap:wrap; gap:16px 26px; align-items:center; justify-content:space-between;
  }
  .coin-door{ display:flex; align-items:center; gap:14px; }
  .slot{
    width:44px; height:52px; border-radius:6px; background:#0a0c0f;
    border:1px solid #2b313c; position:relative; box-shadow:0 2px 0 rgba(255,255,255,.05) inset;
  }
  .slot::after{
    content:""; position:absolute; left:50%; top:12px; width:4px; height:22px; margin-left:-2px;
    background:#000; border:1px solid #3a4150; border-radius:2px; box-shadow:0 0 8px rgba(255,210,63,.25);
  }
  .insert-coin{
    font-family:"Press Start 2P",monospace; font-size:10px; color:var(--yellow);
    animation:blink 1.1s steps(2) infinite;
  }
  @keyframes blink{ 50%{opacity:0} }
  .credits{ font-size:10px; color:var(--dim); letter-spacing:.2em; margin-top:6px; text-transform:uppercase; font-weight:600; }
  .chips{ display:flex; flex-wrap:wrap; gap:9px; }
  .chip{
    font-family:"Chakra Petch",sans-serif; font-weight:700; font-size:11px;
    letter-spacing:.14em; text-transform:uppercase; color:var(--ink);
    background:#20242c; border:1px solid #303743; border-bottom-width:3px; border-radius:7px;
    padding:9px 14px; cursor:pointer;
    transition:transform .12s ease, border-color .12s ease, box-shadow .12s ease, background .12s ease;
  }
  .chip:hover{ transform:translateY(-2px); border-color:var(--cyan); box-shadow:0 6px 18px rgba(63,224,208,.14); }
  .chip:active{ transform:translateY(0); }
  .chip.on{ background:#123c3a; border-color:var(--cyan); color:#bffff4; }
  .chip b{ color:var(--cyan); font-weight:700; }
  .legend{
    display:flex; flex-wrap:wrap; gap:8px 16px; justify-content:center;
    padding:14px 10px 0; font-size:11px; color:var(--dim); letter-spacing:.08em; font-weight:600;
  }
  kbd{
    font-family:"Press Start 2P",monospace; font-size:8px; color:var(--ink);
    background:#20242c; border:1px solid #39414e; border-bottom:3px solid #39414e;
    border-radius:5px; padding:4px 7px; margin-right:2px;
  }
  footer{
    position:relative; z-index:1; margin-top:20px; text-align:center;
    font-size:11px; color:#5d6572; letter-spacing:.1em; max-width:760px; line-height:1.7;
  }
  footer b{ color:var(--dim); }
  @media (prefers-reduced-motion:reduce){
    .marquee::after,.stripes,.rollbar,.crt::before,.insert-coin,.led .pwr{ animation:none; }
  }
</style>
</head>
<body>

<main class="cabinet">
  <header class="marquee">
    <div class="logo-block">
      <div class="logo">RAD&#xA0;<em>HORIZON</em></div>
      <div class="tag">Turbo Grand Prix &#xB7; 7 Stages &#xB7; Est. 1986</div>
    </div>
    <div class="marquee-side">
      <div class="led"><span class="pwr"></span> HI-SCORE <span id="hiLed">0000000</span></div>
      <div class="led"><span class="pwr"></span> CREDITS <span id="credLed">0</span></div>
    </div>
  </header>
  <div class="stripes"></div>

  <div class="bezel">
    <div class="crt" id="crt">
      <canvas id="game" width="960" height="540"></canvas>
      <div class="rollbar"></div>
      <div class="touch">
        <div class="pad" id="padL">&#x25C0;</div>
        <div class="pad" id="padR">&#x25B6;</div>
        <div class="pad" id="padB">BRAKE</div>
        <div class="pad" id="padN">NITRO</div>
      </div>
    </div>
  </div>

  <div class="deck">
    <div class="coin-door">
      <div class="slot"></div>
      <div>
        <div class="insert-coin">INSERT COIN</div>
        <div class="credits">PRESS <kbd>ENTER</kbd> TO RACE</div>
      </div>
    </div>
    <div class="chips">
      <button class="chip on" id="btnSound">SOUND <b>ON</b></button>
      <button class="chip" id="btnRadio">RADIO &#xB7; <b id="radioName">SUNSET BLAZER</b></button>
      <button class="chip" id="btnStereo">3-D GLASSES <b>[G]</b></button>
    </div>
  </div>

  <div class="legend">
    <span><kbd>&#x25C0;</kbd><kbd>&#x25B6;</kbd> STEER</span>
    <span><kbd>&#x25B2;</kbd> THROTTLE</span>
    <span><kbd>&#x25BC;</kbd> BRAKE</span>
    <span><kbd>SHIFT</kbd> NITRO</span>
    <span><kbd>1</kbd><kbd>2</kbd><kbd>3</kbd><kbd>4</kbd> RADIO</span>
    <span><kbd>M</kbd> MUTE</span>
    <span><kbd>G</kbd> 3-D</span>
    <span><kbd>P</kbd> PAUSE</span>
  </div>
</main>

<footer>
  A love letter to <b>OUT RUN</b> (Sega &#xB7; 1986) and <b>RAD RACER</b> (Square &#xB7; 1987) &#x2014; every pixel drawn in code,
  every note synthesized live. Thread the needle between traffic to charge <b>NITRO</b>, hit every checkpoint,
  and pop on the red-cyan glasses for the full 1987 treatment.
  <br>Stage 6 is the <b>NEON TUNNEL</b>: four blacked-out bores where the light rings strobe in time with
  the radio and the music muffles the moment you&apos;re inside &#x2014; and where the corners flatten out so you can
  hold it flat through the dark.
  <br>Stage 7 is <b>THUNDER ROAD</b> &#x2014; a night storm on the coast. Rain streaks that lay back as you accelerate,
  lightning that forks down and flares the whole landscape, thunder that rolls in behind it, and asphalt so wet
  that every tail light, lamp and neon sign mirrors itself down the road. Two tunnels cut through the storm:
  dive in for shelter, then burst back out into the rain.
  <br>Score like a pro: <b>tuck in behind traffic to slipstream</b>, then swing out &#x2014; every clean pass feeds a
  <b>CHAIN</b> multiplier up to x9, crashes wipe it. Grab the hovering <b>TURBO</b> pads for a kick and extra
  nitro, and hold the throttle on GO for a <b>LAUNCH BOOST</b>. Beat 60,000 and the cabinet asks for your initials.
</footer>

<script>
'use strict';
/* =====================================================================
   RAD HORIZON — pseudo-3D arcade racer, everything procedural.
   ===================================================================== */

/* ---------------- tiny helpers ---------------- */
const $ = id => document.getElementById(id);
const clamp = (v,a,b)=> v<a?a : v>b?b : v;
const lerp  = (a,b,t)=> a+(b-a)*t;
const easeIn    = (a,b,t)=> a+(b-a)*t*t;
const easeInOut = (a,b,t)=> a+(b-a)*(-Math.cos(t*Math.PI)/2+0.5);
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
const _rgbCache={};
function hexRGB(h){ let c=_rgbCache[h]; if(!c){ c=[parseInt(h.slice(1,3),16),parseInt(h.slice(3,5),16),parseInt(h.slice(5,7),16)]; _rgbCache[h]=c; } return c; }
function rgba(h,a){ const c=hexRGB(h); return `rgba(${c[0]},${c[1]},${c[2]},${a})`; }
function mixHex(a,b,t){ const A=hexRGB(a),B=hexRGB(b);
  const r=Math.round(lerp(A[0],B[0],t)), g=Math.round(lerp(A[1],B[1],t)), bl=Math.round(lerp(A[2],B[2],t));
  return `rgb(${r},${g},${bl})`; }
function makeCanvas(w,h,fn){ const c=document.createElement('canvas'); c.width=w; c.height=h; fn(c.getContext('2d'),w,h); return c; }
function shade(hex,amt){ const c=hexRGB(hex); const f=v=>clamp(Math.round(v+amt),0,255); return `rgb(${f(c[0])},${f(c[1])},${f(c[2])})`; }

/* ---------------- constants ---------------- */
const SCENE_W=960, SCENE_H=540;
const SEG_LEN=200, ROAD_W=2200, LANES=3, RUMBLE=3, DRAW_DIST=240;
const FOV=100, CAM_DEPTH=1/Math.tan((FOV/2)*Math.PI/180), CAM_H=1050;
const PLAYER_Z=CAM_H*CAM_DEPTH;
const MAX_SPEED=SEG_LEN*60;               // 12000 world units / s
const ACCEL=MAX_SPEED/4.6, BRAKE=-MAX_SPEED*1.15, COAST=-MAX_SPEED/7, OFFROAD_DECEL=-MAX_SPEED*1.05;
const OFFROAD_LIMIT=MAX_SPEED/4, CENTRIFUGAL=0.29, BOOST_MAX=MAX_SPEED*1.38;
const EYE=46;                             // stereo camera separation (world units)

/* ---------------- canvases ---------------- */
const display=$('game'), dctx=display.getContext('2d');
const sceneCv=document.createElement('canvas'); sceneCv.width=SCENE_W; sceneCv.height=SCENE_H;
const sctx=sceneCv.getContext('2d');
const sceneR=document.createElement('canvas'); sceneR.width=SCENE_W; sceneR.height=SCENE_H;
const sctxR=sceneR.getContext('2d');
let tL=null,tR=null;

/* ---------------- stage palettes ---------------- */
const PALETTES=[
 { name:'PALM COAST', type:'coast',
   skyTop:'#0d5c86', skyMid:'#4fb0bf', skyLow:'#ffd9a0', sun:'#fff0c0', sunCore:'#ffe9a8', glow:'#ffd27a',
   mFar:'#2e7286', mNear:'#1e5262', grassA:'#1f9158', grassB:'#187f4c',
   roadA:'#5d626e', roadB:'#585d69', rumbleA:'#e9e9e9', rumbleB:'#d94f3d', lane:'#f4f4f4', fog:'#ffe3b0',
   sea:'#0f6f7e', seaA:1, stars:0 },
 { name:'DUSTY CANYON', type:'canyon',
   skyTop:'#3a1c2c', skyMid:'#a34a30', skyLow:'#ffb26b', sun:'#ffd9a0', sunCore:'#ffcf8f', glow:'#ff9d5c',
   mFar:'#6b3527', mNear:'#4e261d', grassA:'#c98d4f', grassB:'#bb8043',
   roadA:'#63544b', roadB:'#5d4f46', rumbleA:'#ead9b8', rumbleB:'#c0452f', lane:'#efe4cc', fog:'#ffb27a',
   sea:null, stars:0 },
 { name:'NEON CITY', type:'city',
   skyTop:'#040814', skyMid:'#0b1c33', skyLow:'#28495f', sun:'#dfe9f2', sunCore:'#eef6ff', glow:'#7fd4ff',
   mFar:'#0a1626', mNear:'#0d2036', grassA:'#14261f', grassB:'#101f19',
   roadA:'#3c4350', roadB:'#373e4b', rumbleA:'#dfe8ff', rumbleB:'#2a6cff', lane:'#9fe8ff', fog:'#274a63',
   sea:null, stars:1 },
 { name:'ALPINE PASS', type:'alpine',
   skyTop:'#2c7cb6', skyMid:'#7fc4e6', skyLow:'#eaf6ff', sun:'#fff8e0', sunCore:'#ffffff', glow:'#fff3c4',
   mFar:'#5b7d9e', mNear:'#33506b', grassA:'#2e9e52', grassB:'#27914a',
   roadA:'#676d77', roadB:'#61676f', rumbleA:'#e8e8e8', rumbleB:'#d14434', lane:'#f2f2f2', fog:'#dcedf7',
   sea:null, stars:0 },
 { name:'GOLDEN GATE', type:'gate',
   skyTop:'#1c3f66', skyMid:'#c96a3f', skyLow:'#ffd27a', sun:'#fff2c8', sunCore:'#ffe6a0', glow:'#ffb45e',
   mFar:'#5e4a52', mNear:'#3c3040', grassA:'#3f8f4a', grassB:'#37813f',
   roadA:'#5f5a58', roadB:'#5a5553', rumbleA:'#e9e9e9', rumbleB:'#d94f3d', lane:'#f4f4f4', fog:'#ffcf8e',
   sea:'#1a5f6e', seaA:1, stars:0 },
 { name:'NEON TUNNEL', type:'tunnel',
   skyTop:'#04030e', skyMid:'#170a30', skyLow:'#3b1350', sun:'#ff77e1', sunCore:'#ffd3f6', glow:'#ff4fd8',
   mFar:'#150a2b', mNear:'#0c051d', grassA:'#1b1030', grassB:'#150c28',
   roadA:'#241b33', roadB:'#201730', rumbleA:'#eafcff', rumbleB:'#ff2fd0', lane:'#8ef8ff', fog:'#2a0f45',
   sea:null, seaA:0, stars:1 },
 { name:'THUNDER ROAD', type:'storm',
   skyTop:'#05070f', skyMid:'#1b2138', skyLow:'#3b4763', sun:'#9fb4d6', sunCore:'#cfe0ff', glow:'#7f9fd0',
   mFar:'#181f33', mNear:'#0e1322', grassA:'#101726', grassB:'#0c1220',
   roadA:'#2a2f3a', roadB:'#262b35', rumbleA:'#e6f2ff', rumbleB:'#ff3b5c', lane:'#cfe4ff', fog:'#6f88ab',
   sea:'#0a1626', seaA:1, stars:0 }
];

/* ---------------- game state ---------------- */
let segments=[], trackLength=0, stageStarts=[];
let cars=[];
const LEVEL_TIME=[52,50,49,48,47,53,58], CHECKPOINT_BONUS=13;
const SEED_RANK=[{n:'ACE',s:60000,st:6},{n:'FOX',s:44000,st:5},{n:'REI',s:30000,st:4},{n:'MAX',s:18000,st:3},{n:'IVY',s:9000,st:2}];

const G={
  state:'boot',           // boot | attract | count | race | over | victory | pause
  position:0, speed:0, playerX:0,
  steerVis:0,
  timeLeft:64, stageIdx:0,
  score:0, hiScore:0, nearMisses:0, topSpeedKmh:0, raceElapsed:0,
  gauge:0.6, boosting:false, boostSnd:false,
  crashT:0, shakeT:0, flashT:0,
  countT:0, lastCount:-1,
  palA:PALETTES[0], palB:PALETTES[0], palT:1,
  skyOffset:0, cloudOff:0, camYWorld:0, horizonY:SCENE_H*0.5, wasTun:false,
  totalTime:0, totalNM:0, cpDone:[false,false], results:null, raceStart:0,
  combo:0, comboT:0, drafting:false, pendingHi:false, rankings:SEED_RANK.slice(),
  nameEntry:{pos:0,chars:['A','A','A']}, prevGauge:0.6,
  wet:false, rainAlpha:0, storm:{t:2.4,flash:0,bolt:0,boltPts:[],boltB:[[]]},
  now:0, lastTick:0,
  credits:0, muted:false, radioTrack:1, stereo:false, pausedFrom:null,
  popups:[], parts:[]
};
try{ G.hiScore=parseInt(localStorage.getItem('radhorizon_hi')||'0',10)||0; }catch(e){}

const input={left:false,right:false,throttle:false,brake:false,boost:false,touch:false};

/* =====================================================================
   TRACK BUILDING
   ===================================================================== */
function lastY(){ return segments.length? segments[segments.length-1].p2.world.y : 0; }
function addSeg(curve,y){
  const n=segments.length;
  segments.push({ index:n, curve, fog:1, clip:0, shade:Math.floor(n/RUMBLE)%2, checker:(n<6),
    p1:{world:{y:lastY(),z:n*SEG_LEN},camera:{},screen:{scale:0,x:0,y:0,w:0}},
    p2:{world:{y,z:(n+1)*SEG_LEN},camera:{},screen:{scale:0,x:0,y:0,w:0}},
    sprites:[], cars:[] });
}
function addRoad(enter,hold,leave,curve,hill){
  const startY=lastY(), endY=startY+hill*SEG_LEN, total=enter+hold+leave;
  for(let i=0;i<enter;i++) addSeg(easeIn(0,curve,i/enter),          easeInOut(startY,endY,i/total));
  for(let i=0;i<hold;i++)  addSeg(curve,                             easeInOut(startY,endY,(enter+i)/total));
  for(let i=0;i<leave;i++) addSeg(easeInOut(curve,0,i/leave),        easeInOut(startY,endY,(enter+hold+i)/total));
}
/* ---- course generator: one full circuit per level ---- */
let finishSeg=0, checkpointSegs=[], tunnelZones=[];
let mapPts=[], mapHead=[];
function buildLevel(level){
  segments=[]; checkpointSegs=[]; finishSeg=0;
  const rng=mulberry32(9001+level*131);
  const diff=Math.min(1.5,0.85+level*0.13);      // curves bite harder each stage
  addRoad(22,58,22,0,0);                          // launch straight
  const course=2380+level*180;
  while(segments.length<course){
    const t=rng(), sgn=rng()<0.5?-1:1;
    if(t<0.26){                                   // long sweeping bend
      addRoad(28+rng()*18, 55+rng()*45, 28+rng()*18,
              clamp(sgn*(1.5+rng()*2.1)*diff,-4.6,4.6), (rng()-0.5)*44);
    } else if(t<0.5){                             // s-bend
      const c=clamp(sgn*(2.1+rng()*1.5)*diff,-4.7,4.7);
      addRoad(20,28,20, c, (rng()-0.5)*18);
      addRoad(20,28,20, -c*0.92, (rng()-0.5)*18);
    } else if(t<0.68){                            // climb / plunge
      addRoad(24,38,24, clamp(sgn*rng()*1.6*diff,-3,3), sgn*(18+rng()*30));
    } else if(t<0.84){                            // hairpin
      addRoad(14,20,14, clamp(sgn*(3.1+rng()*1.5)*diff,-5,5), (rng()-0.5)*12);
    } else {                                      // flat-out straight + kink
      addRoad(8,50+rng()*32,8, 0, 0);
      addRoad(8,12,8, sgn*1.15, 0);
    }
  }
  checkpointSegs=[Math.floor(segments.length*0.33), Math.floor(segments.length*0.66)];
  addRoad(24,64,24, 0, -lastY()/SEG_LEN);         // level out to the line
  addRoad(8,30,8, 0, 0);
  finishSeg=segments.length;                      // the finish line
  addRoad(8,320,8, 0, 0);                         // cool-down run-off
  for(let i=0;i<6;i++) segments[finishSeg+i].checker=(i%2===0);   // chequered band
  /* neon tunnels — the signature of the final stage */
  tunnelZones=[];
  if(level>=5){
    const zones=4, span=Math.floor(finishSeg/(zones+1));
    for(let i=1;i<=zones;i++){
      tunnelZones.push({start:span*i-Math.floor(span*0.21),
                        end:Math.min(finishSeg-56, span*i+Math.floor(span*0.21))});
    }
    segments.forEach((s,i)=>{ const z=tunnelZones.find(q=>i>=q.start&&i<=q.end);
      if(z){ s.tunnel=true; s.tz=z; s.curve*=0.4; } });   // tunnels flow: flat-out blasts
  }
  trackLength=segments.length*SEG_LEN;
  stageStarts=checkpointSegs.slice();
  buildCourseMap();
}
const findSegment=z=>segments[clamp(Math.floor(z/SEG_LEN),0,segments.length-1)];

/* ---- course map: fold the segment curves into a 2-D polyline ---- */
function buildCourseMap(){
  mapPts=[]; mapHead=[];
  let h=0, x=0, y=0;
  for(const s of segments){
    mapHead.push(h); mapPts.push({x,y});
    h+=s.curve*0.006;
    x+=Math.sin(h); y-=Math.cos(h);
  }
  let minX=1e9,maxX=-1e9,minY=1e9,maxY=-1e9;
  for(const p of mapPts){
    if(p.x<minX)minX=p.x; if(p.x>maxX)maxX=p.x;
    if(p.y<minY)minY=p.y; if(p.y>maxY)maxY=p.y;
  }
  const rw=Math.max(1e-6,maxX-minX), rh=Math.max(1e-6,maxY-minY);
  const ox=26, oy=SCENE_H-162, fw=140, fh=136;        // fit area inside the panel
  const sc=Math.min(fw/rw,fh/rh);
  const cx=ox+(fw-rw*sc)/2-minX*sc, cy=oy+(fh-rh*sc)/2-minY*sc;
  for(const p of mapPts){ p.x=p.x*sc+cx; p.y=p.y*sc+cy; }
}

/* =====================================================================
   PROCEDURAL SPRITES
   ===================================================================== */
const SPR={};
function roundRect(g,x,y,w,h,r){ g.beginPath(); g.moveTo(x+r,y); g.arcTo(x+w,y,x+w,y+h,r); g.arcTo(x+w,y+h,x,y+h,r); g.arcTo(x,y+h,x,y,r); g.arcTo(x,y,x+w,y,r); g.closePath(); }

function buildSprites(){
  /* palm */
  SPR.palm=makeCanvas(240,330,(g)=>{
    g.lineCap='round';
    g.strokeStyle='#6e4222'; g.lineWidth=17;
    g.beginPath(); g.moveTo(132,328); g.quadraticCurveTo(110,210,122,118); g.stroke();
    g.strokeStyle='#59341a'; g.lineWidth=6;
    g.beginPath(); g.moveTo(136,326); g.quadraticCurveTo(118,220,126,130); g.stroke();
    const top={x:122,y:112};
    const fronds=[[-1.05,-0.25],[-0.75,-0.6],[-0.3,-0.82],[0.25,-0.85],[0.72,-0.6],[1.02,-0.22],[0.9,0.12],[-0.95,0.12]];
    fronds.forEach((f,i)=>{
      g.fillStyle= i%2? '#2f9e44':'#1e7a33';
      g.beginPath(); g.moveTo(top.x,top.y);
      g.quadraticCurveTo(top.x+f[0]*70, top.y+f[1]*70-18, top.x+f[0]*118, top.y+f[1]*118+26);
      g.quadraticCurveTo(top.x+f[0]*66, top.y+f[1]*66+16, top.x,top.y+4);
      g.fill();
    });
    g.fillStyle='#59341a'; g.beginPath(); g.arc(114,124,8,0,7); g.arc(132,126,8,0,7); g.fill();
  });
  /* pine */
  SPR.pine=makeCanvas(190,300,(g)=>{
    g.fillStyle='#6e4222'; g.fillRect(86,248,18,50);
    const layers=[[95,36,120],[95,96,150],[95,166,176]];
    layers.forEach((L,i)=>{ g.fillStyle= i%2?'#14522b':'#1c6b3a';
      g.beginPath(); g.moveTo(L[0],L[1]-64); g.lineTo(L[0]-L[2]/2,L[1]+78); g.lineTo(L[0]+L[2]/2,L[1]+78); g.fill();
      g.fillStyle='rgba(240,250,255,.85)';
      g.beginPath(); g.moveTo(L[0],L[1]-64); g.lineTo(L[0]-L[2]*0.16,L[1]-30); g.lineTo(L[0]+L[2]*0.16,L[1]-30); g.fill();
    });
  });
  /* cactus */
  SPR.cactus=makeCanvas(130,230,(g)=>{
    g.fillStyle='#3f9e4d'; roundRect(g,52,46,28,180,14); g.fill();
    roundRect(g,14,88,22,66,11); g.fill(); roundRect(g,14,88,52,20,10); g.fill();
    roundRect(g,96,64,22,58,11); g.fill(); roundRect(g,66,102,52,20,10); g.fill();
    g.fillStyle='#2c7a39';
    [62,70].forEach(x=>{ g.fillRect(x,56,3,160); });
    g.fillStyle='#ff6b81'; g.beginPath(); g.arc(66,44,7,0,7); g.fill();
  });
  /* rock */
  SPR.rock=makeCanvas(170,110,(g)=>{
    g.fillStyle='#8a8f98'; g.beginPath(); g.moveTo(10,100); g.lineTo(34,40); g.lineTo(78,16); g.lineTo(132,34); g.lineTo(160,100); g.fill();
    g.fillStyle='#6d727c'; g.beginPath(); g.moveTo(78,16); g.lineTo(132,34); g.lineTo(160,100); g.lineTo(96,100); g.fill();
    g.fillStyle='#a7adb6'; g.beginPath(); g.moveTo(34,40); g.lineTo(78,16); g.lineTo(70,44); g.lineTo(44,58); g.fill();
  });
  /* roadside sign */
  const sign=(label,bg,fg,border)=>makeCanvas(230,190,(g)=>{
    g.fillStyle='#5a5f68'; g.fillRect(52,120,12,70); g.fillRect(166,120,12,70);
    g.fillStyle=bg; roundRect(g,10,18,210,112,8); g.fill();
    g.lineWidth=7; g.strokeStyle=border; roundRect(g,10,18,210,112,8); g.stroke();
    g.fillStyle=fg; g.font='22px "Press Start 2P", monospace'; g.textAlign='center'; g.textBaseline='middle';
    g.fillText(label,115,76);
  });
  SPR.signTurbo=sign('TURBO','#f3ead9','#c0392b','#c0392b');
  SPR.signRad  =sign('RAD!','#f3ead9','#0e7a6e','#0e7a6e');
  SPR.signRoute=sign('RT-86','#f8f4e6','#1f2937','#e0a62e');
  SPR.signAlp  =sign('ALPINE','#eaf6ff','#20455e','#2c7cb6');
  SPR.signFinal=sign('FINAL','#f3ead9','#b3402a','#b3402a');
  /* neon signs (glow baked) */
  const neon=(label,col)=>makeCanvas(250,200,(g)=>{
    g.fillStyle='#10141c'; g.fillRect(60,130,10,70); g.fillRect(180,130,10,70);
    g.fillStyle='#0b0e15'; roundRect(g,14,16,222,120,10); g.fill();
    g.save(); g.shadowColor=col; g.shadowBlur=20;
    g.strokeStyle=col; g.lineWidth=4; roundRect(g,22,24,206,104,8); g.stroke();
    g.fillStyle=col; g.font='24px "Press Start 2P", monospace'; g.textAlign='center'; g.textBaseline='middle';
    g.fillText(label,125,68); g.restore();
    g.fillStyle=rgba(col,0.28); g.fillRect(30,96,190,5);
  });
  SPR.neonCyan=neon('MOTEL','#3fe0d0');
  SPR.neonPink=neon('SUSHI','#ff4fa0');
  SPR.neonYel =neon('ARCADE','#ffd23f');
  /* street lamp with glow */
  SPR.lamp=makeCanvas(120,330,(g)=>{
    g.fillStyle='#2c323c'; g.fillRect(54,60,10,268);
    g.fillRect(54,60,44,9);
    const glow=g.createRadialGradient(92,74,2,92,74,52);
    glow.addColorStop(0,'rgba(190,235,255,.95)'); glow.addColorStop(1,'rgba(190,235,255,0)');
    g.fillStyle=glow; g.beginPath(); g.arc(92,74,52,0,7); g.fill();
    g.fillStyle='#dff3ff'; roundRect(g,82,64,20,14,5); g.fill();
  });
  /* city tower */
  SPR.tower=makeCanvas(200,420,(g)=>{
    g.fillStyle='#0d1a2c'; g.fillRect(20,40,160,380);
    g.fillStyle='#122440'; g.fillRect(20,40,160,14);
    g.fillStyle='#091322'; g.fillRect(88,6,8,40);
    g.fillStyle='#ff4b4b'; g.beginPath(); g.arc(92,6,5,0,7); g.fill();
    const rng=mulberry32(7);
    for(let y=64;y<400;y+=26) for(let x=32;x<170;x+=22){
      if(rng()<0.42){ g.fillStyle= rng()<0.5? 'rgba(255,210,120,.9)':'rgba(120,225,255,.75)'; g.fillRect(x,y,10,12); }
      else { g.fillStyle='rgba(40,60,90,.5)'; g.fillRect(x,y,10,12); }
    }
  });
  /* checkpoint gantry */
  const gantry=(label)=>makeCanvas(760,250,(g)=>{
    g.fillStyle='#454c58'; g.fillRect(26,60,26,190); g.fillRect(708,60,26,190);
    g.fillStyle='#2e333d'; g.fillRect(26,60,8,190); g.fillRect(708,60,8,190);
    g.fillStyle='#1c212a'; g.fillRect(0,18,760,64);
    g.fillStyle='#f3ead9'; g.font='30px "Press Start 2P", monospace'; g.textAlign='center'; g.textBaseline='middle';
    g.fillText(label,380,50);
    for(let x=0;x<760;x+=24){ g.fillStyle=(x/24)%2? '#111':'#eee'; g.fillRect(x,82,24,16); }
  });
  SPR.gantryCheck=gantry('CHECKPOINT');
  SPR.gantryStart=gantry('RAD HORIZON');
  /* the finish arch */
  SPR.gantryFinish=makeCanvas(840,300,(g)=>{
    g.fillStyle='#454c58'; g.fillRect(26,66,32,234); g.fillRect(782,66,32,234);
    g.fillStyle='#2e333d'; g.fillRect(26,66,10,234); g.fillRect(782,66,10,234);
    g.fillStyle='#1c212a'; g.fillRect(0,12,840,84);
    g.fillStyle='#f3ead9'; g.font='40px "Press Start 2P", monospace'; g.textAlign='center'; g.textBaseline='middle';
    g.fillText('FINISH',420,54);
    for(let x=0;x<840;x+=21){ g.fillStyle=(x/21)%2? '#f3ead9':'#15181e'; g.fillRect(x,96,21,26); }
    for(let i=0;i<2;i++){ const fx=150+i*560;
      g.strokeStyle='#8b93a1'; g.lineWidth=4; g.beginPath(); g.moveTo(fx,122); g.lineTo(fx,212); g.stroke();
      for(let r=0;r<5;r++) for(let c2=0;c2<4;c2++){ g.fillStyle=(r+c2)%2? '#15181e':'#f3ead9'; g.fillRect(fx+c2*10,122+r*10,10,10); }
    }
  });
  /* neon tunnel portal — centre is transparent so you see straight through it */
  SPR.tunnelGate=makeCanvas(780,430,(g)=>{
    g.fillStyle='#150c26'; g.fillRect(0,0,54,430); g.fillRect(726,0,54,430);
    g.fillStyle='#0d0718'; g.fillRect(46,0,8,430); g.fillRect(726,0,8,430);
    g.fillStyle='#150c26'; g.fillRect(0,0,780,132);
    g.fillStyle='#0d0718'; g.fillRect(0,124,780,8);
    g.save(); g.shadowColor='#8ef8ff'; g.shadowBlur=18;
    g.strokeStyle='#8ef8ff'; g.lineWidth=5;
    g.beginPath(); g.moveTo(54,126); g.lineTo(726,126); g.stroke();
    g.strokeStyle='#ff5ce1'; g.lineWidth=4;
    g.beginPath(); g.moveTo(56,0); g.lineTo(56,424); g.moveTo(724,0); g.lineTo(724,424); g.stroke();
    g.restore();
    g.fillStyle='#eafcff'; g.font='24px "Press Start 2P", monospace'; g.textAlign='center'; g.textBaseline='middle';
    g.fillText('NEON TUNNEL',390,64);
    g.fillStyle='#ff5ce1';
    for(let y=152;y<404;y+=42){
      g.beginPath(); g.moveTo(14,y); g.lineTo(40,y+12); g.lineTo(14,y+24); g.fill();
      g.beginPath(); g.moveTo(766,y); g.lineTo(740,y+12); g.lineTo(766,y+24); g.fill();
    }
  });
  /* traffic cars (rear view) */
  const carColors=['#3a86ff','#ffbe0b','#e9ecef','#8d99ae','#2a9d8f','#e76f51'];
  SPR.traffic=carColors.map(col=>makeCanvas(160,112,(g)=>{
    g.fillStyle='#15171b'; roundRect(g,6,84,30,24,6); g.fill(); roundRect(g,124,84,30,24,6); g.fill();
    const body=g.createLinearGradient(0,20,0,100);
    body.addColorStop(0,shade(col,34)); body.addColorStop(0.5,col); body.addColorStop(1,shade(col,-46));
    g.fillStyle=body; roundRect(g,10,34,140,62,12); g.fill();
    const glass=g.createLinearGradient(0,6,0,52);
    glass.addColorStop(0,'#2c3a4c'); glass.addColorStop(1,'#101820');
    g.fillStyle=glass; roundRect(g,26,8,108,44,10); g.fill();
    g.fillStyle=rgba('#bfe8ff',0.25); g.fillRect(34,14,40,6);
    g.fillStyle='#111'; roundRect(g,14,88,132,12,5); g.fill();
    g.fillStyle='#c9cfd6'; g.fillRect(66,90,28,8);
    g.save(); g.shadowColor='#ff2d2d'; g.shadowBlur=10;
    g.fillStyle='#ff4040'; roundRect(g,18,62,30,9,3); g.fill(); roundRect(g,112,62,30,9,3); g.fill(); g.restore();
  }));
  /* big rig — a slow rolling roadblock with real presence */
  SPR.truck=makeCanvas(320,200,(g)=>{
    g.fillStyle='#101215';
    roundRect(g,8,166,52,32,6); g.fill(); roundRect(g,260,166,52,32,6); g.fill();
    roundRect(g,78,166,52,32,6); g.fill(); roundRect(g,190,166,52,32,6); g.fill();
    const box=g.createLinearGradient(0,10,0,170);
    box.addColorStop(0,'#e8eaee'); box.addColorStop(0.5,'#c7ccd4'); box.addColorStop(1,'#9aa1ac');
    g.fillStyle=box; roundRect(g,6,12,308,158,8); g.fill();
    g.strokeStyle='#7d838d'; g.lineWidth=3; roundRect(g,6,12,308,158,8); g.stroke();
    g.fillStyle='#b3b9c2';
    for(let x=26;x<300;x+=28) g.fillRect(x,20,5,116);
    g.fillStyle='#d94f3d'; g.fillRect(6,128,308,16);
    g.fillStyle='#f3ead9'; g.font='19px "Press Start 2P", monospace'; g.textAlign='center';
    g.fillText('VECTOR HAUL',160,100);
    g.fillStyle='#20242c'; g.fillRect(6,160,308,12);
    g.save(); g.shadowColor='#ff2d2d'; g.shadowBlur=9; g.fillStyle='#ff4040';
    g.fillRect(16,162,26,8); g.fillRect(278,162,26,8); g.restore();
    g.fillStyle='#ffd23f'; g.fillRect(60,164,18,5); g.fillRect(242,164,18,5);
  });
  /* turbo pad — hovers over the racing line and shimmers */
  SPR.chevron=makeCanvas(220,140,(g)=>{
    g.fillStyle='rgba(10,16,24,.85)'; roundRect(g,10,88,200,44,10); g.fill();
    g.strokeStyle='#3fe0d0'; g.lineWidth=4; roundRect(g,10,88,200,44,10); g.stroke();
    const chev=(y,col)=>{ g.save(); g.shadowColor=col; g.shadowBlur=12; g.fillStyle=col;
      g.beginPath(); g.moveTo(110,y); g.lineTo(172,y+46); g.lineTo(172,y+66); g.lineTo(110,y+20);
      g.lineTo(48,y+66); g.lineTo(48,y+46); g.closePath(); g.fill(); g.restore(); };
    chev(2,'#ffd23f'); chev(30,'#3fe0d0');
    g.fillStyle='#f3ead9'; g.font='14px "Press Start 2P", monospace'; g.textAlign='center';
    g.fillText('TURBO',110,119);
  });
  /* player car — the Vector GT */
  SPR.player=makeCanvas(260,150,(g)=>{
    g.fillStyle='#141619'; roundRect(g,8,118,44,26,7); g.fill(); roundRect(g,208,118,44,26,7); g.fill();
    const body=g.createLinearGradient(0,30,0,140);
    body.addColorStop(0,'#ff5a48'); body.addColorStop(0.42,'#e63226'); body.addColorStop(1,'#8f1d15');
    g.fillStyle=body;
    g.beginPath(); g.moveTo(14,132); g.lineTo(8,86); g.quadraticCurveTo(10,60,40,54);
    g.lineTo(220,54); g.quadraticCurveTo(250,60,252,86); g.lineTo(246,132);
    g.quadraticCurveTo(130,142,14,132); g.fill();
    g.fillStyle='#a52019'; g.fillRect(14,96,232,7);
    const glass=g.createLinearGradient(0,10,0,58);
    glass.addColorStop(0,'#28384a'); glass.addColorStop(1,'#0d141c');
    g.fillStyle=glass;
    g.beginPath(); g.moveTo(52,52); g.lineTo(66,14); g.quadraticCurveTo(130,4,194,14); g.lineTo(208,52); g.closePath(); g.fill();
    g.fillStyle=rgba('#7fd4ff',0.35); g.beginPath(); g.moveTo(70,18); g.lineTo(120,10); g.lineTo(104,20); g.lineTo(74,26); g.closePath(); g.fill();
    g.fillStyle='#5e120c'; g.fillRect(46,50,168,5);
    /* side strakes */
    g.fillStyle='#7c1710';
    for(let i=0;i<3;i++){ g.fillRect(16,104+i*8,42,4); g.fillRect(202,104+i*8,42,4); }
    /* tail light bar */
    g.fillStyle='#1a0c0a'; roundRect(g,26,72,208,16,5); g.fill();
    g.save(); g.shadowColor='#ff2222'; g.shadowBlur=12; g.fillStyle='#ff3b30';
    for(let x=32;x<226;x+=24) roundRect(g,x,75,16,10,2), g.fill();
    g.restore();
    g.fillStyle='#0d0d0f'; roundRect(g,86,116,88,16,4); g.fill();
    g.fillStyle='#c9cfd6'; g.fillRect(112,119,36,10);
    g.fillStyle='#1c1c1f'; g.beginPath(); g.arc(98,126,5,0,7); g.arc(162,126,5,0,7); g.fill();
    g.fillStyle=rgba('#7fd4ff',0.8); g.fillRect(46,57,20,3); g.fillRect(194,57,20,3);
  });
}

/* sprite placement */
const SPRITE_DEFS={
  palm:{img:'palm',w:1000}, pine:{img:'pine',w:820}, cactus:{img:'cactus',w:480}, rock:{img:'rock',w:720},
  signTurbo:{img:'signTurbo',w:880}, signRad:{img:'signRad',w:880}, signRoute:{img:'signRoute',w:880},
  signAlp:{img:'signAlp',w:880}, signFinal:{img:'signFinal',w:880},
  neonCyan:{img:'neonCyan',w:980}, neonPink:{img:'neonPink',w:980}, neonYel:{img:'neonYel',w:980},
  lamp:{img:'lamp',w:300}, tower:{img:'tower',w:1500},
  gantryCheck:{img:'gantryCheck',w:ROAD_W*3.2}, gantryStart:{img:'gantryStart',w:ROAD_W*3.2},
  gantryFinish:{img:'gantryFinish',w:ROAD_W*3.5},
  tunnelGate:{img:'tunnelGate',w:ROAD_W*2.68},
  chevron:{img:'chevron',w:620}
};
const STAGE_POOLS=[
  [['palm',5],['palm',5],['signTurbo',1],['signRad',1],['rock',1]],
  [['cactus',4],['rock',3],['signRoute',1],['signTurbo',1]],
  [['neonCyan',2],['neonPink',2],['neonYel',1],['lamp',3],['tower',2]],
  [['pine',5],['pine',3],['rock',2],['signAlp',1]],
  [['palm',4],['signFinal',1],['signTurbo',1],['rock',1],['pine',2]],
  [['neonPink',3],['neonCyan',3],['neonYel',1],['lamp',4],['tower',3]],
  [['lamp',5],['tower',3],['neonCyan',2],['neonPink',2],['rock',1]]
];
function placeSprites(level){
  const rng=mulberry32(4400+level*97);
  const pool=STAGE_POOLS[level];
  const pick=p=>{ let tot=0; p.forEach(q=>tot+=q[1]); let r=rng()*tot;
    for(const q of p){ r-=q[1]; if(r<=0) return q[0]; } return p[0][0]; };
  const blocked=n=> Math.abs(n-finishSeg)<12 || checkpointSegs.some(cs=>Math.abs(n-cs)<9)
                   || tunnelZones.some(z=>n>z.start-8 && n<z.end+8);
  for(let n=16;n<segments.length-4;n+= 2+Math.floor(rng()*5)){
    if(blocked(n)) continue;
    const side=rng()<0.5?-1:1;
    const off=side*(1.2+rng()*2.4);
    const key=pick(pool), def=SPRITE_DEFS[key];
    segments[n].sprites.push({off, w:def.w, img:SPR[def.img], kind:key});
    if(key==='palm' && rng()<0.5) segments[n].sprites.push({off:-off, w:def.w, img:SPR[def.img], kind:key});
    if(key==='lamp'){ segments[n].sprites.push({off:-off, w:def.w, img:SPR[def.img], kind:key}); n+=3; }
  }
  segments[5].sprites.push({off:0,w:SPRITE_DEFS.gantryStart.w,img:SPR.gantryStart,kind:'gantry'});
  checkpointSegs.forEach(cs=> segments[cs].sprites.push({off:0,w:SPRITE_DEFS.gantryCheck.w,img:SPR.gantryCheck,kind:'gantry'}));
  segments[finishSeg+2].sprites.push({off:0,w:SPRITE_DEFS.gantryFinish.w,img:SPR.gantryFinish,kind:'gantry'});
  tunnelZones.forEach(z=> segments[z.start-4].sprites.push({off:0,w:SPRITE_DEFS.tunnelGate.w,img:SPR.tunnelGate,kind:'gantry'}));
  /* turbo pads — rare, on the racing line, glowing extra hard inside tunnels */
  const pickLanes=[-0.62,-0.02,0.58];
  for(let n=46;n<finishSeg-18;n+=88+Math.floor(rng()*104)){
    if(Math.abs(n-finishSeg)<16||checkpointSegs.some(cs=>Math.abs(n-cs)<12)) continue;
    segments[n].sprites.push({off:pickLanes[Math.floor(rng()*3)], w:SPRITE_DEFS.chevron.w, img:SPR.chevron, kind:'pickup'});
  }
}

/* =====================================================================
   TRAFFIC
   ===================================================================== */
function resetCars(level){
  cars.forEach(c=>{ if(c.seg){ const i=c.seg.cars.indexOf(c); if(i>=0) c.seg.cars.splice(i,1); } });
  cars=[];
  const rng=mulberry32(7777+((Date.now()/1000|0)%9000));
  const count=30+level*9;                       // thicker traffic every stage
  const lanes=[-0.76,-0.42,-0.08,0.28,0.66];
  const first=70, last=finishSeg-12;
  for(let i=0;i<count;i++){
    const z=(first+Math.floor(rng()*(last-first)))*SEG_LEN + rng()*SEG_LEN;
    const fast = level>=2 && rng()<0.2;
    const truck= rng()<0.14;
    const car={ z, offset:lanes[Math.floor(rng()*lanes.length)],
      speed:MAX_SPEED*(truck? 0.14+rng()*0.06 : fast? 0.6+rng()*0.18 : 0.19+rng()*0.3),
      img:truck? SPR.truck : SPR.traffic[Math.floor(rng()*SPR.traffic.length)],
      hw:truck?0.36:0.26, nmT:-9, seg:null };
    const seg=findSegment(z); seg.cars.push(car); car.seg=seg; cars.push(car);
  }
}

/* =====================================================================
   AUDIO — engine, SFX and three synthesized radio stations
   ===================================================================== */
let AC=null, master=null, musicBus=null, sfxBus=null, delaySend=null, noiseBuf=null, tunnelFilter=null;
let engOsc=null, engOsc2=null, engFilter=null, engGain=null, roadNoise=null, roadGain=null;
let squealNoise=null, squealGain=null, rainNoise=null, rainGain=null;

function ensureAudio(){
  if(AC) { if(AC.state==='suspended') AC.resume(); return; }
  const Ctx=window.AudioContext||window.webkitAudioContext; if(!Ctx) return;
  AC=new Ctx();
  master=AC.createGain(); master.gain.value=G.muted?0:0.9; master.connect(AC.destination);
  musicBus=AC.createGain(); musicBus.gain.value=0.5;
  tunnelFilter=AC.createBiquadFilter(); tunnelFilter.type='lowpass'; tunnelFilter.frequency.value=18000;
  musicBus.connect(tunnelFilter); tunnelFilter.connect(master);
  sfxBus=AC.createGain(); sfxBus.gain.value=0.8; sfxBus.connect(master);
  const dly=AC.createDelay(1); dly.delayTime.value=0.27;
  const fb=AC.createGain(); fb.gain.value=0.34; dly.connect(fb); fb.connect(dly);
  const wet=AC.createGain(); wet.gain.value=0.3; dly.connect(wet); wet.connect(musicBus);
  delaySend=AC.createGain(); delaySend.gain.value=1; delaySend.connect(dly);
  noiseBuf=AC.createBuffer(1,AC.sampleRate,AC.sampleRate);
  const d=noiseBuf.getChannelData(0); for(let i=0;i<d.length;i++) d[i]=Math.random()*2-1;
  /* engine */
  engGain=AC.createGain(); engGain.gain.value=0; engGain.connect(master);
  engFilter=AC.createBiquadFilter(); engFilter.type='lowpass'; engFilter.frequency.value=500; engFilter.connect(engGain);
  engOsc=AC.createOscillator(); engOsc.type='sawtooth'; engOsc.frequency.value=55; engOsc.connect(engFilter); engOsc.start();
  engOsc2=AC.createOscillator(); engOsc2.type='square'; engOsc2.frequency.value=28; engOsc2.detune.value=-8;
  const eg2=AC.createGain(); eg2.gain.value=0.5; engOsc2.connect(eg2); eg2.connect(engFilter); engOsc2.start();
  roadGain=AC.createGain(); roadGain.gain.value=0; roadGain.connect(master);
  const bp=AC.createBiquadFilter(); bp.type='bandpass'; bp.frequency.value=950; bp.Q.value=0.6;
  roadNoise=AC.createBufferSource(); roadNoise.buffer=noiseBuf; roadNoise.loop=true; roadNoise.connect(bp); bp.connect(roadGain); roadNoise.start();
  squealGain=AC.createGain(); squealGain.gain.value=0; squealGain.connect(master);
  const sbp=AC.createBiquadFilter(); sbp.type='bandpass'; sbp.frequency.value=1250; sbp.Q.value=2.2;
  squealNoise=AC.createBufferSource(); squealNoise.buffer=noiseBuf; squealNoise.loop=true;
  squealNoise.connect(sbp); sbp.connect(squealGain); squealNoise.start();
  rainGain=AC.createGain(); rainGain.gain.value=0; rainGain.connect(master);
  const rhp=AC.createBiquadFilter(); rhp.type='highpass'; rhp.frequency.value=400;
  const rlp=AC.createBiquadFilter(); rlp.type='lowpass'; rlp.frequency.value=2400;
  rainNoise=AC.createBufferSource(); rainNoise.buffer=noiseBuf; rainNoise.loop=true;
  rainNoise.connect(rhp); rhp.connect(rlp); rlp.connect(rainGain); rainNoise.start();
  Music.start();
}
const nf=m=>440*Math.pow(2,(m-69)/12);
function env(g,t,a,peak,dur){ g.gain.setValueAtTime(0.0001,t); g.gain.linearRampToValueAtTime(peak,t+a); g.gain.exponentialRampToValueAtTime(0.0001,t+dur); }
function tone(o){ if(!AC) return;
  const {f=440,f2=null,type='square',dur=0.15,vol=0.2,at=0.005,bus=sfxBus,send=0,t=AC.currentTime}=o;
  const osc=AC.createOscillator(); osc.type=type; osc.frequency.setValueAtTime(f,t);
  if(f2) osc.frequency.exponentialRampToValueAtTime(f2,t+dur);
  const g=AC.createGain(); env(g,t,at,vol,dur); osc.connect(g); g.connect(bus);
  if(send&&delaySend){ const s=AC.createGain(); s.gain.value=send; g.connect(s); s.connect(delaySend); }
  osc.start(t); osc.stop(t+dur+0.05);
}
function noiseHit(o){ if(!AC) return;
  const {dur=0.12,vol=0.25,type='highpass',freq=6000,freq2=null,q=0.8,t=AC.currentTime,bus=sfxBus,send=0}=o;
  const src=AC.createBufferSource(); src.buffer=noiseBuf; src.loop=true;
  const f=AC.createBiquadFilter(); f.type=type; f.frequency.setValueAtTime(freq,t); f.Q.value=q;
  if(freq2) f.frequency.exponentialRampToValueAtTime(freq2,t+dur);
  const g=AC.createGain(); env(g,t,0.004,vol,dur);
  src.connect(f); f.connect(g); g.connect(bus);
  if(send&&delaySend){ const s=AC.createGain(); s.gain.value=send; g.connect(s); s.connect(delaySend); }
  src.start(t); src.stop(t+dur+0.05);
}
const SFX={
  coin(){ tone({f:988,dur:0.09,vol:0.22}); tone({f:1319,dur:0.22,vol:0.22,t:AC?AC.currentTime+0.09:0}); },
  beep(hi){ tone({f:hi?880:440,dur:hi?0.4:0.12,vol:0.25}); },
  tick(){ tone({f:1250,dur:0.04,vol:0.12}); },
  crash(){ if(!AC)return; noiseHit({type:'lowpass',freq:900,dur:0.3,vol:0.5}); tone({f:170,f2:48,type:'sine',dur:0.32,vol:0.5}); },
  whoosh(){ noiseHit({type:'bandpass',freq:320,freq2:2800,q:1.4,dur:0.24,vol:0.3}); },
  nitro(){ tone({f:150,f2:760,type:'sawtooth',dur:0.5,vol:0.2}); noiseHit({type:'highpass',freq:2400,dur:0.5,vol:0.16}); },
  turbo(){ tone({f:420,f2:980,type:'sawtooth',dur:0.16,vol:0.2}); noiseHit({type:'highpass',freq:3200,dur:0.12,vol:0.1}); },
  thunder(){ if(!AC) return; const t=AC.currentTime;
    noiseHit({type:'bandpass',freq:2800,q:0.5,dur:0.16,vol:0.34,t});           // the crack
    noiseHit({type:'lowpass',freq:560,freq2:70,dur:1.8,vol:0.5,t:t+0.18});      // the roll
    tone({f:98,f2:36,type:'sine',dur:1.9,vol:0.4,t:t+0.18}); },
  checkpoint(){ [523,659,784,1047].forEach((f,i)=>tone({f,dur:0.14,vol:0.22,t:AC?AC.currentTime+i*0.09:0})); },
  fanfare(){ [392,523,659,784,1047,1319].forEach((f,i)=>tone({f,dur:0.2,vol:0.2,t:AC?AC.currentTime+i*0.12:0,type:'square'})); },
  gameover(){ [392,330,262,196].forEach((f,i)=>tone({f,dur:0.3,vol:0.2,t:AC?AC.currentTime+i*0.22:0,type:'triangle'})); }
};

/* ---- the radio ---- */
const MINOR_PENT=[0,3,5,7,10,12,15,17,19,22];
const MAJOR_PENT=[0,2,4,7,9,12,14,16,19,21];
function genMelody(scale,root,seed,density){
  const r=mulberry32(seed), out=new Array(64).fill(-1);
  let saved=null, lastI=3;
  for(let bar=0; bar<4; bar++){
    let phrase;
    if(bar%2===0){
      phrase=[]; let i=(bar===0)? 2+Math.floor(r()*3) : lastI;
      for(let k=0;k<8;k++){
        if(r()<density){ i=clamp(i+(r()<0.5?-1:1)*(r()<0.72?1:2),0,scale.length-1); phrase.push(scale[i]); }
        else phrase.push(-1);
      }
      if(bar===2){ phrase[6]=scale[1]; phrase[7]=scale[0]; }
      saved=phrase.slice(); lastI=i;
    } else phrase=saved.slice();
    if(bar===3){ phrase[6]=scale[0]; phrase[7]=-1; }
    for(let k=0;k<8;k++) out[bar*16+k*2]=phrase[k]<0?-1:(root+24+phrase[k]);
  }
  return out;
}
const TRACKS={
  1:{ name:'SUNSET BLAZER', bpm:132, prog:[45,41,48,43], bassWave:'square',
      bassPat:[0,-1,0,-1,0,-1,12,-1,0,-1,0,-1,7,-1,12,-1],
      kick:[0,4,8,12], clap:[4,12], hats:'eighths', openHat:14, pad:null,
      melody:genMelody(MINOR_PENT,45,101,0.8), leadWave:'square', leadVol:0.085 },
  2:{ name:'MAGNOLIA BREEZE', bpm:100, prog:[41,45,38,36], bassWave:'triangle',
      bassPat:[0,-1,-1,0,-1,-1,7,-1,0,-1,-1,0,-1,12,-1,-1],
      kick:[0,8], clap:[], hats:'soft', openHat:-1, pad:[0,4,7,11],
      melody:genMelody(MAJOR_PENT,41,202,0.62), leadWave:'triangle', leadVol:0.16 },
  3:{ name:'NEON NIGHTDRIVE', bpm:118, prog:[33,31,36,38], bassWave:'square',
      bassPat:[0,-1,-1,0,-1,0,-1,-1,12,-1,-1,0,-1,-1,10,-1],
      kick:[0,4,8,12], clap:[4,12], hats:'sixteenths', openHat:14, pad:[0,3,7,10],
      melody:genMelody(MINOR_PENT,33,303,0.5), leadWave:'square', leadVol:0.07 },
  4:{ name:'NEON TUNNEL', bpm:142, prog:[33,31,29,31], bassWave:'sawtooth',
      bassPat:[0,-1,0,0,-1,0,-1,0,12,-1,0,-1,7,-1,12,-1],
      kick:[0,3,4,8,11,12], clap:[4,12], hats:'sixteenths', openHat:14, pad:[0,3,7,10],
      melody:genMelody(MINOR_PENT,33,404,0.56), leadWave:'sawtooth', leadVol:0.06 }
};
const Music={
  timer:null, step:0, nextTime:0,
  start(){ if(this.timer||!AC) return; this.nextTime=AC.currentTime+0.08;
    this.timer=setInterval(()=>this.schedule(),30); },
  schedule(){ while(this.nextTime<AC.currentTime+0.15){ this.playStep(this.step,this.nextTime);
      this.nextTime+=(60/TRACKS[G.radioTrack].bpm)/4; this.step=(this.step+1)%64; } },
  playStep(step,t){
    const T=TRACKS[G.radioTrack], s16=step%16, root=T.prog[Math.floor(step/16)%T.prog.length];
    if(T.kick.includes(s16)){ this.kickT=t;
      tone({f:150,f2:46,type:'sine',dur:0.16,vol:0.5,bus:musicBus,t}); }
    if(T.clap.includes(s16)){ noiseHit({type:'bandpass',freq:1700,q:1,dur:0.13,vol:0.24,bus:musicBus,send:0.5,t});
      noiseHit({type:'bandpass',freq:1700,q:1,dur:0.1,vol:0.16,bus:musicBus,t:t+0.014}); }
    if(T.hats==='eighths' && s16%2===0) noiseHit({type:'highpass',freq:8200,dur:s16===T.openHat?0.1:0.035,vol:s16%4?0.07:0.12,bus:musicBus,t});
    if(T.hats==='soft' && [2,6,10,14].includes(s16)) noiseHit({type:'highpass',freq:9000,dur:0.05,vol:0.05,bus:musicBus,t});
    if(T.hats==='sixteenths') noiseHit({type:'highpass',freq:8600,dur:s16===T.openHat?0.11:0.026,vol:s16%4?0.045:0.09,bus:musicBus,t});
    const b=T.bassPat[s16];
    if(b>=0){ const m=root+b;
      const osc=AC.createOscillator(); osc.type=T.bassWave; osc.frequency.value=nf(m);
      const f=AC.createBiquadFilter(); f.type='lowpass'; f.frequency.setValueAtTime(900,t); f.frequency.exponentialRampToValueAtTime(240,t+0.16);
      const g=AC.createGain(); env(g,t,0.004,0.22,0.19);
      osc.connect(f); f.connect(g); g.connect(musicBus); osc.start(t); osc.stop(t+0.25); }
    if(s16===0 && T.pad){ T.pad.forEach(iv=>{ [-6,6].forEach(det=>{
        const osc=AC.createOscillator(); osc.type='triangle'; osc.frequency.value=nf(root+12+iv); osc.detune.value=det;
        const g=AC.createGain(); g.gain.setValueAtTime(0.0001,t); g.gain.linearRampToValueAtTime(0.035,t+0.5);
        g.gain.linearRampToValueAtTime(0.0001,t+(60/T.bpm)*3.6);
        osc.connect(g); g.connect(musicBus); osc.start(t); osc.stop(t+(60/T.bpm)*4); }); }); }
    const mv=T.melody[step];
    if(mv>0){ const long=(step+2<64 && T.melody[step+2]<0);
      const osc=AC.createOscillator(); osc.type=T.leadWave; osc.frequency.value=nf(mv);
      const g=AC.createGain(); env(g,t,0.008,T.leadVol,long?0.42:0.2);
      osc.connect(g); g.connect(musicBus);
      const s=AC.createGain(); s.gain.value=0.9; g.connect(s); s.connect(delaySend);
      osc.start(t); osc.stop(t+0.5); }
  }
};
function setRadio(n){ G.radioTrack=n; if(AC) tone({f:1560,dur:0.05,vol:0.12});
  $('radioName').textContent=TRACKS[n].name; }
/* 0..1 pulse that decays after every kick — drives the tunnel light rings */
function beatPulse(){
  const T=TRACKS[G.radioTrack];
  if(!AC||!Music.kickT||G.muted) return 0.55+0.45*Math.abs(Math.sin(G.now*6));
  const ph=(AC.currentTime-Music.kickT)/(60/T.bpm);
  return ph<0? 1 : 1/(1+Math.max(0,ph)*3.4);
}
function toggleMute(){ G.muted=!G.muted; if(master) master.gain.value=G.muted?0:0.9;
  const b=$('btnSound'); b.classList.toggle('on',!G.muted); b.innerHTML=G.muted? 'SOUND <b>OFF</b>' : 'SOUND <b>ON</b>'; }

/* =====================================================================
   BACKGROUND — sky, sun, ridges, stars, sea
   ===================================================================== */
const ridgeA=[], ridgeB=[], skyline=[], stars=[], skyWin=[];
function buildScenery(){
  const r1=mulberry32(11), r2=mulberry32(23), r3=mulberry32(37);
  const ph=[r1()*9,r1()*9,r1()*9];
  for(let i=0;i<512;i++) ridgeA.push(0.42+0.34*Math.sin(i*0.045+ph[0])+0.24*Math.sin(i*0.11+ph[1]));
  const ph2=[r2()*9,r2()*9];
  for(let i=0;i<512;i++) ridgeB.push(0.5+0.3*Math.sin(i*0.03+ph2[0])+0.2*Math.sin(i*0.16+ph2[1]));
  let x=0;
  while(x<1500){ const w=26+r3()*60, h=40+r3()*150; skyline.push({x,w,h}); x+=w+2+r3()*8; }
  const rs=mulberry32(99);
  for(let i=0;i<110;i++) stars.push({x:rs()*SCENE_W, y:rs()*SCENE_H*0.42, s:rs()<0.2?2:1, tw:rs()*9});
  const rw=mulberry32(55);
  skyline.forEach(b=>{ for(let wy=10; wy<b.h-8; wy+=13) for(let wx=5; wx<b.w-6; wx+=10)
    if(rw()<0.3) skyWin.push({x:b.x+wx, y:wy, c:rw()<0.6?'#ffd27a':'#7fe0ff', tw:rw()*9}); });
}
function skyColorAt(pal,u){ // u: 0 top .. 1 horizon
  if(u<0.6) return mixHex(pal.skyTop,pal.skyMid,u/0.6);
  return mixHex(pal.skyMid,pal.skyLow,(u-0.6)/0.4);
}
function drawRidge(c,pal,heights,par,amp,baseY,col,rim){
  const off=(G.skyOffset*par + G.playerX*par*26);
  c.fillStyle=col; c.beginPath(); c.moveTo(0,SCENE_H);
  for(let sx=0;sx<=SCENE_W;sx+=4){
    const w=sx+off; const idx=((Math.floor(w/3)%512)+512)%512;
    c.lineTo(sx, baseY-heights[idx]*amp);
  }
  c.lineTo(SCENE_W,SCENE_H); c.closePath(); c.fill();
  if(rim){ c.strokeStyle=rim; c.lineWidth=1.5; c.stroke(); }
}
function drawBackground(c,pal,eyeShift){
  const W=SCENE_W,H=SCENE_H;
  const horizonY=clamp(G.horizonY||H*0.5, H*0.2, H*0.76);
  const ex=eyeShift*0.012;
  const g=c.createLinearGradient(0,0,0,horizonY+2);
  g.addColorStop(0,pal.skyTop); g.addColorStop(0.6,pal.skyMid); g.addColorStop(1,pal.skyLow);
  c.fillStyle=g; c.fillRect(0,0,W,H);
  /* stars */
  if(pal.stars>0){ for(const s of stars){
      const a=pal.stars*(0.35+0.65*Math.abs(Math.sin(G.now*1.4+s.tw)));
      c.fillStyle=rgba('#cfeaff',a); c.fillRect(s.x,s.y,s.s,s.s); } }
  /* sun / moon */
  const isCity = pal.type==='city', isStorm = pal.type==='storm';
  const prog = (G.state==='race'||G.state==='count') ? G.position/trackLength : 0.16;
  const sunX=W*0.64 - (G.skyOffset*0.045+ex) ;
  const sunY=horizonY - H*0.16 + prog*H*0.14;
  const r= isCity? 34 : isStorm? 52 : 92;
  const glow=c.createRadialGradient(sunX,sunY,r*0.2,sunX,sunY,r*2.6);
  glow.addColorStop(0,rgba(pal.glow,isStorm?0.3:0.55)); glow.addColorStop(1,rgba(pal.glow,0));
  c.fillStyle=glow; c.fillRect(sunX-r*3,sunY-r*3,r*6,r*6);
  if(!isStorm){                                     // the storm hides the sun behind cloud
    c.fillStyle=pal.sunCore; c.beginPath(); c.arc(sunX,sunY,r,0,7); c.fill();
  }
  if(!isCity){ /* synthwave slice gaps, filled with matching sky color */
    for(let i=0;i<6;i++){ const gy=sunY+r*(0.06+i*0.155), gh=2+i*1.8;
      if(gy<sunY+r){ c.fillStyle=skyColorAt(pal,clamp(gy/horizonY,0,1)*0.999);
        c.fillRect(sunX-r-4,gy,r*2+8,gh); } }
  } else { c.fillStyle='#0b1c33'; c.beginPath(); c.arc(sunX-12,sunY-8,r*0.82,0,7); c.fill(); }
  /* sea */
  if(pal.sea && (pal.seaA===undefined||pal.seaA>0.02)){
    c.save(); c.globalAlpha=(pal.seaA===undefined?1:pal.seaA);
    const sg=c.createLinearGradient(0,horizonY,0,horizonY+H*0.12);
    sg.addColorStop(0,pal.sea); sg.addColorStop(1,shade(pal.sea,-26));
    c.fillStyle=sg; c.fillRect(0,horizonY,W,H*0.12);
    c.fillStyle=rgba('#ffffff',0.16);
    for(let i=0;i<10;i++){ const yy=horizonY+6+i*(H*0.011);
      const xx=((i*173 + G.now*30*(1+i*0.2) - ex*2)%(W+160))-80;
      c.fillRect(xx,yy,34+i*6,2); }
    c.restore();
  }
  /* ridges / skyline */
  if(pal.type==='city'){
    const off=(G.skyOffset*0.22 + G.playerX*7 + ex);
    c.fillStyle=pal.mFar;
    skyline.forEach(b=>{ const bx=((b.x-off)%(1500)+1500)%1500 - 60;
      c.fillRect(bx,horizonY-b.h*0.62-14,b.w,b.h*0.62+14); });
    const off2=(G.skyOffset*0.4 + G.playerX*13 + ex);
    c.fillStyle=pal.mNear;
    skyline.forEach(b=>{ const bx=((b.x*1.3-off2)%(1500)+1500)%1500 - 60;
      c.fillRect(bx,horizonY-b.h*0.9,b.w,b.h*0.9+6); c.fillRect(bx+b.w*0.4,horizonY-b.h*0.9-10,3,10); });
    skyWin.forEach(wd=>{ const bx=((wd.x*1.3-off2)%(1500)+1500)%1500 - 60;
      const a=0.4+0.6*Math.abs(Math.sin(G.now*0.8+wd.tw));
      c.fillStyle=rgba(wd.c,a*0.8); c.fillRect(bx,horizonY-wd.y*0.9,3,4); });
  } else {
    drawRidge(c,pal,ridgeA,0.22,H*0.16,horizonY+8,pal.mFar,null);
    drawRidge(c,pal,ridgeB,0.42,H*0.11,horizonY+20,pal.mNear,rgba(pal.glow,0.22));
  }
  /* lightning — the whole landscape flares, then the bolt lands */
  const fl=G.storm.flash;
  if(fl>0){
    c.save(); c.globalCompositeOperation='lighter';
    c.fillStyle=rgba('#c9dcff',fl*0.4); c.fillRect(0,0,W,horizonY+H*0.03);
    c.restore();
  }
  drawBolt(c,horizonY);
  /* clouds */
  const cloudCol = isStorm? '#232b42' : (isCity? '#16283e' : '#ffffff');
  const cloudA   = isStorm? 0.8 : (isCity?0.5:0.5);
  c.fillStyle=rgba(cloudCol,cloudA);
  const nClouds = isStorm? 9 : 5;
  for(let i=0;i<nClouds;i++){
    const cw=140+ (i%3)*70;
    const cx=((i*290 + G.cloudOff*(12+i*4) - ex*1.5)%(W+400))-200;
    const cy=H*(isStorm?0.03:0.1)+ (i*53)% (isStorm? H*0.3 : H*0.2);
    c.beginPath(); c.ellipse(cx,cy,cw*0.5,13+i*3,0,0,7); c.ellipse(cx+cw*0.22,cy-9,cw*0.32,11,0,0,7); c.fill();
  }
  if(isStorm){                                      // low scud racing under the stormhead
    c.fillStyle=rgba('#161d2e',0.85);
    for(let i=0;i<7;i++){
      const cw=200+(i%4)*90;
      const cx=((i*431 + G.cloudOff*46 - ex*2.4)%(W+700))-350;
      const cy=horizonY-H*0.1-((i*37)%Math.max(1,H*0.16));
      c.beginPath(); c.ellipse(cx,cy,cw*0.5,9+(i%3)*5,0,0,7); c.fill();
    }
  }
}

/* =====================================================================
   RENDERING — the pseudo-3D pipeline
   ===================================================================== */
function project(p,camX,camY,camZ,cd,W,H){
  p.camera.x=-camX;
  p.camera.y=p.world.y-camY;
  p.camera.z=p.world.z-camZ;
  p.screen.scale=cd/p.camera.z;
  p.screen.x=Math.round(W/2 + p.screen.scale*p.camera.x*W/2);
  p.screen.y=Math.round(H/2 - p.screen.scale*p.camera.y*H/2);
  p.screen.w=Math.round(p.screen.scale*ROAD_W*W/2);
}
function poly(c,x1,y1,x2,y2,x3,y3,x4,y4,col){
  c.fillStyle=col; c.beginPath(); c.moveTo(x1,y1); c.lineTo(x2,y2); c.lineTo(x3,y3); c.lineTo(x4,y4); c.closePath(); c.fill();
}
let camDepthCur=CAM_DEPTH;
function fogAt(d){ return 1/Math.exp(d*d*5); }

function drawSegment(c,seg,pal){
  const p1=seg.p1.screen, p2=seg.p2.screen, W=SCENE_W;
  const alt=seg.shade;
  c.fillStyle= seg.tunnel? (alt? '#120b1f':'#170e2a') : (alt? pal.grassB : pal.grassA);
  c.fillRect(0,p2.y,W,p1.y-p2.y);
  const r1=Math.max(2,p1.w*0.14), r2=Math.max(2,p2.w*0.14);
  const rc= alt? pal.rumbleB : pal.rumbleA;
  poly(c,p1.x-p1.w-r1,p1.y, p1.x-p1.w,p1.y, p2.x-p2.w,p2.y, p2.x-p2.w-r2,p2.y, rc);
  poly(c,p1.x+p1.w+r1,p1.y, p1.x+p1.w,p1.y, p2.x+p2.w,p2.y, p2.x+p2.w+r2,p2.y, rc);
  const roadCol= alt? pal.roadB : pal.roadA;
  if(seg.checker){ /* start / checkpoint strip */
    poly(c,p1.x-p1.w,p1.y, p1.x+p1.w,p1.y, p2.x+p2.w,p2.y, p2.x-p2.w,p2.y, (seg.index%2)? '#e8e8e8':'#20242c');
  } else {
    poly(c,p1.x-p1.w,p1.y, p1.x+p1.w,p1.y, p2.x+p2.w,p2.y, p2.x-p2.w,p2.y, roadCol);
    if(!alt){ const l1=Math.max(1,p1.w/30), l2=Math.max(1,p2.w/30);
      const lw1=p1.w*2/LANES, lw2=p2.w*2/LANES;
      for(let i=1;i<LANES;i++){
        const lx1=p1.x-p1.w+lw1*i, lx2=p2.x-p2.w+lw2*i;
        poly(c,lx1-l1,p1.y,lx1+l1,p1.y,lx2+l2,p2.y,lx2-l2,p2.y,pal.lane);
      }
    }
  }
  const fa=1-seg.fog;
  if(fa>0.015){ c.fillStyle=rgba(pal.fog,fa); c.fillRect(0,p2.y,W,p1.y-p2.y); }
}
function clipDraw(c,img,dx,dy,dw,dh,clipY,alpha){
  if(dw<2||alpha<=0.02) return;
  if(dy-dh>=clipY) return;
  c.save();
  if(clipY<SCENE_H){ c.beginPath(); c.rect(0,0,SCENE_W,clipY); c.clip(); }
  c.globalAlpha=alpha;
  c.drawImage(img,dx,dy-dh,dw,dh);
  c.restore(); c.globalAlpha=1;
}
/* ---- neon tunnel geometry ---- */
const TUN_BAND=['#8ef8ff','#ff5ce1','#ffd23f'];
function drawTunnel(c,seg){
  const p1=seg.p1.screen, p2=seg.p2.screen;
  if(p1.w<0.4) return;
  const h1=p1.w*1.2, h2=p2.w*1.2, o1=p1.w*1.34, o2=p2.w*1.34;
  const t1=p1.y-h1, t2=p2.y-h2;
  const fog=clamp(seg.fog*1.7,0,1), alt=seg.shade;
  c.globalAlpha=fog;
  poly(c, p1.x-o1,t1, p1.x+o1,t1, p2.x+o2,t2, p2.x-o2,t2, alt?'#170e28':'#1d1330');      // ceiling
  poly(c, p1.x-o1,p1.y, p1.x-o1,t1, p2.x-o2,t2, p2.x-o2,p2.y, alt?'#221540':'#271848');  // left wall
  poly(c, p1.x+o1,p1.y, p1.x+o1,t1, p2.x+o2,t2, p2.x+o2,p2.y, alt?'#2a1b50':'#301f5a');  // right wall
  if(seg.index%3===0){                                                                    // light rings
    const col=TUN_BAND[Math.floor(seg.index/3)%3];
    const bh=Math.max(2,p1.w*0.075);
    c.globalAlpha=fog*(0.42+0.58*beatPulse());
    poly(c, p1.x-o1,t1, p1.x+o1,t1, p1.x+o1,t1+bh, p1.x-o1,t1+bh, col);
    poly(c, p1.x-o1,t1, p1.x-o1+bh*0.8,t1, p2.x-o2+bh*0.8,t2, p2.x-o2,t2, col);
    poly(c, p1.x+o1,t1, p1.x+o1-bh*0.8,t1, p2.x+o2-bh*0.8,t2, p2.x+o2,t2, col);
    c.globalAlpha=fog*0.17;                              // neon spill on the tarmac
    poly(c, p1.x-p1.w,p1.y, p1.x+p1.w,p1.y, p2.x+p2.w,p2.y, p2.x-p2.w,p2.y, col);
  }
  c.globalAlpha=1;
}
function drawRoadside(c,seg,spr){
  const s=seg.p1.screen;
  const dw=spr.w*s.scale*SCENE_W/2;
  if(dw<3) return;
  const dh=dw*spr.img.height/spr.img.width;
  const dx=s.x + s.scale*spr.off*ROAD_W*SCENE_W/2 - dw/2;
  let dy=s.y, alpha=Math.min(1,seg.fog*1.35);
  if(spr.kind==='pickup'){                          // hovers and shimmers
    dy-=dh*0.16+Math.sin(G.now*5+seg.index*1.7)*dh*0.09;
    alpha*=0.75+0.25*Math.sin(G.now*7+seg.index);
    c.save();
    if(seg.clip<SCENE_H){ c.beginPath(); c.rect(0,0,SCENE_W,seg.clip); c.clip(); }
    c.globalAlpha=alpha*0.4; c.fillStyle='#8ef8ff';
    c.beginPath(); c.ellipse(dx+dw/2,s.y-2,dw*0.42,dw*0.07,0,0,7); c.fill();
    c.restore(); c.globalAlpha=1;
  }
  clipDraw(c,spr.img,dx,dy,dw,dh,seg.clip,alpha);
  if(G.wet && dw>26 && REFLECT_KINDS.has(spr.kind))      // mirror on the wet tarmac
    blitReflection(c,spr.img,dx,s.y,dw,dh,0.24,1.5);
}
function drawTraffic(c,seg,car){
  const pct=(car.z%SEG_LEN)/SEG_LEN;
  const s=lerp(seg.p1.screen.scale,seg.p2.screen.scale,pct);
  if(!(s>0.00001)) return;
  const sx=lerp(seg.p1.screen.x,seg.p2.screen.x,pct) + s*car.offset*ROAD_W*SCENE_W/2;
  const sy=lerp(seg.p1.screen.y,seg.p2.screen.y,pct);
  if(sy>SCENE_H+120) return;
  const dw=640*s*SCENE_W/2;
  const dh=dw*car.img.height/car.img.width;
  c.save();
  if(seg.clip<SCENE_H){ c.beginPath(); c.rect(0,0,SCENE_W,seg.clip); c.clip(); }
  c.globalAlpha=Math.min(1,seg.fog*1.35);
  c.fillStyle='rgba(0,0,0,.3)';
  c.beginPath(); c.ellipse(sx,sy-2,dw*0.5,dw*0.09,0,0,7); c.fill();
  c.drawImage(car.img,sx-dw/2,sy-dh,dw,dh);
  if(G.wet && dw>26) blitReflection(c,car.img,sx-dw/2,sy,dw,dh,0.3,1.6);
  c.restore(); c.globalAlpha=1;
}

function renderScene(c,eyeShift){
  const pal=curPal();
  const W=SCENE_W,H=SCENE_H;
  const base=findSegment(G.position);
  const basePct=(G.position%SEG_LEN)/SEG_LEN;
  const pSeg=findSegment(G.position+PLAYER_Z);
  const pPct=((G.position+PLAYER_Z)%SEG_LEN)/SEG_LEN;
  const pY=lerp(pSeg.p1.world.y,pSeg.p2.world.y,pPct);
  G.camYWorld=pY;
  let maxy=H, x=0, dx=-(base.curve*basePct), topY=H;
  const camX=G.playerX*ROAD_W + eyeShift;
  const camY=pY+CAM_H;
  /* pass 1 — project everything, and find where the road meets the sky */
  for(let n=0;n<DRAW_DIST;n++){
    const seg=segments[(base.index+n)%segments.length];
    seg.clip=maxy;
    seg.fog=fogAt(n/DRAW_DIST);
    seg.visible=false;
    const camZ=G.position - (seg.index<base.index? trackLength:0);
    project(seg.p1,camX-x,     camY,camZ,camDepthCur,W,H);
    project(seg.p2,camX-x-dx,  camY,camZ,camDepthCur,W,H);
    x+=dx; dx+=seg.curve;
    if(seg.p1.camera.z<=camDepthCur || seg.p2.screen.y>=seg.p1.screen.y || seg.p2.screen.y>=maxy) continue;
    seg.visible=true;
    if(seg.p2.screen.y<topY) topY=seg.p2.screen.y;
    maxy=seg.p2.screen.y;
  }
  /* the skyline rides on top of the road, so crests lift the horizon */
  G.horizonY=clamp(topY>=H? H*0.5 : topY, H*0.22, H*0.74);
  drawBackground(c,pal,eyeShift);
  /* pass 2 — paint the road front-to-back */
  for(let n=0;n<DRAW_DIST;n++){
    const seg=segments[(base.index+n)%segments.length];
    if(seg.visible) drawSegment(c,seg,pal);
  }
  /* pass 3 — tunnel shell, far to near so the exit stays visible */
  for(let n=DRAW_DIST-1;n>=0;n--){
    const seg=segments[(base.index+n)%segments.length];
    if(seg.tunnel && seg.visible) drawTunnel(c,seg);
  }
  for(let n=DRAW_DIST-1;n>=0;n--){
    const seg=segments[(base.index+n)%segments.length];
    for(const car of seg.cars) drawTraffic(c,seg,car);
    for(const spr of seg.sprites) drawRoadside(c,seg,spr);
  }
  drawPlayer(c,pal);
  drawParticles(c);
  if(G.boosting||G.speed/MAX_SPEED>0.92) drawSpeedLines(c);
  drawRain(c);
}

/* ---------------- player car ---------------- */
function drawPlayer(c){
  if(G.crashT>0 && Math.floor(G.now*12)%2===0 && G.state==='race') return;
  const cw=SCENE_W*0.19, ch=cw*SPR.player.height/SPR.player.width;
  const ratio=G.speed/MAX_SPEED;
  const bounce=Math.sin(G.now*ratio*42)*1.7*ratio + (G.offroad? Math.sin(G.now*60)*2.4*Math.min(1,ratio*2):0);
  const cx=SCENE_W/2 + G.steerVis*10;
  const cy=SCENE_H-12+bounce;
  const lean=G.steerVis*0.10*Math.min(1,ratio*1.6);
  c.save();
  c.translate(cx,cy);
  c.transform(1,0,lean,1,0,0);
  c.fillStyle='rgba(0,0,0,.36)';
  c.beginPath(); c.ellipse(0,4,cw*0.53,cw*0.085,0,0,7); c.fill();
  if(G.boosting){ /* exhaust flames */
    for(const ex of [-cw*0.16,cw*0.16]){
      const fl=ch*0.28*(0.55+Math.random()*0.9);
      const fg=c.createLinearGradient(0,-8,0,fl);
      fg.addColorStop(0,'#fff3c0'); fg.addColorStop(0.35,'#ffb43a'); fg.addColorStop(1,'rgba(255,90,40,0)');
      c.fillStyle=fg;
      c.beginPath(); c.moveTo(ex-7,-6); c.lineTo(ex+7,-6); c.lineTo(ex+(Math.random()*6-3),fl); c.closePath(); c.fill();
    }
  }
  if(G.wet && ratio>0.3){                              // wheel spray off the wet road
    const sp=(ratio-0.25)*1.3;
    for(const sx of [-cw*0.3,cw*0.3]){
      c.fillStyle=rgba('#dcecff',0.1+0.16*sp);
      c.beginPath(); c.ellipse(sx,-3,cw*0.15*sp+7,cw*0.1*sp+4,0,0,7); c.fill();
    }
  }
  c.drawImage(SPR.player,-cw/2,-ch,cw,ch);
  if(input.brake && G.state==='race'){
    c.fillStyle=rgba('#ff3b30',0.55+0.3*Math.sin(G.now*30));
    c.fillRect(-cw*0.36,-ch*0.5,cw*0.72,ch*0.075);
  }
  c.restore();
}
function drawSpeedLines(c){
  const n=G.boosting?16:8, cx=SCENE_W/2, cy=SCENE_H*0.42;
  c.strokeStyle=rgba('#ffffff',G.boosting?0.16:0.07);
  c.lineWidth=2;
  for(let i=0;i<n;i++){
    const a=Math.random()*Math.PI*2;
    const r0=Math.min(SCENE_W,SCENE_H)*(0.42+Math.random()*0.2);
    const r1=r0+60+Math.random()*130;
    c.beginPath();
    c.moveTo(cx+Math.cos(a)*r0,cy+Math.sin(a)*r0*0.7);
    c.lineTo(cx+Math.cos(a)*r1,cy+Math.sin(a)*r1*0.7);
    c.stroke();
  }
}

/* ---------------- rain ---------------- */
let rainCv=null;
function buildRain(){
  rainCv=makeCanvas(512,512,(g)=>{
    const r=mulberry32(4242);
    for(let i=0;i<200;i++){
      const x=r()*512, y=r()*512, len=12+r()*54;
      g.strokeStyle=`rgba(206,228,255,${0.14+r()*0.36})`;
      g.lineWidth=r()<0.22?2:1;
      g.beginPath(); g.moveTo(x,y); g.lineTo(x-3,y+len); g.stroke();
    }
  });
}
function drawRain(c){
  const a=G.rainAlpha;
  if(a<=0.02) return;
  if(!rainCv) buildRain();
  const W=SCENE_W,H=SCENE_H, r=G.speed/MAX_SPEED;
  const ang=-0.16-r*0.52+G.steerVis*0.12;             // streaks lay back as you speed up
  const half=Math.max(W,H)*0.8;
  c.save();
  c.translate(W/2,H/2); c.rotate(ang);
  for(let layer=0;layer<2;layer++){
    const sc=layer? 2.1 : 1.05, ts=512*sc;
    const ox=-((G.now*(layer?420:210)*(1+r*2.2))%ts);
    const oy=-((G.now*(layer?900:520)*(1+r*1.4))%ts);
    c.globalAlpha=a*(layer?0.46:0.3);
    for(let tx=Math.floor(-half/ts); tx*ts<half+ts; tx++)
      for(let ty=Math.floor(-half/ts); ty*ts<half+ts; ty++)
        c.drawImage(rainCv,ox+tx*ts,oy+ty*ts,ts,ts);
  }
  c.restore(); c.globalAlpha=1;
}
/* ---------------- wet-road reflections ---------------- */
let reflCv=null, reflG=null;
function blitReflection(c,img,dx,baseY,dw,dh,alpha,stretch){
  if(dw<6||alpha<=0.02) return;
  /* quantised buffer so the canvas isn't reallocated every frame */
  const rw=Math.min(216,Math.max(16,Math.round(dw/12)*12));
  const rh=Math.min(288,Math.max(16,Math.round(dh*stretch/12)*12));
  if(!reflCv){ reflCv=document.createElement('canvas'); reflG=reflCv.getContext('2d'); }
  if(reflCv.width!==rw||reflCv.height!==rh){ reflCv.width=rw; reflCv.height=rh; }
  reflG.setTransform(1,0,0,1,0,0);
  reflG.clearRect(0,0,rw,rh);
  reflG.save(); reflG.translate(0,rh); reflG.scale(1,-1);
  reflG.drawImage(img,0,0,rw,rh);
  reflG.restore();
  const g=reflG.createLinearGradient(0,0,0,rh);
  g.addColorStop(0,`rgba(0,0,0,${alpha})`);
  g.addColorStop(0.5,`rgba(0,0,0,${alpha*0.4})`);
  g.addColorStop(1,'rgba(0,0,0,0)');
  reflG.globalCompositeOperation='destination-in';
  reflG.fillStyle=g; reflG.fillRect(0,0,rw,rh);
  reflG.globalCompositeOperation='source-over';
  c.drawImage(reflCv,dx,baseY,dw,dh*stretch);
}
const REFLECT_KINDS=new Set(['lamp','neonCyan','neonPink','neonYel','tower','gantry','pickup']);

/* ---------------- particles & popups ---------------- */
function spawnPart(o){ if(G.parts.length<320) G.parts.push(Object.assign({life:1,maxLife:1,vx:0,vy:0,size:3,grav:0},o)); }
function drawParticles(c){
  for(let i=G.parts.length-1;i>=0;i--){
    const p=G.parts[i];
    p.life-=p.dt||0.016; p.x+=p.vx; p.y+=p.vy; p.vy+=p.grav;
    if(p.life<=0){ G.parts.splice(i,1); continue; }
    c.globalAlpha=clamp(p.life/p.maxLife,0,1)*p.alpha||1;
    c.fillStyle=p.color;
    c.fillRect(p.x-p.size/2,p.y-p.size/2,p.size,p.size);
  }
  c.globalAlpha=1;
}
function addPopup(text,color,x,y){ G.popups.push({text,color,x,y,life:1.15}); }

/* =====================================================================
   UPDATE
   ===================================================================== */
function isHex(v){ return typeof v==='string' && v && v[0]==='#'; }
function curPal(){
  if(G.palT>=1) return G.palB;
  const t=easeInOut(0,1,G.palT), a=G.palA, b=G.palB, o={};
  for(const k of Object.keys(b)){
    o[k]= (isHex(b[k]) && isHex(a[k])) ? mixHex(a[k],b[k],t) : b[k];
  }
  /* the sea fades out rather than blending with a null value */
  o.seaA=lerp(a.seaA||0, b.seaA||0, t);
  o.sea=isHex(a.sea)&&isHex(b.sea) ? o.sea : (t<0.5? (a.sea||b.sea) : (b.sea||a.sea));
  o.type=t<0.5?a.type:b.type; o.stars=lerp(a.stars||0,b.stars||0,t); o.name=b.name;
  return o;
}
function setStagePalette(idx,snap){
  G.palA=curPal(); G.palB=PALETTES[idx]; G.palT=snap?1:0;
}
function startRace(){ G.stageIdx=0; startLevel(); }
function startLevel(){
  const L=G.stageIdx;
  buildLevel(L); placeSprites(L); resetCars(L);
  G.position=0; G.playerX=0; G.speed=0; G.steerVis=0;
  G.timeLeft=LEVEL_TIME[L]; G.cpDone=[false,false]; G.results=null;
  G.nearMisses=0; G.topSpeedKmh=0; G.raceElapsed=0;
  G.gauge=0.55; G.boosting=false; G.crashT=0; G.shakeT=0; G.flashT=0;
  G.popups.length=0; G.parts.length=0;
  setStagePalette(L,true);
  if(L>=5 && G.radioTrack!==4) setRadio(4);        // the tunnel picks its own station
  G.state='count'; G.countT=3.0; G.lastCount=4;
  if(AC) SFX.coin();
  G.credits=1; updateLeds();
  const bTxt = L>=6? 'THUNDER ROAD · STORM WARNING' :
                L>=5? 'NEON TUNNEL · TUNNEL FM LOCKED ON' : PALETTES[L].name;
  const bCol = L>=6? '#cfe4ff' : (L>=5? '#8ef8ff':'#ffd23f');
  showBanner(`STAGE ${L+1}/7`, bTxt, bCol, L>=5?3.2:2.8);
}
function nextLevel(){
  if(G.stageIdx>=6){ victory(); return; }
  G.stageIdx++; startLevel();
}
let banner=null;
function showBanner(main,sub,color,dur){ banner={main,sub,color,dur,t:0}; }

function update(dt){
  G.now+=dt;
  if(G.palT<1) G.palT=Math.min(1,G.palT+dt/1.25);
  if(banner){ banner.t+=dt; if(banner.t>banner.dur) banner=null; }
  if(G.state==='name') return;                       // initials entry freezes the frame

  const pSeg=findSegment(G.position+PLAYER_Z);
  const speedRatio=G.speed/MAX_SPEED;
  G.offroad=Math.abs(G.playerX)>1.02;

  if(G.state==='attract'||G.state==='over'||G.state==='victory'){
    /* autopilot demo */
    const look=segments[(pSeg.index+16)%segments.length];
    let target=clamp(-look.curve*0.5,-0.8,0.8);
    for(const car of cars){
      const ahead=(pSeg.index-car.seg.index+segments.length)%segments.length;
      if(ahead<20 && Math.abs(car.offset-G.playerX)<0.55)
        target=clamp(car.offset>G.playerX? car.offset-1 : car.offset+1,-0.85,0.85);
    }
    G.playerX+=(target-G.playerX)*Math.min(1,dt*2.3);
    const cruise=G.state==='victory'?0.45:0.74;
    G.speed+=(MAX_SPEED*cruise-G.speed)*Math.min(1,dt*0.7);
    G.position=(G.position+G.speed*dt)%trackLength;
    G.steerVis+=(clamp(target-G.playerX,-1,1)-G.steerVis)*Math.min(1,dt*6);
    if(G.offroad){ /* the demo car throws dust too */
      for(let k=0;k<2;k++) spawnPart({x:SCENE_W/2+(Math.random()<0.5?-1:1)*(SCENE_W*0.09),y:SCENE_H-20,
        vx:Math.random()*4-2,vy:-Math.random()*3-1,grav:0.12,size:4+Math.random()*4,
        color:rgba(curPal().grassA,0.8),life:0.45,maxLife:0.45,dt});
    }
  }
  else if(G.state==='results'){
    /* rolling cool-down past the chequered line while the tally counts up */
    G.results.t+=dt;
    G.speed=Math.max(0,G.speed+COAST*2.4*dt);
    G.position=Math.min(G.position+G.speed*dt,(segments.length-26)*SEG_LEN);
    G.playerX+=(0-G.playerX)*Math.min(1,dt*1.8);
    G.steerVis*=Math.max(0,1-dt*4);
    if(G.boosting){ G.boosting=false; }
    G.gauge=Math.max(0,G.gauge-dt*0.4);
  }
  else if(G.state==='count'){
    G.countT-=dt;
    const whole=Math.ceil(G.countT);
    if(whole!==G.lastCount && whole>0){ G.lastCount=whole; if(AC) SFX.beep(false); }
    if(G.countT<=0){ G.state='race'; if(AC) SFX.beep(true); G.raceStart=G.now;
      if(input.throttle){                                   // perfect launch
        G.speed=MAX_SPEED*0.5; G.gauge=Math.min(1,G.gauge+0.25);
        addPopup('LAUNCH BOOST!','#3fe0d0',SCENE_W/2,SCENE_H*0.45);
        if(AC) SFX.nitro();
      }
    }
    if(input.throttle) G.speed=Math.min(G.speed+ACCEL*0.35*dt, MAX_SPEED*0.06);
  }
  else if(G.state==='race'){
    /* steering */
    const dx=dt*2*speedRatio;
    let steer=0;
    if(input.left) steer-=1;
    if(input.right) steer+=1;
    G.playerX+=steer*dx;
    G.playerX-=dx*speedRatio*pSeg.curve*CENTRIFUGAL;
    G.playerX=clamp(G.playerX,-2.6,2.6);
    G.steerVis+=(steer-G.steerVis)*Math.min(1,dt*10);
    /* nitro */
    const wantBoost=input.boost&&G.gauge>0.03;
    if(wantBoost&&!G.boosting&&AC) SFX.nitro();
    G.boosting=wantBoost;
    if(G.boosting){ G.gauge=Math.max(0,G.gauge-dt/2.6); }
    else G.gauge=Math.min(1,G.gauge+dt/24);
    /* longitudinal */
    const vmax=G.boosting?BOOST_MAX:MAX_SPEED;
    if(G.boosting) G.speed+=ACCEL*2.1*dt;
    else if(input.throttle||(input.touch&&!input.brake)) G.speed+=ACCEL*dt;
    else if(input.brake) G.speed+=BRAKE*dt;
    else G.speed+=COAST*dt;
    if(G.offroad&&G.speed>OFFROAD_LIMIT&&!G.boosting) G.speed+=OFFROAD_DECEL*dt;
    G.speed=clamp(G.speed,0,vmax);
    G.position=(G.position+G.speed*dt)%trackLength;
    G.raceElapsed+=dt;
    G.topSpeedKmh=Math.max(G.topSpeedKmh,Math.round(speedRatio*300));
    G.score+=G.speed*dt*0.085*(G.boosting?1.5:1);
    /* turbo pads */
    for(let k=0;k<2;k++){
      const segP=segments[(pSeg.index+k)%segments.length];
      for(let si=segP.sprites.length-1;si>=0;si--){
        const spr=segP.sprites[si];
        if(spr.kind!=='pickup'||Math.abs(spr.off-G.playerX)>0.4) continue;
        segP.sprites.splice(si,1);
        G.speed=Math.min(G.speed+MAX_SPEED*0.22,BOOST_MAX*0.94);
        G.gauge=Math.min(1,G.gauge+0.38);
        G.score+=400;
        if(AC) SFX.turbo();
        addPopup('TURBO +400','#ffd23f',SCENE_W/2,SCENE_H*0.56);
        for(let p2=0;p2<10;p2++) spawnPart({x:SCENE_W/2+(Math.random()*120-60),y:SCENE_H-60,
          vx:Math.random()*6-3,vy:-Math.random()*6-2,grav:0.2,size:3,color:'#8ef8ff',life:0.5,maxLife:0.5,dt});
      }
    }
    /* slipstream — tuck behind a car, then slingshot past it */
    let draft=false;
    if(G.speed>MAX_SPEED*0.6){
      for(const car of cars){
        const dz=car.z-(G.position+PLAYER_Z);
        const dzW=dz<-trackLength/2?dz+trackLength:dz;
        if(dzW>0&&dzW<SEG_LEN*7&&Math.abs(car.offset-G.playerX)<0.32){ draft=true; break; }
      }
    }
    if(draft){
      G.speed=Math.min(G.speed+ACCEL*0.55*dt,G.boosting?BOOST_MAX:MAX_SPEED*1.04);
      G.gauge=Math.min(1,G.gauge+dt/14);
    }
    if(draft&&!G.drafting&&AC) tone({f:300,f2:700,type:'sine',dur:0.2,vol:0.1});
    G.drafting=draft;
    /* chain timer + nitro-ready ping */
    if(G.comboT>0){ G.comboT-=dt; if(G.comboT<=0) G.combo=0; }
    if(G.gauge>=1&&G.prevGauge<1){ if(AC) tone({f:1180,dur:0.1,vol:0.16}); addPopup('NITRO READY','#ffd23f',SCENE_W/2,SCENE_H*0.5); }
    G.prevGauge=G.gauge;
    /* timer */
    G.timeLeft-=dt;
    const tl=Math.ceil(G.timeLeft);
    if(tl!==G.lastTick && tl<=10 && tl>0){ G.lastTick=tl; if(AC) SFX.tick(); }
    if(G.timeLeft<=0){ G.timeLeft=0; gameOver(); }
    /* mid-level checkpoints */
    for(let i=0;i<checkpointSegs.length;i++){
      if(!G.cpDone[i] && G.position+PLAYER_Z>=checkpointSegs[i]*SEG_LEN){
        G.cpDone[i]=true; checkpoint(i);
      }
    }
    /* the finish line */
    if(G.position+PLAYER_Z>=finishSeg*SEG_LEN){ finishLevel(); }
    /* collisions: traffic */
    if(G.crashT>0) G.crashT-=dt;
    for(const car of cars){
      const dz=Math.abs(car.z-(G.position+PLAYER_Z));
      const dzWrap=Math.min(dz,trackLength-dz);
      if(dzWrap<SEG_LEN*1.1){
        const ddx=Math.abs(car.offset-G.playerX);
        if(ddx<car.hw && G.crashT<=0){
          G.speed*=0.3; G.crashT=0.9; G.shakeT=0.55; G.flashT=0.4;
          if(G.combo>=2) addPopup('CHAIN LOST','#ff4b4b',SCENE_W/2,SCENE_H*0.52);
          G.combo=0; G.comboT=0;
          G.playerX+=(G.playerX>car.offset?0.25:-0.25);
          if(AC) SFX.crash();
          for(let k=0;k<16;k++) spawnPart({x:SCENE_W/2+(Math.random()*90-45),y:SCENE_H-70,
            vx:Math.random()*8-4,vy:-Math.random()*7-2,grav:0.35,size:3+Math.random()*3,
            color:Math.random()<0.5?'#ffd23f':'#ff6a3d',life:0.6,maxLife:0.6,dt});
        } else if(ddx<car.hw+0.28&&ddx>=car.hw&&G.speed>MAX_SPEED*0.5&&G.now-car.nmT>1.2){
          car.nmT=G.now; G.nearMisses++;
          G.combo=Math.min(9,G.combo+1); G.comboT=2.6;
          const pts=500*G.combo;
          G.gauge=Math.min(1,G.gauge+0.22);
          G.score+=pts;
          if(AC) SFX.whoosh();
          if(G.combo>1&&AC) tone({f:500+G.combo*140,dur:0.07,vol:0.14});
          addPopup(`NEAR MISS +${pts}${G.combo>1?'  x'+G.combo:''}`,'#3fe0d0',SCENE_W/2+(car.offset-G.playerX)*-300,SCENE_H*0.6);
        }
      }
    }
    /* collisions: roadside sprites */
    if(G.offroad){
      const seg=findSegment(G.position+PLAYER_Z);
      for(const spr of seg.sprites){
        if(spr.kind==='gantry'||spr.kind==='lamp') continue;
        if(Math.abs(spr.off-G.playerX)<0.24 && G.crashT<=0){
          G.speed*=0.22; G.crashT=0.8; G.shakeT=0.5; G.flashT=0.35;
          G.playerX=clamp(G.playerX+(G.playerX>spr.off?0.3:-0.3),-2.6,2.6);
          if(AC) SFX.crash();
        }
      }
      /* dust */
      if(G.speed>MAX_SPEED*0.08) for(let k=0;k<2;k++)
        spawnPart({x:SCENE_W/2+(Math.random()<0.5?-1:1)*(SCENE_W*0.085),y:SCENE_H-22,
          vx:Math.random()*4-2,vy:-Math.random()*3-1,grav:0.12,size:4+Math.random()*4,
          color:rgba(curPal().grassA,0.85),life:0.5,maxLife:0.5,dt});
    }
    if(G.boosting) for(let k=0;k<2;k++)
      spawnPart({x:SCENE_W/2+(Math.random()<0.5?-1:1)*SCENE_W*0.032,y:SCENE_H-16,
        vx:Math.random()*2-1,vy:2+Math.random()*2,grav:0,size:3,
        color:Math.random()<0.4?'#7fd4ff':'#ffb43a',life:0.3,maxLife:0.3,dt});
    if(G.wet && G.speed>MAX_SPEED*0.28)               // puddle spray off the wheels
      for(let k=0;k<2;k++)
        spawnPart({x:SCENE_W/2+(Math.random()<0.5?-1:1)*SCENE_W*0.055,y:SCENE_H-30,
          vx:Math.random()*6-3,vy:2+Math.random()*4,grav:0.18,size:2+Math.random()*2,
          color:rgba('#cfe4ff',0.85),life:0.34,maxLife:0.34,dt});
    if(G.shakeT>0) G.shakeT-=dt;
    if(G.flashT>0) G.flashT-=dt;
  }

  /* traffic */
  for(const car of cars){
    const old=car.seg;
    car.z+=car.speed*dt; if(car.z>=trackLength) car.z-=trackLength;
    const seg=findSegment(car.z);
    if(seg!==old){ const i=old.cars.indexOf(car); if(i>=0) old.cars.splice(i,1); seg.cars.push(car); car.seg=seg; }
    const ahead=(pSeg.index-seg.index+segments.length)%segments.length;
    if(ahead>0&&ahead<26&&Math.abs(car.offset-G.playerX)<0.48){
      const dir=car.offset>G.playerX?1:-1;
      car.offset=clamp(car.offset+dir*0.85*dt,-0.82,0.82);
    }
  }

  /* tunnel transitions */
  const inTun=!!(pSeg&&pSeg.tunnel);
  if(inTun!==G.wasTun){
    G.wasTun=inTun;
    if(inTun){
      if(AC) SFX.whoosh();
      if(G.state==='race')
        showBanner(G.wet?'SHELTER':'NEON TUNNEL',
                   G.wet?'DIVE OUT OF THE STORM':'LIGHTS OUT — FULL THROTTLE','#8ef8ff',1.6);
      G.shakeT=Math.max(G.shakeT,0.14);
    }
  }
  /* ---- storm weather ---- */
  G.wet = PALETTES[G.stageIdx].type==='storm';
  const S=G.storm;
  G.rainAlpha += ((G.wet && !inTun ? 1 : 0) - G.rainAlpha)*Math.min(1,dt*3.5);
  if(G.wet){
    if(S.flash>0) S.flash=Math.max(0,S.flash-dt*(S.flash>0.7?9:2.4));
    if(S.bolt>0) S.bolt-=dt;
    S.t-=dt;
    if(S.t<=0 && G.state!=='name') strikeLightning();
  } else { S.flash=Math.max(0,S.flash-dt*4); S.bolt=0; G.rainAlpha*=Math.max(0,1-dt*4); }
  /* ambience */
  G.skyOffset+=pSeg.curve*speedRatio*dt*44;
  G.cloudOff+=dt*(1+speedRatio*2);

  /* engine audio */
  if(AC&&engOsc){
    const r=G.speed/MAX_SPEED;
    engOsc.frequency.setTargetAtTime(52+r*190+(G.boosting?40:0)+Math.sin(G.now*31)*3,AC.currentTime,0.03);
    engOsc2.frequency.setTargetAtTime(26+r*95,AC.currentTime,0.03);
    engFilter.frequency.setTargetAtTime(280+r*2600,AC.currentTime,0.05);
    engGain.gain.setTargetAtTime(((G.state==='race'||G.state==='count'||G.state==='results')?0.045+r*0.075:(G.state==='attract'?0.03:0.02))*(G.wasTun?1.4:1),AC.currentTime,0.08);
    roadGain.gain.setTargetAtTime(G.offroad?Math.min(0.2,r*0.3):0,AC.currentTime,0.05);
    squealGain.gain.setTargetAtTime(G.state==='race'? Math.max(0,Math.abs(G.steerVis)-0.3)*r*r*0.2 : 0,AC.currentTime,0.06);
    if(rainGain) rainGain.gain.setTargetAtTime(G.rainAlpha*0.14,AC.currentTime,0.3);
    if(tunnelFilter) tunnelFilter.frequency.setTargetAtTime(G.wasTun?1000:18000,AC.currentTime,0.16);
  }
}
/* ---- lightning ---- */
function strikeLightning(){
  const S=G.storm;
  S.flash=1; S.bolt=0.20; S.t=2.4+Math.random()*7.5;
  const horizon=clamp(G.horizonY||SCENE_H*0.5,SCENE_H*0.2,SCENE_H*0.76);
  const pts=[]; let x=SCENE_W*(0.12+Math.random()*0.76), y=0;
  const steps=8+Math.floor(Math.random()*4);
  for(let i=0;i<=steps;i++){
    const t=i/steps;
    pts.push([x, y]);
    x+= (Math.random()*2-1)*SCENE_W*0.055;
    y = easeInOut(-10,horizon*0.94,t);
  }
  S.boltPts=pts;
  const branches=[];
  const nb=1+Math.floor(Math.random()*2);
  for(let b=0;b<nb;b++){
    const start=pts[2+Math.floor(Math.random()*4)]; if(!start) break;
    const bp=[start.slice()]; let bx=start[0],by=start[1];
    for(let k=0;k<3;k++){ bx+=(Math.random()<0.5?-1:1)*SCENE_W*(0.03+Math.random()*0.05);
      by+=(horizon-start[1])/5; bp.push([bx,by]); }
    branches.push(bp);
  }
  S.boltB=branches;
  G.shakeT=Math.max(G.shakeT,0.1);
  if(AC) SFX.thunder();
}
function drawBolt(c,horizonY){
  const S=G.storm; if(S.bolt<=0||!S.boltPts.length) return;
  const a=clamp(S.bolt/0.20,0,1);
  const paths=[S.boltPts].concat(S.boltB);
  c.save(); c.lineCap='round'; c.lineJoin='round';
  c.globalAlpha=a;
  c.shadowColor='#bcd8ff'; c.shadowBlur=26;
  for(const path of paths){
    c.strokeStyle='rgba(180,215,255,.55)'; c.lineWidth=9;
    c.beginPath(); path.forEach((p,i)=> i?c.lineTo(p[0],p[1]):c.moveTo(p[0],p[1])); c.stroke();
    c.strokeStyle='#eaf4ff'; c.lineWidth=3.2; c.stroke();
  }
  c.restore(); c.globalAlpha=1;
}
function checkpoint(i){
  G.timeLeft+=CHECKPOINT_BONUS; G.score+=5000; G.gauge=1;
  if(AC) SFX.checkpoint();
  const last=(i===checkpointSegs.length-1);
  showBanner('CHECKPOINT', `+${CHECKPOINT_BONUS} SEC · ${last? 'FINAL PUSH TO THE LINE':'HALFWAY THERE'}`,'#3fe0d0',2.2);
  addPopup(`+${CHECKPOINT_BONUS} SEC`,'#3fe0d0',SCENE_W/2,SCENE_H*0.4);
  addPopup('+5000','#ffd23f',SCENE_W/2,SCENE_H*0.46);
  G.flashT=0.2;
}
function finishLevel(){
  if(G.state!=='race') return;
  G.state='results'; G.boosting=false;
  const timeBonus=Math.round(G.timeLeft)*400;
  G.results={ t:0, timeBonus, nmBonus:G.nearMisses*500, clear:10000+G.stageIdx*4000 };
  G.score+=timeBonus+G.results.nmBonus+G.results.clear;
  G.totalTime+=G.raceElapsed; G.totalNM+=G.nearMisses;
  saveHi();
  if(AC) SFX.fanfare();
  for(let k=0;k<110;k++) spawnPart({x:Math.random()*SCENE_W,y:SCENE_H*0.2+Math.random()*SCENE_H*0.5,
    vx:Math.random()*7-3.5,vy:-Math.random()*5-1,grav:0.16,size:3+Math.random()*4,
    color:['#ffd23f','#3fe0d0','#ff6a3d','#ff4fa0','#f3ead9'][k%5],life:1.6,maxLife:1.6,dt:0.016});
}
function gameOver(){
  G.state='over'; G.boosting=false; G.totalTime+=G.raceElapsed; G.totalNM+=G.nearMisses;
  if(AC) SFX.gameover();
  saveHi();
}
function victory(){
  G.state='victory';
  if(AC) SFX.fanfare();
  saveHi();
  for(let k=0;k<140;k++) spawnPart({x:Math.random()*SCENE_W,y:SCENE_H*0.2+Math.random()*SCENE_H*0.55,
    vx:Math.random()*7-3.5,vy:-Math.random()*6-1,grav:0.15,size:3+Math.random()*4,
    color:['#ffd23f','#3fe0d0','#ff6a3d','#ff4fa0','#f3ead9'][k%5],life:2,maxLife:2,dt:0.016});
}
function saveHi(){
  const s=Math.floor(G.score);
  if(s>G.hiScore){ G.hiScore=s; G.pendingHi=true;
    try{ localStorage.setItem('radhorizon_hi',String(s)); }catch(e){} }
  updateLeds();
}
function confirmInitials(){
  G.rankings.push({n:G.nameEntry.chars.join(''), s:Math.floor(G.score), st:G.stageIdx+1});
  G.rankings.sort((a,b)=>b.s-a.s);
  G.rankings=G.rankings.slice(0,5);
  try{ localStorage.setItem('radhorizon_rank',JSON.stringify(G.rankings)); }catch(e){}
  G.pendingHi=false; G.state='attract';
}
function updateLeds(){
  $('hiLed').textContent=String(G.hiScore).padStart(7,'0');
  $('credLed').textContent=String(Math.max(0,G.credits));
}

/* =====================================================================
   HUD + OVERLAYS (canvas)
   ===================================================================== */
function txt(c,str,x,y,size,color,align='left',ps=true){
  c.font=`${size}px ${ps?'"Press Start 2P", monospace':'"Chakra Petch", sans-serif'}`;
  c.textAlign=align; c.textBaseline='alphabetic';
  c.fillStyle='rgba(0,0,0,.55)'; c.fillText(str,x+Math.max(2,size*0.12),y+Math.max(2,size*0.12));
  c.fillStyle=color; c.fillText(str,x,y);
}
/* ---- overview course map, bottom left ---- */
function drawCourseMap(c){
  if(G.state==='attract'||!mapPts.length) return;
  const H=SCENE_H;
  c.fillStyle='rgba(8,11,16,.74)'; c.strokeStyle='rgba(63,224,208,.3)'; c.lineWidth=1.5;
  roundRect(c,16,H-186,162,170,8); c.fill(); c.stroke();
  txt(c,'COURSE',28,H-170,8,'#8b93a1');
  txt(c,PALETTES[G.stageIdx].name,166,H-170,7,'#5d6572','right');
  c.lineJoin='round'; c.lineCap='round';
  /* full route, dim */
  c.strokeStyle='rgba(243,234,217,.15)'; c.lineWidth=2.5;
  c.beginPath();
  mapPts.forEach((p,i)=> i? c.lineTo(p.x,p.y) : c.moveTo(p.x,p.y));
  c.stroke();
  /* the stretch ahead — bright comet that fades with distance */
  const idx=clamp(Math.floor((G.position+PLAYER_Z)/SEG_LEN),0,mapPts.length-1);
  const AHEAD=330, chunks=5;
  for(let ch=0; ch<chunks; ch++){
    const a0=idx+Math.floor(AHEAD*ch/chunks), a1=idx+Math.floor(AHEAD*(ch+1)/chunks);
    const b0=Math.min(a0,finishSeg), b1=Math.min(a1,finishSeg);
    if(b1<=b0) break;
    c.strokeStyle=rgba('#3fe0d0',0.85-0.16*ch); c.lineWidth=3.4-0.3*ch;
    c.beginPath(); c.moveTo(mapPts[b0].x,mapPts[b0].y);
    for(let i=b0+1;i<=b1;i+=2) c.lineTo(mapPts[i].x,mapPts[i].y);
    c.stroke();
  }
  /* traffic ahead, as pin dots */
  c.fillStyle='rgba(255,138,61,.85)';
  for(const car of cars){
    const i=Math.floor(car.z/SEG_LEN)%mapPts.length;
    c.fillRect(mapPts[i].x-1.5,mapPts[i].y-1.5,3,3);
  }
  /* checkpoints — next one pings */
  checkpointSegs.forEach((cs,i)=>{
    const done=G.cpDone&&G.cpDone[i], p=mapPts[cs];
    const next=!done&&cs>idx;
    c.fillStyle=done? 'rgba(63,224,208,.5)' : '#ffd23f';
    c.beginPath(); c.arc(p.x,p.y,next?4+1.6*Math.sin(G.now*7):3.4,0,7); c.fill();
    if(next){ c.strokeStyle='rgba(255,210,63,.55)'; c.lineWidth=1.5;
      c.beginPath(); c.arc(p.x,p.y,8+2*Math.sin(G.now*7),0,7); c.stroke(); }
  });
  /* chequered finish */
  const fp=mapPts[Math.min(finishSeg,mapPts.length-1)];
  for(let r=0;r<2;r++) for(let cc=0;cc<2;cc++){
    c.fillStyle=(r+cc)%2?'#f3ead9':'#15181e';
    c.fillRect(fp.x-5+cc*5,fp.y-5+r*5,5,5);
  }
  /* the car — arrow pointing along the route, with a breathing halo */
  const p=mapPts[idx], q=mapPts[Math.min(idx+6,mapPts.length-1)];
  const ang=Math.atan2(q.y-p.y,q.x-p.x);
  c.save(); c.translate(p.x,p.y); c.rotate(ang);
  c.fillStyle='rgba(255,90,72,.22)';
  c.beginPath(); c.arc(0,0,7.5+1.6*Math.sin(G.now*5),0,7); c.fill();
  c.fillStyle='#ff5a48';
  c.beginPath(); c.moveTo(7.5,0); c.lineTo(-5.5,-4.8); c.lineTo(-5.5,4.8); c.closePath(); c.fill();
  c.restore();
}
function drawHUD(c){
  const W=SCENE_W,H=SCENE_H;
  if(G.state==='attract') return;
  /* TIME */
  const t=Math.ceil(G.timeLeft);
  const danger=t<=10;
  txt(c,'TIME',30,32,10,'#8b93a1');
  const blink=!danger||Math.floor(G.now*3)%2===0;
  if(blink) txt(c,String(t).padStart(2,'0'),30,74,30,danger?'#ff4b4b':'#f3ead9');
  /* SCORE */
  txt(c,'SCORE',W/2,32,9,'#8b93a1','center');
  txt(c,String(Math.floor(G.score)).padStart(7,'0'),W/2,56,13,'#f3ead9','center');
  /* route progress to the finish line */
  const bw=320,bx=W/2-bw/2,by=68, total=finishSeg*SEG_LEN;
  c.fillStyle='rgba(10,14,20,.75)'; c.fillRect(bx,by,bw,7);
  c.fillStyle='#3fe0d0'; c.fillRect(bx,by,bw*clamp(G.position/total,0,1),7);
  checkpointSegs.forEach((cs,i)=>{
    c.fillStyle=(G.cpDone&&G.cpDone[i])? '#3fe0d0':'#ffd23f';
    c.fillRect(bx+bw*(cs*SEG_LEN/total)-1,by-3,3,13);
  });
  for(let r=0;r<3;r++) for(let c2=0;c2<2;c2++){        // chequered flag at the end
    c.fillStyle=(r+c2)%2? '#f3ead9':'#15181e';
    c.fillRect(bx+bw+4+c2*4,by-4+r*5,4,5);
  }
  if(G.state==='race') txt(c,`${Math.max(0,Math.round((total-G.position)/145))} M TO FINISH`,W/2,by+22,8,'#8b93a1','center');
  if(G.combo>=2){                                   // near-miss chain
    const cs=1+0.12*Math.sin(G.now*14);
    c.save(); c.translate(W/2,118); c.scale(cs,cs);
    txt(c,`CHAIN x${G.combo}`,0,0,13,G.combo>=5?'#ff4fa0':'#ffd23f','center');
    c.restore();
  }
  if(G.drafting&&G.state==='race') txt(c,'S L I P S T R E A M',W/2,140,8,'#8ef8ff','center');
  /* SPEED */
  const kmh=Math.round(G.speed/MAX_SPEED*300);
  txt(c,String(kmh).padStart(3,'0'),W-30,74,30,G.boosting?'#ffd23f':'#f3ead9','right');
  txt(c,'KM/H',W-30,92,9,'#8b93a1','right');
  txt(c,`STAGE ${G.stageIdx+1}/7 · ${PALETTES[G.stageIdx].name}`,W-30,112,8,'#8b93a1','right');
  if(G.stereo) txt(c,'3-D ON',W-30,130,8,'#ff4fa0','right');
  if(G.wet && G.rainAlpha>0.3) txt(c,'STORM · WET ROADS',W-30,148,8,'#cfe4ff','right');
  drawCourseMap(c);
  /* nitro box */
  c.fillStyle='rgba(8,11,16,.72)'; c.strokeStyle=G.gauge>=1?'rgba(255,210,63,.5)':'rgba(63,224,208,.28)';
  roundRect(c,W-286,H-80,262,56,8); c.fill(); c.stroke();
  txt(c,'NITRO',W-272,H-58,8,'#8b93a1');
  txt(c,'SHIFT',W-36,H-58,7,'#5d6572','right');
  const cells=16;
  for(let i=0;i<cells;i++){
    const on=G.gauge>=(i+0.5)/cells;
    const pulse=G.gauge>=1? (0.75+0.25*Math.sin(G.now*8)) : 1;
    c.fillStyle=on? rgba(i>cells*0.7?'#ffd23f':'#3fe0d0',pulse) : 'rgba(255,255,255,.09)';
    c.fillRect(W-272+i*15,H-48,11,16);
  }
  /* popups */
  for(let i=G.popups.length-1;i>=0;i--){
    const p=G.popups[i]; p.life-=0.016; p.y-=40*0.016;
    if(p.life<=0){ G.popups.splice(i,1); continue; }
    c.globalAlpha=clamp(p.life,0,1);
    txt(c,p.text,p.x,p.y,11,p.color,'center');
    c.globalAlpha=1;
  }
  /* banner */
  if(banner){
    const k=banner.t/banner.dur;
    const scale=k<0.12? lerp(1.7,1,k/0.12) : 1;
    const alpha=k>0.85? (1-k)/0.15 : 1;
    c.save(); c.translate(W/2,H*0.30); c.scale(scale,scale); c.globalAlpha=alpha;
    txt(c,banner.main,0,0,30,banner.color,'center');
    if(banner.sub) txt(c,banner.sub,0,34,11,'#f3ead9','center');
    c.restore(); c.globalAlpha=1;
  }
  /* countdown */
  if(G.state==='count'){
    const whole=Math.ceil(G.countT);
    const frac=G.countT-Math.floor(G.countT);
    const s=1+frac*0.5;
    c.save(); c.translate(W/2,H*0.42); c.scale(s,s);
    txt(c,String(whole),0,0,72,['#ff4b4b','#ffd23f','#3fe0d0','#3fe0d0'][whole]||'#fff','center');
    c.restore();
  } else if(G.state==='race'&&G.now-G.raceStart<0.9){
    txt(c,'GO!!',W/2,H*0.42,64,'#3fe0d0','center');
  }
  /* low time vignette */
  if(danger&&G.state==='race'){
    c.fillStyle=rgba('#ff2020',0.08+0.07*Math.sin(G.now*6));
    c.fillRect(0,0,W,H);
  }
  if(G.flashT>0){ c.fillStyle=rgba('#ff3020',G.flashT*0.5); c.fillRect(0,0,W,H); }
}
function drawOverlay(c){
  const W=SCENE_W,H=SCENE_H;
  if(G.state==='attract'){
    const g=c.createLinearGradient(0,0,0,H);
    g.addColorStop(0,'rgba(5,8,12,.82)'); g.addColorStop(0.32,'rgba(5,8,12,.28)');
    g.addColorStop(0.62,'rgba(5,8,12,.28)'); g.addColorStop(1,'rgba(5,8,12,.86)');
    c.fillStyle=g; c.fillRect(0,0,W,H);
    const bob=Math.sin(G.now*1.6)*4;
    c.save(); c.translate(0,bob);
    c.font='44px "Press Start 2P", monospace'; c.textAlign='center';
    c.fillStyle='#12151c'; c.fillText('RAD HORIZON',W/2+6,H*0.24+6);
    c.fillStyle='#3fe0d0'; c.fillText('RAD HORIZON',W/2+3,H*0.24+3);
    c.fillStyle='#ffd23f'; c.fillText('RAD HORIZON',W/2,H*0.24);
    txt(c,'T U R B O   G R A N D   P R I X',W/2,H*0.24+36,11,'#f3ead9','center');
    c.restore();
    if(Math.floor(G.now*1.4)%2===0)
      txt(c,'PRESS ENTER TO START',W/2,H*0.5,15,'#f3ead9','center');
    txt(c,`HIGH SCORE ${String(G.hiScore).padStart(7,'0')}`,W/2,H*0.575,11,'#ffd23f','center');
    /* hall of fame */
    const rx=W*0.665, rw2=W*0.255;
    c.fillStyle='rgba(8,11,16,.72)'; c.strokeStyle='rgba(255,210,63,.3)'; c.lineWidth=2;
    roundRect(c,rx,H*0.255,rw2,H*0.345,8); c.fill(); c.stroke();
    txt(c,'TOP DRIVERS',rx+rw2/2,H*0.255+24,8,'#ffd23f','center');
    if(G.rankings.length) G.rankings.forEach((rk,i)=>{
      txt(c,`${i+1}. ${rk.n}`,rx+14,H*0.255+48+i*24,8,i===0?'#ffd23f':'#f3ead9');
      txt(c,String(rk.s).padStart(7,'0'),rx+rw2-14,H*0.255+48+i*24,8,'#8b93a1','right');
    });
    else txt(c,'— — —',rx+rw2/2,H*0.255+56,8,'#5d6572','center');
    txt(c,'7 STAGES · 2 CHECKPOINTS EACH · CHEQUERED FINISH',W/2,H*0.645,9,'#8b93a1','center');
    txt(c,'DRAFT CARS · CHAIN NEAR MISSES · RIDE THE TURBO PADS',W/2,H*0.685,9,'#8b93a1','center');
    txt(c,'STAGE 6 — NEON TUNNEL · RINGS THAT PULSE TO THE RADIO',W/2,H*0.725,9,'#ff5ce1','center');
    txt(c,'STAGE 7 — THUNDER ROAD · RAIN, LIGHTNING, WET ASPHALT',W/2,H*0.762,9,'#cfe4ff','center');
    txt(c,'ARROWS DRIVE · SHIFT NITRO · HOLD THROTTLE ON GO · 1-4 RADIO · G = 3-D GLASSES',W/2,H*0.78,8,'#5d6572','center');
    txt(c,'© 1986 VECTORWORKS AMUSEMENT',W/2,H*0.94,8,'#3d4450','center');
  }
  else if(G.state==='results'){
    const R=G.results; if(!R) return;
    c.fillStyle='rgba(5,8,12,.62)'; c.fillRect(W*0.13,H*0.19,W*0.74,H*0.62);
    c.strokeStyle='rgba(63,224,208,.4)'; c.lineWidth=2; c.strokeRect(W*0.13,H*0.19,W*0.74,H*0.62);
    c.strokeStyle='rgba(255,210,63,.25)'; c.strokeRect(W*0.13+6,H*0.19+6,W*0.74-12,H*0.62-12);
    if(R.t<1.25){                                  // chequered-flag flash
      const k=R.t/1.25, s=1+(1-k)*0.55;
      c.save(); c.translate(W/2,H*0.47); c.scale(s,s); c.globalAlpha=Math.min(1,(1-k)*3);
      txt(c,'FINISH!',0,0,52,'#ffd23f','center');
      c.restore(); c.globalAlpha=1;
    }
    txt(c,'STAGE CLEAR',W/2,H*0.29,26,'#3fe0d0','center');
    txt(c,PALETTES[G.stageIdx].name,W/2,H*0.29+30,9,'#8b93a1','center');
    const rows=[['TIME BONUS',R.timeBonus,0.8],['NEAR MISS x '+G.nearMisses,R.nmBonus,1.35],['STAGE CLEAR',R.clear,1.9]];
    let ty=H*0.41;
    rows.forEach(rw=>{
      if(R.t>rw[2]){
        txt(c,rw[0],W*0.22,ty,10,'#f3ead9');
        txt(c,String(rw[1]).padStart(7,'0'),W*0.78,ty,12,'#ffd23f','right');
      }
      ty+=34;
    });
    txt(c,`RUN ${G.raceElapsed.toFixed(1)} SEC   ·   TOP ${G.topSpeedKmh} KM/H   ·   TIME LEFT ${G.timeLeft.toFixed(1)}`,W/2,H*0.585,9,'#8b93a1','center');
    if(R.t>2.4){
      txt(c,'TOTAL',W*0.22,H*0.665,10,'#8b93a1');
      txt(c,String(Math.floor(G.score)).padStart(7,'0'),W*0.78,H*0.665,14,'#f3ead9','right');
      if(Math.floor(G.now*1.5)%2===0)
        txt(c,G.stageIdx>=6? 'FINAL STAGE CLEARED — PRESS ENTER':
            G.stageIdx>=5? 'LAST STAGE — ENTER':'ENTER — NEXT STAGE',W/2,H*0.755,11,'#ffd23f','center');
    }
  }
  else if(G.state==='over'){
    c.fillStyle='rgba(5,8,12,.72)'; c.fillRect(0,0,W,H);
    txt(c,'GAME OVER',W/2,H*0.34,40,'#ff4b4b','center');
    txt(c,'TIME UP — THE CLOCK BEAT YOU',W/2,H*0.34+40,10,'#f3ead9','center');
    txt(c,`SCORE ${String(Math.floor(G.score)).padStart(7,'0')}   ·   STAGE ${G.stageIdx+1}/5   ·   TOP ${G.topSpeedKmh} KM/H`,W/2,H*0.55,11,'#ffd23f','center');
    if(Math.floor(G.score)>=G.hiScore&&G.hiScore>0&&Math.floor(G.now*2)%2===0)
      txt(c,'★ NEW HIGH SCORE ★',W/2,H*0.62,12,'#3fe0d0','center');
    if(Math.floor(G.now*1.4)%2===0)
      txt(c,G.pendingHi? 'ENTER — SIGN THE LEADERBOARD':'ENTER — TITLE      R — RETRY STAGE',W/2,H*0.78,10,'#f3ead9','center');
  }
  else if(G.state==='victory'){
    c.fillStyle='rgba(5,8,12,.66)'; c.fillRect(0,0,W,H);
    const bob=Math.sin(G.now*2)*3;
    txt(c,'CONGRATULATIONS!',W/2,H*0.28+bob,30,'#ffd23f','center');
    txt(c,'ALL 5 STAGES CLEARED — YOU OWN THE HORIZON',W/2,H*0.28+38+bob,10,'#3fe0d0','center');
    const m=Math.floor(G.totalTime/60), s=Math.floor(G.totalTime%60);
    txt(c,`SCORE  ${String(Math.floor(G.score)).padStart(7,'0')}`,W/2,H*0.5,13,'#f3ead9','center');
    txt(c,`TOTAL TIME  ${m}:${String(s).padStart(2,'0')}   ·   NEAR MISSES  ${G.totalNM}   ·   TOP  ${G.topSpeedKmh} KM/H`,W/2,H*0.575,10,'#f3ead9','center');
    if(Math.floor(G.score)>=G.hiScore&&Math.floor(G.now*2)%2===0)
      txt(c,'★ NEW HIGH SCORE ★',W/2,H*0.645,12,'#ff4fa0','center');
    if(Math.floor(G.now*1.4)%2===0) txt(c,'PRESS ENTER',W/2,H*0.8,11,'#f3ead9','center');
  }
  else if(G.state==='name'){
    c.fillStyle='rgba(5,8,12,.78)'; c.fillRect(0,0,W,H);
    const bob=Math.sin(G.now*2)*3;
    txt(c,'NEW HIGH SCORE!',W/2,H*0.24+bob,22,'#ffd23f','center');
    txt(c,`${String(Math.floor(G.score)).padStart(7,'0')}  POINTS`,W/2,H*0.24+34+bob,12,'#f3ead9','center');
    txt(c,'ENTER YOUR INITIALS',W/2,H*0.42,10,'#8b93a1','center');
    const ne=G.nameEntry;
    for(let i=0;i<3;i++){
      const x=W/2+(i-1)*64, sel=i===ne.pos;
      c.fillStyle=sel? 'rgba(63,224,208,.14)':'rgba(20,26,34,.8)';
      c.strokeStyle=sel? '#3fe0d0':'#2b313c'; c.lineWidth=2;
      roundRect(c,x-24,H*0.46,48,54,6); c.fill(); c.stroke();
      if(!sel||Math.floor(G.now*3)%2===0)
        txt(c,ne.chars[i],x,H*0.46+40,22,sel?'#3fe0d0':'#f3ead9','center');
    }
    txt(c,'UP/DOWN LETTER    LEFT/RIGHT SLOT    A-Z TYPE    ENTER OK',W/2,H*0.68,9,'#8b93a1','center');
  }
  else if(G.state==='pause'){
    c.fillStyle='rgba(5,8,12,.7)'; c.fillRect(0,0,W,H);
    txt(c,'PAUSED',W/2,H*0.45,34,'#f3ead9','center');
    txt(c,'P — RESUME',W/2,H*0.55,10,'#8b93a1','center');
  }
}

/* =====================================================================
   STEREO 3-D (Rad Racer anaglyph homage)
   ===================================================================== */
function tint(src,dst,col){
  const g=dst.getContext('2d');
  g.globalCompositeOperation='source-over';
  g.drawImage(src,0,0);
  g.globalCompositeOperation='multiply';
  g.fillStyle=col;
  g.fillRect(0,0,SCENE_W,SCENE_H);
  g.globalCompositeOperation='source-over';
}
function compositeFrame(){
  const c=dctx;
  if(G.stereo){
    if(!tL){ tL=document.createElement('canvas'); tL.width=SCENE_W; tL.height=SCENE_H;
      tR=document.createElement('canvas'); tR.width=SCENE_W; tR.height=SCENE_H; }
    renderScene(sctx,-EYE);
    renderScene(sctxR, EYE);
    tint(sceneCv,tL,'rgb(255,0,0)');
    tint(sceneR,tR,'rgb(0,255,255)');
    c.drawImage(tL,0,0);
    c.globalCompositeOperation='lighter';
    c.drawImage(tR,0,0);
    c.globalCompositeOperation='source-over';
  } else {
    renderScene(sctx,0);
    c.drawImage(sceneCv,0,0);
  }
  if(G.storm.flash>0){                                  // lightning washes out the whole screen
    c.save(); c.globalCompositeOperation='lighter';
    c.fillStyle=rgba('#dbe8ff',G.storm.flash*0.3);
    c.fillRect(0,0,SCENE_W,SCENE_H);
    c.restore();
  }
  applyShake(c);
}
let shakeCv=null;
function applyShake(c){
  if(G.shakeT<=0) return;
  if(!shakeCv){ shakeCv=document.createElement('canvas'); shakeCv.width=SCENE_W; shakeCv.height=SCENE_H; }
  const g=shakeCv.getContext('2d');
  g.clearRect(0,0,SCENE_W,SCENE_H); g.drawImage(display,0,0);
  const a=G.shakeT*9;
  c.save(); c.setTransform(1,0,0,1,0,0);
  c.drawImage(shakeCv,Math.random()*a-a/2,Math.random()*a-a/2);
  if(G.shakeT>0.3){                                  // CRT tearing on big hits
    for(let i=0;i<4;i++){
      const sy=Math.random()*SCENE_H, sh=6+Math.random()*22, ox=(Math.random()*44-22)*G.shakeT;
      c.drawImage(shakeCv,0,sy,SCENE_W,sh,ox,sy,SCENE_W,sh);
    }
  }
  c.restore();
}
function toggleStereo(){
  G.stereo=!G.stereo;
  $('btnStereo').classList.toggle('on',G.stereo);
  if(G.stereo) showBanner('3-D MODE','PUT ON YOUR RED / CYAN GLASSES','#ff4fa0',2.4);
  if(AC) tone({f:G.stereo?1200:700,dur:0.08,vol:0.15});
}

/* =====================================================================
   INPUT
   ===================================================================== */
const KEYMAP={ArrowLeft:'left',KeyA:'left',ArrowRight:'right',KeyD:'right',
  ArrowUp:'throttle',KeyW:'throttle',ArrowDown:'brake',KeyS:'brake',
  ShiftLeft:'boost',ShiftRight:'boost',Space:'boost'};
window.addEventListener('keydown',e=>{
  if(KEYMAP[e.code]!==undefined){ input[KEYMAP[e.code]]=true; e.preventDefault(); }
  ensureAudio();
  if(e.repeat) return;
  if(G.state==='name'){                              // initials entry
    const ne=G.nameEntry, AZ='ABCDEFGHIJKLMNOPQRSTUVWXYZ';
    const cyc=d=>{ ne.chars[ne.pos]=AZ[(AZ.indexOf(ne.chars[ne.pos])+d+26)%26]; if(AC) tone({f:700,dur:0.04,vol:0.08}); };
    if(e.code==='ArrowUp'){ cyc(1); e.preventDefault(); }
    else if(e.code==='ArrowDown'){ cyc(-1); e.preventDefault(); }
    else if(e.code==='ArrowLeft'){ ne.pos=(ne.pos+2)%3; if(AC) tone({f:600,dur:0.04,vol:0.08}); }
    else if(e.code==='ArrowRight'){ ne.pos=(ne.pos+1)%3; if(AC) tone({f:600,dur:0.04,vol:0.08}); }
    else if(/^Key[A-Z]$/.test(e.code)){ ne.chars[ne.pos]=e.code.slice(3); if(ne.pos<2) ne.pos++;
      if(AC) tone({f:900,dur:0.05,vol:0.1}); }
  }
  switch(e.code){
    case 'Enter':
      if(G.state==='name'){ confirmInitials(); break; }
      if(G.state==='attract') startRace();
      else if(G.state==='results') nextLevel();
      else if(G.state==='over'||G.state==='victory'){
        if(G.pendingHi){ G.nameEntry={pos:0,chars:['A','A','A']}; G.state='name';
          if(AC) engGain.gain.setTargetAtTime(0,AC.currentTime,0.1); }
        else { G.state='attract'; G.stageIdx=0; }
      }
      break;
    case 'KeyR':
      if(G.state==='results') startLevel();
      else if(G.state==='over'||G.state==='victory') startRace();
      break;
    case 'KeyM': toggleMute(); break;
    case 'KeyG': toggleStereo(); break;
    case 'KeyP':
      if(G.state==='race'){ G.pausedFrom='race'; G.state='pause'; if(AC) AC.suspend(); }
      else if(G.state==='pause'){ G.state='race'; if(AC) AC.resume(); }
      break;
    case 'Digit1': setRadio(1); break;
    case 'Digit2': setRadio(2); break;
    case 'Digit3': setRadio(3); break;
    case 'Digit4': setRadio(4); break;
  }
});
window.addEventListener('keyup',e=>{ if(KEYMAP[e.code]!==undefined) input[KEYMAP[e.code]]=false; });
window.addEventListener('blur',()=>{ if(G.state==='race'){ G.state='pause'; if(AC) AC.suspend(); } });
document.addEventListener('visibilitychange',()=>{ if(document.hidden&&G.state==='race'){ G.state='pause'; if(AC) AC.suspend(); } });

$('crt').addEventListener('pointerdown',()=>{
  ensureAudio(); input.touch=true;
  if(G.state==='attract') startRace();
  else if(G.state==='results'){ if(G.results&&G.results.t>2.4) nextLevel(); }
  else if(G.state==='name'){ confirmInitials(); }
  else if(G.state==='over'||G.state==='victory'){
    if(G.pendingHi){ G.nameEntry={pos:0,chars:['A','A','A']}; G.state='name'; }
    else { G.state='attract'; G.stageIdx=0; }
  }
});
function bindPad(id,flag){
  const el=$(id);
  const on=e=>{ e.preventDefault(); ensureAudio(); input.touch=true; input[flag]=true; };
  const off=e=>{ e.preventDefault(); input[flag]=false; };
  el.addEventListener('pointerdown',on); el.addEventListener('pointerup',off);
  el.addEventListener('pointerleave',off); el.addEventListener('pointercancel',off);
}
bindPad('padL','left'); bindPad('padR','right'); bindPad('padB','brake'); bindPad('padN','boost');

$('btnSound').addEventListener('click',()=>{ ensureAudio(); toggleMute(); });
$('btnRadio').addEventListener('click',()=>{ ensureAudio(); setRadio(G.radioTrack%4+1); });
$('btnStereo').addEventListener('click',()=>{ ensureAudio(); toggleStereo(); });

/* =====================================================================
   MAIN LOOP
   ===================================================================== */
let lastFrame=performance.now();
function frame(now){
  const dt=clamp((now-lastFrame)/1000,0.001,0.05);
  lastFrame=now;
  if(G.state!=='pause') update(dt);
  /* camera FOV kick while boosting */
  camDepthCur+= ((G.boosting?0.72:CAM_DEPTH)-camDepthCur)*Math.min(1,dt*5);
  compositeFrame();
  drawHUD(dctx);
  drawOverlay(dctx);
  requestAnimationFrame(frame);
}
function init(){
  buildSprites();
  buildScenery();
  G.stageIdx=0;
  buildLevel(0); placeSprites(0); resetCars(0);
  setStagePalette(0,true);
  G.state='attract';
  G.speed=MAX_SPEED*0.55;
  G.totalTime=0; G.totalNM=0;
  try{ G.rankings=JSON.parse(localStorage.getItem('radhorizon_rank')||'null')||SEED_RANK.slice(); }
  catch(e){ G.rankings=SEED_RANK.slice(); }
  updateLeds();
  requestAnimationFrame(frame);
}
if(document.fonts&&document.fonts.load){
  Promise.all([
    document.fonts.load('12px "Press Start 2P"'),
    document.fonts.load('700 16px "Chakra Petch"')
  ]).then(init,init);
} else init();
</script>
</body>
</html><!--kg-card-end: html--><p>Download racing90sdotv2.html:</p>
        <div class="kg-card kg-file-card kg-file-card-medium">
            <a class="kg-file-card-container" href="https://learn.piescale.com/content/files/2026/09/racing90sdotv2-1.html" title="Download" download>
                <div class="kg-file-card-contents">
                    <div class="kg-file-card-title">Racing90sdotv2</div>
                    
                    <div class="kg-file-card-metadata">
                        <div class="kg-file-card-filename">racing90sdotv2.html</div>
                        <div class="kg-file-card-filesize">103 KB</div>
                    </div>
                </div>
                <div class="kg-file-card-icon">
                    <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24"><defs><style>.a{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.5px;}</style></defs><title>download-circle</title><polyline class="a" points="8.25 14.25 12 18 15.75 14.25"/><line class="a" x1="12" y1="6.75" x2="12" y2="18"/><circle class="a" cx="12" cy="12" r="11.25"/></svg>
                </div>
            </a>
        </div>
        ]]></content:encoded></item><item><title><![CDATA[Best Rad Racer/Out Run Yet]]></title><description><![CDATA[<p>A Mix of Qwen 3.8 max, and GLM 5.2 max just made by far the best 16 bit era quality racer game in a single .html file. &#xA0;These are the big 2.4 trillion, and 753 billion parameter Chinese models running on arena.ai hardware. &#xA0;Arena</p>]]></description><link>https://learn.piescale.com/best-rad-racer-out-run/</link><guid isPermaLink="false">6aa4fb748ea9b20001c1636d</guid><dc:creator><![CDATA[Josh]]></dc:creator><pubDate>Sat, 12 Sep 2026 07:48:18 GMT</pubDate><media:content url="https://learn.piescale.com/content/images/2026/09/Selection_832.png" medium="image"/><content:encoded><![CDATA[<img src="https://learn.piescale.com/content/images/2026/09/Selection_832.png" alt="Best Rad Racer/Out Run Yet"><p>A Mix of Qwen 3.8 max, and GLM 5.2 max just made by far the best 16 bit era quality racer game in a single .html file. &#xA0;These are the big 2.4 trillion, and 753 billion parameter Chinese models running on arena.ai hardware. &#xA0;Arena ranks models against each other, and appears to be the best way to get free tokens with a big context window, although they may flag game development, and I somehow got lucky with this. &#xA0;My general sense is providers don&apos;t want to fund free game development, and it&apos;s easy to burn tokens here, although hard to tell exactly how much. &#xA0;My guess would be the final product here was around a million tokens, which prices out to around $6 at current prices.</p><!--kg-card-begin: html--><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RAD HORIZON &#x2014; Turbo Grand Prix</title>
<link rel="icon" href="data:image/svg+xml,&lt;svg xmlns=&apos;http://www.w3.org/2000/svg&apos; viewBox=&apos;0 0 100 100&apos;&gt;&lt;text y=&apos;.9em&apos; font-size=&apos;90&apos;&gt;&#x1F3C1;&lt;/text&gt;&lt;/svg&gt;">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&amp;family=Chakra+Petch:wght@400;600;700&amp;display=swap" rel="stylesheet">
<style>
  :root{
    --bg:#0b0d10; --panel:#14171c; --panel2:#1a1e25; --line:#2b313c;
    --ink:#f3ead9; --dim:#8b93a1; --cyan:#3fe0d0; --orange:#ff8a3d;
    --red:#ff4b4b; --yellow:#ffd23f;
  }
  *{ margin:0; padding:0; box-sizing:border-box; }
  html,body{ height:100%; }
  body{
    background:var(--bg);
    color:var(--ink);
    font-family:"Chakra Petch", sans-serif;
    display:flex; flex-direction:column; align-items:center;
    min-height:100vh; padding:26px 14px 34px;
    overflow-x:hidden;
    -webkit-user-select:none; user-select:none;
  }
  /* layered ambient page texture */
  body::before{
    content:""; position:fixed; inset:0; z-index:0; pointer-events:none;
    background:
      repeating-linear-gradient(115deg, rgba(255,255,255,.016) 0 2px, transparent 2px 16px),
      radial-gradient(120% 70% at 50% 118%, rgba(255,138,61,.10), transparent 60%),
      radial-gradient(90% 60% at 50% -18%, rgba(63,224,208,.07), transparent 60%);
  }
  body::after{
    content:""; position:fixed; inset:0; z-index:0; pointer-events:none; opacity:.5;
    background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E");
  }
  .cabinet{ position:relative; z-index:1; width:min(1060px, 96vw); }

  /* ---------- MARQUEE ---------- */
  .marquee{
    background:linear-gradient(180deg,#171b22,#0d0f14 70%);
    border:1px solid #262c36; border-bottom:none;
    border-radius:16px 16px 0 0;
    padding:18px 26px 14px;
    display:flex; align-items:center; justify-content:space-between; gap:18px;
    position:relative; overflow:hidden;
    box-shadow:0 -1px 0 rgba(255,255,255,.06) inset;
  }
  .marquee::after{ /* shine sweep */
    content:""; position:absolute; top:0; bottom:0; width:38%;
    background:linear-gradient(105deg, transparent, rgba(255,255,255,.05), transparent);
    transform:translateX(-160%);
    animation:shine 7s ease-in-out infinite;
  }
  @keyframes shine{ 0%,55%{transform:translateX(-160%)} 80%,100%{transform:translateX(320%)} }
  .logo-block{ line-height:1; }
  .logo{
    font-family:"Press Start 2P", monospace;
    font-size:clamp(19px, 3.4vw, 34px);
    color:var(--yellow); letter-spacing:.02em;
    text-shadow:3px 3px 0 #b3402a, 6px 6px 0 #14171c, 0 0 26px rgba(255,210,63,.25);
    white-space:nowrap;
  }
  .logo em{ font-style:normal; color:var(--orange); }
  .tag{
    margin-top:9px; font-weight:600; font-size:11px; letter-spacing:.34em;
    color:var(--cyan); text-transform:uppercase; opacity:.9;
  }
  .marquee-side{
    display:flex; flex-direction:column; align-items:flex-end; gap:8px;
    font-size:10px; letter-spacing:.22em; color:var(--dim); text-transform:uppercase; font-weight:600;
  }
  .led{
    font-family:"Press Start 2P", monospace; font-size:10px;
    background:#0a0c0f; border:1px solid #23293388; border-radius:6px;
    padding:8px 12px; color:#ff6a4d; text-shadow:0 0 8px rgba(255,106,77,.7);
    display:flex; gap:10px; align-items:center;
  }
  .led .pwr{ width:7px; height:7px; border-radius:50%; background:#3dff8b; box-shadow:0 0 8px #3dff8b; animation:pulse 2.2s infinite; }
  @keyframes pulse{ 0%,100%{opacity:1} 50%{opacity:.35} }
  .stripes{
    height:9px; border-radius:0;
    background:repeating-linear-gradient(115deg, var(--orange) 0 16px, #171a20 16px 30px, var(--cyan) 30px 34px, #171a20 34px 48px);
    background-size:96px 100%;
    animation:stripeMove 3.2s linear infinite;
    border-left:1px solid #262c36; border-right:1px solid #262c36;
  }
  @keyframes stripeMove{ to{ background-position:96px 0; } }

  /* ---------- CRT BEZEL ---------- */
  .bezel{
    background:#0d0f13;
    border:1px solid #262c36;
    padding:clamp(10px, 2.2vw, 22px);
    box-shadow:0 34px 90px rgba(0,0,0,.6), 0 2px 0 rgba(255,255,255,.04) inset;
  }
  .crt{
    position:relative; border-radius:10px; overflow:hidden;
    background:#000; aspect-ratio:16/9;
    box-shadow:0 0 0 3px #05070a, 0 0 0 4px #23293388, 0 0 60px rgba(63,224,208,.06) inset;
  }
  canvas#game{ display:block; width:100%; height:100%; image-rendering:auto; }
  .crt::before{ /* scanlines */
    content:""; position:absolute; inset:0; z-index:5; pointer-events:none;
    background:repeating-linear-gradient(0deg, rgba(0,0,0,.17) 0 1px, transparent 1px 3px);
    animation:flicker 4s steps(2) infinite;
  }
  @keyframes flicker{ 0%,100%{opacity:.9} 50%{opacity:1} }
  .crt::after{ /* vignette + tube curvature */
    content:""; position:absolute; inset:0; z-index:6; pointer-events:none;
    background:radial-gradient(115% 90% at 50% 45%, transparent 55%, rgba(0,0,0,.45) 100%);
    box-shadow:inset 0 0 90px rgba(0,0,0,.55);
    border-radius:10px;
  }
  .rollbar{
    position:absolute; left:0; right:0; top:-140px; height:130px; z-index:4; pointer-events:none;
    background:linear-gradient(180deg, transparent, rgba(255,255,255,.028), transparent);
    animation:roll 8s linear infinite;
  }
  @keyframes roll{ to{ transform:translateY(820px); } }

  /* touch pads */
  .touch{ display:none; position:absolute; inset:0; z-index:7; pointer-events:none; }
  .pad{
    position:absolute; pointer-events:auto; touch-action:none;
    width:64px; height:64px; border-radius:50%;
    background:rgba(10,14,18,.55); border:1px solid rgba(63,224,208,.4);
    color:var(--ink); font-family:"Press Start 2P",monospace; font-size:16px;
    display:flex; align-items:center; justify-content:center;
  }
  .pad:active{ background:rgba(63,224,208,.35); }
  #padL{ left:14px; bottom:16px; } #padR{ left:92px; bottom:16px; }
  #padB{ right:92px; bottom:16px; font-size:9px; } #padN{ right:14px; bottom:16px; font-size:9px; color:var(--yellow); border-color:rgba(255,210,63,.5); }
  @media (pointer:coarse){ .touch{ display:block; } }

  /* ---------- CONTROL DECK ---------- */
  .deck{
    background:linear-gradient(180deg,#171b22,#101318);
    border:1px solid #262c36; border-top:1px solid #1f242d;
    border-radius:0 0 16px 16px;
    padding:16px 22px 18px;
    display:flex; flex-wrap:wrap; gap:16px 26px; align-items:center; justify-content:space-between;
  }
  .coin-door{ display:flex; align-items:center; gap:14px; }
  .slot{
    width:44px; height:52px; border-radius:6px; background:#0a0c0f;
    border:1px solid #2b313c; position:relative; box-shadow:0 2px 0 rgba(255,255,255,.05) inset;
  }
  .slot::after{
    content:""; position:absolute; left:50%; top:12px; width:4px; height:22px; margin-left:-2px;
    background:#000; border:1px solid #3a4150; border-radius:2px; box-shadow:0 0 8px rgba(255,210,63,.25);
  }
  .insert-coin{
    font-family:"Press Start 2P",monospace; font-size:10px; color:var(--yellow);
    animation:blink 1.1s steps(2) infinite;
  }
  @keyframes blink{ 50%{opacity:0} }
  .credits{ font-size:10px; color:var(--dim); letter-spacing:.2em; margin-top:6px; text-transform:uppercase; font-weight:600; }
  .chips{ display:flex; flex-wrap:wrap; gap:9px; }
  .chip{
    font-family:"Chakra Petch",sans-serif; font-weight:700; font-size:11px;
    letter-spacing:.14em; text-transform:uppercase; color:var(--ink);
    background:#20242c; border:1px solid #303743; border-bottom-width:3px; border-radius:7px;
    padding:9px 14px; cursor:pointer;
    transition:transform .12s ease, border-color .12s ease, box-shadow .12s ease, background .12s ease;
  }
  .chip:hover{ transform:translateY(-2px); border-color:var(--cyan); box-shadow:0 6px 18px rgba(63,224,208,.14); }
  .chip:active{ transform:translateY(0); }
  .chip.on{ background:#123c3a; border-color:var(--cyan); color:#bffff4; }
  .chip b{ color:var(--cyan); font-weight:700; }
  .legend{
    display:flex; flex-wrap:wrap; gap:8px 16px; justify-content:center;
    padding:14px 10px 0; font-size:11px; color:var(--dim); letter-spacing:.08em; font-weight:600;
  }
  kbd{
    font-family:"Press Start 2P",monospace; font-size:8px; color:var(--ink);
    background:#20242c; border:1px solid #39414e; border-bottom:3px solid #39414e;
    border-radius:5px; padding:4px 7px; margin-right:2px;
  }
  footer{
    position:relative; z-index:1; margin-top:20px; text-align:center;
    font-size:11px; color:#5d6572; letter-spacing:.1em; max-width:760px; line-height:1.7;
  }
  footer b{ color:var(--dim); }
  @media (prefers-reduced-motion:reduce){
    .marquee::after,.stripes,.rollbar,.crt::before,.insert-coin,.led .pwr{ animation:none; }
  }
</style>
</head>
<body>

<main class="cabinet">
  <header class="marquee">
    <div class="logo-block">
      <div class="logo">RAD&#xA0;<em>HORIZON</em></div>
      <div class="tag">Turbo Grand Prix &#xB7; 6 Stages &#xB7; Est. 1986</div>
    </div>
    <div class="marquee-side">
      <div class="led"><span class="pwr"></span> HI-SCORE <span id="hiLed">0000000</span></div>
      <div class="led"><span class="pwr"></span> CREDITS <span id="credLed">0</span></div>
    </div>
  </header>
  <div class="stripes"></div>

  <div class="bezel">
    <div class="crt" id="crt">
      <canvas id="game" width="960" height="540"></canvas>
      <div class="rollbar"></div>
      <div class="touch">
        <div class="pad" id="padL">&#x25C0;</div>
        <div class="pad" id="padR">&#x25B6;</div>
        <div class="pad" id="padB">BRAKE</div>
        <div class="pad" id="padN">NITRO</div>
      </div>
    </div>
  </div>

  <div class="deck">
    <div class="coin-door">
      <div class="slot"></div>
      <div>
        <div class="insert-coin">INSERT COIN</div>
        <div class="credits">PRESS <kbd>ENTER</kbd> TO RACE</div>
      </div>
    </div>
    <div class="chips">
      <button class="chip on" id="btnSound">SOUND <b>ON</b></button>
      <button class="chip" id="btnRadio">RADIO &#xB7; <b id="radioName">SUNSET BLAZER</b></button>
      <button class="chip" id="btnStereo">3-D GLASSES <b>[G]</b></button>
    </div>
  </div>

  <div class="legend">
    <span><kbd>&#x25C0;</kbd><kbd>&#x25B6;</kbd> STEER</span>
    <span><kbd>&#x25B2;</kbd> THROTTLE</span>
    <span><kbd>&#x25BC;</kbd> BRAKE</span>
    <span><kbd>SHIFT</kbd> NITRO</span>
    <span><kbd>1</kbd><kbd>2</kbd><kbd>3</kbd><kbd>4</kbd> RADIO</span>
    <span><kbd>M</kbd> MUTE</span>
    <span><kbd>G</kbd> 3-D</span>
    <span><kbd>P</kbd> PAUSE</span>
  </div>
</main>

<footer>
  A love letter to <b>OUT RUN</b> (Sega &#xB7; 1986) and <b>RAD RACER</b> (Square &#xB7; 1987) &#x2014; every pixel drawn in code,
  every note synthesized live. Thread the needle between traffic to charge <b>NITRO</b>, hit every checkpoint,
  and pop on the red-cyan glasses for the full 1987 treatment.
  <br>Six stages end in the <b>NEON TUNNEL</b>: four blacked-out bores where the light rings strobe in time with
  the radio and the music muffles the moment you&apos;re inside &#x2014; and where the corners flatten out so you can
  hold it flat through the dark.
</footer>

<script>
'use strict';
/* =====================================================================
   RAD HORIZON — pseudo-3D arcade racer, everything procedural.
   ===================================================================== */

/* ---------------- tiny helpers ---------------- */
const $ = id => document.getElementById(id);
const clamp = (v,a,b)=> v<a?a : v>b?b : v;
const lerp  = (a,b,t)=> a+(b-a)*t;
const easeIn    = (a,b,t)=> a+(b-a)*t*t;
const easeInOut = (a,b,t)=> a+(b-a)*(-Math.cos(t*Math.PI)/2+0.5);
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
const _rgbCache={};
function hexRGB(h){ let c=_rgbCache[h]; if(!c){ c=[parseInt(h.slice(1,3),16),parseInt(h.slice(3,5),16),parseInt(h.slice(5,7),16)]; _rgbCache[h]=c; } return c; }
function rgba(h,a){ const c=hexRGB(h); return `rgba(${c[0]},${c[1]},${c[2]},${a})`; }
function mixHex(a,b,t){ const A=hexRGB(a),B=hexRGB(b);
  const r=Math.round(lerp(A[0],B[0],t)), g=Math.round(lerp(A[1],B[1],t)), bl=Math.round(lerp(A[2],B[2],t));
  return `rgb(${r},${g},${bl})`; }
function makeCanvas(w,h,fn){ const c=document.createElement('canvas'); c.width=w; c.height=h; fn(c.getContext('2d'),w,h); return c; }
function shade(hex,amt){ const c=hexRGB(hex); const f=v=>clamp(Math.round(v+amt),0,255); return `rgb(${f(c[0])},${f(c[1])},${f(c[2])})`; }

/* ---------------- constants ---------------- */
const SCENE_W=960, SCENE_H=540;
const SEG_LEN=200, ROAD_W=2200, LANES=3, RUMBLE=3, DRAW_DIST=240;
const FOV=100, CAM_DEPTH=1/Math.tan((FOV/2)*Math.PI/180), CAM_H=1050;
const PLAYER_Z=CAM_H*CAM_DEPTH;
const MAX_SPEED=SEG_LEN*60;               // 12000 world units / s
const ACCEL=MAX_SPEED/4.6, BRAKE=-MAX_SPEED*1.15, COAST=-MAX_SPEED/7, OFFROAD_DECEL=-MAX_SPEED*1.05;
const OFFROAD_LIMIT=MAX_SPEED/4, CENTRIFUGAL=0.29, BOOST_MAX=MAX_SPEED*1.38;
const EYE=46;                             // stereo camera separation (world units)

/* ---------------- canvases ---------------- */
const display=$('game'), dctx=display.getContext('2d');
const sceneCv=document.createElement('canvas'); sceneCv.width=SCENE_W; sceneCv.height=SCENE_H;
const sctx=sceneCv.getContext('2d');
const sceneR=document.createElement('canvas'); sceneR.width=SCENE_W; sceneR.height=SCENE_H;
const sctxR=sceneR.getContext('2d');
let tL=null,tR=null;

/* ---------------- stage palettes ---------------- */
const PALETTES=[
 { name:'PALM COAST', type:'coast',
   skyTop:'#0d5c86', skyMid:'#4fb0bf', skyLow:'#ffd9a0', sun:'#fff0c0', sunCore:'#ffe9a8', glow:'#ffd27a',
   mFar:'#2e7286', mNear:'#1e5262', grassA:'#1f9158', grassB:'#187f4c',
   roadA:'#5d626e', roadB:'#585d69', rumbleA:'#e9e9e9', rumbleB:'#d94f3d', lane:'#f4f4f4', fog:'#ffe3b0',
   sea:'#0f6f7e', seaA:1, stars:0 },
 { name:'DUSTY CANYON', type:'canyon',
   skyTop:'#3a1c2c', skyMid:'#a34a30', skyLow:'#ffb26b', sun:'#ffd9a0', sunCore:'#ffcf8f', glow:'#ff9d5c',
   mFar:'#6b3527', mNear:'#4e261d', grassA:'#c98d4f', grassB:'#bb8043',
   roadA:'#63544b', roadB:'#5d4f46', rumbleA:'#ead9b8', rumbleB:'#c0452f', lane:'#efe4cc', fog:'#ffb27a',
   sea:null, stars:0 },
 { name:'NEON CITY', type:'city',
   skyTop:'#040814', skyMid:'#0b1c33', skyLow:'#28495f', sun:'#dfe9f2', sunCore:'#eef6ff', glow:'#7fd4ff',
   mFar:'#0a1626', mNear:'#0d2036', grassA:'#14261f', grassB:'#101f19',
   roadA:'#3c4350', roadB:'#373e4b', rumbleA:'#dfe8ff', rumbleB:'#2a6cff', lane:'#9fe8ff', fog:'#274a63',
   sea:null, stars:1 },
 { name:'ALPINE PASS', type:'alpine',
   skyTop:'#2c7cb6', skyMid:'#7fc4e6', skyLow:'#eaf6ff', sun:'#fff8e0', sunCore:'#ffffff', glow:'#fff3c4',
   mFar:'#5b7d9e', mNear:'#33506b', grassA:'#2e9e52', grassB:'#27914a',
   roadA:'#676d77', roadB:'#61676f', rumbleA:'#e8e8e8', rumbleB:'#d14434', lane:'#f2f2f2', fog:'#dcedf7',
   sea:null, stars:0 },
 { name:'GOLDEN GATE', type:'gate',
   skyTop:'#1c3f66', skyMid:'#c96a3f', skyLow:'#ffd27a', sun:'#fff2c8', sunCore:'#ffe6a0', glow:'#ffb45e',
   mFar:'#5e4a52', mNear:'#3c3040', grassA:'#3f8f4a', grassB:'#37813f',
   roadA:'#5f5a58', roadB:'#5a5553', rumbleA:'#e9e9e9', rumbleB:'#d94f3d', lane:'#f4f4f4', fog:'#ffcf8e',
   sea:'#1a5f6e', seaA:1, stars:0 },
 { name:'NEON TUNNEL', type:'tunnel',
   skyTop:'#04030e', skyMid:'#170a30', skyLow:'#3b1350', sun:'#ff77e1', sunCore:'#ffd3f6', glow:'#ff4fd8',
   mFar:'#150a2b', mNear:'#0c051d', grassA:'#1b1030', grassB:'#150c28',
   roadA:'#241b33', roadB:'#201730', rumbleA:'#eafcff', rumbleB:'#ff2fd0', lane:'#8ef8ff', fog:'#2a0f45',
   sea:null, seaA:0, stars:1 }
];

/* ---------------- game state ---------------- */
let segments=[], trackLength=0, stageStarts=[];
let cars=[];
const LEVEL_TIME=[52,50,49,48,47,53], CHECKPOINT_BONUS=13;

const G={
  state:'boot',           // boot | attract | count | race | over | victory | pause
  position:0, speed:0, playerX:0,
  steerVis:0,
  timeLeft:64, stageIdx:0,
  score:0, hiScore:0, nearMisses:0, topSpeedKmh:0, raceElapsed:0,
  gauge:0.6, boosting:false, boostSnd:false,
  crashT:0, shakeT:0, flashT:0,
  countT:0, lastCount:-1,
  palA:PALETTES[0], palB:PALETTES[0], palT:1,
  skyOffset:0, cloudOff:0, camYWorld:0, horizonY:SCENE_H*0.5, wasTun:false,
  totalTime:0, totalNM:0, cpDone:[false,false], results:null, raceStart:0,
  now:0, lastTick:0,
  credits:0, muted:false, radioTrack:1, stereo:false, pausedFrom:null,
  popups:[], parts:[]
};
try{ G.hiScore=parseInt(localStorage.getItem('radhorizon_hi')||'0',10)||0; }catch(e){}

const input={left:false,right:false,throttle:false,brake:false,boost:false,touch:false};

/* =====================================================================
   TRACK BUILDING
   ===================================================================== */
function lastY(){ return segments.length? segments[segments.length-1].p2.world.y : 0; }
function addSeg(curve,y){
  const n=segments.length;
  segments.push({ index:n, curve, fog:1, clip:0, shade:Math.floor(n/RUMBLE)%2, checker:(n<6),
    p1:{world:{y:lastY(),z:n*SEG_LEN},camera:{},screen:{scale:0,x:0,y:0,w:0}},
    p2:{world:{y,z:(n+1)*SEG_LEN},camera:{},screen:{scale:0,x:0,y:0,w:0}},
    sprites:[], cars:[] });
}
function addRoad(enter,hold,leave,curve,hill){
  const startY=lastY(), endY=startY+hill*SEG_LEN, total=enter+hold+leave;
  for(let i=0;i<enter;i++) addSeg(easeIn(0,curve,i/enter),          easeInOut(startY,endY,i/total));
  for(let i=0;i<hold;i++)  addSeg(curve,                             easeInOut(startY,endY,(enter+i)/total));
  for(let i=0;i<leave;i++) addSeg(easeInOut(curve,0,i/leave),        easeInOut(startY,endY,(enter+hold+i)/total));
}
/* ---- course generator: one full circuit per level ---- */
let finishSeg=0, checkpointSegs=[], tunnelZones=[];
function buildLevel(level){
  segments=[]; checkpointSegs=[]; finishSeg=0;
  const rng=mulberry32(9001+level*131);
  const diff=Math.min(1.5,0.85+level*0.13);      // curves bite harder each stage
  addRoad(22,58,22,0,0);                          // launch straight
  const course=2380+level*180;
  while(segments.length<course){
    const t=rng(), sgn=rng()<0.5?-1:1;
    if(t<0.26){                                   // long sweeping bend
      addRoad(28+rng()*18, 55+rng()*45, 28+rng()*18,
              clamp(sgn*(1.5+rng()*2.1)*diff,-4.6,4.6), (rng()-0.5)*44);
    } else if(t<0.5){                             // s-bend
      const c=clamp(sgn*(2.1+rng()*1.5)*diff,-4.7,4.7);
      addRoad(20,28,20, c, (rng()-0.5)*18);
      addRoad(20,28,20, -c*0.92, (rng()-0.5)*18);
    } else if(t<0.68){                            // climb / plunge
      addRoad(24,38,24, clamp(sgn*rng()*1.6*diff,-3,3), sgn*(18+rng()*30));
    } else if(t<0.84){                            // hairpin
      addRoad(14,20,14, clamp(sgn*(3.1+rng()*1.5)*diff,-5,5), (rng()-0.5)*12);
    } else {                                      // flat-out straight + kink
      addRoad(8,50+rng()*32,8, 0, 0);
      addRoad(8,12,8, sgn*1.15, 0);
    }
  }
  checkpointSegs=[Math.floor(segments.length*0.33), Math.floor(segments.length*0.66)];
  addRoad(24,64,24, 0, -lastY()/SEG_LEN);         // level out to the line
  addRoad(8,30,8, 0, 0);
  finishSeg=segments.length;                      // the finish line
  addRoad(8,320,8, 0, 0);                         // cool-down run-off
  for(let i=0;i<6;i++) segments[finishSeg+i].checker=(i%2===0);   // chequered band
  /* neon tunnels — the signature of the final stage */
  tunnelZones=[];
  if(level>=5){
    const zones=4, span=Math.floor(finishSeg/(zones+1));
    for(let i=1;i<=zones;i++){
      tunnelZones.push({start:span*i-Math.floor(span*0.21),
                        end:Math.min(finishSeg-56, span*i+Math.floor(span*0.21))});
    }
    segments.forEach((s,i)=>{ const z=tunnelZones.find(q=>i>=q.start&&i<=q.end);
      if(z){ s.tunnel=true; s.tz=z; s.curve*=0.4; } });   // tunnels flow: flat-out blasts
  }
  trackLength=segments.length*SEG_LEN;
  stageStarts=checkpointSegs.slice();
}
const findSegment=z=>segments[clamp(Math.floor(z/SEG_LEN),0,segments.length-1)];

/* =====================================================================
   PROCEDURAL SPRITES
   ===================================================================== */
const SPR={};
function roundRect(g,x,y,w,h,r){ g.beginPath(); g.moveTo(x+r,y); g.arcTo(x+w,y,x+w,y+h,r); g.arcTo(x+w,y+h,x,y+h,r); g.arcTo(x,y+h,x,y,r); g.arcTo(x,y,x+w,y,r); g.closePath(); }

function buildSprites(){
  /* palm */
  SPR.palm=makeCanvas(240,330,(g)=>{
    g.lineCap='round';
    g.strokeStyle='#6e4222'; g.lineWidth=17;
    g.beginPath(); g.moveTo(132,328); g.quadraticCurveTo(110,210,122,118); g.stroke();
    g.strokeStyle='#59341a'; g.lineWidth=6;
    g.beginPath(); g.moveTo(136,326); g.quadraticCurveTo(118,220,126,130); g.stroke();
    const top={x:122,y:112};
    const fronds=[[-1.05,-0.25],[-0.75,-0.6],[-0.3,-0.82],[0.25,-0.85],[0.72,-0.6],[1.02,-0.22],[0.9,0.12],[-0.95,0.12]];
    fronds.forEach((f,i)=>{
      g.fillStyle= i%2? '#2f9e44':'#1e7a33';
      g.beginPath(); g.moveTo(top.x,top.y);
      g.quadraticCurveTo(top.x+f[0]*70, top.y+f[1]*70-18, top.x+f[0]*118, top.y+f[1]*118+26);
      g.quadraticCurveTo(top.x+f[0]*66, top.y+f[1]*66+16, top.x,top.y+4);
      g.fill();
    });
    g.fillStyle='#59341a'; g.beginPath(); g.arc(114,124,8,0,7); g.arc(132,126,8,0,7); g.fill();
  });
  /* pine */
  SPR.pine=makeCanvas(190,300,(g)=>{
    g.fillStyle='#6e4222'; g.fillRect(86,248,18,50);
    const layers=[[95,36,120],[95,96,150],[95,166,176]];
    layers.forEach((L,i)=>{ g.fillStyle= i%2?'#14522b':'#1c6b3a';
      g.beginPath(); g.moveTo(L[0],L[1]-64); g.lineTo(L[0]-L[2]/2,L[1]+78); g.lineTo(L[0]+L[2]/2,L[1]+78); g.fill();
      g.fillStyle='rgba(240,250,255,.85)';
      g.beginPath(); g.moveTo(L[0],L[1]-64); g.lineTo(L[0]-L[2]*0.16,L[1]-30); g.lineTo(L[0]+L[2]*0.16,L[1]-30); g.fill();
    });
  });
  /* cactus */
  SPR.cactus=makeCanvas(130,230,(g)=>{
    g.fillStyle='#3f9e4d'; roundRect(g,52,46,28,180,14); g.fill();
    roundRect(g,14,88,22,66,11); g.fill(); roundRect(g,14,88,52,20,10); g.fill();
    roundRect(g,96,64,22,58,11); g.fill(); roundRect(g,66,102,52,20,10); g.fill();
    g.fillStyle='#2c7a39';
    [62,70].forEach(x=>{ g.fillRect(x,56,3,160); });
    g.fillStyle='#ff6b81'; g.beginPath(); g.arc(66,44,7,0,7); g.fill();
  });
  /* rock */
  SPR.rock=makeCanvas(170,110,(g)=>{
    g.fillStyle='#8a8f98'; g.beginPath(); g.moveTo(10,100); g.lineTo(34,40); g.lineTo(78,16); g.lineTo(132,34); g.lineTo(160,100); g.fill();
    g.fillStyle='#6d727c'; g.beginPath(); g.moveTo(78,16); g.lineTo(132,34); g.lineTo(160,100); g.lineTo(96,100); g.fill();
    g.fillStyle='#a7adb6'; g.beginPath(); g.moveTo(34,40); g.lineTo(78,16); g.lineTo(70,44); g.lineTo(44,58); g.fill();
  });
  /* roadside sign */
  const sign=(label,bg,fg,border)=>makeCanvas(230,190,(g)=>{
    g.fillStyle='#5a5f68'; g.fillRect(52,120,12,70); g.fillRect(166,120,12,70);
    g.fillStyle=bg; roundRect(g,10,18,210,112,8); g.fill();
    g.lineWidth=7; g.strokeStyle=border; roundRect(g,10,18,210,112,8); g.stroke();
    g.fillStyle=fg; g.font='22px "Press Start 2P", monospace'; g.textAlign='center'; g.textBaseline='middle';
    g.fillText(label,115,76);
  });
  SPR.signTurbo=sign('TURBO','#f3ead9','#c0392b','#c0392b');
  SPR.signRad  =sign('RAD!','#f3ead9','#0e7a6e','#0e7a6e');
  SPR.signRoute=sign('RT-86','#f8f4e6','#1f2937','#e0a62e');
  SPR.signAlp  =sign('ALPINE','#eaf6ff','#20455e','#2c7cb6');
  SPR.signFinal=sign('FINAL','#f3ead9','#b3402a','#b3402a');
  /* neon signs (glow baked) */
  const neon=(label,col)=>makeCanvas(250,200,(g)=>{
    g.fillStyle='#10141c'; g.fillRect(60,130,10,70); g.fillRect(180,130,10,70);
    g.fillStyle='#0b0e15'; roundRect(g,14,16,222,120,10); g.fill();
    g.save(); g.shadowColor=col; g.shadowBlur=20;
    g.strokeStyle=col; g.lineWidth=4; roundRect(g,22,24,206,104,8); g.stroke();
    g.fillStyle=col; g.font='24px "Press Start 2P", monospace'; g.textAlign='center'; g.textBaseline='middle';
    g.fillText(label,125,68); g.restore();
    g.fillStyle=rgba(col,0.28); g.fillRect(30,96,190,5);
  });
  SPR.neonCyan=neon('MOTEL','#3fe0d0');
  SPR.neonPink=neon('SUSHI','#ff4fa0');
  SPR.neonYel =neon('ARCADE','#ffd23f');
  /* street lamp with glow */
  SPR.lamp=makeCanvas(120,330,(g)=>{
    g.fillStyle='#2c323c'; g.fillRect(54,60,10,268);
    g.fillRect(54,60,44,9);
    const glow=g.createRadialGradient(92,74,2,92,74,52);
    glow.addColorStop(0,'rgba(190,235,255,.95)'); glow.addColorStop(1,'rgba(190,235,255,0)');
    g.fillStyle=glow; g.beginPath(); g.arc(92,74,52,0,7); g.fill();
    g.fillStyle='#dff3ff'; roundRect(g,82,64,20,14,5); g.fill();
  });
  /* city tower */
  SPR.tower=makeCanvas(200,420,(g)=>{
    g.fillStyle='#0d1a2c'; g.fillRect(20,40,160,380);
    g.fillStyle='#122440'; g.fillRect(20,40,160,14);
    g.fillStyle='#091322'; g.fillRect(88,6,8,40);
    g.fillStyle='#ff4b4b'; g.beginPath(); g.arc(92,6,5,0,7); g.fill();
    const rng=mulberry32(7);
    for(let y=64;y<400;y+=26) for(let x=32;x<170;x+=22){
      if(rng()<0.42){ g.fillStyle= rng()<0.5? 'rgba(255,210,120,.9)':'rgba(120,225,255,.75)'; g.fillRect(x,y,10,12); }
      else { g.fillStyle='rgba(40,60,90,.5)'; g.fillRect(x,y,10,12); }
    }
  });
  /* checkpoint gantry */
  const gantry=(label)=>makeCanvas(760,250,(g)=>{
    g.fillStyle='#454c58'; g.fillRect(26,60,26,190); g.fillRect(708,60,26,190);
    g.fillStyle='#2e333d'; g.fillRect(26,60,8,190); g.fillRect(708,60,8,190);
    g.fillStyle='#1c212a'; g.fillRect(0,18,760,64);
    g.fillStyle='#f3ead9'; g.font='30px "Press Start 2P", monospace'; g.textAlign='center'; g.textBaseline='middle';
    g.fillText(label,380,50);
    for(let x=0;x<760;x+=24){ g.fillStyle=(x/24)%2? '#111':'#eee'; g.fillRect(x,82,24,16); }
  });
  SPR.gantryCheck=gantry('CHECKPOINT');
  SPR.gantryStart=gantry('RAD HORIZON');
  /* the finish arch */
  SPR.gantryFinish=makeCanvas(840,300,(g)=>{
    g.fillStyle='#454c58'; g.fillRect(26,66,32,234); g.fillRect(782,66,32,234);
    g.fillStyle='#2e333d'; g.fillRect(26,66,10,234); g.fillRect(782,66,10,234);
    g.fillStyle='#1c212a'; g.fillRect(0,12,840,84);
    g.fillStyle='#f3ead9'; g.font='40px "Press Start 2P", monospace'; g.textAlign='center'; g.textBaseline='middle';
    g.fillText('FINISH',420,54);
    for(let x=0;x<840;x+=21){ g.fillStyle=(x/21)%2? '#f3ead9':'#15181e'; g.fillRect(x,96,21,26); }
    for(let i=0;i<2;i++){ const fx=150+i*560;
      g.strokeStyle='#8b93a1'; g.lineWidth=4; g.beginPath(); g.moveTo(fx,122); g.lineTo(fx,212); g.stroke();
      for(let r=0;r<5;r++) for(let c2=0;c2<4;c2++){ g.fillStyle=(r+c2)%2? '#15181e':'#f3ead9'; g.fillRect(fx+c2*10,122+r*10,10,10); }
    }
  });
  /* neon tunnel portal — centre is transparent so you see straight through it */
  SPR.tunnelGate=makeCanvas(780,430,(g)=>{
    g.fillStyle='#150c26'; g.fillRect(0,0,54,430); g.fillRect(726,0,54,430);
    g.fillStyle='#0d0718'; g.fillRect(46,0,8,430); g.fillRect(726,0,8,430);
    g.fillStyle='#150c26'; g.fillRect(0,0,780,132);
    g.fillStyle='#0d0718'; g.fillRect(0,124,780,8);
    g.save(); g.shadowColor='#8ef8ff'; g.shadowBlur=18;
    g.strokeStyle='#8ef8ff'; g.lineWidth=5;
    g.beginPath(); g.moveTo(54,126); g.lineTo(726,126); g.stroke();
    g.strokeStyle='#ff5ce1'; g.lineWidth=4;
    g.beginPath(); g.moveTo(56,0); g.lineTo(56,424); g.moveTo(724,0); g.lineTo(724,424); g.stroke();
    g.restore();
    g.fillStyle='#eafcff'; g.font='24px "Press Start 2P", monospace'; g.textAlign='center'; g.textBaseline='middle';
    g.fillText('NEON TUNNEL',390,64);
    g.fillStyle='#ff5ce1';
    for(let y=152;y<404;y+=42){
      g.beginPath(); g.moveTo(14,y); g.lineTo(40,y+12); g.lineTo(14,y+24); g.fill();
      g.beginPath(); g.moveTo(766,y); g.lineTo(740,y+12); g.lineTo(766,y+24); g.fill();
    }
  });
  /* traffic cars (rear view) */
  const carColors=['#3a86ff','#ffbe0b','#e9ecef','#8d99ae','#2a9d8f','#e76f51'];
  SPR.traffic=carColors.map(col=>makeCanvas(160,112,(g)=>{
    g.fillStyle='#15171b'; roundRect(g,6,84,30,24,6); g.fill(); roundRect(g,124,84,30,24,6); g.fill();
    const body=g.createLinearGradient(0,20,0,100);
    body.addColorStop(0,shade(col,34)); body.addColorStop(0.5,col); body.addColorStop(1,shade(col,-46));
    g.fillStyle=body; roundRect(g,10,34,140,62,12); g.fill();
    const glass=g.createLinearGradient(0,6,0,52);
    glass.addColorStop(0,'#2c3a4c'); glass.addColorStop(1,'#101820');
    g.fillStyle=glass; roundRect(g,26,8,108,44,10); g.fill();
    g.fillStyle=rgba('#bfe8ff',0.25); g.fillRect(34,14,40,6);
    g.fillStyle='#111'; roundRect(g,14,88,132,12,5); g.fill();
    g.fillStyle='#c9cfd6'; g.fillRect(66,90,28,8);
    g.save(); g.shadowColor='#ff2d2d'; g.shadowBlur=10;
    g.fillStyle='#ff4040'; roundRect(g,18,62,30,9,3); g.fill(); roundRect(g,112,62,30,9,3); g.fill(); g.restore();
  }));
  /* player car — the Vector GT */
  SPR.player=makeCanvas(260,150,(g)=>{
    g.fillStyle='#141619'; roundRect(g,8,118,44,26,7); g.fill(); roundRect(g,208,118,44,26,7); g.fill();
    const body=g.createLinearGradient(0,30,0,140);
    body.addColorStop(0,'#ff5a48'); body.addColorStop(0.42,'#e63226'); body.addColorStop(1,'#8f1d15');
    g.fillStyle=body;
    g.beginPath(); g.moveTo(14,132); g.lineTo(8,86); g.quadraticCurveTo(10,60,40,54);
    g.lineTo(220,54); g.quadraticCurveTo(250,60,252,86); g.lineTo(246,132);
    g.quadraticCurveTo(130,142,14,132); g.fill();
    g.fillStyle='#a52019'; g.fillRect(14,96,232,7);
    const glass=g.createLinearGradient(0,10,0,58);
    glass.addColorStop(0,'#28384a'); glass.addColorStop(1,'#0d141c');
    g.fillStyle=glass;
    g.beginPath(); g.moveTo(52,52); g.lineTo(66,14); g.quadraticCurveTo(130,4,194,14); g.lineTo(208,52); g.closePath(); g.fill();
    g.fillStyle=rgba('#7fd4ff',0.35); g.beginPath(); g.moveTo(70,18); g.lineTo(120,10); g.lineTo(104,20); g.lineTo(74,26); g.closePath(); g.fill();
    g.fillStyle='#5e120c'; g.fillRect(46,50,168,5);
    /* side strakes */
    g.fillStyle='#7c1710';
    for(let i=0;i<3;i++){ g.fillRect(16,104+i*8,42,4); g.fillRect(202,104+i*8,42,4); }
    /* tail light bar */
    g.fillStyle='#1a0c0a'; roundRect(g,26,72,208,16,5); g.fill();
    g.save(); g.shadowColor='#ff2222'; g.shadowBlur=12; g.fillStyle='#ff3b30';
    for(let x=32;x<226;x+=24) roundRect(g,x,75,16,10,2), g.fill();
    g.restore();
    g.fillStyle='#0d0d0f'; roundRect(g,86,116,88,16,4); g.fill();
    g.fillStyle='#c9cfd6'; g.fillRect(112,119,36,10);
    g.fillStyle='#1c1c1f'; g.beginPath(); g.arc(98,126,5,0,7); g.arc(162,126,5,0,7); g.fill();
    g.fillStyle=rgba('#7fd4ff',0.8); g.fillRect(46,57,20,3); g.fillRect(194,57,20,3);
  });
}

/* sprite placement */
const SPRITE_DEFS={
  palm:{img:'palm',w:1000}, pine:{img:'pine',w:820}, cactus:{img:'cactus',w:480}, rock:{img:'rock',w:720},
  signTurbo:{img:'signTurbo',w:880}, signRad:{img:'signRad',w:880}, signRoute:{img:'signRoute',w:880},
  signAlp:{img:'signAlp',w:880}, signFinal:{img:'signFinal',w:880},
  neonCyan:{img:'neonCyan',w:980}, neonPink:{img:'neonPink',w:980}, neonYel:{img:'neonYel',w:980},
  lamp:{img:'lamp',w:300}, tower:{img:'tower',w:1500},
  gantryCheck:{img:'gantryCheck',w:ROAD_W*3.2}, gantryStart:{img:'gantryStart',w:ROAD_W*3.2},
  gantryFinish:{img:'gantryFinish',w:ROAD_W*3.5},
  tunnelGate:{img:'tunnelGate',w:ROAD_W*2.68}
};
const STAGE_POOLS=[
  [['palm',5],['palm',5],['signTurbo',1],['signRad',1],['rock',1]],
  [['cactus',4],['rock',3],['signRoute',1],['signTurbo',1]],
  [['neonCyan',2],['neonPink',2],['neonYel',1],['lamp',3],['tower',2]],
  [['pine',5],['pine',3],['rock',2],['signAlp',1]],
  [['palm',4],['signFinal',1],['signTurbo',1],['rock',1],['pine',2]],
  [['neonPink',3],['neonCyan',3],['neonYel',1],['lamp',4],['tower',3]]
];
function placeSprites(level){
  const rng=mulberry32(4400+level*97);
  const pool=STAGE_POOLS[level];
  const pick=p=>{ let tot=0; p.forEach(q=>tot+=q[1]); let r=rng()*tot;
    for(const q of p){ r-=q[1]; if(r<=0) return q[0]; } return p[0][0]; };
  const blocked=n=> Math.abs(n-finishSeg)<12 || checkpointSegs.some(cs=>Math.abs(n-cs)<9)
                   || tunnelZones.some(z=>n>z.start-8 && n<z.end+8);
  for(let n=16;n<segments.length-4;n+= 2+Math.floor(rng()*5)){
    if(blocked(n)) continue;
    const side=rng()<0.5?-1:1;
    const off=side*(1.2+rng()*2.4);
    const key=pick(pool), def=SPRITE_DEFS[key];
    segments[n].sprites.push({off, w:def.w, img:SPR[def.img], kind:key});
    if(key==='palm' && rng()<0.5) segments[n].sprites.push({off:-off, w:def.w, img:SPR[def.img], kind:key});
    if(key==='lamp'){ segments[n].sprites.push({off:-off, w:def.w, img:SPR[def.img], kind:key}); n+=3; }
  }
  segments[5].sprites.push({off:0,w:SPRITE_DEFS.gantryStart.w,img:SPR.gantryStart,kind:'gantry'});
  checkpointSegs.forEach(cs=> segments[cs].sprites.push({off:0,w:SPRITE_DEFS.gantryCheck.w,img:SPR.gantryCheck,kind:'gantry'}));
  segments[finishSeg+2].sprites.push({off:0,w:SPRITE_DEFS.gantryFinish.w,img:SPR.gantryFinish,kind:'gantry'});
  tunnelZones.forEach(z=> segments[z.start-4].sprites.push({off:0,w:SPRITE_DEFS.tunnelGate.w,img:SPR.tunnelGate,kind:'gantry'}));
}

/* =====================================================================
   TRAFFIC
   ===================================================================== */
function resetCars(level){
  cars.forEach(c=>{ if(c.seg){ const i=c.seg.cars.indexOf(c); if(i>=0) c.seg.cars.splice(i,1); } });
  cars=[];
  const rng=mulberry32(7777+((Date.now()/1000|0)%9000));
  const count=30+level*9;                       // thicker traffic every stage
  const lanes=[-0.76,-0.42,-0.08,0.28,0.66];
  const first=70, last=finishSeg-12;
  for(let i=0;i<count;i++){
    const z=(first+Math.floor(rng()*(last-first)))*SEG_LEN + rng()*SEG_LEN;
    const fast = level>=2 && rng()<0.2;
    const car={ z, offset:lanes[Math.floor(rng()*lanes.length)],
      speed:MAX_SPEED*(fast? 0.6+rng()*0.18 : 0.19+rng()*0.3),
      img:SPR.traffic[Math.floor(rng()*SPR.traffic.length)], nmT:-9, seg:null };
    const seg=findSegment(z); seg.cars.push(car); car.seg=seg; cars.push(car);
  }
}

/* =====================================================================
   AUDIO — engine, SFX and three synthesized radio stations
   ===================================================================== */
let AC=null, master=null, musicBus=null, sfxBus=null, delaySend=null, noiseBuf=null, tunnelFilter=null;
let engOsc=null, engOsc2=null, engFilter=null, engGain=null, roadNoise=null, roadGain=null;

function ensureAudio(){
  if(AC) { if(AC.state==='suspended') AC.resume(); return; }
  const Ctx=window.AudioContext||window.webkitAudioContext; if(!Ctx) return;
  AC=new Ctx();
  master=AC.createGain(); master.gain.value=G.muted?0:0.9; master.connect(AC.destination);
  musicBus=AC.createGain(); musicBus.gain.value=0.5;
  tunnelFilter=AC.createBiquadFilter(); tunnelFilter.type='lowpass'; tunnelFilter.frequency.value=18000;
  musicBus.connect(tunnelFilter); tunnelFilter.connect(master);
  sfxBus=AC.createGain(); sfxBus.gain.value=0.8; sfxBus.connect(master);
  const dly=AC.createDelay(1); dly.delayTime.value=0.27;
  const fb=AC.createGain(); fb.gain.value=0.34; dly.connect(fb); fb.connect(dly);
  const wet=AC.createGain(); wet.gain.value=0.3; dly.connect(wet); wet.connect(musicBus);
  delaySend=AC.createGain(); delaySend.gain.value=1; delaySend.connect(dly);
  noiseBuf=AC.createBuffer(1,AC.sampleRate,AC.sampleRate);
  const d=noiseBuf.getChannelData(0); for(let i=0;i<d.length;i++) d[i]=Math.random()*2-1;
  /* engine */
  engGain=AC.createGain(); engGain.gain.value=0; engGain.connect(master);
  engFilter=AC.createBiquadFilter(); engFilter.type='lowpass'; engFilter.frequency.value=500; engFilter.connect(engGain);
  engOsc=AC.createOscillator(); engOsc.type='sawtooth'; engOsc.frequency.value=55; engOsc.connect(engFilter); engOsc.start();
  engOsc2=AC.createOscillator(); engOsc2.type='square'; engOsc2.frequency.value=28; engOsc2.detune.value=-8;
  const eg2=AC.createGain(); eg2.gain.value=0.5; engOsc2.connect(eg2); eg2.connect(engFilter); engOsc2.start();
  roadGain=AC.createGain(); roadGain.gain.value=0; roadGain.connect(master);
  const bp=AC.createBiquadFilter(); bp.type='bandpass'; bp.frequency.value=950; bp.Q.value=0.6;
  roadNoise=AC.createBufferSource(); roadNoise.buffer=noiseBuf; roadNoise.loop=true; roadNoise.connect(bp); bp.connect(roadGain); roadNoise.start();
  Music.start();
}
const nf=m=>440*Math.pow(2,(m-69)/12);
function env(g,t,a,peak,dur){ g.gain.setValueAtTime(0.0001,t); g.gain.linearRampToValueAtTime(peak,t+a); g.gain.exponentialRampToValueAtTime(0.0001,t+dur); }
function tone(o){ if(!AC) return;
  const {f=440,f2=null,type='square',dur=0.15,vol=0.2,at=0.005,bus=sfxBus,send=0,t=AC.currentTime}=o;
  const osc=AC.createOscillator(); osc.type=type; osc.frequency.setValueAtTime(f,t);
  if(f2) osc.frequency.exponentialRampToValueAtTime(f2,t+dur);
  const g=AC.createGain(); env(g,t,at,vol,dur); osc.connect(g); g.connect(bus);
  if(send&&delaySend){ const s=AC.createGain(); s.gain.value=send; g.connect(s); s.connect(delaySend); }
  osc.start(t); osc.stop(t+dur+0.05);
}
function noiseHit(o){ if(!AC) return;
  const {dur=0.12,vol=0.25,type='highpass',freq=6000,freq2=null,q=0.8,t=AC.currentTime,bus=sfxBus,send=0}=o;
  const src=AC.createBufferSource(); src.buffer=noiseBuf; src.loop=true;
  const f=AC.createBiquadFilter(); f.type=type; f.frequency.setValueAtTime(freq,t); f.Q.value=q;
  if(freq2) f.frequency.exponentialRampToValueAtTime(freq2,t+dur);
  const g=AC.createGain(); env(g,t,0.004,vol,dur);
  src.connect(f); f.connect(g); g.connect(bus);
  if(send&&delaySend){ const s=AC.createGain(); s.gain.value=send; g.connect(s); s.connect(delaySend); }
  src.start(t); src.stop(t+dur+0.05);
}
const SFX={
  coin(){ tone({f:988,dur:0.09,vol:0.22}); tone({f:1319,dur:0.22,vol:0.22,t:AC?AC.currentTime+0.09:0}); },
  beep(hi){ tone({f:hi?880:440,dur:hi?0.4:0.12,vol:0.25}); },
  tick(){ tone({f:1250,dur:0.04,vol:0.12}); },
  crash(){ if(!AC)return; noiseHit({type:'lowpass',freq:900,dur:0.3,vol:0.5}); tone({f:170,f2:48,type:'sine',dur:0.32,vol:0.5}); },
  whoosh(){ noiseHit({type:'bandpass',freq:320,freq2:2800,q:1.4,dur:0.24,vol:0.3}); },
  nitro(){ tone({f:150,f2:760,type:'sawtooth',dur:0.5,vol:0.2}); noiseHit({type:'highpass',freq:2400,dur:0.5,vol:0.16}); },
  checkpoint(){ [523,659,784,1047].forEach((f,i)=>tone({f,dur:0.14,vol:0.22,t:AC?AC.currentTime+i*0.09:0})); },
  fanfare(){ [392,523,659,784,1047,1319].forEach((f,i)=>tone({f,dur:0.2,vol:0.2,t:AC?AC.currentTime+i*0.12:0,type:'square'})); },
  gameover(){ [392,330,262,196].forEach((f,i)=>tone({f,dur:0.3,vol:0.2,t:AC?AC.currentTime+i*0.22:0,type:'triangle'})); }
};

/* ---- the radio ---- */
const MINOR_PENT=[0,3,5,7,10,12,15,17,19,22];
const MAJOR_PENT=[0,2,4,7,9,12,14,16,19,21];
function genMelody(scale,root,seed,density){
  const r=mulberry32(seed), out=new Array(64).fill(-1);
  let saved=null, lastI=3;
  for(let bar=0; bar<4; bar++){
    let phrase;
    if(bar%2===0){
      phrase=[]; let i=(bar===0)? 2+Math.floor(r()*3) : lastI;
      for(let k=0;k<8;k++){
        if(r()<density){ i=clamp(i+(r()<0.5?-1:1)*(r()<0.72?1:2),0,scale.length-1); phrase.push(scale[i]); }
        else phrase.push(-1);
      }
      if(bar===2){ phrase[6]=scale[1]; phrase[7]=scale[0]; }
      saved=phrase.slice(); lastI=i;
    } else phrase=saved.slice();
    if(bar===3){ phrase[6]=scale[0]; phrase[7]=-1; }
    for(let k=0;k<8;k++) out[bar*16+k*2]=phrase[k]<0?-1:(root+24+phrase[k]);
  }
  return out;
}
const TRACKS={
  1:{ name:'SUNSET BLAZER', bpm:132, prog:[45,41,48,43], bassWave:'square',
      bassPat:[0,-1,0,-1,0,-1,12,-1,0,-1,0,-1,7,-1,12,-1],
      kick:[0,4,8,12], clap:[4,12], hats:'eighths', openHat:14, pad:null,
      melody:genMelody(MINOR_PENT,45,101,0.8), leadWave:'square', leadVol:0.085 },
  2:{ name:'MAGNOLIA BREEZE', bpm:100, prog:[41,45,38,36], bassWave:'triangle',
      bassPat:[0,-1,-1,0,-1,-1,7,-1,0,-1,-1,0,-1,12,-1,-1],
      kick:[0,8], clap:[], hats:'soft', openHat:-1, pad:[0,4,7,11],
      melody:genMelody(MAJOR_PENT,41,202,0.62), leadWave:'triangle', leadVol:0.16 },
  3:{ name:'NEON NIGHTDRIVE', bpm:118, prog:[33,31,36,38], bassWave:'square',
      bassPat:[0,-1,-1,0,-1,0,-1,-1,12,-1,-1,0,-1,-1,10,-1],
      kick:[0,4,8,12], clap:[4,12], hats:'sixteenths', openHat:14, pad:[0,3,7,10],
      melody:genMelody(MINOR_PENT,33,303,0.5), leadWave:'square', leadVol:0.07 },
  4:{ name:'NEON TUNNEL', bpm:142, prog:[33,31,29,31], bassWave:'sawtooth',
      bassPat:[0,-1,0,0,-1,0,-1,0,12,-1,0,-1,7,-1,12,-1],
      kick:[0,3,4,8,11,12], clap:[4,12], hats:'sixteenths', openHat:14, pad:[0,3,7,10],
      melody:genMelody(MINOR_PENT,33,404,0.56), leadWave:'sawtooth', leadVol:0.06 }
};
const Music={
  timer:null, step:0, nextTime:0,
  start(){ if(this.timer||!AC) return; this.nextTime=AC.currentTime+0.08;
    this.timer=setInterval(()=>this.schedule(),30); },
  schedule(){ while(this.nextTime<AC.currentTime+0.15){ this.playStep(this.step,this.nextTime);
      this.nextTime+=(60/TRACKS[G.radioTrack].bpm)/4; this.step=(this.step+1)%64; } },
  playStep(step,t){
    const T=TRACKS[G.radioTrack], s16=step%16, root=T.prog[Math.floor(step/16)%T.prog.length];
    if(T.kick.includes(s16)){ this.kickT=t;
      tone({f:150,f2:46,type:'sine',dur:0.16,vol:0.5,bus:musicBus,t}); }
    if(T.clap.includes(s16)){ noiseHit({type:'bandpass',freq:1700,q:1,dur:0.13,vol:0.24,bus:musicBus,send:0.5,t});
      noiseHit({type:'bandpass',freq:1700,q:1,dur:0.1,vol:0.16,bus:musicBus,t:t+0.014}); }
    if(T.hats==='eighths' && s16%2===0) noiseHit({type:'highpass',freq:8200,dur:s16===T.openHat?0.1:0.035,vol:s16%4?0.07:0.12,bus:musicBus,t});
    if(T.hats==='soft' && [2,6,10,14].includes(s16)) noiseHit({type:'highpass',freq:9000,dur:0.05,vol:0.05,bus:musicBus,t});
    if(T.hats==='sixteenths') noiseHit({type:'highpass',freq:8600,dur:s16===T.openHat?0.11:0.026,vol:s16%4?0.045:0.09,bus:musicBus,t});
    const b=T.bassPat[s16];
    if(b>=0){ const m=root+b;
      const osc=AC.createOscillator(); osc.type=T.bassWave; osc.frequency.value=nf(m);
      const f=AC.createBiquadFilter(); f.type='lowpass'; f.frequency.setValueAtTime(900,t); f.frequency.exponentialRampToValueAtTime(240,t+0.16);
      const g=AC.createGain(); env(g,t,0.004,0.22,0.19);
      osc.connect(f); f.connect(g); g.connect(musicBus); osc.start(t); osc.stop(t+0.25); }
    if(s16===0 && T.pad){ T.pad.forEach(iv=>{ [-6,6].forEach(det=>{
        const osc=AC.createOscillator(); osc.type='triangle'; osc.frequency.value=nf(root+12+iv); osc.detune.value=det;
        const g=AC.createGain(); g.gain.setValueAtTime(0.0001,t); g.gain.linearRampToValueAtTime(0.035,t+0.5);
        g.gain.linearRampToValueAtTime(0.0001,t+(60/T.bpm)*3.6);
        osc.connect(g); g.connect(musicBus); osc.start(t); osc.stop(t+(60/T.bpm)*4); }); }); }
    const mv=T.melody[step];
    if(mv>0){ const long=(step+2<64 && T.melody[step+2]<0);
      const osc=AC.createOscillator(); osc.type=T.leadWave; osc.frequency.value=nf(mv);
      const g=AC.createGain(); env(g,t,0.008,T.leadVol,long?0.42:0.2);
      osc.connect(g); g.connect(musicBus);
      const s=AC.createGain(); s.gain.value=0.9; g.connect(s); s.connect(delaySend);
      osc.start(t); osc.stop(t+0.5); }
  }
};
function setRadio(n){ G.radioTrack=n; if(AC) tone({f:1560,dur:0.05,vol:0.12});
  $('radioName').textContent=TRACKS[n].name; }
/* 0..1 pulse that decays after every kick — drives the tunnel light rings */
function beatPulse(){
  const T=TRACKS[G.radioTrack];
  if(!AC||!Music.kickT||G.muted) return 0.55+0.45*Math.abs(Math.sin(G.now*6));
  const ph=(AC.currentTime-Music.kickT)/(60/T.bpm);
  return ph<0? 1 : 1/(1+Math.max(0,ph)*3.4);
}
function toggleMute(){ G.muted=!G.muted; if(master) master.gain.value=G.muted?0:0.9;
  const b=$('btnSound'); b.classList.toggle('on',!G.muted); b.innerHTML=G.muted? 'SOUND <b>OFF</b>' : 'SOUND <b>ON</b>'; }

/* =====================================================================
   BACKGROUND — sky, sun, ridges, stars, sea
   ===================================================================== */
const ridgeA=[], ridgeB=[], skyline=[], stars=[], skyWin=[];
function buildScenery(){
  const r1=mulberry32(11), r2=mulberry32(23), r3=mulberry32(37);
  const ph=[r1()*9,r1()*9,r1()*9];
  for(let i=0;i<512;i++) ridgeA.push(0.42+0.34*Math.sin(i*0.045+ph[0])+0.24*Math.sin(i*0.11+ph[1]));
  const ph2=[r2()*9,r2()*9];
  for(let i=0;i<512;i++) ridgeB.push(0.5+0.3*Math.sin(i*0.03+ph2[0])+0.2*Math.sin(i*0.16+ph2[1]));
  let x=0;
  while(x<1500){ const w=26+r3()*60, h=40+r3()*150; skyline.push({x,w,h}); x+=w+2+r3()*8; }
  const rs=mulberry32(99);
  for(let i=0;i<110;i++) stars.push({x:rs()*SCENE_W, y:rs()*SCENE_H*0.42, s:rs()<0.2?2:1, tw:rs()*9});
  const rw=mulberry32(55);
  skyline.forEach(b=>{ for(let wy=10; wy<b.h-8; wy+=13) for(let wx=5; wx<b.w-6; wx+=10)
    if(rw()<0.3) skyWin.push({x:b.x+wx, y:wy, c:rw()<0.6?'#ffd27a':'#7fe0ff', tw:rw()*9}); });
}
function skyColorAt(pal,u){ // u: 0 top .. 1 horizon
  if(u<0.6) return mixHex(pal.skyTop,pal.skyMid,u/0.6);
  return mixHex(pal.skyMid,pal.skyLow,(u-0.6)/0.4);
}
function drawRidge(c,pal,heights,par,amp,baseY,col,rim){
  const off=(G.skyOffset*par + G.playerX*par*26);
  c.fillStyle=col; c.beginPath(); c.moveTo(0,SCENE_H);
  for(let sx=0;sx<=SCENE_W;sx+=4){
    const w=sx+off; const idx=((Math.floor(w/3)%512)+512)%512;
    c.lineTo(sx, baseY-heights[idx]*amp);
  }
  c.lineTo(SCENE_W,SCENE_H); c.closePath(); c.fill();
  if(rim){ c.strokeStyle=rim; c.lineWidth=1.5; c.stroke(); }
}
function drawBackground(c,pal,eyeShift){
  const W=SCENE_W,H=SCENE_H;
  const horizonY=clamp(G.horizonY||H*0.5, H*0.2, H*0.76);
  const ex=eyeShift*0.012;
  const g=c.createLinearGradient(0,0,0,horizonY+2);
  g.addColorStop(0,pal.skyTop); g.addColorStop(0.6,pal.skyMid); g.addColorStop(1,pal.skyLow);
  c.fillStyle=g; c.fillRect(0,0,W,H);
  /* stars */
  if(pal.stars>0){ for(const s of stars){
      const a=pal.stars*(0.35+0.65*Math.abs(Math.sin(G.now*1.4+s.tw)));
      c.fillStyle=rgba('#cfeaff',a); c.fillRect(s.x,s.y,s.s,s.s); } }
  /* sun / moon */
  const isCity = pal.type==='city';
  const prog = (G.state==='race'||G.state==='count') ? G.position/trackLength : 0.16;
  const sunX=W*0.64 - (G.skyOffset*0.045+ex) ;
  const sunY=horizonY - H*0.16 + prog*H*0.14;
  const r= isCity? 34 : 92;
  const glow=c.createRadialGradient(sunX,sunY,r*0.2,sunX,sunY,r*2.6);
  glow.addColorStop(0,rgba(pal.glow,0.55)); glow.addColorStop(1,rgba(pal.glow,0));
  c.fillStyle=glow; c.fillRect(sunX-r*3,sunY-r*3,r*6,r*6);
  c.fillStyle=pal.sunCore; c.beginPath(); c.arc(sunX,sunY,r,0,7); c.fill();
  if(!isCity){ /* synthwave slice gaps, filled with matching sky color */
    for(let i=0;i<6;i++){ const gy=sunY+r*(0.06+i*0.155), gh=2+i*1.8;
      if(gy<sunY+r){ c.fillStyle=skyColorAt(pal,clamp(gy/horizonY,0,1)*0.999);
        c.fillRect(sunX-r-4,gy,r*2+8,gh); } }
  } else { c.fillStyle='#0b1c33'; c.beginPath(); c.arc(sunX-12,sunY-8,r*0.82,0,7); c.fill(); }
  /* sea */
  if(pal.sea && (pal.seaA===undefined||pal.seaA>0.02)){
    c.save(); c.globalAlpha=(pal.seaA===undefined?1:pal.seaA);
    const sg=c.createLinearGradient(0,horizonY,0,horizonY+H*0.12);
    sg.addColorStop(0,pal.sea); sg.addColorStop(1,shade(pal.sea,-26));
    c.fillStyle=sg; c.fillRect(0,horizonY,W,H*0.12);
    c.fillStyle=rgba('#ffffff',0.16);
    for(let i=0;i<10;i++){ const yy=horizonY+6+i*(H*0.011);
      const xx=((i*173 + G.now*30*(1+i*0.2) - ex*2)%(W+160))-80;
      c.fillRect(xx,yy,34+i*6,2); }
    c.restore();
  }
  /* ridges / skyline */
  if(pal.type==='city'){
    const off=(G.skyOffset*0.22 + G.playerX*7 + ex);
    c.fillStyle=pal.mFar;
    skyline.forEach(b=>{ const bx=((b.x-off)%(1500)+1500)%1500 - 60;
      c.fillRect(bx,horizonY-b.h*0.62-14,b.w,b.h*0.62+14); });
    const off2=(G.skyOffset*0.4 + G.playerX*13 + ex);
    c.fillStyle=pal.mNear;
    skyline.forEach(b=>{ const bx=((b.x*1.3-off2)%(1500)+1500)%1500 - 60;
      c.fillRect(bx,horizonY-b.h*0.9,b.w,b.h*0.9+6); c.fillRect(bx+b.w*0.4,horizonY-b.h*0.9-10,3,10); });
    skyWin.forEach(wd=>{ const bx=((wd.x*1.3-off2)%(1500)+1500)%1500 - 60;
      const a=0.4+0.6*Math.abs(Math.sin(G.now*0.8+wd.tw));
      c.fillStyle=rgba(wd.c,a*0.8); c.fillRect(bx,horizonY-wd.y*0.9,3,4); });
  } else {
    drawRidge(c,pal,ridgeA,0.22,H*0.16,horizonY+8,pal.mFar,null);
    drawRidge(c,pal,ridgeB,0.42,H*0.11,horizonY+20,pal.mNear,rgba(pal.glow,0.22));
  }
  /* clouds */
  c.fillStyle=rgba(pal.type==='city'? '#16283e' : '#ffffff', pal.type==='city'?0.5:0.5);
  for(let i=0;i<5;i++){
    const cw=140+ (i%3)*70;
    const cx=((i*290 + G.cloudOff*(12+i*4) - ex*1.5)%(W+400))-200;
    const cy=H*0.1+ (i*53)% (H*0.2);
    c.beginPath(); c.ellipse(cx,cy,cw*0.5,13+i*3,0,0,7); c.ellipse(cx+cw*0.22,cy-9,cw*0.32,11,0,0,7); c.fill();
  }
}

/* =====================================================================
   RENDERING — the pseudo-3D pipeline
   ===================================================================== */
function project(p,camX,camY,camZ,cd,W,H){
  p.camera.x=-camX;
  p.camera.y=p.world.y-camY;
  p.camera.z=p.world.z-camZ;
  p.screen.scale=cd/p.camera.z;
  p.screen.x=Math.round(W/2 + p.screen.scale*p.camera.x*W/2);
  p.screen.y=Math.round(H/2 - p.screen.scale*p.camera.y*H/2);
  p.screen.w=Math.round(p.screen.scale*ROAD_W*W/2);
}
function poly(c,x1,y1,x2,y2,x3,y3,x4,y4,col){
  c.fillStyle=col; c.beginPath(); c.moveTo(x1,y1); c.lineTo(x2,y2); c.lineTo(x3,y3); c.lineTo(x4,y4); c.closePath(); c.fill();
}
let camDepthCur=CAM_DEPTH;
function fogAt(d){ return 1/Math.exp(d*d*5); }

function drawSegment(c,seg,pal){
  const p1=seg.p1.screen, p2=seg.p2.screen, W=SCENE_W;
  const alt=seg.shade;
  c.fillStyle= seg.tunnel? (alt? '#120b1f':'#170e2a') : (alt? pal.grassB : pal.grassA);
  c.fillRect(0,p2.y,W,p1.y-p2.y);
  const r1=Math.max(2,p1.w*0.14), r2=Math.max(2,p2.w*0.14);
  const rc= alt? pal.rumbleB : pal.rumbleA;
  poly(c,p1.x-p1.w-r1,p1.y, p1.x-p1.w,p1.y, p2.x-p2.w,p2.y, p2.x-p2.w-r2,p2.y, rc);
  poly(c,p1.x+p1.w+r1,p1.y, p1.x+p1.w,p1.y, p2.x+p2.w,p2.y, p2.x+p2.w+r2,p2.y, rc);
  const roadCol= alt? pal.roadB : pal.roadA;
  if(seg.checker){ /* start / checkpoint strip */
    poly(c,p1.x-p1.w,p1.y, p1.x+p1.w,p1.y, p2.x+p2.w,p2.y, p2.x-p2.w,p2.y, (seg.index%2)? '#e8e8e8':'#20242c');
  } else {
    poly(c,p1.x-p1.w,p1.y, p1.x+p1.w,p1.y, p2.x+p2.w,p2.y, p2.x-p2.w,p2.y, roadCol);
    if(!alt){ const l1=Math.max(1,p1.w/30), l2=Math.max(1,p2.w/30);
      const lw1=p1.w*2/LANES, lw2=p2.w*2/LANES;
      for(let i=1;i<LANES;i++){
        const lx1=p1.x-p1.w+lw1*i, lx2=p2.x-p2.w+lw2*i;
        poly(c,lx1-l1,p1.y,lx1+l1,p1.y,lx2+l2,p2.y,lx2-l2,p2.y,pal.lane);
      }
    }
  }
  const fa=1-seg.fog;
  if(fa>0.015){ c.fillStyle=rgba(pal.fog,fa); c.fillRect(0,p2.y,W,p1.y-p2.y); }
}
function clipDraw(c,img,dx,dy,dw,dh,clipY,alpha){
  if(dw<2||alpha<=0.02) return;
  if(dy-dh>=clipY) return;
  c.save();
  if(clipY<SCENE_H){ c.beginPath(); c.rect(0,0,SCENE_W,clipY); c.clip(); }
  c.globalAlpha=alpha;
  c.drawImage(img,dx,dy-dh,dw,dh);
  c.restore(); c.globalAlpha=1;
}
/* ---- neon tunnel geometry ---- */
const TUN_BAND=['#8ef8ff','#ff5ce1','#ffd23f'];
function drawTunnel(c,seg){
  const p1=seg.p1.screen, p2=seg.p2.screen;
  if(p1.w<0.4) return;
  const h1=p1.w*1.2, h2=p2.w*1.2, o1=p1.w*1.34, o2=p2.w*1.34;
  const t1=p1.y-h1, t2=p2.y-h2;
  const fog=clamp(seg.fog*1.7,0,1), alt=seg.shade;
  c.globalAlpha=fog;
  poly(c, p1.x-o1,t1, p1.x+o1,t1, p2.x+o2,t2, p2.x-o2,t2, alt?'#170e28':'#1d1330');      // ceiling
  poly(c, p1.x-o1,p1.y, p1.x-o1,t1, p2.x-o2,t2, p2.x-o2,p2.y, alt?'#221540':'#271848');  // left wall
  poly(c, p1.x+o1,p1.y, p1.x+o1,t1, p2.x+o2,t2, p2.x+o2,p2.y, alt?'#2a1b50':'#301f5a');  // right wall
  if(seg.index%3===0){                                                                    // light rings
    const col=TUN_BAND[Math.floor(seg.index/3)%3];
    const bh=Math.max(2,p1.w*0.075);
    c.globalAlpha=fog*(0.42+0.58*beatPulse());
    poly(c, p1.x-o1,t1, p1.x+o1,t1, p1.x+o1,t1+bh, p1.x-o1,t1+bh, col);
    poly(c, p1.x-o1,t1, p1.x-o1+bh*0.8,t1, p2.x-o2+bh*0.8,t2, p2.x-o2,t2, col);
    poly(c, p1.x+o1,t1, p1.x+o1-bh*0.8,t1, p2.x+o2-bh*0.8,t2, p2.x+o2,t2, col);
    c.globalAlpha=fog*0.17;                              // neon spill on the tarmac
    poly(c, p1.x-p1.w,p1.y, p1.x+p1.w,p1.y, p2.x+p2.w,p2.y, p2.x-p2.w,p2.y, col);
  }
  c.globalAlpha=1;
}
function drawRoadside(c,seg,spr){
  const s=seg.p1.screen;
  const dw=spr.w*s.scale*SCENE_W/2;
  if(dw<3) return;
  const dh=dw*spr.img.height/spr.img.width;
  const dx=s.x + s.scale*spr.off*ROAD_W*SCENE_W/2 - dw/2;
  clipDraw(c,spr.img,dx,s.y,dw,dh,seg.clip,Math.min(1,seg.fog*1.35));
}
function drawTraffic(c,seg,car){
  const pct=(car.z%SEG_LEN)/SEG_LEN;
  const s=lerp(seg.p1.screen.scale,seg.p2.screen.scale,pct);
  if(!(s>0.00001)) return;
  const sx=lerp(seg.p1.screen.x,seg.p2.screen.x,pct) + s*car.offset*ROAD_W*SCENE_W/2;
  const sy=lerp(seg.p1.screen.y,seg.p2.screen.y,pct);
  if(sy>SCENE_H+120) return;
  const dw=640*s*SCENE_W/2;
  const dh=dw*car.img.height/car.img.width;
  c.save();
  if(seg.clip<SCENE_H){ c.beginPath(); c.rect(0,0,SCENE_W,seg.clip); c.clip(); }
  c.globalAlpha=Math.min(1,seg.fog*1.35);
  c.fillStyle='rgba(0,0,0,.3)';
  c.beginPath(); c.ellipse(sx,sy-2,dw*0.5,dw*0.09,0,0,7); c.fill();
  c.drawImage(car.img,sx-dw/2,sy-dh,dw,dh);
  c.restore(); c.globalAlpha=1;
}

function renderScene(c,eyeShift){
  const pal=curPal();
  const W=SCENE_W,H=SCENE_H;
  const base=findSegment(G.position);
  const basePct=(G.position%SEG_LEN)/SEG_LEN;
  const pSeg=findSegment(G.position+PLAYER_Z);
  const pPct=((G.position+PLAYER_Z)%SEG_LEN)/SEG_LEN;
  const pY=lerp(pSeg.p1.world.y,pSeg.p2.world.y,pPct);
  G.camYWorld=pY;
  let maxy=H, x=0, dx=-(base.curve*basePct), topY=H;
  const camX=G.playerX*ROAD_W + eyeShift;
  const camY=pY+CAM_H;
  /* pass 1 — project everything, and find where the road meets the sky */
  for(let n=0;n<DRAW_DIST;n++){
    const seg=segments[(base.index+n)%segments.length];
    seg.clip=maxy;
    seg.fog=fogAt(n/DRAW_DIST);
    seg.visible=false;
    const camZ=G.position - (seg.index<base.index? trackLength:0);
    project(seg.p1,camX-x,     camY,camZ,camDepthCur,W,H);
    project(seg.p2,camX-x-dx,  camY,camZ,camDepthCur,W,H);
    x+=dx; dx+=seg.curve;
    if(seg.p1.camera.z<=camDepthCur || seg.p2.screen.y>=seg.p1.screen.y || seg.p2.screen.y>=maxy) continue;
    seg.visible=true;
    if(seg.p2.screen.y<topY) topY=seg.p2.screen.y;
    maxy=seg.p2.screen.y;
  }
  /* the skyline rides on top of the road, so crests lift the horizon */
  G.horizonY=clamp(topY>=H? H*0.5 : topY, H*0.22, H*0.74);
  drawBackground(c,pal,eyeShift);
  /* pass 2 — paint the road front-to-back */
  for(let n=0;n<DRAW_DIST;n++){
    const seg=segments[(base.index+n)%segments.length];
    if(seg.visible) drawSegment(c,seg,pal);
  }
  /* pass 3 — tunnel shell, far to near so the exit stays visible */
  for(let n=DRAW_DIST-1;n>=0;n--){
    const seg=segments[(base.index+n)%segments.length];
    if(seg.tunnel && seg.visible) drawTunnel(c,seg);
  }
  for(let n=DRAW_DIST-1;n>=0;n--){
    const seg=segments[(base.index+n)%segments.length];
    for(const car of seg.cars) drawTraffic(c,seg,car);
    for(const spr of seg.sprites) drawRoadside(c,seg,spr);
  }
  drawPlayer(c,pal);
  drawParticles(c);
  if(G.boosting||G.speed/MAX_SPEED>0.92) drawSpeedLines(c);
}

/* ---------------- player car ---------------- */
function drawPlayer(c){
  if(G.crashT>0 && Math.floor(G.now*12)%2===0 && G.state==='race') return;
  const cw=SCENE_W*0.19, ch=cw*SPR.player.height/SPR.player.width;
  const ratio=G.speed/MAX_SPEED;
  const bounce=Math.sin(G.now*ratio*42)*1.7*ratio + (G.offroad? Math.sin(G.now*60)*2.4*Math.min(1,ratio*2):0);
  const cx=SCENE_W/2 + G.steerVis*10;
  const cy=SCENE_H-12+bounce;
  const lean=G.steerVis*0.10*Math.min(1,ratio*1.6);
  c.save();
  c.translate(cx,cy);
  c.transform(1,0,lean,1,0,0);
  c.fillStyle='rgba(0,0,0,.36)';
  c.beginPath(); c.ellipse(0,4,cw*0.53,cw*0.085,0,0,7); c.fill();
  if(G.boosting){ /* exhaust flames */
    for(const ex of [-cw*0.16,cw*0.16]){
      const fl=ch*0.28*(0.55+Math.random()*0.9);
      const fg=c.createLinearGradient(0,-8,0,fl);
      fg.addColorStop(0,'#fff3c0'); fg.addColorStop(0.35,'#ffb43a'); fg.addColorStop(1,'rgba(255,90,40,0)');
      c.fillStyle=fg;
      c.beginPath(); c.moveTo(ex-7,-6); c.lineTo(ex+7,-6); c.lineTo(ex+(Math.random()*6-3),fl); c.closePath(); c.fill();
    }
  }
  c.drawImage(SPR.player,-cw/2,-ch,cw,ch);
  if(input.brake && G.state==='race'){
    c.fillStyle=rgba('#ff3b30',0.55+0.3*Math.sin(G.now*30));
    c.fillRect(-cw*0.36,-ch*0.5,cw*0.72,ch*0.075);
  }
  c.restore();
}
function drawSpeedLines(c){
  const n=G.boosting?16:8, cx=SCENE_W/2, cy=SCENE_H*0.42;
  c.strokeStyle=rgba('#ffffff',G.boosting?0.16:0.07);
  c.lineWidth=2;
  for(let i=0;i<n;i++){
    const a=Math.random()*Math.PI*2;
    const r0=Math.min(SCENE_W,SCENE_H)*(0.42+Math.random()*0.2);
    const r1=r0+60+Math.random()*130;
    c.beginPath();
    c.moveTo(cx+Math.cos(a)*r0,cy+Math.sin(a)*r0*0.7);
    c.lineTo(cx+Math.cos(a)*r1,cy+Math.sin(a)*r1*0.7);
    c.stroke();
  }
}

/* ---------------- particles & popups ---------------- */
function spawnPart(o){ if(G.parts.length<320) G.parts.push(Object.assign({life:1,maxLife:1,vx:0,vy:0,size:3,grav:0},o)); }
function drawParticles(c){
  for(let i=G.parts.length-1;i>=0;i--){
    const p=G.parts[i];
    p.life-=p.dt||0.016; p.x+=p.vx; p.y+=p.vy; p.vy+=p.grav;
    if(p.life<=0){ G.parts.splice(i,1); continue; }
    c.globalAlpha=clamp(p.life/p.maxLife,0,1)*p.alpha||1;
    c.fillStyle=p.color;
    c.fillRect(p.x-p.size/2,p.y-p.size/2,p.size,p.size);
  }
  c.globalAlpha=1;
}
function addPopup(text,color,x,y){ G.popups.push({text,color,x,y,life:1.15}); }

/* =====================================================================
   UPDATE
   ===================================================================== */
function isHex(v){ return typeof v==='string' && v && v[0]==='#'; }
function curPal(){
  if(G.palT>=1) return G.palB;
  const t=easeInOut(0,1,G.palT), a=G.palA, b=G.palB, o={};
  for(const k of Object.keys(b)){
    o[k]= (isHex(b[k]) && isHex(a[k])) ? mixHex(a[k],b[k],t) : b[k];
  }
  /* the sea fades out rather than blending with a null value */
  o.seaA=lerp(a.seaA||0, b.seaA||0, t);
  o.sea=isHex(a.sea)&&isHex(b.sea) ? o.sea : (t<0.5? (a.sea||b.sea) : (b.sea||a.sea));
  o.type=t<0.5?a.type:b.type; o.stars=lerp(a.stars||0,b.stars||0,t); o.name=b.name;
  return o;
}
function setStagePalette(idx,snap){
  G.palA=curPal(); G.palB=PALETTES[idx]; G.palT=snap?1:0;
}
function startRace(){ G.stageIdx=0; startLevel(); }
function startLevel(){
  const L=G.stageIdx;
  buildLevel(L); placeSprites(L); resetCars(L);
  G.position=0; G.playerX=0; G.speed=0; G.steerVis=0;
  G.timeLeft=LEVEL_TIME[L]; G.cpDone=[false,false]; G.results=null;
  G.nearMisses=0; G.topSpeedKmh=0; G.raceElapsed=0;
  G.gauge=0.55; G.boosting=false; G.crashT=0; G.shakeT=0; G.flashT=0;
  G.popups.length=0; G.parts.length=0;
  setStagePalette(L,true);
  if(L>=5 && G.radioTrack!==4) setRadio(4);        // the tunnel picks its own station
  G.state='count'; G.countT=3.0; G.lastCount=4;
  if(AC) SFX.coin();
  G.credits=1; updateLeds();
  showBanner(`STAGE ${L+1}/6`, L>=5? 'NEON TUNNEL · TUNNEL FM LOCKED ON' : PALETTES[L].name,
             L>=5?'#8ef8ff':'#ffd23f', L>=5?3.2:2.8);
}
function nextLevel(){
  if(G.stageIdx>=5){ victory(); return; }
  G.stageIdx++; startLevel();
}
let banner=null;
function showBanner(main,sub,color,dur){ banner={main,sub,color,dur,t:0}; }

function update(dt){
  G.now+=dt;
  if(G.palT<1) G.palT=Math.min(1,G.palT+dt/1.25);
  if(banner){ banner.t+=dt; if(banner.t>banner.dur) banner=null; }

  const pSeg=findSegment(G.position+PLAYER_Z);
  const speedRatio=G.speed/MAX_SPEED;
  G.offroad=Math.abs(G.playerX)>1.02;

  if(G.state==='attract'||G.state==='over'||G.state==='victory'){
    /* autopilot demo */
    const look=segments[(pSeg.index+16)%segments.length];
    let target=clamp(-look.curve*0.5,-0.8,0.8);
    for(const car of cars){
      const ahead=(pSeg.index-car.seg.index+segments.length)%segments.length;
      if(ahead<20 && Math.abs(car.offset-G.playerX)<0.55)
        target=clamp(car.offset>G.playerX? car.offset-1 : car.offset+1,-0.85,0.85);
    }
    G.playerX+=(target-G.playerX)*Math.min(1,dt*2.3);
    const cruise=G.state==='victory'?0.45:0.74;
    G.speed+=(MAX_SPEED*cruise-G.speed)*Math.min(1,dt*0.7);
    G.position=(G.position+G.speed*dt)%trackLength;
    G.steerVis+=(clamp(target-G.playerX,-1,1)-G.steerVis)*Math.min(1,dt*6);
    if(G.offroad){ /* the demo car throws dust too */
      for(let k=0;k<2;k++) spawnPart({x:SCENE_W/2+(Math.random()<0.5?-1:1)*(SCENE_W*0.09),y:SCENE_H-20,
        vx:Math.random()*4-2,vy:-Math.random()*3-1,grav:0.12,size:4+Math.random()*4,
        color:rgba(curPal().grassA,0.8),life:0.45,maxLife:0.45,dt});
    }
  }
  else if(G.state==='results'){
    /* rolling cool-down past the chequered line while the tally counts up */
    G.results.t+=dt;
    G.speed=Math.max(0,G.speed+COAST*2.4*dt);
    G.position=Math.min(G.position+G.speed*dt,(segments.length-26)*SEG_LEN);
    G.playerX+=(0-G.playerX)*Math.min(1,dt*1.8);
    G.steerVis*=Math.max(0,1-dt*4);
    if(G.boosting){ G.boosting=false; }
    G.gauge=Math.max(0,G.gauge-dt*0.4);
  }
  else if(G.state==='count'){
    G.countT-=dt;
    const whole=Math.ceil(G.countT);
    if(whole!==G.lastCount && whole>0){ G.lastCount=whole; if(AC) SFX.beep(false); }
    if(G.countT<=0){ G.state='race'; if(AC) SFX.beep(true); G.raceStart=G.now; }
    if(input.throttle) G.speed=Math.min(G.speed+ACCEL*0.35*dt, MAX_SPEED*0.06);
  }
  else if(G.state==='race'){
    /* steering */
    const dx=dt*2*speedRatio;
    let steer=0;
    if(input.left) steer-=1;
    if(input.right) steer+=1;
    G.playerX+=steer*dx;
    G.playerX-=dx*speedRatio*pSeg.curve*CENTRIFUGAL;
    G.playerX=clamp(G.playerX,-2.6,2.6);
    G.steerVis+=(steer-G.steerVis)*Math.min(1,dt*10);
    /* nitro */
    const wantBoost=input.boost&&G.gauge>0.03;
    if(wantBoost&&!G.boosting&&AC) SFX.nitro();
    G.boosting=wantBoost;
    if(G.boosting){ G.gauge=Math.max(0,G.gauge-dt/2.6); }
    else G.gauge=Math.min(1,G.gauge+dt/24);
    /* longitudinal */
    const vmax=G.boosting?BOOST_MAX:MAX_SPEED;
    if(G.boosting) G.speed+=ACCEL*2.1*dt;
    else if(input.throttle||(input.touch&&!input.brake)) G.speed+=ACCEL*dt;
    else if(input.brake) G.speed+=BRAKE*dt;
    else G.speed+=COAST*dt;
    if(G.offroad&&G.speed>OFFROAD_LIMIT&&!G.boosting) G.speed+=OFFROAD_DECEL*dt;
    G.speed=clamp(G.speed,0,vmax);
    G.position=(G.position+G.speed*dt)%trackLength;
    G.raceElapsed+=dt;
    G.topSpeedKmh=Math.max(G.topSpeedKmh,Math.round(speedRatio*300));
    G.score+=G.speed*dt*0.085*(G.boosting?1.5:1);
    /* timer */
    G.timeLeft-=dt;
    const tl=Math.ceil(G.timeLeft);
    if(tl!==G.lastTick && tl<=10 && tl>0){ G.lastTick=tl; if(AC) SFX.tick(); }
    if(G.timeLeft<=0){ G.timeLeft=0; gameOver(); }
    /* mid-level checkpoints */
    for(let i=0;i<checkpointSegs.length;i++){
      if(!G.cpDone[i] && G.position+PLAYER_Z>=checkpointSegs[i]*SEG_LEN){
        G.cpDone[i]=true; checkpoint(i);
      }
    }
    /* the finish line */
    if(G.position+PLAYER_Z>=finishSeg*SEG_LEN){ finishLevel(); }
    /* collisions: traffic */
    if(G.crashT>0) G.crashT-=dt;
    for(const car of cars){
      const dz=Math.abs(car.z-(G.position+PLAYER_Z));
      const dzWrap=Math.min(dz,trackLength-dz);
      if(dzWrap<SEG_LEN*1.1){
        const ddx=Math.abs(car.offset-G.playerX);
        if(ddx<0.26 && G.crashT<=0){
          G.speed*=0.3; G.crashT=0.9; G.shakeT=0.55; G.flashT=0.4;
          G.playerX+=(G.playerX>car.offset?0.25:-0.25);
          if(AC) SFX.crash();
          for(let k=0;k<16;k++) spawnPart({x:SCENE_W/2+(Math.random()*90-45),y:SCENE_H-70,
            vx:Math.random()*8-4,vy:-Math.random()*7-2,grav:0.35,size:3+Math.random()*3,
            color:Math.random()<0.5?'#ffd23f':'#ff6a3d',life:0.6,maxLife:0.6,dt});
        } else if(ddx<0.5&&ddx>=0.26&&G.speed>MAX_SPEED*0.55&&G.now-car.nmT>2){
          car.nmT=G.now; G.nearMisses++;
          G.gauge=Math.min(1,G.gauge+0.22);
          G.score+=500;
          if(AC) SFX.whoosh();
          addPopup('NEAR MISS +500','#3fe0d0',SCENE_W/2+(car.offset-G.playerX)*-300,SCENE_H*0.6);
        }
      }
    }
    /* collisions: roadside sprites */
    if(G.offroad){
      const seg=findSegment(G.position+PLAYER_Z);
      for(const spr of seg.sprites){
        if(spr.kind==='gantry'||spr.kind==='lamp') continue;
        if(Math.abs(spr.off-G.playerX)<0.24 && G.crashT<=0){
          G.speed*=0.22; G.crashT=0.8; G.shakeT=0.5; G.flashT=0.35;
          G.playerX=clamp(G.playerX+(G.playerX>spr.off?0.3:-0.3),-2.6,2.6);
          if(AC) SFX.crash();
        }
      }
      /* dust */
      if(G.speed>MAX_SPEED*0.08) for(let k=0;k<2;k++)
        spawnPart({x:SCENE_W/2+(Math.random()<0.5?-1:1)*(SCENE_W*0.085),y:SCENE_H-22,
          vx:Math.random()*4-2,vy:-Math.random()*3-1,grav:0.12,size:4+Math.random()*4,
          color:rgba(curPal().grassA,0.85),life:0.5,maxLife:0.5,dt});
    }
    if(G.boosting) for(let k=0;k<2;k++)
      spawnPart({x:SCENE_W/2+(Math.random()<0.5?-1:1)*SCENE_W*0.032,y:SCENE_H-16,
        vx:Math.random()*2-1,vy:2+Math.random()*2,grav:0,size:3,
        color:Math.random()<0.4?'#7fd4ff':'#ffb43a',life:0.3,maxLife:0.3,dt});
    if(G.shakeT>0) G.shakeT-=dt;
    if(G.flashT>0) G.flashT-=dt;
  }

  /* traffic */
  for(const car of cars){
    const old=car.seg;
    car.z+=car.speed*dt; if(car.z>=trackLength) car.z-=trackLength;
    const seg=findSegment(car.z);
    if(seg!==old){ const i=old.cars.indexOf(car); if(i>=0) old.cars.splice(i,1); seg.cars.push(car); car.seg=seg; }
    const ahead=(pSeg.index-seg.index+segments.length)%segments.length;
    if(ahead>0&&ahead<26&&Math.abs(car.offset-G.playerX)<0.48){
      const dir=car.offset>G.playerX?1:-1;
      car.offset=clamp(car.offset+dir*0.85*dt,-0.82,0.82);
    }
  }

  /* tunnel transitions */
  const inTun=!!(pSeg&&pSeg.tunnel);
  if(inTun!==G.wasTun){
    G.wasTun=inTun;
    if(inTun){
      if(AC) SFX.whoosh();
      if(G.state==='race') showBanner('NEON TUNNEL','LIGHTS OUT — FULL THROTTLE','#8ef8ff',1.7);
      G.shakeT=Math.max(G.shakeT,0.14);
    }
  }
  /* ambience */
  G.skyOffset+=pSeg.curve*speedRatio*dt*44;
  G.cloudOff+=dt*(1+speedRatio*2);

  /* engine audio */
  if(AC&&engOsc){
    const r=G.speed/MAX_SPEED;
    engOsc.frequency.setTargetAtTime(52+r*190+(G.boosting?40:0)+Math.sin(G.now*31)*3,AC.currentTime,0.03);
    engOsc2.frequency.setTargetAtTime(26+r*95,AC.currentTime,0.03);
    engFilter.frequency.setTargetAtTime(280+r*2600,AC.currentTime,0.05);
    engGain.gain.setTargetAtTime(((G.state==='race'||G.state==='count'||G.state==='results')?0.045+r*0.075:(G.state==='attract'?0.03:0.02))*(G.wasTun?1.4:1),AC.currentTime,0.08);
    roadGain.gain.setTargetAtTime(G.offroad?Math.min(0.2,r*0.3):0,AC.currentTime,0.05);
    if(tunnelFilter) tunnelFilter.frequency.setTargetAtTime(G.wasTun?1000:18000,AC.currentTime,0.16);
  }
}
function checkpoint(i){
  G.timeLeft+=CHECKPOINT_BONUS; G.score+=5000; G.gauge=1;
  if(AC) SFX.checkpoint();
  const last=(i===checkpointSegs.length-1);
  showBanner('CHECKPOINT', `+${CHECKPOINT_BONUS} SEC · ${last? 'FINAL PUSH TO THE LINE':'HALFWAY THERE'}`,'#3fe0d0',2.2);
  addPopup(`+${CHECKPOINT_BONUS} SEC`,'#3fe0d0',SCENE_W/2,SCENE_H*0.4);
  addPopup('+5000','#ffd23f',SCENE_W/2,SCENE_H*0.46);
  G.flashT=0.2;
}
function finishLevel(){
  if(G.state!=='race') return;
  G.state='results'; G.boosting=false;
  const timeBonus=Math.round(G.timeLeft)*400;
  G.results={ t:0, timeBonus, nmBonus:G.nearMisses*500, clear:10000+G.stageIdx*4000 };
  G.score+=timeBonus+G.results.nmBonus+G.results.clear;
  G.totalTime+=G.raceElapsed; G.totalNM+=G.nearMisses;
  saveHi();
  if(AC) SFX.fanfare();
  for(let k=0;k<110;k++) spawnPart({x:Math.random()*SCENE_W,y:SCENE_H*0.2+Math.random()*SCENE_H*0.5,
    vx:Math.random()*7-3.5,vy:-Math.random()*5-1,grav:0.16,size:3+Math.random()*4,
    color:['#ffd23f','#3fe0d0','#ff6a3d','#ff4fa0','#f3ead9'][k%5],life:1.6,maxLife:1.6,dt:0.016});
}
function gameOver(){
  G.state='over'; G.boosting=false; G.totalTime+=G.raceElapsed; G.totalNM+=G.nearMisses;
  if(AC) SFX.gameover();
  saveHi();
}
function victory(){
  G.state='victory';
  if(AC) SFX.fanfare();
  saveHi();
  for(let k=0;k<140;k++) spawnPart({x:Math.random()*SCENE_W,y:SCENE_H*0.2+Math.random()*SCENE_H*0.55,
    vx:Math.random()*7-3.5,vy:-Math.random()*6-1,grav:0.15,size:3+Math.random()*4,
    color:['#ffd23f','#3fe0d0','#ff6a3d','#ff4fa0','#f3ead9'][k%5],life:2,maxLife:2,dt:0.016});
}
function saveHi(){
  const s=Math.floor(G.score);
  if(s>G.hiScore){ G.hiScore=s; try{ localStorage.setItem('radhorizon_hi',String(s)); }catch(e){} }
  updateLeds();
}
function updateLeds(){
  $('hiLed').textContent=String(G.hiScore).padStart(7,'0');
  $('credLed').textContent=String(Math.max(0,G.credits));
}

/* =====================================================================
   HUD + OVERLAYS (canvas)
   ===================================================================== */
function txt(c,str,x,y,size,color,align='left',ps=true){
  c.font=`${size}px ${ps?'"Press Start 2P", monospace':'"Chakra Petch", sans-serif'}`;
  c.textAlign=align; c.textBaseline='alphabetic';
  c.fillStyle='rgba(0,0,0,.55)'; c.fillText(str,x+Math.max(2,size*0.12),y+Math.max(2,size*0.12));
  c.fillStyle=color; c.fillText(str,x,y);
}
const eq=[0.3,0.5,0.4,0.6,0.35];
function drawHUD(c){
  const W=SCENE_W,H=SCENE_H;
  if(G.state==='attract') return;
  /* TIME */
  const t=Math.ceil(G.timeLeft);
  const danger=t<=10;
  txt(c,'TIME',30,32,10,'#8b93a1');
  const blink=!danger||Math.floor(G.now*3)%2===0;
  if(blink) txt(c,String(t).padStart(2,'0'),30,74,30,danger?'#ff4b4b':'#f3ead9');
  /* SCORE */
  txt(c,'SCORE',W/2,32,9,'#8b93a1','center');
  txt(c,String(Math.floor(G.score)).padStart(7,'0'),W/2,56,13,'#f3ead9','center');
  /* route progress to the finish line */
  const bw=320,bx=W/2-bw/2,by=68, total=finishSeg*SEG_LEN;
  c.fillStyle='rgba(10,14,20,.75)'; c.fillRect(bx,by,bw,7);
  c.fillStyle='#3fe0d0'; c.fillRect(bx,by,bw*clamp(G.position/total,0,1),7);
  checkpointSegs.forEach((cs,i)=>{
    c.fillStyle=(G.cpDone&&G.cpDone[i])? '#3fe0d0':'#ffd23f';
    c.fillRect(bx+bw*(cs*SEG_LEN/total)-1,by-3,3,13);
  });
  for(let r=0;r<3;r++) for(let c2=0;c2<2;c2++){        // chequered flag at the end
    c.fillStyle=(r+c2)%2? '#f3ead9':'#15181e';
    c.fillRect(bx+bw+4+c2*4,by-4+r*5,4,5);
  }
  if(G.state==='race') txt(c,`${Math.max(0,Math.round((total-G.position)/145))} M TO FINISH`,W/2,by+22,8,'#8b93a1','center');
  /* SPEED */
  const kmh=Math.round(G.speed/MAX_SPEED*300);
  txt(c,String(kmh).padStart(3,'0'),W-30,74,30,G.boosting?'#ffd23f':'#f3ead9','right');
  txt(c,'KM/H',W-30,92,9,'#8b93a1','right');
  txt(c,`STAGE ${G.stageIdx+1}/6 · ${PALETTES[G.stageIdx].name}`,W-30,112,8,'#8b93a1','right');
  if(G.stereo) txt(c,'3-D ON',W-30,130,8,'#ff4fa0','right');
  /* radio box */
  c.fillStyle='rgba(8,11,16,.72)'; c.strokeStyle='rgba(63,224,208,.28)';
  roundRect(c,24,H-80,262,56,8); c.fill(); c.stroke();
  txt(c,G.muted?'RADIO · MUTED':`RADIO ${G.radioTrack}`,38,H-58,8,'#8b93a1');
  txt(c,G.muted?'———':TRACKS[G.radioTrack].name,38,H-38,10,'#f3ead9');
  for(let i=0;i<5;i++){
    if(!G.muted&&AC) eq[i]=clamp(eq[i]+(Math.random()-0.5)*0.4,0.12,1);
    const bh=(G.muted?3:eq[i]*26);
    c.fillStyle=i%2?'#3fe0d0':'#ffd23f';
    c.fillRect(216+i*12,H-32-bh,7,bh);
  }
  /* nitro box */
  c.fillStyle='rgba(8,11,16,.72)'; c.strokeStyle=G.gauge>=1?'rgba(255,210,63,.5)':'rgba(63,224,208,.28)';
  roundRect(c,W-286,H-80,262,56,8); c.fill(); c.stroke();
  txt(c,'NITRO',W-272,H-58,8,'#8b93a1');
  txt(c,'SHIFT',W-36,H-58,7,'#5d6572','right');
  const cells=16;
  for(let i=0;i<cells;i++){
    const on=G.gauge>=(i+0.5)/cells;
    const pulse=G.gauge>=1? (0.75+0.25*Math.sin(G.now*8)) : 1;
    c.fillStyle=on? rgba(i>cells*0.7?'#ffd23f':'#3fe0d0',pulse) : 'rgba(255,255,255,.09)';
    c.fillRect(W-272+i*15,H-48,11,16);
  }
  /* popups */
  for(let i=G.popups.length-1;i>=0;i--){
    const p=G.popups[i]; p.life-=0.016; p.y-=40*0.016;
    if(p.life<=0){ G.popups.splice(i,1); continue; }
    c.globalAlpha=clamp(p.life,0,1);
    txt(c,p.text,p.x,p.y,11,p.color,'center');
    c.globalAlpha=1;
  }
  /* banner */
  if(banner){
    const k=banner.t/banner.dur;
    const scale=k<0.12? lerp(1.7,1,k/0.12) : 1;
    const alpha=k>0.85? (1-k)/0.15 : 1;
    c.save(); c.translate(W/2,H*0.30); c.scale(scale,scale); c.globalAlpha=alpha;
    txt(c,banner.main,0,0,30,banner.color,'center');
    if(banner.sub) txt(c,banner.sub,0,34,11,'#f3ead9','center');
    c.restore(); c.globalAlpha=1;
  }
  /* countdown */
  if(G.state==='count'){
    const whole=Math.ceil(G.countT);
    const frac=G.countT-Math.floor(G.countT);
    const s=1+frac*0.5;
    c.save(); c.translate(W/2,H*0.42); c.scale(s,s);
    txt(c,String(whole),0,0,72,['#ff4b4b','#ffd23f','#3fe0d0','#3fe0d0'][whole]||'#fff','center');
    c.restore();
  } else if(G.state==='race'&&G.now-G.raceStart<0.9){
    txt(c,'GO!!',W/2,H*0.42,64,'#3fe0d0','center');
  }
  /* low time vignette */
  if(danger&&G.state==='race'){
    c.fillStyle=rgba('#ff2020',0.08+0.07*Math.sin(G.now*6));
    c.fillRect(0,0,W,H);
  }
  if(G.flashT>0){ c.fillStyle=rgba('#ff3020',G.flashT*0.5); c.fillRect(0,0,W,H); }
}
function drawOverlay(c){
  const W=SCENE_W,H=SCENE_H;
  if(G.state==='attract'){
    const g=c.createLinearGradient(0,0,0,H);
    g.addColorStop(0,'rgba(5,8,12,.82)'); g.addColorStop(0.32,'rgba(5,8,12,.28)');
    g.addColorStop(0.62,'rgba(5,8,12,.28)'); g.addColorStop(1,'rgba(5,8,12,.86)');
    c.fillStyle=g; c.fillRect(0,0,W,H);
    const bob=Math.sin(G.now*1.6)*4;
    c.save(); c.translate(0,bob);
    c.font='44px "Press Start 2P", monospace'; c.textAlign='center';
    c.fillStyle='#12151c'; c.fillText('RAD HORIZON',W/2+6,H*0.24+6);
    c.fillStyle='#3fe0d0'; c.fillText('RAD HORIZON',W/2+3,H*0.24+3);
    c.fillStyle='#ffd23f'; c.fillText('RAD HORIZON',W/2,H*0.24);
    txt(c,'T U R B O   G R A N D   P R I X',W/2,H*0.24+36,11,'#f3ead9','center');
    c.restore();
    if(Math.floor(G.now*1.4)%2===0)
      txt(c,'PRESS ENTER TO START',W/2,H*0.5,15,'#f3ead9','center');
    txt(c,`HIGH SCORE ${String(G.hiScore).padStart(7,'0')}`,W/2,H*0.575,11,'#ffd23f','center');
    txt(c,'6 STAGES · 2 CHECKPOINTS EACH · CHEQUERED FINISH',W/2,H*0.645,9,'#8b93a1','center');
    txt(c,'SLICE THROUGH TRAFFIC TO CHARGE NITRO · DON\'T LIFT',W/2,H*0.685,9,'#8b93a1','center');
    txt(c,'STAGE 6 — THE NEON TUNNEL · LIGHTS THAT PULSE TO THE RADIO',W/2,H*0.725,9,'#ff5ce1','center');
    txt(c,'ARROWS DRIVE · SHIFT NITRO · 1-2-3 RADIO · G = 3-D GLASSES',W/2,H*0.78,8,'#5d6572','center');
    txt(c,'© 1986 VECTORWORKS AMUSEMENT',W/2,H*0.94,8,'#3d4450','center');
  }
  else if(G.state==='results'){
    const R=G.results; if(!R) return;
    c.fillStyle='rgba(5,8,12,.62)'; c.fillRect(W*0.13,H*0.19,W*0.74,H*0.62);
    c.strokeStyle='rgba(63,224,208,.4)'; c.lineWidth=2; c.strokeRect(W*0.13,H*0.19,W*0.74,H*0.62);
    c.strokeStyle='rgba(255,210,63,.25)'; c.strokeRect(W*0.13+6,H*0.19+6,W*0.74-12,H*0.62-12);
    if(R.t<1.25){                                  // chequered-flag flash
      const k=R.t/1.25, s=1+(1-k)*0.55;
      c.save(); c.translate(W/2,H*0.47); c.scale(s,s); c.globalAlpha=Math.min(1,(1-k)*3);
      txt(c,'FINISH!',0,0,52,'#ffd23f','center');
      c.restore(); c.globalAlpha=1;
    }
    txt(c,'STAGE CLEAR',W/2,H*0.29,26,'#3fe0d0','center');
    txt(c,PALETTES[G.stageIdx].name,W/2,H*0.29+30,9,'#8b93a1','center');
    const rows=[['TIME BONUS',R.timeBonus,0.8],['NEAR MISS x '+G.nearMisses,R.nmBonus,1.35],['STAGE CLEAR',R.clear,1.9]];
    let ty=H*0.41;
    rows.forEach(rw=>{
      if(R.t>rw[2]){
        txt(c,rw[0],W*0.22,ty,10,'#f3ead9');
        txt(c,String(rw[1]).padStart(7,'0'),W*0.78,ty,12,'#ffd23f','right');
      }
      ty+=34;
    });
    txt(c,`RUN ${G.raceElapsed.toFixed(1)} SEC   ·   TOP ${G.topSpeedKmh} KM/H   ·   TIME LEFT ${G.timeLeft.toFixed(1)}`,W/2,H*0.585,9,'#8b93a1','center');
    if(R.t>2.4){
      txt(c,'TOTAL',W*0.22,H*0.665,10,'#8b93a1');
      txt(c,String(Math.floor(G.score)).padStart(7,'0'),W*0.78,H*0.665,14,'#f3ead9','right');
      if(Math.floor(G.now*1.5)%2===0)
        txt(c,G.stageIdx>=5? 'PRESS ENTER':'ENTER — NEXT STAGE',W/2,H*0.755,12,'#ffd23f','center');
    }
  }
  else if(G.state==='over'){
    c.fillStyle='rgba(5,8,12,.72)'; c.fillRect(0,0,W,H);
    txt(c,'GAME OVER',W/2,H*0.34,40,'#ff4b4b','center');
    txt(c,'TIME UP — THE CLOCK BEAT YOU',W/2,H*0.34+40,10,'#f3ead9','center');
    txt(c,`SCORE ${String(Math.floor(G.score)).padStart(7,'0')}   ·   STAGE ${G.stageIdx+1}/5   ·   TOP ${G.topSpeedKmh} KM/H`,W/2,H*0.55,11,'#ffd23f','center');
    if(Math.floor(G.score)>=G.hiScore&&G.hiScore>0&&Math.floor(G.now*2)%2===0)
      txt(c,'★ NEW HIGH SCORE ★',W/2,H*0.62,12,'#3fe0d0','center');
    if(Math.floor(G.now*1.4)%2===0) txt(c,'ENTER — TITLE      R — RETRY STAGE',W/2,H*0.78,10,'#f3ead9','center');
  }
  else if(G.state==='victory'){
    c.fillStyle='rgba(5,8,12,.66)'; c.fillRect(0,0,W,H);
    const bob=Math.sin(G.now*2)*3;
    txt(c,'CONGRATULATIONS!',W/2,H*0.28+bob,30,'#ffd23f','center');
    txt(c,'ALL 5 STAGES CLEARED — YOU OWN THE HORIZON',W/2,H*0.28+38+bob,10,'#3fe0d0','center');
    const m=Math.floor(G.totalTime/60), s=Math.floor(G.totalTime%60);
    txt(c,`SCORE  ${String(Math.floor(G.score)).padStart(7,'0')}`,W/2,H*0.5,13,'#f3ead9','center');
    txt(c,`TOTAL TIME  ${m}:${String(s).padStart(2,'0')}   ·   NEAR MISSES  ${G.totalNM}   ·   TOP  ${G.topSpeedKmh} KM/H`,W/2,H*0.575,10,'#f3ead9','center');
    if(Math.floor(G.score)>=G.hiScore&&Math.floor(G.now*2)%2===0)
      txt(c,'★ NEW HIGH SCORE ★',W/2,H*0.645,12,'#ff4fa0','center');
    if(Math.floor(G.now*1.4)%2===0) txt(c,'PRESS ENTER',W/2,H*0.8,11,'#f3ead9','center');
  }
  else if(G.state==='pause'){
    c.fillStyle='rgba(5,8,12,.7)'; c.fillRect(0,0,W,H);
    txt(c,'PAUSED',W/2,H*0.45,34,'#f3ead9','center');
    txt(c,'P — RESUME',W/2,H*0.55,10,'#8b93a1','center');
  }
}

/* =====================================================================
   STEREO 3-D (Rad Racer anaglyph homage)
   ===================================================================== */
function tint(src,dst,col){
  const g=dst.getContext('2d');
  g.globalCompositeOperation='source-over';
  g.drawImage(src,0,0);
  g.globalCompositeOperation='multiply';
  g.fillStyle=col;
  g.fillRect(0,0,SCENE_W,SCENE_H);
  g.globalCompositeOperation='source-over';
}
function compositeFrame(){
  const c=dctx;
  if(G.stereo){
    if(!tL){ tL=document.createElement('canvas'); tL.width=SCENE_W; tL.height=SCENE_H;
      tR=document.createElement('canvas'); tR.width=SCENE_W; tR.height=SCENE_H; }
    renderScene(sctx,-EYE);
    renderScene(sctxR, EYE);
    tint(sceneCv,tL,'rgb(255,0,0)');
    tint(sceneR,tR,'rgb(0,255,255)');
    c.drawImage(tL,0,0);
    c.globalCompositeOperation='lighter';
    c.drawImage(tR,0,0);
    c.globalCompositeOperation='source-over';
  } else {
    renderScene(sctx,0);
    c.drawImage(sceneCv,0,0);
  }
  applyShake(c);
}
let shakeCv=null;
function applyShake(c){
  if(G.shakeT<=0) return;
  if(!shakeCv){ shakeCv=document.createElement('canvas'); shakeCv.width=SCENE_W; shakeCv.height=SCENE_H; }
  const g=shakeCv.getContext('2d');
  g.clearRect(0,0,SCENE_W,SCENE_H); g.drawImage(display,0,0);
  const a=G.shakeT*9;
  c.save(); c.setTransform(1,0,0,1,0,0);
  c.drawImage(shakeCv,Math.random()*a-a/2,Math.random()*a-a/2);
  c.restore();
}
function toggleStereo(){
  G.stereo=!G.stereo;
  $('btnStereo').classList.toggle('on',G.stereo);
  if(G.stereo) showBanner('3-D MODE','PUT ON YOUR RED / CYAN GLASSES','#ff4fa0',2.4);
  if(AC) tone({f:G.stereo?1200:700,dur:0.08,vol:0.15});
}

/* =====================================================================
   INPUT
   ===================================================================== */
const KEYMAP={ArrowLeft:'left',KeyA:'left',ArrowRight:'right',KeyD:'right',
  ArrowUp:'throttle',KeyW:'throttle',ArrowDown:'brake',KeyS:'brake',
  ShiftLeft:'boost',ShiftRight:'boost',Space:'boost'};
window.addEventListener('keydown',e=>{
  if(KEYMAP[e.code]!==undefined){ input[KEYMAP[e.code]]=true; e.preventDefault(); }
  ensureAudio();
  if(e.repeat) return;
  switch(e.code){
    case 'Enter':
      if(G.state==='attract') startRace();
      else if(G.state==='results') nextLevel();
      else if(G.state==='over'||G.state==='victory'){ G.state='attract'; G.stageIdx=0; }
      break;
    case 'KeyR':
      if(G.state==='results') startLevel();
      else if(G.state==='over'||G.state==='victory') startRace();
      break;
    case 'KeyM': toggleMute(); break;
    case 'KeyG': toggleStereo(); break;
    case 'KeyP':
      if(G.state==='race'){ G.pausedFrom='race'; G.state='pause'; if(AC) AC.suspend(); }
      else if(G.state==='pause'){ G.state='race'; if(AC) AC.resume(); }
      break;
    case 'Digit1': setRadio(1); break;
    case 'Digit2': setRadio(2); break;
    case 'Digit3': setRadio(3); break;
    case 'Digit4': setRadio(4); break;
  }
});
window.addEventListener('keyup',e=>{ if(KEYMAP[e.code]!==undefined) input[KEYMAP[e.code]]=false; });
window.addEventListener('blur',()=>{ if(G.state==='race'){ G.state='pause'; if(AC) AC.suspend(); } });
document.addEventListener('visibilitychange',()=>{ if(document.hidden&&G.state==='race'){ G.state='pause'; if(AC) AC.suspend(); } });

$('crt').addEventListener('pointerdown',()=>{
  ensureAudio(); input.touch=true;
  if(G.state==='attract') startRace();
  else if(G.state==='results'){ if(G.results&&G.results.t>2.4) nextLevel(); }
  else if(G.state==='over'||G.state==='victory'){ G.state='attract'; G.stageIdx=0; }
});
function bindPad(id,flag){
  const el=$(id);
  const on=e=>{ e.preventDefault(); ensureAudio(); input.touch=true; input[flag]=true; };
  const off=e=>{ e.preventDefault(); input[flag]=false; };
  el.addEventListener('pointerdown',on); el.addEventListener('pointerup',off);
  el.addEventListener('pointerleave',off); el.addEventListener('pointercancel',off);
}
bindPad('padL','left'); bindPad('padR','right'); bindPad('padB','brake'); bindPad('padN','boost');

$('btnSound').addEventListener('click',()=>{ ensureAudio(); toggleMute(); });
$('btnRadio').addEventListener('click',()=>{ ensureAudio(); setRadio(G.radioTrack%4+1); });
$('btnStereo').addEventListener('click',()=>{ ensureAudio(); toggleStereo(); });

/* =====================================================================
   MAIN LOOP
   ===================================================================== */
let lastFrame=performance.now();
function frame(now){
  const dt=clamp((now-lastFrame)/1000,0.001,0.05);
  lastFrame=now;
  if(G.state!=='pause') update(dt);
  /* camera FOV kick while boosting */
  camDepthCur+= ((G.boosting?0.72:CAM_DEPTH)-camDepthCur)*Math.min(1,dt*5);
  compositeFrame();
  drawHUD(dctx);
  drawOverlay(dctx);
  requestAnimationFrame(frame);
}
function init(){
  buildSprites();
  buildScenery();
  G.stageIdx=0;
  buildLevel(0); placeSprites(0); resetCars(0);
  setStagePalette(0,true);
  G.state='attract';
  G.speed=MAX_SPEED*0.55;
  G.totalTime=0; G.totalNM=0;
  updateLeds();
  requestAnimationFrame(frame);
}
if(document.fonts&&document.fonts.load){
  Promise.all([
    document.fonts.load('12px "Press Start 2P"'),
    document.fonts.load('700 16px "Chakra Petch"')
  ]).then(init,init);
} else init();
</script>
</body>
</html><!--kg-card-end: html--><p>If I am to be meticulously critical, besides the obvious sound track, and missing win animations, the biggest difference between this, and the originals is the physics engine, and turning/drifting effects. &#xA0;Both games somehow created an experience that made you lean in your seat, anticipating real G forces. &#xA0;And those programmers were doing it in assembly language.</p><p>Click the box below to download a single HTML file that will open and play in your browser, full screen, without internet, better than embedded in this blog here. &#xA0;You can also open with a text editor, and start changing things:</p>
        <div class="kg-card kg-file-card kg-file-card-medium">
            <a class="kg-file-card-container" href="https://learn.piescale.com/content/files/2026/09/racing90s.html" title="Download" download>
                <div class="kg-file-card-contents">
                    <div class="kg-file-card-title">Racing90s</div>
                    
                    <div class="kg-file-card-metadata">
                        <div class="kg-file-card-filename">racing90s.html</div>
                        <div class="kg-file-card-filesize">82 KB</div>
                    </div>
                </div>
                <div class="kg-file-card-icon">
                    <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24"><defs><style>.a{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.5px;}</style></defs><title>download-circle</title><polyline class="a" points="8.25 14.25 12 18 15.75 14.25"/><line class="a" x1="12" y1="6.75" x2="12" y2="18"/><circle class="a" cx="12" cy="12" r="11.25"/></svg>
                </div>
            </a>
        </div>
        <p>V2 with overhead map, turbo boosts, more obstacle vehicles, and a rainy level 7.<br>Play on different page here: &#xA0;<a href="https://learn.piescale.com/ai-failure/">https://learn.piescale.com/ai-failure/</a></p><p><br>Or</p><p>Download racing90sdotv2.html:</p>
        <div class="kg-card kg-file-card kg-file-card-medium">
            <a class="kg-file-card-container" href="https://learn.piescale.com/content/files/2026/09/racing90sdotv2.html" title="Download" download>
                <div class="kg-file-card-contents">
                    <div class="kg-file-card-title">Racing90sdotv2</div>
                    
                    <div class="kg-file-card-metadata">
                        <div class="kg-file-card-filename">racing90sdotv2.html</div>
                        <div class="kg-file-card-filesize">103 KB</div>
                    </div>
                </div>
                <div class="kg-file-card-icon">
                    <svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24"><defs><style>.a{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.5px;}</style></defs><title>download-circle</title><polyline class="a" points="8.25 14.25 12 18 15.75 14.25"/><line class="a" x1="12" y1="6.75" x2="12" y2="18"/><circle class="a" cx="12" cy="12" r="11.25"/></svg>
                </div>
            </a>
        </div>
        ]]></content:encoded></item><item><title><![CDATA[AI Slop]]></title><description><![CDATA[<p>Will you eat what they feed you?</p><p>By April 2025, it is becoming clear there are going to be two AI tracks:</p><ol><li> Slop brought to you by Microsoft, and Google</li><li>Something zeroing in on pure intelligence, currently lead by Twitter, DeepSeek, and Alibaba</li></ol><p>Intelligence is inherently efficient. &#xA0;It is</p>]]></description><link>https://learn.piescale.com/ai-slop/</link><guid isPermaLink="false">67ebfb1a2f08c600019dc9ab</guid><dc:creator><![CDATA[Josh]]></dc:creator><pubDate>Tue, 01 Apr 2025 14:52:05 GMT</pubDate><media:content url="https://learn.piescale.com/content/images/2025/04/Google-AI-studios-getting-worse.png" medium="image"/><content:encoded><![CDATA[<img src="https://learn.piescale.com/content/images/2025/04/Google-AI-studios-getting-worse.png" alt="AI Slop"><p>Will you eat what they feed you?</p><p>By April 2025, it is becoming clear there are going to be two AI tracks:</p><ol><li> Slop brought to you by Microsoft, and Google</li><li>Something zeroing in on pure intelligence, currently lead by Twitter, DeepSeek, and Alibaba</li></ol><p>Intelligence is inherently efficient. &#xA0;It is going to run on hardware that is currently being turned into e-waste. &#xA0;It is not going to require buildings full of the newest silicon, dedicated power plants, and trillions of dollars.</p><p>The slop pumpers are chained to a religion of maximizing entropy.</p><p>Pure intelligence is going to be way more zen.</p>]]></content:encoded></item><item><title><![CDATA[Quit Microsoft]]></title><description><![CDATA[<p>It&apos;s easier than you think, and the productivity gains alone are worth it.</p><p>Linux Mint is now easier to use than Windows, it&apos;s also faster, more secure, and more stable. &#xA0;OnlyOffice is better than Microsoft Office, and it&apos;s free. &#xA0;You can install</p>]]></description><link>https://learn.piescale.com/quit-microsoft/</link><guid isPermaLink="false">66ee9beb5e569e000122d9fb</guid><dc:creator><![CDATA[Josh]]></dc:creator><pubDate>Sat, 21 Sep 2024 10:47:06 GMT</pubDate><media:content url="https://learn.piescale.com/content/images/2024/09/microsoft-evil.jpeg" medium="image"/><content:encoded><![CDATA[<img src="https://learn.piescale.com/content/images/2024/09/microsoft-evil.jpeg" alt="Quit Microsoft"><p>It&apos;s easier than you think, and the productivity gains alone are worth it.</p><p>Linux Mint is now easier to use than Windows, it&apos;s also faster, more secure, and more stable. &#xA0;OnlyOffice is better than Microsoft Office, and it&apos;s free. &#xA0;You can install it on your computer, and use it without internet, or you can install it on a server, and run your own cloud collaboration better than Microsoft 365.</p><p>And you can use this forever without ever paying anyone anything, or being forced to change.</p><p>Microsoft uses security theater to cost you money, and harm our environment by sending computers to an early grave. &#xA0;They are in the business of turning money into new software that has to run on new hardware, and center every business decision around accelerating this.</p><p>But normal people want stability. &#xA0;The only way to achieve stability is to quit the chaos agents at Microsoft.</p>]]></content:encoded></item><item><title><![CDATA[Do you long for boring tech]]></title><description><![CDATA[<p>Whatever happened to stability? &#xA0;Have you ever met a normal person who wanted their software to change after they learned how to use it? &#xA0;How has it become so commonplace, especially among open source projects?</p>]]></description><link>https://learn.piescale.com/do-you-long-for-boring-tech/</link><guid isPermaLink="false">66ee27b25e569e000122d9af</guid><dc:creator><![CDATA[Josh]]></dc:creator><pubDate>Sat, 21 Sep 2024 01:58:52 GMT</pubDate><media:content url="https://learn.piescale.com/content/images/2024/09/Bliss_-Windows_XP-.png" medium="image"/><content:encoded><![CDATA[<img src="https://learn.piescale.com/content/images/2024/09/Bliss_-Windows_XP-.png" alt="Do you long for boring tech"><p>Whatever happened to stability? &#xA0;Have you ever met a normal person who wanted their software to change after they learned how to use it? &#xA0;How has it become so commonplace, especially among open source projects?</p>]]></content:encoded></item></channel></rss>