← Rally results

Rally

Deepseek v4.1 Flash · full

◈Preview unavailable — try playing the result

Browser check: Preview unavailable or timed out. This is a technical check, not a quality review.

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 84,010 bytes · SHA-256 54f4e1714265
<!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">
<title>RALLY RUSH — 3D Rally Racer</title>
<style>
  :root{
    --panel: rgba(9,13,19,.60);
    --panel-solid: rgba(9,13,19,.92);
    --stroke: rgba(255,255,255,.16);
    --accent: #ffcf3f;
    --accent2: #ff4d3d;
    --text: #eef3f8;
    --mono: ui-monospace, "SF Mono", "Roboto Mono", Menlo, Consolas, monospace;
  }
  *{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
  html,body{
    width:100%;height:100%;overflow:hidden;background:#10151c;color:var(--text);
    font-family:"Segoe UI",system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif;
    user-select:none;-webkit-user-select:none;touch-action:none;
  }
  #viewport{display:block;width:100%;height:100%;position:absolute;inset:0;background:
    linear-gradient(#2d6bb0 0%, #7fb0d8 48%, #cfe1ec 52%, #6f7f5e 55%, #3d4a2c 100%);}
  #hud{position:absolute;inset:0;pointer-events:none;z-index:5}

  .panel{
    position:absolute;background:var(--panel);border:1px solid var(--stroke);
    border-radius:12px;backdrop-filter:blur(9px);-webkit-backdrop-filter:blur(9px);
    box-shadow:0 8px 30px rgba(0,0,0,.42);
  }

  /* ---------- top-left timing panel ---------- */
  #lapPanel{top:14px;left:14px;padding:10px 14px 11px;min-width:196px}
  #lapPanel .row{display:flex;justify-content:space-between;align-items:baseline;gap:14px;line-height:1.5}
  #lapPanel .label{font-size:10px;letter-spacing:.16em;font-weight:700;color:#9fb2c6}
  #lapPanel .value{font-family:var(--mono);font-size:16px;font-weight:700;letter-spacing:.02em}
  #lapPanel .row.big .value{font-size:22px;color:var(--accent)}
  #lapPanel .value.best{color:#8ef0a8}
  #lapPanel .value.last{color:#ffd9a0}
  .sep{height:1px;background:var(--stroke);margin:6px 0}

  /* ---------- minimap ---------- */
  #minimapWrap{position:absolute;top:14px;right:14px;width:172px;height:172px;
    background:var(--panel);border:1px solid var(--stroke);border-radius:12px;
    backdrop-filter:blur(9px);-webkit-backdrop-filter:blur(9px);
    box-shadow:0 8px 30px rgba(0,0,0,.42);overflow:hidden}
  #minimap{display:block;width:100%;height:100%}
  #minimapWrap .mmLabel{position:absolute;left:9px;bottom:6px;font-size:9px;letter-spacing:.18em;
    color:#9fb2c6;font-weight:700}

  /* ---------- speedo ---------- */
  #speedo{position:absolute;right:14px;bottom:14px;width:250px;height:152px}
  #gauge{display:block;width:100%;height:100%;filter:drop-shadow(0 8px 24px rgba(0,0,0,.5))}

  /* ---------- countdown / toast ---------- */
  #countdown{
    position:absolute;left:50%;top:42%;transform:translate(-50%,-50%);
    font-size:clamp(70px,15vw,190px);font-weight:900;letter-spacing:.04em;
    color:#fff;text-shadow:0 0 26px rgba(255,207,63,.85),0 10px 40px rgba(0,0,0,.7);
    opacity:0;
  }
  #countdown.show{animation:pop .85s cubic-bezier(.2,.9,.3,1) forwards}
  #countdown.go{color:#7dffa1;text-shadow:0 0 34px rgba(125,255,161,.9),0 10px 40px rgba(0,0,0,.7)}
  @keyframes pop{
    0%{opacity:0;transform:translate(-50%,-50%) scale(1.7)}
    22%{opacity:1;transform:translate(-50%,-50%) scale(1)}
    75%{opacity:1;transform:translate(-50%,-50%) scale(1)}
    100%{opacity:0;transform:translate(-50%,-50%) scale(.86)}
  }
  #toast{position:absolute;left:50%;top:16%;transform:translateX(-50%);
    font-family:var(--mono);font-size:20px;font-weight:800;letter-spacing:.06em;
    padding:8px 20px;border-radius:10px;background:var(--panel-solid);border:1px solid var(--stroke);
    opacity:0;transition:opacity .3s;white-space:nowrap}
  #toast.show{opacity:1}
  #toast.fast{color:#8ef0a8;border-color:rgba(142,240,168,.5)}

  /* ---------- overlays ---------- */
  .overlay{
    position:absolute;inset:0;display:flex;align-items:center;justify-content:center;z-index:20;
    background:radial-gradient(circle at 50% 40%, rgba(12,20,30,.55), rgba(4,7,11,.88));
    backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);
  }
  .overlay.hidden{display:none}
  .card{
    width:min(560px,92vw);max-height:92vh;overflow:auto;background:var(--panel-solid);
    border:1px solid var(--stroke);border-radius:18px;padding:26px 28px 24px;
    box-shadow:0 24px 80px rgba(0,0,0,.7);text-align:center;
  }
  .card h1{
    font-size:clamp(30px,6vw,50px);letter-spacing:.06em;font-weight:900;line-height:1;
    background:linear-gradient(180deg,#fff,#ffcf3f);-webkit-background-clip:text;background-clip:text;
    -webkit-text-fill-color:transparent;margin-bottom:6px;
  }
  .card h2{font-size:26px;letter-spacing:.08em;margin-bottom:8px}
  .card .sub{font-size:12px;letter-spacing:.22em;color:#9fb2c6;font-weight:700;margin-bottom:20px}
  .keys{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px 18px;
    text-align:left;margin:0 auto 20px;max-width:430px}
  .keys div{font-size:12.5px;color:#c6d5e4;display:flex;align-items:center;gap:8px}
  kbd{font-family:var(--mono);font-size:11px;background:#1b2430;border:1px solid rgba(255,255,255,.2);
    border-bottom-width:2px;border-radius:5px;padding:2px 6px;color:#fff;min-width:22px;display:inline-block;text-align:center}
  .btn{
    pointer-events:auto;cursor:pointer;border:none;border-radius:12px;padding:14px 30px;
    font-family:inherit;font-weight:900;font-size:16px;letter-spacing:.12em;color:#181205;
    background:linear-gradient(180deg,#ffe07a,#ffb703);box-shadow:0 6px 0 #a06f00,0 12px 30px rgba(0,0,0,.5);
    transition:transform .08s,box-shadow .08s;
  }
  .btn:active{transform:translateY(3px);box-shadow:0 3px 0 #a06f00,0 8px 20px rgba(0,0,0,.5)}
  .btn.ghost{background:transparent;border:1px solid var(--stroke);color:#dfe9f3;
    box-shadow:none;font-size:13px;padding:12px 22px}
  .btn.ghost:active{transform:translateY(2px);box-shadow:none}
  .btnRow{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
  .stats{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:0 auto 18px;max-width:400px}
  .stat{background:#111823;border:1px solid var(--stroke);border-radius:10px;padding:10px 12px;text-align:left}
  .stat .k{font-size:10px;letter-spacing:.16em;color:#9fb2c6;font-weight:700}
  .stat .v{font-family:var(--mono);font-size:19px;font-weight:800;margin-top:2px}
  .stat .v.best{color:#8ef0a8}
  #lapList{font-family:var(--mono);font-size:13px;color:#c6d5e4;margin:0 auto 18px;max-width:400px;
    display:flex;flex-direction:column;gap:4px}
  #lapList .li{display:flex;justify-content:space-between;border-bottom:1px dashed rgba(255,255,255,.12);padding:3px 2px}
  #bootErr{color:#ffb4ac;font-size:13px;margin-top:16px;line-height:1.5}

  /* ---------- touch controls ---------- */
  #touch{position:absolute;inset:0;z-index:10;pointer-events:none;display:none}
  #touch.on{display:block}
  .tbtn{
    position:absolute;pointer-events:auto;display:flex;align-items:center;justify-content:center;
    background:rgba(16,24,34,.55);border:1.5px solid var(--stroke);border-radius:50%;
    color:#e8f0f8;font-weight:900;font-size:15px;letter-spacing:.06em;
    backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
    transition:background .08s,transform .08s;user-select:none;
  }
  .tbtn.active{background:rgba(255,207,63,.55);transform:scale(.94)}
  #tLeft{left:18px;bottom:22px;width:74px;height:74px}
  #tRight{left:112px;bottom:22px;width:74px;height:74px}
  #tGas{right:18px;bottom:26px;width:104px;height:104px;font-size:17px}
  #tBrake{right:132px;bottom:30px;width:76px;height:76px}
  #tDrift{right:38px;bottom:146px;width:76px;height:76px;font-size:12px}
  @media (max-width:820px){
    #minimapWrap{width:118px;height:118px}
    #speedo{width:186px;height:114px}
    #lapPanel{min-width:0;padding:8px 11px}
    #lapPanel .value{font-size:13px}
    #lapPanel .row.big .value{font-size:17px}
    .keys{display:none}
  }
</style>
</head>
<body>
<canvas id="viewport"></canvas>

<div id="hud">
  <div class="panel" id="lapPanel">
    <div class="row big"><span class="label">LAP</span><span class="value" id="lapValue">1 / 3</span></div>
    <div class="sep"></div>
    <div class="row"><span class="label">TIME</span><span class="value" id="timeValue">0:00.00</span></div>
    <div class="row"><span class="label">LAST</span><span class="value last" id="lastValue">--:--.--</span></div>
    <div class="row"><span class="label">BEST</span><span class="value best" id="bestValue">--:--.--</span></div>
  </div>

  <div id="minimapWrap"><canvas id="minimap" width="360" height="360"></canvas><span class="mmLabel">STAGE MAP</span></div>

  <div id="speedo"><canvas id="gauge" width="500" height="304"></canvas></div>

  <div id="countdown"></div>
  <div id="toast"></div>
</div>

<div id="touch">
  <div class="tbtn" id="tLeft">◀</div>
  <div class="tbtn" id="tRight">▶</div>
  <div class="tbtn" id="tGas">GAS</div>
  <div class="tbtn" id="tBrake">BRAKE</div>
  <div class="tbtn" id="tDrift">DRIFT</div>
</div>

<div class="overlay" id="overlayStart">
  <div class="card">
    <h1>RALLY RUSH</h1>
    <div class="sub">3 LAPS · POINT TO POINT STAGE</div>
    <div class="keys">
      <div><kbd>W</kbd><kbd>↑</kbd> Throttle</div>
      <div><kbd>S</kbd><kbd>↓</kbd> Brake / Reverse</div>
      <div><kbd>A</kbd><kbd>D</kbd> Steer</div>
      <div><kbd>Space</kbd> Handbrake drift</div>
      <div><kbd>C</kbd> Camera view</div>
      <div><kbd>P</kbd> Pause</div>
      <div><kbd>R</kbd> Restart stage</div>
      <div><kbd>M</kbd> Mute audio</div>
    </div>
    <button class="btn" id="btnStart">START ENGINE</button>
    <div id="bootErr"></div>
  </div>
</div>

<div class="overlay hidden" id="overlayPause">
  <div class="card">
    <h2>PAUSED</h2>
    <div class="sub">STAGE HALTED</div>
    <div class="btnRow">
      <button class="btn" id="btnResume">RESUME</button>
      <button class="btn ghost" id="btnRestartPause">RESTART STAGE</button>
    </div>
  </div>
</div>

<div class="overlay hidden" id="overlayFinish">
  <div class="card">
    <h1 id="finishTitle">FINISH</h1>
    <div class="sub" id="finishSub">STAGE COMPLETE</div>
    <div class="stats">
      <div class="stat"><div class="k">TOTAL TIME</div><div class="v" id="finTotal">0:00.00</div></div>
      <div class="stat"><div class="k">BEST LAP</div><div class="v best" id="finBest">0:00.00</div></div>
    </div>
    <div id="lapList"></div>
    <button class="btn" id="btnRestartFin">RESTART STAGE</button>
  </div>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function(){
'use strict';

/* =========================================================================
   RALLY RUSH  —  3D rally racer
   Single-file artifact. Three.js (CDN) + procedural everything else.
   Modules: Utils, Config, Audio, Input, Track, World, Car, FX, CameraRig,
            HUD, Game
   ========================================================================= */

/* ------------------------------------------------------------------ Utils */
const U = {
  clamp:(v,a,b)=>v<a?a:(v>b?b:v),
  lerp:(a,b,t)=>a+(b-a)*t,
  damp:(a,b,l,dt)=>U.lerp(a,b,1-Math.exp(-l*dt)),
  smooth:(e0,e1,x)=>{const t=U.clamp((x-e0)/(e1-e0),0,1);return t*t*(3-2*t);},
  rand:(a,b)=>a+Math.random()*(b-a),
  randInt:(a,b)=>Math.floor(a+Math.random()*(b-a+1)),
  pick:arr=>arr[Math.floor(Math.random()*arr.length)],
  angleLerp:(a,b,t)=>{let d=((b-a+Math.PI)%(Math.PI*2)+Math.PI*2)%(Math.PI*2)-Math.PI;return a+d*t;},
  fmt(ms){
    if(ms==null||!isFinite(ms)) return "--:--.--";
    const t=Math.max(0,ms), m=Math.floor(t/60000), s=Math.floor(t%60000/1000), c=Math.floor(t%1000/10);
    return m+":"+(s<10?"0":"")+s+"."+(c<10?"0":"")+c;
  },
  fmtShort(ms){
    if(ms==null||!isFinite(ms)) return "--.--";
    const t=Math.max(0,ms), s=Math.floor(t/1000), c=Math.floor(t%1000/10);
    return s+"."+(c<10?"0":"")+c;
  }
};
window.Rally = { Utils:U };

/* ----------------------------------------------------------------- Config */
const CFG = {
  LAPS:3,
  SAMPLES:900,
  HALF_WIDTH:6.0,       // asphalt/dirt ribbon half width (m)
  SHOULDER:3.4,         // gravel shoulder width each side (m)
  CAR_HALF:1.5,         // collision radius of the car (m)
  GATES:[0,.25,.5,.75], // normalized lap positions of checkpoint gates
  SEED:1337
};
window.Rally.Config = CFG;

/* small deterministic PRNG so scenery is stable between restarts */
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 RNG = mulberry32(CFG.SEED);

/* ---- surface table: grip/rolling/max-speed per ground type ---- */
const SURFACES = {
  asphalt:{name:"ASPHALT", grip:9.0, roll:0.30, top:60, accel:1.00, color:[0.24,0.25,0.28]},
  dirt:   {name:"DIRT",    grip:6.2, roll:0.55, top:50, accel:0.92, color:[0.45,0.34,0.21]},
  gravel: {name:"GRAVEL",  grip:6.3, roll:0.70, top:43, accel:0.86, color:[0.55,0.49,0.39]},
  mud:    {name:"MUD",     grip:4.2, roll:1.35, top:26, accel:0.60, color:[0.30,0.23,0.16]},
  water:  {name:"WATER",   grip:4.6, roll:1.60, top:20, accel:0.52, color:[0.18,0.30,0.42]},
  grass:  {name:"GRASS",   grip:3.4, roll:1.10, top:32, accel:0.62, color:[0.27,0.42,0.19]}
};

/* ================================================================== AUDIO
   Everything synthesised with the Web Audio API: engine drone, tyre noise,
   countdown beeps, crash thuds, finish jingle.
   ======================================================================== */
const Audio = {
  ctx:null, ok:false, muted:false, ready:false,
  init(){
    if(this.ready) return;
    const AC = window.AudioContext || window.webkitAudioContext;
    if(!AC){ this.ok=false; return; }
    try{ this.ctx = new AC(); }catch(e){ this.ok=false; return; }
    const ctx = this.ctx;
    this.master = ctx.createGain(); this.master.gain.value = 0.9; this.master.connect(ctx.destination);

    /* ---- engine: two saw-ish oscillators through a moving lowpass ---- */
    this.engGain = ctx.createGain(); this.engGain.gain.value = 0.0001;
    this.engFilter = ctx.createBiquadFilter(); this.engFilter.type="lowpass";
    this.engFilter.frequency.value = 700; this.engFilter.Q.value = 1.2;
    this.engGain.connect(this.engFilter); this.engFilter.connect(this.master);

    this.oscA = ctx.createOscillator(); this.oscA.type="sawtooth";
    this.oscB = ctx.createOscillator(); this.oscB.type="square";
    this.oscC = ctx.createOscillator(); this.oscC.type="sawtooth";
    const gA = ctx.createGain(); gA.gain.value=0.6;
    const gB = ctx.createGain(); gB.gain.value=0.22;
    const gC = ctx.createGain(); gC.gain.value=0.35;
    this.oscA.connect(gA); this.oscB.connect(gB); this.oscC.connect(gC);
    gA.connect(this.engGain); gB.connect(this.engGain); gC.connect(this.engGain);
    this.oscA.start(); this.oscB.start(); this.oscC.start();

    /* ---- looping white-noise source for tyres / gravel / wind ---- */
    const len = ctx.sampleRate*2;
    const buf = ctx.createBuffer(1,len,ctx.sampleRate);
    const d = buf.getChannelData(0);
    for(let i=0;i<len;i++) d[i] = Math.random()*2-1;
    this.noiseSrc = ctx.createBufferSource(); this.noiseSrc.buffer=buf; this.noiseSrc.loop=true;
    this.noiseFilter = ctx.createBiquadFilter(); this.noiseFilter.type="bandpass";
    this.noiseFilter.frequency.value=1400; this.noiseFilter.Q.value=0.7;
    this.noiseGain = ctx.createGain(); this.noiseGain.gain.value=0.0001;
    this.noiseSrc.connect(this.noiseFilter); this.noiseFilter.connect(this.noiseGain);
    this.noiseGain.connect(this.master); this.noiseSrc.start();

    this.ready = true; this.ok = true;
  },
  resume(){ if(this.ctx && this.ctx.state==="suspended") this.ctx.resume(); },
  setMuted(m){ this.muted=m; if(this.master) this.master.gain.setTargetAtTime(m?0:0.9, this.ctx.currentTime, 0.05); },
  /* rpm: 0..1 (normalised), load: 0..1, speed: m/s */
  engine(rpmN, load, speed){
    if(!this.ok) return;
    const t = this.ctx.currentTime;
    const f = 34 + rpmN*186;                    // idle rumble -> screaming
    this.oscA.frequency.setTargetAtTime(f, t, 0.05);
    this.oscB.frequency.setTargetAtTime(f*0.5, t, 0.05);
    this.oscC.frequency.setTargetAtTime(f*2.02, t, 0.05);
    this.engFilter.frequency.setTargetAtTime(360 + rpmN*2600, t, 0.06);
    const g = 0.035 + load*0.075 + rpmN*0.03;
    this.engGain.gain.setTargetAtTime(g, t, 0.08);
  },
  tyres(amount, kind, speed){
    if(!this.ok) return;
    const t=this.ctx.currentTime;
    this.noiseGain.gain.setTargetAtTime(amount*0.30, t, 0.06);
    const base = kind==="water"?700: kind==="gravel"?1900: 2600;
    this.noiseFilter.frequency.setTargetAtTime(base + speed*14, t, 0.08);
    this.noiseFilter.Q.value = kind==="skid"?3.2:0.8;
  },
  quiet(){ if(!this.ok) return;
    this.engGain.gain.setTargetAtTime(0.0001,this.ctx.currentTime,0.1);
    this.noiseGain.gain.setTargetAtTime(0.0001,this.ctx.currentTime,0.1);
  },
  beep(freq, dur, vol, type){
    if(!this.ok) return;
    const ctx=this.ctx, t=ctx.currentTime;
    const o=ctx.createOscillator(), g=ctx.createGain();
    o.type=type||"square"; o.frequency.value=freq;
    g.gain.setValueAtTime(0,t);
    g.gain.linearRampToValueAtTime(vol||0.22, t+0.012);
    g.gain.exponentialRampToValueAtTime(0.0001, t+dur);
    o.connect(g); g.connect(this.master); o.start(t); o.stop(t+dur+0.05);
  },
  thud(power){
    if(!this.ok) return;
    const ctx=this.ctx, t=ctx.currentTime, p=U.clamp(power,0.15,1);
    const len=Math.floor(ctx.sampleRate*0.35);
    const buf=ctx.createBuffer(1,len,ctx.sampleRate);
    const d=buf.getChannelData(0);
    for(let i=0;i<len;i++){ const k=i/len; d[i]=(Math.random()*2-1)*Math.pow(1-k,3); }
    const s=ctx.createBufferSource(); s.buffer=buf;
    const f=ctx.createBiquadFilter(); f.type="lowpass"; f.frequency.value=900;
    const g=ctx.createGain(); g.gain.value=0.55*p;
    s.connect(f); f.connect(g); g.connect(this.master); s.start(t);
    const o=ctx.createOscillator(); o.type="sine";
    o.frequency.setValueAtTime(190*p+60,t);
    o.frequency.exponentialRampToValueAtTime(40,t+0.25);
    const og=ctx.createGain(); og.gain.setValueAtTime(0.5*p,t);
    og.gain.exponentialRampToValueAtTime(0.0001,t+0.32);
    o.connect(og); og.connect(this.master); o.start(t); o.stop(t+0.34);
  },
  finishJingle(){
    if(!this.ok) return;
    const seq=[[523,0],[659,0.13],[784,0.26],[1046,0.40],[1318,0.56]];
    seq.forEach(([f,dt])=>setTimeout(()=>this.beep(f,0.34,0.22,"triangle"), dt*1000));
  }
};
window.Rally.Audio = Audio;

/* ================================================================== INPUT */
const Input = {
  keys:Object.create(null),
  touch:{left:false,right:false,gas:false,brake:false,drift:false},
  onAction:null,
  init(){
    addEventListener("keydown",e=>{
      const k=e.key.toLowerCase();
      if([" ","arrowup","arrowdown","arrowleft","arrowright"].includes(k)) e.preventDefault();
      if(this.keys[k]) return;
      this.keys[k]=true;
      if(this.onAction) this.onAction(k);
    },{passive:false});
    addEventListener("keyup",e=>{ this.keys[e.key.toLowerCase()]=false; });
    addEventListener("blur",()=>{ this.keys=Object.create(null); });
    this.bindTouch();
  },
  bindTouch(){
    const map={tLeft:"left",tRight:"right",tGas:"gas",tBrake:"brake",tDrift:"drift"};
    const el=document.getElementById("touch");
    if(("ontouchstart" in window) || navigator.maxTouchPoints>0) el.classList.add("on");
    Object.keys(map).forEach(id=>{
      const node=document.getElementById(id), prop=map[id];
      const on=e=>{e.preventDefault(); this.touch[prop]=true; node.classList.add("active");};
      const off=e=>{e.preventDefault(); this.touch[prop]=false; node.classList.remove("active");};
      node.addEventListener("pointerdown",on);
      node.addEventListener("pointerup",off);
      node.addEventListener("pointercancel",off);
      node.addEventListener("pointerleave",off);
      node.addEventListener("contextmenu",e=>e.preventDefault());
    });
  },
  /* normalised controls */
  read(){
    const k=this.keys;
    const up=k["arrowup"]||k["w"], dn=k["arrowdown"]||k["s"];
    const lf=k["arrowleft"]||k["a"], rt=k["arrowright"]||k["d"];
    return {
      throttle:(up||this.touch.gas)?1:0,
      brake:(dn||this.touch.brake)?1:0,
      steer:((rt||this.touch.right)?1:0)-((lf||this.touch.left)?1:0),
      handbrake:!!(k[" "]||this.touch.drift)
    };
  }
};
window.Rally.Input = Input;

/* ================================================================== TRACK
   Procedure:
     1. Ring of control points on polar coordinates (radius + hill height
        harmonics) -> closed Catmull-Rom spline -> N spaced samples.
     2. Each sample stores position, tangent, side vector, surface type,
        arc length. A uniform grid allows O(1) nearest-sample lookups used
        for terrain flattening, surface queries and lap progress.
     3. Meshes: road ribbon (vertex coloured), gravel shoulders, dashed
        centre line, water ribbons, plus barriers/signs/banners.
   ======================================================================== */
const UP = {x:0,y:1,z:0};
const Track = {
  samples:[], length:0, group:null,
  grid:{cell:34, min:-360, n:22, buckets:null},
  gates:[],
  build(){
    const n=24, pts=[];
    for(let i=0;i<n;i++){
      const a=i/n*Math.PI*2;
      const r = 118 + 26*Math.sin(3*a+0.30) + 9*Math.sin(5*a+1.10);
      const y = 6.2*Math.sin(2*a+0.60) + 4.1*Math.sin(3*a+2.30) + 2.2*Math.sin(5*a+1.00);
      pts.push(new THREE.Vector3(Math.cos(a)*r, y, Math.sin(a)*r));
    }
    const curve = new THREE.CatmullRomCurve3(pts, true, "catmullrom", 0.5);
    const raw = curve.getSpacedPoints(CFG.SAMPLES);       // returns N+1, last==first
    const N = CFG.SAMPLES;

    // ---- sample data ----
    this.samples = [];
    for(let i=0;i<N;i++){
      const p = raw[i].clone();
      this.samples.push({p, t:new THREE.Vector3(), s:new THREE.Vector3(), arc:0, surf:"asphalt"});
    }
    let arc=0;
    for(let i=0;i<N;i++){
      const prev=this.samples[(i-1+N)%N].p, next=this.samples[(i+1)%N].p;
      const t=new THREE.Vector3().subVectors(next,prev).normalize();
      const s=new THREE.Vector3().crossVectors(t,new THREE.Vector3(0,1,0)).normalize();
      this.samples[i].t.copy(t); this.samples[i].s.copy(s);
      this.samples[i].arc = arc;
      arc += this.samples[i].p.distanceTo(next);
    }
    this.length = arc;

    // ---- surface segmentation (fast -> slow variety) ----
    const SEG=[[0,.13,"asphalt"],[.13,.25,"dirt"],[.25,.30,"mud"],[.30,.47,"asphalt"],
               [.47,.60,"dirt"],[.60,.655,"water"],[.655,.80,"asphalt"],[.80,.90,"gravel"],
               [.90,.945,"water"],[.945,1.0,"asphalt"]];
    for(let i=0;i<N;i++){
      const f=i/N; let s="asphalt";
      for(const [a,b,name] of SEG){ if(f>=a&&f<b){ s=name; break; } }
      this.samples[i].surf=s;
    }
    for(let i=0;i<N;i++) this.samples[i].y = this.samples[i].p.y;

    this.buildGrid();
    this.gates = CFG.GATES.map(f=>({idx:Math.round(f*N)%N, hit:false, f}));
    this.buildMeshes();
  },

  buildGrid(){
    const g=this.grid; g.buckets=new Map();
    const key=(cx,cz)=>cx*1000+cz;
    for(let i=0;i<this.samples.length;i++){
      const p=this.samples[i].p;
      const cx=Math.floor((p.x-g.min)/g.cell), cz=Math.floor((p.z-g.min)/g.cell);
      const k=key(cx,cz);
      let b=g.buckets.get(k); if(!b){b=[];g.buckets.set(k,b);}
      b.push(i);
    }
  },
  /* nearest centre-line sample within `rad` grid cells of (x,z) */
  nearest(x,z,rad){
    const g=this.grid, key=(cx,cz)=>cx*1000+cz;
    const cx=Math.floor((x-g.min)/g.cell), cz=Math.floor((z-g.min)/g.cell);
    let best=-1, bd=Infinity;
    for(let i=cx-rad;i<=cx+rad;i++)for(let j=cz-rad;j<=cz+rad;j++){
      const b=g.buckets.get(key(i,j)); if(!b) continue;
      for(let q=0;q<b.length;q++){
        const p=this.samples[b[q]].p;
        const dx=p.x-x, dz=p.z-z, d=dx*dx+dz*dz;
        if(d<bd){ bd=d; best=b[q]; }
      }
    }
    return best<0?null:{idx:best, d:Math.sqrt(bd)};
  },
  /* nearest sample using a window around `hint` — used for lap progress so
     the car can never "jump" to a far part of the track that folds close by */
  nearestWindow(x,z,hint,win){
    const N=this.samples.length; let best=-1,bd=Infinity;
    for(let k=-win;k<=win;k++){
      const i=((hint+k)%N+N)%N;
      const p=this.samples[i].p, dx=p.x-x, dz=p.z-z, d=dx*dx+dz*dz;
      if(d<bd){bd=d;best=i;}
    }
    return {idx:best, d:Math.sqrt(bd)};
  },
  sampleAt(x,z,hint){
    let res, idx;
    if(hint==null){ res=this.nearest(x,z,2); if(!res) return {idx:0,d:1e9,lat:1e9,surf:"grass",y:0}; idx=res.idx; }
    else { res=this.nearestWindow(x,z,hint,42); idx=res.idx; }
    const s=this.samples[idx];
    // signed lateral distance from the centre line
    const dx=x-s.p.x, dz=z-s.p.z;
    const lat=dx*s.s.x+dz*s.s.z;
    const a=Math.abs(lat);
    let surf;
    if(a<CFG.HALF_WIDTH) surf=s.surf;
    else if(a<CFG.HALF_WIDTH+CFG.SHOULDER) surf="gravel";
    else surf="grass";
    return {idx, d:res.d, lat, surf, y:s.p.y};
  },
  surfaceOf(s){ return SURFACES[s]||SURFACES.grass; },

  /* ---------- geometry builders ---------- */
  buildMeshes(){
    const g=new THREE.Group(); this.group=g;
    const N=this.samples.length, S=this.samples;
    const HW=CFG.HALF_WIDTH, SH=CFG.SHOULDER, LIFT=0.30;

    const ribbon=(offA,offB,yOff,colorFn,mat)=>{
      const cols=2, verts=N+1;
      const pos=new Float32Array(verts*cols*3), col=new Float32Array(verts*cols*3);
      const idx=[];
      for(let i=0;i<=N;i++){
        const s=S[i%N];
        for(let c=0;c<cols;c++){
          const off=(c===0?offA:offB);
          const v=(i*cols+c);
          pos[v*3  ]=s.p.x+s.s.x*off;
          pos[v*3+1]=(s.p.y+LIFT)+yOff;
          pos[v*3+2]=s.p.z+s.s.z*off;
          const rgb=colorFn(s,i,c);
          col[v*3]=rgb[0]; col[v*3+1]=rgb[1]; col[v*3+2]=rgb[2];
        }
      }
      for(let i=0;i<N;i++){
        const a=i*cols, b=(i+1)*cols, c=a+1, d=b+1;
        idx.push(a,b,c, c,b,d);
      }
      const geo=new THREE.BufferGeometry();
      geo.setAttribute("position",new THREE.BufferAttribute(pos,3));
      geo.setAttribute("color",new THREE.BufferAttribute(col,3));
      geo.setIndex(idx); geo.computeVertexNormals();
      const m=new THREE.Mesh(geo,mat); m.receiveShadow=true;
      return m;
    };

    const roadMat=new THREE.MeshLambertMaterial({vertexColors:true});
    const dirtTint=(s,i)=>{
      const base=SURFACES[s.surf].color;
      const j=0.86+0.14*Math.sin(i*0.7);
      // subtle dappling so the surface doesn't look flat
      const n=(Math.sin(i*0.31)*0.5+0.5)*0.10-0.05;
      return [base[0]*j+n, base[1]*j+n, base[2]*j+n];
    };
    // Left half and right half separately so the centre joint stays hidden
    g.add(ribbon( HW,0,0, dirtTint, roadMat));
    g.add(ribbon(0,-HW,0, dirtTint, roadMat));

    // gravel shoulders
    const shMat=new THREE.MeshLambertMaterial({vertexColors:true});
    const shTint=(s,i,c)=>{
      const b=SURFACES.gravel.color, j=(c===0?0.78:1.0)*(0.9+0.2*(0.5+0.5*Math.sin(i*0.53)));
      return [b[0]*j,b[1]*j,b[2]*j];
    };
    g.add(ribbon( HW+SH, HW, -0.06, shTint, shMat));
    g.add(ribbon(-HW, -(HW+SH), -0.06, shTint, shMat));

    // dashed white centre line
    {
      const pos=[], idx=[]; let v=0;
      for(let i=0;i<N;i++){
        if(i%13>=7) continue;
        const s=S[i], s2=S[(i+1)%N], w=0.16;
        const y=s.p.y+LIFT+0.02, y2=s2.p.y+LIFT+0.02;
        pos.push(s.p.x-s.s.x*w,y,s.p.z-s.s.z*w, s.p.x+s.s.x*w,y,s.p.z+s.s.z*w,
                 s2.p.x-s.s.x*w,y2,s2.p.z-s.s.z*w, s2.p.x+s.s.x*w,y2,s2.p.z+s.s.z*w);
        idx.push(v,v+2,v+1, v+1,v+2,v+3); v+=4;
      }
      const geo=new THREE.BufferGeometry();
      geo.setAttribute("position",new THREE.Float32BufferAttribute(pos,3));
      geo.setIndex(idx); geo.computeVertexNormals();
      const m=new THREE.Mesh(geo,new THREE.MeshBasicMaterial({color:0xdfe6ea,transparent:true,opacity:0.55,
        polygonOffset:true,polygonOffsetFactor:-2,polygonOffsetUnits:-2}));
      g.add(m);
    }

    // water / mud ribbons (shallow water on the track)
    {
      const pos=[], idx=[]; const waterMat=new THREE.MeshPhongMaterial({color:0x4a7d9e,transparent:true,
        opacity:0.58,shininess:130,specular:0xcfeaff,side:THREE.DoubleSide,
        polygonOffset:true,polygonOffsetFactor:-3,polygonOffsetUnits:-3});
      let v=0;
      for(let i=0;i<N;i++){
        if(S[i].surf!=="water") continue;
        const s=S[i], n=S[(i+1)%N];
        if(n.surf!=="water") continue;
        const y=s.p.y+LIFT+0.04, y2=n.p.y+LIFT+0.04, w=HW+1.1;
        pos.push(s.p.x-s.s.x*w,y,s.p.z-s.s.z*w, s.p.x+s.s.x*w,y,s.p.z+s.s.z*w,
                 n.p.x-n.s.x*w,y2,n.p.z-n.s.z*w, n.p.x+n.s.x*w,y2,n.p.z+n.s.z*w);
        idx.push(v,v+2,v+1, v+1,v+2,v+3); v+=4;
      }
      const geo=new THREE.BufferGeometry();
      geo.setAttribute("position",new THREE.Float32BufferAttribute(pos,3));
      geo.setIndex(idx); geo.computeVertexNormals();
      g.add(new THREE.Mesh(geo,waterMat));
    }
  }
};
window.Rally.Track = Track;

/* ================================================================== WORLD
   Terrain, sky, lights, scenery (trees/rocks/crowd/signs/gantry) and the
   obstacle collision index.
   ======================================================================== */
const World = {
  group:null, obstacles:[], obsGrid:new Map(), CELL:14,
  trees:null, sun:null,

  /* --- rolling base terrain (unrelated to the track spline) --- */
  baseNoise(x,z){
    return 7.2*Math.sin(x*0.0075)*Math.cos(z*0.0091)
         + 3.4*Math.sin(x*0.019+1.3)*Math.sin(z*0.0163+0.7)
         + 1.7*Math.cos(x*0.031+z*0.026)
         + 0.9*Math.sin(x*0.061)*Math.cos(z*0.055);
  },
  /* --- ground height: blended to the track bed near the ribbon --- */
  terrainY(x,z){
    const n=this.baseNoise(x,z);
    const hit=Track.nearest(x,z,2);
    if(!hit) return n;
    const ry=Track.samples[hit.idx].p.y+0.18;
    const t=U.smooth(CFG.HALF_WIDTH+CFG.SHOULDER, 30, hit.d);
    return U.lerp(ry,n,t);
  },

  build(scene){
    const g=new THREE.Group(); this.group=g; scene.add(g);

    /* ---------- sky dome ---------- */
    const sky=new THREE.Mesh(
      new THREE.SphereGeometry(900,24,14),
      new THREE.ShaderMaterial({
        side:THREE.BackSide, depthWrite:false, fog:false,
        uniforms:{ cTop:{value:new THREE.Color(0x2a6ab4)}, cMid:{value:new THREE.Color(0xbdd8ea)}, cBot:{value:new THREE.Color(0x8b9a80)} },
        vertexShader:"varying vec3 vP; void main(){ vP=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);} ",
        fragmentShader:"uniform vec3 cTop; uniform vec3 cMid; uniform vec3 cBot; varying vec3 vP;"+
          "void main(){ float h=normalize(vP).y; vec3 c = h>0.0 ? mix(cMid,cTop,pow(h,0.60)) : mix(cMid,cBot,pow(-h,0.45)); gl_FragColor=vec4(c,1.0);} "
      })
    );
    sky.frustumCulled=false;
    this.sky=sky; scene.add(sky);

    /* ---------- lights ---------- */
    scene.add(new THREE.HemisphereLight(0xcfe4ff,0x4a5b34,0.62));
    scene.add(new THREE.AmbientLight(0xffffff,0.20));
    const sun=new THREE.DirectionalLight(0xfff2d6,1.05);
    sun.position.set(70,110,40);
    sun.castShadow=true;
    sun.shadow.mapSize.set(2048,2048);
    const sc=sun.shadow.camera;
    sc.near=10; sc.far=340; sc.left=-75; sc.right=75; sc.top=75; sc.bottom=-75;
    sun.shadow.bias=-0.0009; sun.shadow.normalBias=0.6;
    scene.add(sun); scene.add(sun.target);
    this.sun=sun;

    /* ---------- terrain mesh ---------- */
    const SIZE=760, SEG=190;
    const geo=new THREE.PlaneGeometry(SIZE,SIZE,SEG,SEG);
    geo.rotateX(-Math.PI/2);
    const pos=geo.attributes.position, col=new Float32Array(pos.count*3);
    for(let i=0;i<pos.count;i++){
      const x=pos.getX(i), z=pos.getZ(i);
      const y=this.terrainY(x,z);
      pos.setY(i,y);
      // grass tone, drier on the ridges, darker in the hollows
      const v=0.5+0.5*Math.sin(x*0.043)*Math.cos(z*0.037);
      const h=U.clamp((y+12)/26,0,1);
      const r=0.20+0.13*v+0.10*h, gg=0.34+0.16*v+0.06*h, b=0.13+0.09*v;
      col[i*3]=r; col[i*3+1]=gg; col[i*3+2]=b;
    }
    geo.setAttribute("color",new THREE.BufferAttribute(col,3));
    geo.computeVertexNormals();
    const ground=new THREE.Mesh(geo,new THREE.MeshLambertMaterial({vertexColors:true}));
    ground.receiveShadow=true;
    g.add(ground);

    this.buildScenery(g);
    this.buildTrackFurniture(g);
    this.buildObstacleGrid();
  },

  /* ---------- trees, rocks, crowds ---------- */
  buildScenery(g){
    const placed=[];                       // {x,z,s}
    const tryPlace=(minD,maxD,clear)=>{
      for(let k=0;k<50;k++){
        const a=RNG()*Math.PI*2, r=minD+Math.sqrt(RNG())*(maxD-minD);
        const x=Math.cos(a)*r, z=Math.sin(a)*r;
        const hit=Track.nearest(x,z,1);
        if(hit && hit.d<clear) continue;
        let bad=false;
        for(const p of placed){ if((p.x-x)**2+(p.z-z)**2 < 30) { bad=true; break; } }
        if(bad) continue;
        placed.push({x,z});
        return {x,z,y:this.terrainY(x,z)};
      }
      return null;
    };

    /* -- pine trees (trunk + cone), instanced -- */
    const TREES=520;
    const trunkGeo=new THREE.CylinderGeometry(0.20,0.34,3.0,5); trunkGeo.translate(0,1.5,0);
    const foliGeo=new THREE.ConeGeometry(2.0,5.4,7); foliGeo.translate(0,5.2,0);
    const trunkMat=new THREE.MeshPhongMaterial({color:0x4a3423,flatShading:true,shininess:0});
    const foliMat=new THREE.MeshPhongMaterial({color:0xffffff,flatShading:true,shininess:0});
    const trunks=new THREE.InstancedMesh(trunkGeo,trunkMat,TREES);
    const foliage=new THREE.InstancedMesh(foliGeo,foliMat,TREES);
    trunks.castShadow=foliage.castShadow=true; foliage.receiveShadow=true;
    const mtx=new THREE.Matrix4(), q=new THREE.Quaternion(), vv=new THREE.Vector3(), sv=new THREE.Vector3();
    const cgreens=[0x2f5d2a,0x3b6f31,0x274d24,0x486b2c,0x355c3a];
    let n=0;
    for(let i=0;i<TREES;i++){
      const p=tryPlace(30,340,15.5);
      if(!p) continue;
      const s=0.72+RNG()*1.05;
      vv.set(p.x,p.y-0.2,p.z);
      q.setFromAxisAngle(new THREE.Vector3(0,1,0),RNG()*6.28);
      sv.set(s,s*(0.85+RNG()*0.5),s);
      mtx.compose(vv,q,sv);
      trunks.setMatrixAt(n,mtx); foliage.setMatrixAt(n,mtx);
      const c=new THREE.Color(cgreens[Math.floor(RNG()*cgreens.length)]);
      c.offsetHSL((RNG()-0.5)*0.03,0,(RNG()-0.5)*0.08);
      foliage.setColorAt(n,c);
      this.obstacles.push({x:p.x,z:p.z,r:0.55*s+0.5,type:"tree"});
      n++;
    }
    trunks.count=foliage.count=n;
    trunks.instanceMatrix.needsUpdate=foliage.instanceMatrix.needsUpdate=true;
    if(foliage.instanceColor) foliage.instanceColor.needsUpdate=true;
    g.add(trunks); g.add(foliage); this.trees=foliage;

    /* -- rocks -- */
    const ROCKS=110;
    const rockGeo=new THREE.DodecahedronGeometry(1.15,0);
    const rockMat=new THREE.MeshPhongMaterial({color:0xffffff,flatShading:true,shininess:0});
    const rocks=new THREE.InstancedMesh(rockGeo,rockMat,ROCKS);
    rocks.castShadow=rocks.receiveShadow=true;
    let rn=0;
    for(let i=0;i<ROCKS;i++){
      const p=tryPlace(26,330,13);
      if(!p) continue;
      const s=0.45+RNG()*1.35;
      vv.set(p.x,p.y+s*0.35,p.z);
      q.setFromEuler(new THREE.Euler(RNG()*3,RNG()*6.28,RNG()*3));
      sv.set(s,s*(0.6+RNG()*0.8),s*(0.8+RNG()*0.5));
      mtx.compose(vv,q,sv);
      rocks.setMatrixAt(rn,mtx);
      const c=new THREE.Color(0x7d7f82); c.offsetHSL(0,0,(RNG()-0.5)*0.16);
      rocks.setColorAt(rn,c);
      this.obstacles.push({x:p.x,z:p.z,r:0.9*s+0.35,type:"rock"});
      rn++;
    }
    rocks.count=rn; rocks.instanceMatrix.needsUpdate=true;
    if(rocks.instanceColor) rocks.instanceColor.needsUpdate=true;
    g.add(rocks);

    /* -- crowd (cylinders + heads) around the start line + two spots -- */
    const spots=[0,0.19,0.72].map(f=>Track.samples[Math.floor(f*CFG.SAMPLES)]);
    const people=[];
    for(const s of spots){
      for(let i=0;i<44;i++){
        const side=i%2?1:-1;
        const off=(CFG.HALF_WIDTH+CFG.SHOULDER+2.4+RNG()*4.5)*side;
        const along=(RNG()-0.5)*36;
        const x=s.p.x+s.s.x*off+s.t.x*along, z=s.p.z+s.s.z*off+s.t.z*along;
        people.push({x,z,s:0.9+RNG()*0.25});
      }
    }
    const bodyGeo=new THREE.CylinderGeometry(0.30,0.36,1.15,6); bodyGeo.translate(0,0.58,0);
    const headGeo=new THREE.SphereGeometry(0.24,7,6); headGeo.translate(0,1.34,0);
    const bodyMat=new THREE.MeshPhongMaterial({color:0xffffff,flatShading:true,shininess:0});
    const headMat=new THREE.MeshPhongMaterial({color:0xd9b48c,flatShading:true,shininess:0});
    const bodies=new THREE.InstancedMesh(bodyGeo,bodyMat,people.length);
    const heads=new THREE.InstancedMesh(headGeo,headMat,people.length);
    bodies.castShadow=true;
    const shades=[0xe0483a,0x2f6ec4,0xe6c34a,0xdedede,0x2c2f38,0x3fa06a,0xc2609b];
    people.forEach((p,i)=>{
      const y=this.terrainY(p.x,p.z);
      vv.set(p.x,y,p.z); q.setFromAxisAngle(new THREE.Vector3(0,1,0),RNG()*6.28);
      sv.set(p.s,p.s,p.s); mtx.compose(vv,q,sv);
      bodies.setMatrixAt(i,mtx); heads.setMatrixAt(i,mtx);
      bodies.setColorAt(i,new THREE.Color(U.pick(shades)));
    });
    bodies.instanceMatrix.needsUpdate=heads.instanceMatrix.needsUpdate=true;
    if(bodies.instanceColor) bodies.instanceColor.needsUpdate=true;
    g.add(bodies); g.add(heads);
  },

  /* ---------- barriers, signs, gate banners, start gantry, flags ---------- */
  buildTrackFurniture(g){
    const S=Track.samples, N=S.length;
    const HW=CFG.HALF_WIDTH, SH=CFG.SHOULDER;

    /* -- guard rails on the outside of the fastest corners -- */
    const posts=[];
    for(let i=0;i<N;i++){
      const t1=S[i].t, t2=S[(i+2)%N].t;
      const curv=Math.abs(t1.x*t2.z-t1.z*t2.x);
      const cross=Math.sign(t1.x*t2.z-t1.z*t2.x)||1;
      if(curv>0.010 && S[i].surf!=="water"){
        const off=-(cross)*(HW+SH+1.0);
        posts.push({i,off});
      }
    }
    const bGeo=new THREE.BoxGeometry(0.28,0.6,2.9);
    const bMat=new THREE.MeshPhongMaterial({color:0xffffff,flatShading:true,shininess:0});
    const rail=new THREE.InstancedMesh(bGeo,bMat,posts.length+8);
    rail.castShadow=rail.receiveShadow=true;
    const mtx=new THREE.Matrix4(), q=new THREE.Quaternion(), vv=new THREE.Vector3(), sv=new THREE.Vector3(1,1,1);
    posts.forEach((p,k)=>{
      const s=S[p.i];
      const x=s.p.x+s.s.x*p.off, z=s.p.z+s.s.z*p.off;
      vv.set(x,s.p.y+0.72,z);
      q.setFromAxisAngle(new THREE.Vector3(0,1,0),Math.atan2(s.t.x,s.t.z));
      mtx.compose(vv,q,sv);
      rail.setMatrixAt(k,mtx);
      rail.setColorAt(k,new THREE.Color(k%2?0xe9e9ec:0xc0392b));
      this.obstacles.push({x,z,r:1.5,type:"barrier"});
    });
    rail.count=posts.length;
    rail.instanceMatrix.needsUpdate=true;
    if(rail.instanceColor) rail.instanceColor.needsUpdate=true;
    g.add(rail);

    /* -- warning signs on the approach to tight corners -- */
    const signs=[];
    for(let i=6;i<N;i+=1){
      const t1=S[i].t, t2=S[(i+16)%N].t;
      const curv=Math.abs(t1.x*t2.z-t1.z*t2.x);
      const cross=Math.sign(t1.x*t2.z-t1.z*t2.x)||1;
      if(curv>0.055 && (!signs.length || i-signs[signs.length-1].i>45)) signs.push({i,cross});
    }
    const signTex=[this.texture(c=>this.drawSign(c,"left")),this.texture(c=>this.drawSign(c,"right")),
                   this.texture(c=>this.drawSign(c,"warn")),this.texture(c=>this.drawSign(c,"info"))];
    const postMat=new THREE.MeshPhongMaterial({color:0xbfc4c9,flatShading:true,shininess:0});
    signs.forEach((sg,k)=>{
      const s=S[sg.i];
      const off=-(sg.cross)*(HW+SH+2.0);
      const x=s.p.x+s.s.x*off, z=s.p.z+s.s.z*off, y=s.p.y;
      const post=new THREE.Mesh(new THREE.CylinderGeometry(0.09,0.09,2.6,5),postMat);
      post.position.set(x,y+1.3,z); post.castShadow=true; g.add(post);
      const tex=sg.cross>0?signTex[0]:signTex[1];
      const board=new THREE.Mesh(new THREE.PlaneGeometry(1.7,1.25),
        new THREE.MeshLambertMaterial({map:tex,transparent:true,side:THREE.DoubleSide}));
      board.position.set(x,y+2.75,z);
      board.rotation.y=Math.atan2(s.t.x,s.t.z)+Math.PI;
      board.castShadow=true; g.add(board);
      this.obstacles.push({x,z,r:0.7,type:"sign"});
    });

    /* -- checkpoint banners + start gantry -- */
    const gateNames=["FINISH","CHECKPOINT 1","CHECKPOINT 2","CHECKPOINT 3"];
    CFG.GATES.forEach((f,k)=>{
      const idx=Math.round(f*N)%N, s=S[idx];
      const w=(HW+SH+1.6)*2;
      const yaw=Math.atan2(s.t.x,s.t.z);
      const gx=new THREE.Group();
      gx.position.copy(s.p); gx.rotation.y=yaw;
      const text = k===0? gateNames[0] : gateNames[k];
      const tex=this.texture(c=>this.drawBanner(c,512,64,text,k===0),512,64);
      const banner=new THREE.Mesh(new THREE.PlaneGeometry(w,2.1),
        new THREE.MeshBasicMaterial({map:tex,side:THREE.DoubleSide}));
      banner.position.set(0,5.6,0); banner.rotation.y=Math.PI; banner.castShadow=true;
      gx.add(banner);
      const legMat=new THREE.MeshPhongMaterial({color:k===0?0x2b3340:0x9aa2ac,flatShading:true,shininess:0});
      [-w/2,w/2].forEach(ox=>{
        const leg=new THREE.Mesh(new THREE.CylinderGeometry(0.20,0.26,6.6,6),legMat);
        leg.position.set(ox,3.3,0); leg.castShadow=true; gx.add(leg);
        this.obstacles.push({x:s.p.x+s.s.x*Math.sign(ox)*(HW+SH+1.7),z:s.p.z+s.s.z*Math.sign(ox)*(HW+SH+1.7),r:0.6,type:"gate"});
      });
      g.add(gx);
    });
    /* start line checkered strip */
    {
      const s=S[0], w=(HW+SH)*2;
      const c=document.createElement("canvas"); c.width=32;c.height=8;
      const cx=c.getContext("2d");
      for(let i=0;i<8;i++)for(let j=0;j<2;j++){ cx.fillStyle=((i+j)%2)?"#f2f2f2":"#1b1b1b"; cx.fillRect(i*4,j*4,4,4); }
      const tex=new THREE.CanvasTexture(c);
      tex.wrapS=tex.wrapT=THREE.RepeatWrapping; tex.repeat.set(6,1);
      const strip=new THREE.Mesh(new THREE.PlaneGeometry(w,1.6),
        new THREE.MeshLambertMaterial({map:tex}));
      strip.rotation.x=-Math.PI/2; strip.rotation.z=-Math.atan2(s.t.x,s.t.z);
      strip.position.set(s.p.x,s.p.y+0.33,s.p.z);
      g.add(strip);
    }
    /* -- roadside flags along the stage -- */
    {
      const flagN=26, flagGeo=new THREE.PlaneGeometry(1.1,0.7);
      for(let i=0;i<flagN;i++){
        const idx=Math.floor(i/flagN*N);
        const s=S[idx];
        const side=(i%2)?1:-1;
        const off=side*(HW+SH+3.4);
        const x=s.p.x+s.s.x*off, z=s.p.z+s.s.z*off, y=this.terrainY(x,z);
        const pole=new THREE.Mesh(new THREE.CylinderGeometry(0.05,0.05,2.4,4),
          new THREE.MeshPhongMaterial({color:0xdddddd}));
        pole.position.set(x,y+1.2,z); g.add(pole);
        const col=[0xffcf3f,0xff4d3d,0x4dc3ff,0x7dffa1][i%4];
        const flag=new THREE.Mesh(flagGeo,new THREE.MeshPhongMaterial({color:col,side:THREE.DoubleSide}));
        flag.position.set(x+0.55,y+2.1,z); flag.rotation.y=Math.atan2(s.t.x,s.t.z)+1.5708;
        flag.userData.wave=(i%7)*0.9;
        g.add(flag);
      }
    }
  },

  /* canvas texture helper */
  texture(draw,w,h){
    const c=document.createElement("canvas"); c.width=w||256; c.height=h||256;
    const ctx=c.getContext("2d"); draw(ctx,c.width,c.height);
    const t=new THREE.CanvasTexture(c);
    t.anisotropy=4;
    return t;
  },
  drawSign(ctx,w,h,kind){
    ctx.clearRect(0,0,w,h);
    if(kind==="left"||kind==="right"||kind==="warn"){
      const tri=()=>{ctx.beginPath();ctx.moveTo(w*0.5,16);ctx.lineTo(w-18,h-24);ctx.lineTo(18,h-24);ctx.closePath();};
      tri(); ctx.fillStyle="#ffd23f"; ctx.fill();
      ctx.lineWidth=11; ctx.strokeStyle="#20242b"; ctx.stroke();
      ctx.fillStyle="#20242b";
      if(kind==="warn"){
        ctx.font="900 108px Arial"; ctx.textAlign="center"; ctx.textBaseline="middle";
        ctx.fillText("!",w*0.5,h*0.63);
      } else {
        ctx.save(); ctx.translate(w*0.5,h*0.62); ctx.scale(kind==="left"?-1:1,1);
        ctx.beginPath(); ctx.moveTo(0,-52); ctx.lineTo(30,10); ctx.lineTo(10,10); ctx.lineTo(10,48);
        ctx.lineTo(-10,48); ctx.lineTo(-10,10); ctx.lineTo(-30,10); ctx.closePath(); ctx.fill();
        ctx.restore();
      }
    } else {
      ctx.fillStyle="#1d3a63"; ctx.fillRect(14,14,w-28,h-28);
      ctx.strokeStyle="#ffffff"; ctx.lineWidth=8; ctx.strokeRect(14,14,w-28,h-28);
      ctx.fillStyle="#fff"; ctx.font="900 44px Arial"; ctx.textAlign="center";
      ctx.fillText("STAGE",w*0.5,h*0.42); ctx.fillText("AHEAD",w*0.5,h*0.62);
    }
  },
  drawBanner(ctx,w,h,text,isFinish){
    ctx.clearRect(0,0,w,h);
    ctx.fillStyle=isFinish?"#15191f":"#173a68"; ctx.fillRect(0,0,w,h);
    ctx.fillStyle=isFinish?"#ffcf3f":"#4dc3ff";
    ctx.fillRect(0,0,w,6); ctx.fillRect(0,h-6,w,6);
    /* checkered band on the finish banner */
    if(isFinish){
      for(let i=0;i<w/8;i++)for(let j=0;j<2;j++){
        ctx.fillStyle=((i+j)%2)?"#f2f2f2":"#141414";
        ctx.fillRect(i*8,6+j*8,8,8); ctx.fillRect(i*8,h-22+j*8,8,8);
      }
    }
    ctx.fillStyle="#ffffff"; ctx.textAlign="center"; ctx.textBaseline="middle";
    ctx.font="900 30px Arial, sans-serif";
    ctx.fillText(text,w*0.5,h*0.52);
  },

  buildObstacleGrid(){
    this.obsGrid=new Map();
    for(const o of this.obstacles){
      const cx=Math.floor(o.x/this.CELL), cz=Math.floor(o.z/this.CELL);
      const k=cx*10000+cz;
      let b=this.obsGrid.get(k); if(!b){b=[];this.obsGrid.set(k,b);}
      b.push(o);
    }
  },
  /* obstacles near (x,z) inside `rad` units */
  nearby(x,z,rad){
    const out=[], c=this.CELL;
    const cx=Math.floor(x/c), cz=Math.floor(z/c);
    for(let i=cx-1;i<=cx+1;i++)for(let j=cz-1;j<=cz+1;j++){
      const b=this.obsGrid.get(i*10000+j); if(!b) continue;
      for(const o of b) if(Math.abs(o.x-x)<rad && Math.abs(o.z-z)<rad) out.push(o);
    }
    return out;
  }
};
window.Rally.World = World;

/* ===================================================================== FX
   GPU particle pools (Points + ShaderMaterial) for dust/smoke/sparks and a
   pooled quad mesh for skid marks. Textures are generated on a canvas.
   ======================================================================== */
const FX = {
  scene:null, pixelRatio:1,
  softTex:null, systems:[],

  init(scene, pixelRatio){
    this.scene=scene; this.pixelRatio=pixelRatio;
    /* soft radial sprite */
    const c=document.createElement("canvas"); c.width=c.height=64;
    const ctx=c.getContext("2d");
    const grd=ctx.createRadialGradient(32,32,0,32,32,32);
    grd.addColorStop(0,"rgba(255,255,255,1)");
    grd.addColorStop(0.45,"rgba(255,255,255,0.55)");
    grd.addColorStop(1,"rgba(255,255,255,0)");
    ctx.fillStyle=grd; ctx.fillRect(0,0,64,64);
    this.softTex=new THREE.CanvasTexture(c);

    this.dust=this.makePoints(scene,820,this.softTex,THREE.NormalBlending);
    this.sparks=this.makePoints(scene,420,this.softTex,THREE.AdditiveBlending);
    this.buildSkids(scene);
  },

  makePoints(scene,max,map,blending){
    const pos=new Float32Array(max*3), col=new Float32Array(max*3),
          sz=new Float32Array(max), al=new Float32Array(max);
    for(let i=0;i<max;i++){ pos[i*3+1]=-999; }
    const geo=new THREE.BufferGeometry();
    geo.setAttribute("position",new THREE.BufferAttribute(pos,3));
    geo.setAttribute("aColor",new THREE.BufferAttribute(col,3));
    geo.setAttribute("aSize",new THREE.BufferAttribute(sz,1));
    geo.setAttribute("aAlpha",new THREE.BufferAttribute(al,1));
    const mat=new THREE.ShaderMaterial({
      uniforms:{ map:{value:map}, uPR:{value:this.pixelRatio} },
      transparent:true, depthWrite:false, blending:blending,
      vertexShader:
        "attribute float aSize; attribute float aAlpha; attribute vec3 aColor;"+
        "uniform float uPR; varying float vA; varying vec3 vC;"+
        "void main(){ vA=aAlpha; vC=aColor;"+
        " vec4 mv=modelViewMatrix*vec4(position,1.0);"+
        " gl_PointSize=min(aSize*uPR*230.0/max(1.0,-mv.z), 130.0*uPR);"+
        " gl_Position=projectionMatrix*mv; }",
      fragmentShader:
        "uniform sampler2D map; varying float vA; varying vec3 vC;"+
        "void main(){ float a=texture2D(map,gl_PointCoord).a*vA;"+
        " if(a<0.01) discard; gl_FragColor=vec4(vC,a); }"
    });
    const pts=new THREE.Points(geo,mat);
    pts.frustumCulled=false;
    scene.add(pts);
    const sys={pts,geo,max,next:0,
      life:new Float32Array(max), maxLife:new Float32Array(max),
      vel:new Float32Array(max*3), grow:new Float32Array(max), base:new Float32Array(max)};
    this.systems.push(sys);
    return sys;
  },
  spawn(sys,x,y,z,vx,vy,vz,size,life,color,grow,alpha){
    const i=sys.next; sys.next=(sys.next+1)%sys.max;
    const p=sys.geo.attributes.position.array;
    p[i*3]=x; p[i*3+1]=y; p[i*3+2]=z;
    sys.vel[i*3]=vx; sys.vel[i*3+1]=vy; sys.vel[i*3+2]=vz;
    sys.life[i]=life; sys.maxLife[i]=life; sys.base[i]=size; sys.grow[i]=grow;
    const c=sys.geo.attributes.aColor.array;
    c[i*3]=color[0]; c[i*3+1]=color[1]; c[i*3+2]=color[2];
    sys.geo.attributes.aSize.array[i]=size;
    sys.geo.attributes.aAlpha.array[i]=alpha==null?1:alpha;
    sys.geo.attributes.aColor.needsUpdate=true;
  },
  updateParticles(sys,dt,gravity,drag){
    const p=sys.geo.attributes.position.array,
          a=sys.geo.attributes.aAlpha.array,
          s=sys.geo.attributes.aSize.array;
    for(let i=0;i<sys.max;i++){
      if(sys.life[i]<=0){ if(a[i]!==0) a[i]=0; continue; }
      sys.life[i]-=dt;
      if(sys.life[i]<=0){ a[i]=0; p[i*3+1]=-999; continue; }
      const k=sys.life[i]/sys.maxLife[i];     // 1 -> 0
      sys.vel[i*3+1]-=gravity*dt;
      const d=1-Math.min(1,drag*dt);
      sys.vel[i*3]*=d; sys.vel[i*3+1]*=d; sys.vel[i*3+2]*=d;
      p[i*3]+=sys.vel[i*3]*dt; p[i*3+1]+=sys.vel[i*3+1]*dt; p[i*3+2]+=sys.vel[i*3+2]*dt;
      s[i]=sys.base[i]*(1+sys.grow[i]*(1-k));
      a[i]=k<0.25? k*4 : Math.min(1,(1-k)*4)*0.85;
    }
    sys.geo.attributes.position.needsUpdate=true;
    sys.geo.attributes.aAlpha.needsUpdate=true;
    sys.geo.attributes.aSize.needsUpdate=true;
  },

  /* ---------------- skid marks ---------------- */
  buildSkids(scene){
    const max=340;
    const pos=new Float32Array(max*4*3), al=new Float32Array(max*4);
    const idx=new Uint16Array(max*6);
    for(let i=0;i<max;i++){
      const v=i*4, o=i*6;
      idx[o]=v; idx[o+1]=v+1; idx[o+2]=v+2; idx[o+3]=v+1; idx[o+4]=v+3; idx[o+5]=v+2;
    }
    const geo=new THREE.BufferGeometry();
    geo.setAttribute("position",new THREE.BufferAttribute(pos,3));
    geo.setAttribute("aAlpha",new THREE.BufferAttribute(al,1));
    geo.setIndex(new THREE.BufferAttribute(idx,1));
    const mat=new THREE.ShaderMaterial({
      transparent:true, depthWrite:false,
      polygonOffset:true, polygonOffsetFactor:-4, polygonOffsetUnits:-4,
      vertexShader:"attribute float aAlpha; varying float vA; uniform float uY;"+
        "void main(){ vA=aAlpha; vec4 mv=modelViewMatrix*vec4(position,1.0); gl_Position=projectionMatrix*mv; }",
      fragmentShader:"varying float vA; void main(){ if(vA<0.01) discard; gl_FragColor=vec4(0.055,0.05,0.045,vA); }"
    });
    const mesh=new THREE.Mesh(geo,mat);
    mesh.frustumCulled=false; mesh.renderOrder=1;
    scene.add(mesh);
    this.skid={mesh,geo,max,next:0,life:new Float32Array(max),cut:new Float32Array(max),
      fresh:new Uint8Array(max)};
  },
  addSkid(x,y,z,dx,dz,w){
    const s=this.skid, i=s.next; s.next=(s.next+1)%s.max;
    const len=1.5, hx=dx*len*0.5, hz=dz*len*0.5, px=-dz*w*0.5, pz=dx*w*0.5;
    const p=s.geo.attributes.position.array, o=i*12;
    p[o+0]=x-hx-px; p[o+1]=y; p[o+2]=z-hz-pz;
    p[o+3]=x-hx+px; p[o+4]=y; p[o+5]=z-hz+pz;
    p[o+6]=x+hx-px; p[o+7]=y; p[o+8]=z+hz-pz;
    p[o+9]=x+hx+px; p[o+10]=y; p[o+11]=z+hz+pz;
    s.life[i]=9.0; s.cut[i]=4.0; s.fresh[i]=1;
    this.skid.geo.attributes.position.needsUpdate=true;
  },
  updateSkids(dt){
    const s=this.skid, a=s.geo.attributes.aAlpha.array;
    let dirty=false;
    for(let i=0;i<s.max;i++){
      if(s.life[i]<=0){ if(a[i*4]!==0){ for(let q=0;q<4;q++) a[i*4+q]=0; dirty=true; } continue; }
      s.life[i]-=dt;
      const k=Math.max(0,s.life[i]/9.0);
      const v=k*k*0.62;
      for(let q=0;q<4;q++) a[i*4+q]=v;
      dirty=true;
    }
    if(dirty) s.geo.attributes.aAlpha.needsUpdate=true;
  },
  update(dt){
    this.updateParticles(this.dust,dt,2.4,0.9);
    this.updateParticles(this.sparks,dt,16,0.4);
    this.updateSkids(dt);
  },
  /* convenience emitters */
  dustPuff(x,y,z,n,col,spd,size,life){
    for(let i=0;i<n;i++){
      const a=Math.random()*6.283, r=Math.random()*spd;
      this.spawn(this.dust,x,y,z,Math.cos(a)*r,Math.random()*spd*0.7+0.3,Math.sin(a)*r,
        size*(0.7+Math.random()*0.7),life*(0.7+Math.random()*0.6),col,1.6,0.75);
    }
  },
  sparkBurst(x,y,z,n,power){
    for(let i=0;i<n;i++){
      const a=Math.random()*6.283, r=Math.random();
      this.spawn(this.sparks,x,y,z,Math.cos(a)*r*7*power,Math.random()*7*power,(Math.sin(a))*r*7*power,
        0.22+Math.random()*0.2,0.35+Math.random()*0.4,[1,0.72+Math.random()*0.25,0.3],-0.5,1);
    }
  },
  splash(x,y,z,n){
    for(let i=0;i<n;i++){
      const a=Math.random()*6.283, r=Math.random();
      this.spawn(this.dust,x,y+0.1,z,Math.cos(a)*r*3.4,2.6+Math.random()*3,Math.sin(a)*r*3.4,
        0.34+Math.random()*0.34,0.7,[0.85,0.93,1.0],1.4,0.8);
    }
  }
};
window.Rally.FX = FX;

/* ==================================================================== CAR
   Low-poly rally car + arcade-sim hybrid physics with slip, surface grip,
   handbrake drifting and obstacle bounces.
   ======================================================================== */
const Car = {
  root:null, chassis:null, wheels:[], headlights:[],
  x:0,y:0,z:0, yaw:0, vx:0,vz:0, steerAngle:0, wheelSpin:0,
  surf:"asphalt", onTrack:true, drift:0, slip:0, rpm:0.15, gear:1,
  impact:0, airTime:0, lastSkid:0, surfTime:0, hitFlash:0,
  WHEELBASE:2.7, ENGINE:23.5, BRAKE:30,

  build(scene){
    const g=new THREE.Group();
    const paint=new THREE.MeshPhongMaterial({color:0xe8352a,flatShading:true,shininess:26});
    const paint2=new THREE.MeshPhongMaterial({color:0xf5f7fa,flatShading:true,shininess:18});
    const glass=new THREE.MeshPhongMaterial({color:0x141a22,flatShading:true,shininess:70});
    const dark=new THREE.MeshPhongMaterial({color:0x1a1c20,flatShading:true,shininess:12});
    const rubber=new THREE.MeshPhongMaterial({color:0x141618,flatShading:true,shininess:4});
    const chrome=new THREE.MeshPhongMaterial({color:0xa8afb8,flatShading:true,shininess:60});
    const add=(geo,mat,x,y,z,parent)=>{
      const m=new THREE.Mesh(geo,mat); m.position.set(x,y,z);
      m.castShadow=true; (parent||g).add(m); return m;
    };
    const chassis=new THREE.Group(); g.add(chassis); this.chassis=chassis;

    /* body shell */
    add(new THREE.BoxGeometry(1.82,0.44,4.05),paint,0,0.62,0,chassis);
    add(new THREE.BoxGeometry(1.68,0.30,1.35),paint,0,0.90,1.28,chassis);      // hood
    add(new THREE.BoxGeometry(1.66,0.50,1.95),glass,0,1.04,-0.42,chassis);     // cabin
    add(new THREE.BoxGeometry(0.42,0.11,1.30),paint2,0,1.30,-0.55,chassis);   // roof stripe
    add(new THREE.BoxGeometry(1.90,0.26,0.34),dark,0,0.52,2.02,chassis);       // front bumper
    add(new THREE.BoxGeometry(1.90,0.24,0.30),dark,0,0.54,-2.02,chassis);      // rear bumper
    add(new THREE.BoxGeometry(0.30,0.34,3.9),dark,0.98,0.60,0,chassis);        // sills
    add(new THREE.BoxGeometry(0.30,0.34,3.9),dark,-0.98,0.60,0,chassis);
    /* spoiler */
    add(new THREE.BoxGeometry(1.84,0.08,0.46),dark,0,1.30,-2.00,chassis);
    add(new THREE.BoxGeometry(0.10,0.34,0.10),dark,0.72,1.12,-1.96,chassis);
    add(new THREE.BoxGeometry(0.10,0.34,0.10),dark,-0.72,1.12,-1.96,chassis);
    /* roof vents + mud flaps */
    add(new THREE.BoxGeometry(0.80,0.10,0.36),dark,0,1.38,0.55,chassis);
    /* lights */
    const head=new THREE.MeshBasicMaterial({color:0xfff6d0});
    const tail=new THREE.MeshBasicMaterial({color:0xff2b1a});
    [[0.62],[ -0.62]].forEach(([ox])=>{
      const h=add(new THREE.BoxGeometry(0.44,0.16,0.08),head,ox,0.80,2.06,chassis);
      this.headlights.push(h);
    });
    [[0.60],[-0.60]].forEach(([ox])=>{
      add(new THREE.BoxGeometry(0.40,0.14,0.07),tail,ox,0.84,-2.06,chassis);
    });
    /* number plates */
    add(new THREE.BoxGeometry(0.70,0.26,0.05),paint2,0,0.66,2.20,chassis);

    /* wheels: pivot (steer) -> spin mesh */
    const wheelGeo=new THREE.CylinderGeometry(0.44,0.44,0.34,12);
    wheelGeo.rotateZ(Math.PI/2);
    const rimGeo=new THREE.CylinderGeometry(0.26,0.26,0.36,8);
    rimGeo.rotateZ(Math.PI/2);
    const positions=[[1.00,1.35],[-1.00,1.35],[1.00,-1.42],[-1.00,-1.42]];
    positions.forEach(([ox,oz],i)=>{
      const pivot=new THREE.Group(); pivot.position.set(ox,0.46,oz); g.add(pivot);
      const tyre=new THREE.Mesh(wheelGeo,rubber); tyre.castShadow=true; pivot.add(tyre);
      const rim=new THREE.Mesh(rimGeo,chrome); pivot.add(rim);
      this.wheels.push({pivot,tyre,rim,front:i<2,side:Math.sign(ox)});
    });
    scene.add(g);
    this.root=g;
  },

  reset(){
    this.x=0;this.z=0;this.vx=0;this.vz=0;this.yaw=0;this.steerAngle=0;this.wheelSpin=0;
    this.drift=0;this.slip=0;this.rpm=0.15;this.gear=1;this.impact=0;this.hitFlash=0;
    const s=Track.samples[6];                 // a little behind the start line
    this.x=s.p.x; this.z=s.p.z; this.y=s.p.y+0.18;
    this.yaw=Math.atan2(s.t.x,s.t.z);
    this.trackIdx=6;
    this.updateTransform(0);
  },

  basis(yaw){ return {fx:Math.sin(yaw), fz:Math.cos(yaw), rx:Math.cos(yaw), rz:-Math.sin(yaw)}; },

  update(dt, inp, paused){
    const b0=this.basis(this.yaw);
    let vf=this.vx*b0.fx+this.vz*b0.fz;
    let vl=this.vx*b0.rx+this.vz*b0.rz;
    const speed=Math.hypot(this.vx,this.vz);
    const surf=SURFACES[this.surf];

    /* ---- steering: tight at low speed, calmer at high speed ---- */
    const steerMax=0.66*U.clamp(1-speed/70,0.30,1);
    const target=inp.steer*steerMax;
    this.steerAngle=U.damp(this.steerAngle,target,inp.steer?10:14,dt);

    /* ---- yaw rate, limited by the lateral grip available ---- */
    let yawRate=vf*this.steerAngle*0.11;
    const gripF=U.clamp(surf.grip/9,0.35,1.10);
    const maxYaw=(16*gripF/Math.max(Math.abs(vf),3.5))*(inp.handbrake?1.6:1);
    yawRate=U.clamp(yawRate,-maxYaw,maxYaw);
    yawRate+=U.clamp(vl*-0.02,-0.35,0.35);    // counter-steer feel
    this.yaw+=yawRate*dt;

    /* ---- slip: re-project world velocity into the new heading ---- */
    const b1=this.basis(this.yaw);
    vf=this.vx*b1.fx+this.vz*b1.fz;
    vl=this.vx*b1.rx+this.vz*b1.rz;

    /* ---- longitudinal forces ---- */
    const top=surf.top*(this.onTrack?1:0.94);
    if(!paused){
      if(inp.throttle>0 && !inp.handbrake){
        const f=this.ENGINE*surf.accel*(1-Math.pow(U.clamp(vf/top,0,1),1.35));
        vf+=f*dt;
      }
      if(inp.brake>0){
        if(vf>0.6) vf-=this.BRAKE*surf.accel*dt;
        else vf-=7.5*surf.accel*dt;           // reverse
      }
      if(inp.handbrake) vf-=18*dt;
    }
    const roll=surf.roll*0.42 + 0.012*Math.abs(vf);
    vf-=vf*roll*dt;
    if(vf>top) vf=U.damp(vf,top,3,dt);

    /* ---- lateral grip / drift ---- */
    let grip=surf.grip*(inp.handbrake?0.30:1);
    if(this.onTrack && this.surf==="asphalt") grip*=1.0;
    vl*=Math.exp(-grip*dt);
    vl-=vl*Math.sign(vl)*0.3*dt;

    /* ---- rebuild world velocity ---- */
    this.vx=b1.fx*vf+b1.rx*vl;
    this.vz=b1.fz*vf+b1.rz*vl;

    /* ---- integrate ---- */
    this.x+=this.vx*dt; this.z+=this.vz*dt;

    /* ---- ground query ---- */
    const info=Track.sampleAt(this.x,this.z,this.trackIdx);
    this.trackIdx=info.idx;
    const prevSurf=this.surf;
    this.surf=info.surf;
    this.onTrack=info.surf!=="grass";
    /* surfY = actual visible ground beneath the wheels (road ribbon height when
       on the ribbon, blended terrain otherwise) — used for FX placement too */
    const onRibbon=Math.abs(info.lat)<CFG.HALF_WIDTH+CFG.SHOULDER;
    this.surfY = onRibbon ? info.y+0.315 : World.terrainY(this.x,this.z);
    this.y=U.damp(this.y,this.surfY+0.055,16,dt);

    /* ---- collisions ---- */
    const speedBefore=Math.hypot(this.vx,this.vz);
    const obs=World.nearby(this.x,this.z,4.2);
    for(let i=0;i<obs.length;i++){
      const o=obs[i];
      const dx=this.x-o.x, dz=this.z-o.z;
      const d=Math.hypot(dx,dz), rr=o.r+CFG.CAR_HALF;
      if(d<rr && d>1e-4){
        const nx=dx/d, nz=dz/d;
        this.x=o.x+nx*rr; this.z=o.z+nz*rr;
        const vn=this.vx*nx+this.vz*nz;
        if(vn<0){
          this.vx-=1.5*vn*nx; this.vz-=1.5*vn*nz;
          const power=Math.min(1,-vn/26);
          this.vx*=0.66; this.vz*=0.66;
          if(power>0.12){
            this.impact=Math.max(this.impact,power);
            this.hitFlash=1;
            FX.sparkBurst(this.x+nx*1.0,this.y+0.6,this.z+nz*1.0,18+Math.floor(power*26),0.6+power);
            Audio.thud(power);
          }
        }
      }
    }

    /* ---- lateral acceleration & attitude for visuals ---- */
    const speedNow=Math.hypot(this.vx,this.vz);
    this.drift=U.clamp(Math.abs(vl)/(Math.abs(vf)+2.5),0,1);
    this.slip=Math.abs(vl);

    /* ---- surface transition puff ---- */
    this.surfTime+=dt;
    if(prevSurf!==this.surf && this.surfTime>0.3){
      this.surfTime=0;
      const col=this.dustColor(prevSurf);
      FX.dustPuff(this.x,this.y+0.1,this.z,7,col,2.4,0.7,0.55);
    }

    /* ---- gearbox simulation for HUD/audio ---- */
    const spd=Math.max(speedNow,Math.abs(vf));
    let gear, ratio;
    if(vf<-0.6){ gear=0; ratio=Math.min(1,Math.abs(vf)/12); }          // reverse
    else if(spd<0.6){ gear=1; ratio=0.12; }
    else {
      const gears=[0,13,25,38,50,64];
      gear=1; for(let i=1;i<=5;i++) if(spd>gears[i-1]) gear=i;
      const lo=gears[gear-1], hi=gears[gear];
      ratio=0.22+0.78*U.clamp((spd-lo)/(hi-lo),0,1);
    }
    this.gear=gear;
    const targetRpm=gear===0? U.clamp(Math.abs(vf)/12,0,1)*0.6 : ratio;
    this.rpm=U.damp(this.rpm,targetRpm,gear===0?5:9,dt);
    if(inp.throttle>0 && gear>0) this.rpm=Math.max(this.rpm,U.clamp(this.rpm+0.03,0,1));

    this.updateTransform(dt, yawRate, speedNow);
  },

  dustColor(surf){
    if(surf==="water") return [0.85,0.92,1.0];
    if(surf==="mud") return [0.32,0.24,0.16];
    if(surf==="grass") return [0.30,0.42,0.20];
    if(surf==="gravel") return [0.62,0.56,0.44];
    if(surf==="dirt") return [0.55,0.44,0.30];
    return [0.62,0.62,0.64];
  },

  updateTransform(dt, yawRate, speed){
    const g=this.root;
    if(!g) return;
    g.position.set(this.x,this.y,this.z);
    g.rotation.y=this.yaw;

    /* body roll & pitch from current dynamics (visual only) */
    const yawRateNow = yawRate||0;
    const lat = yawRateNow*(speed||0)*0.5;
    const rollT=U.clamp(-lat*0.012,-0.14,0.14);
    const pitchT=this.drift*0.05;
    this.chassis.rotation.z=U.damp(this.chassis.rotation.z,rollT,8,dt||0.016);
    this.chassis.rotation.x=U.damp(this.chassis.rotation.x,pitchT,8,dt||0.016);

    /* wheels: spin + steer */
    const spin=(this.vx*Math.sin(this.yaw)+this.vz*Math.cos(this.yaw))/0.44;
    this.wheelSpin+=spin*(dt||0);
    for(const w of this.wheels){
      if(w.front) w.pivot.rotation.y=this.steerAngle*0.9;
      w.tyre.rotation.x=this.wheelSpin;
      w.rim.rotation.x=this.wheelSpin;
    }
    /* brake lights when braking */
    if(this.hitFlash>0) this.hitFlash=Math.max(0,this.hitFlash-(dt||0.016)*3);
  },

  speedKmh(){ return Math.hypot(this.vx,this.vz)*3.6; },
  forward(){ return {x:Math.sin(this.yaw), z:Math.cos(this.yaw)}; },
  worldWheel(i){ const w=this.wheels[i]; const b=this.basis(this.yaw);
    return {x:this.x+b.fx*w.pivot.position.z+b.rx*w.pivot.position.x,
            y:this.y+0.1,
            z:this.z+b.fz*w.pivot.position.z+b.rz*w.pivot.position.x}; }
};
window.Rally.Car = Car;

/* =================================================================== HUD */
const HUD = {
  els:{}, gctx:null, mctx:null, mmBase:null, mmScale:1, mmOff:{x:0,y:0}, toastT:0,

  init(){
    const $=id=>document.getElementById(id);
    this.els={lap:$("lapValue"),time:$("timeValue"),last:$("lastValue"),best:$("bestValue"),
      countdown:$("countdown"),toast:$("toast")};
    this.gctx=this.setup($("gauge"),250,152);
    this.mctx=this.setup($("minimap"),180,180);
    this.buildMinimap();
  },
  setup(canvas,w,h){
    const ctx=canvas.getContext("2d");
    ctx.scale(canvas.width/w, canvas.height/h);
    return ctx;
  },
  setTiming(lap,laps,time,last,best){
    this.els.lap.textContent=Math.min(lap,laps)+" / "+laps;
    this.els.time.textContent=U.fmt(time);
    this.els.last.textContent=U.fmtShort(last);
    this.els.best.textContent=U.fmtShort(best);
  },
  toast(msg,cls,dur){
    const t=this.els.toast;
    t.textContent=msg;
    t.className="show"+(cls?" "+cls:"");
    this.toastT=dur||1.8;
  },
  updateToast(dt){
    if(this.toastT>0){ this.toastT-=dt; if(this.toastT<=0) this.els.toast.className=""; }
  },
  showCountdown(text,isGo){
    const c=this.els.countdown;
    c.textContent=text;
    c.className="";
    void c.offsetWidth;                    // restart the CSS animation
    c.className="show"+(isGo?" go":"");
  },
  hideCountdown(){ this.els.countdown.className=""; },

  /* ------------- analogue speedo + rpm ring + gear ------------- */
  drawGauge(kmh,rpmN,gear,surfName,offTrack){
    const ctx=this.gctx, W=250,H=152;
    const cx=125, cy=141, R=110;
    const a0=Math.PI, a1=Math.PI*2, MAXK=240;
    ctx.clearRect(0,0,W,H);
    /* dial plate — half disc */
    ctx.beginPath();
    ctx.moveTo(cx-R-13,cy);
    ctx.arc(cx,cy,R+13,Math.PI,0);
    ctx.closePath();
    ctx.fillStyle="rgba(9,13,19,0.62)"; ctx.fill();
    ctx.strokeStyle="rgba(255,255,255,0.16)"; ctx.lineWidth=1.2; ctx.stroke();

    /* rpm ring */
    ctx.beginPath(); ctx.arc(cx,cy,R,a0,a1);
    ctx.lineWidth=9; ctx.strokeStyle="rgba(255,255,255,0.13)"; ctx.lineCap="round"; ctx.stroke();
    const grad=ctx.createLinearGradient(cx-R,0,cx+R,0);
    grad.addColorStop(0,"#6ee7a0"); grad.addColorStop(.55,"#ffd23f"); grad.addColorStop(1,"#ff4d3d");
    ctx.beginPath(); ctx.arc(cx,cy,R,a0,a0+(a1-a0)*U.clamp(rpmN,0,1));
    ctx.lineWidth=9; ctx.strokeStyle=rpmN>0.92?"#ff4d3d":grad; ctx.stroke();
    ctx.lineCap="butt";

    /* ticks + numbering */
    ctx.textAlign="center"; ctx.textBaseline="middle";
    for(let k=0;k<=MAXK;k+=20){
      const a=a0+(a1-a0)*(k/MAXK), c1=Math.cos(a), s1=Math.sin(a);
      const big=(k%40===0);
      ctx.beginPath();
      ctx.moveTo(cx+c1*(R-13),cy+s1*(R-13));
      ctx.lineTo(cx+c1*(R-(big?27:20)),cy+s1*(R-(big?27:20)));
      ctx.lineWidth=big?2:1;
      ctx.strokeStyle=big?"rgba(230,240,250,0.85)":"rgba(230,240,250,0.30)";
      ctx.stroke();
      if(big && k<MAXK){
        ctx.fillStyle="rgba(200,215,230,0.85)";
        ctx.font="600 10px ui-monospace,monospace";
        ctx.fillText(k, cx+c1*(R-34), cy+s1*(R-34));
      }
    }
    /* needle */
    const na=a0+(a1-a0)*U.clamp(kmh/MAXK,0,1);
    ctx.save();
    ctx.translate(cx,cy); ctx.rotate(na);
    ctx.beginPath();
    ctx.moveTo(-8,-3.2); ctx.lineTo(R-30,-1.2); ctx.lineTo(R-30,1.2); ctx.lineTo(-8,3.2);
    ctx.closePath();
    ctx.fillStyle="#ff4d3d"; ctx.shadowColor="rgba(255,77,61,0.9)"; ctx.shadowBlur=9; ctx.fill();
    ctx.restore();
    ctx.shadowBlur=0;
    ctx.beginPath(); ctx.arc(cx,cy,7,0,6.283); ctx.fillStyle="#0f151d"; ctx.fill();
    ctx.strokeStyle="rgba(255,255,255,0.3)"; ctx.lineWidth=1.5; ctx.stroke();

    /* digital plate (also hides the needle where the digits sit) */
    ctx.fillStyle="rgba(8,12,18,0.88)";
    this.roundRect(ctx,cx-45,cy-68,90,48,10); ctx.fill();
    ctx.strokeStyle="rgba(255,255,255,0.12)"; ctx.lineWidth=1; ctx.stroke();
    ctx.fillStyle="#ffffff"; ctx.font="800 31px ui-monospace,monospace";
    ctx.fillText(String(Math.round(kmh)), cx, cy-48);
    ctx.fillStyle="rgba(170,190,210,0.95)"; ctx.font="700 9px system-ui,sans-serif";
    ctx.fillText("KM / H", cx, cy-28);
    /* surface readout */
    ctx.fillStyle=offTrack?"#ff9d8a":"rgba(165,185,205,0.95)";
    ctx.font="700 9.5px system-ui,sans-serif";
    ctx.fillText(surfName, cx, cy-15);
    /* gear */
    const gx=cx+76, gy=cy-25;
    ctx.fillStyle="rgba(12,18,26,0.95)"; this.roundRect(ctx,gx-19,gy-22,38,44,9); ctx.fill();
    ctx.strokeStyle="rgba(255,255,255,0.22)"; ctx.lineWidth=1.2; ctx.stroke();
    ctx.fillStyle=gear===0?"#ff9d3f":"#ffd23f"; ctx.font="900 26px ui-monospace,monospace";
    ctx.fillText(gear===0?"R":String(gear), gx, gy+1);
  },
  roundRect(ctx,x,y,w,h,r){
    ctx.beginPath();
    ctx.moveTo(x+r,y); ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r);
    ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath();
  },

  /* ------------- minimap ------------- */
  buildMinimap(){
    const S=Track.samples;
    let minX=1e9,maxX=-1e9,minZ=1e9,maxZ=-1e9;
    for(const s of S){
      minX=Math.min(minX,s.p.x); maxX=Math.max(maxX,s.p.x);
      minZ=Math.min(minZ,s.p.z); maxZ=Math.max(maxZ,s.p.z);
    }
    const pad=16, W=180,H=180;
    const sc=Math.min((W-pad*2)/(maxX-minX),(H-pad*2)/(maxZ-minZ));
    this.mmScale=sc;
    this.mmOff={x:(W-(maxX+minX)*sc)/2, y:(H-(maxZ+minZ)*sc)/2};
    const c=document.createElement("canvas"); c.width=360; c.height=360;
    const ctx=c.getContext("2d"); ctx.scale(2,2);
    /* track ribbon */
    ctx.beginPath();
    S.forEach((s,i)=>{
      const x=s.p.x*sc+this.mmOff.x, y=s.p.z*sc+this.mmOff.y;
      if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
    });
    ctx.closePath();
    ctx.lineJoin="round"; ctx.lineCap="round";
    ctx.strokeStyle="rgba(255,255,255,0.10)"; ctx.lineWidth=11; ctx.stroke();
    ctx.strokeStyle="#4a5a6c"; ctx.lineWidth=8; ctx.stroke();
    ctx.strokeStyle="#cdd8e4"; ctx.lineWidth=3.2; ctx.stroke();
    /* checkpoints */
    Track.gates.forEach((g,i)=>{
      const s=S[g.idx];
      const x=s.p.x*sc+this.mmOff.x, y=s.p.z*sc+this.mmOff.y;
      ctx.fillStyle=i===0?"#ffd23f":"#5ad1ff";
      ctx.beginPath(); ctx.arc(x,y,i===0?4.5:3,0,6.283); ctx.fill();
    });
    this.mmBase=c;
  },
  drawMinimap(car){
    const ctx=this.mctx;
    ctx.clearRect(0,0,180,180);
    ctx.fillStyle="rgba(8,13,20,0.55)";
    this.roundRect(ctx,0,0,180,180,10); ctx.fill();
    ctx.drawImage(this.mmBase,0,0,180,180);
    /* car marker */
    const x=car.x*this.mmScale+this.mmOff.x, y=car.z*this.mmScale+this.mmOff.y;
    ctx.save();
    ctx.translate(x,y);
    ctx.rotate(-car.yaw+Math.PI);
    ctx.beginPath(); ctx.moveTo(0,-7); ctx.lineTo(5,5); ctx.lineTo(0,2.5); ctx.lineTo(-5,5); ctx.closePath();
    ctx.fillStyle="#ff4d3d"; ctx.fill();
    ctx.strokeStyle="rgba(255,255,255,0.9)"; ctx.lineWidth=1.2; ctx.stroke();
    ctx.restore();
  }
};
window.Rally.HUD = HUD;

/* =============================================================== CAMERA */
const CameraRig = {
  cam:null, pos:new THREE.Vector3(), look:new THREE.Vector3(), camYaw:0,
  mode:0, shake:0, baseFov:62, wantPos:new THREE.Vector3(), wantLook:new THREE.Vector3(),
  init(cam){ this.cam=cam; this.pos.set(0,8,-14); this.look.set(0,1,0); },
  toggle(){ this.mode=(this.mode+1)%2; },
  addShake(v){ this.shake=Math.min(1.2,this.shake+v); },
  update(dt, car){
    const speed=Math.hypot(car.vx,car.vz);
    if(this.mode===0){
      /* chase: yaw lags behind the car -> drift reads on screen */
      this.camYaw=U.angleLerp(this.camYaw, car.yaw, 1-Math.exp(-(3.6+speed*0.045)*dt));
      const back=7.0+speed*0.055, up=2.55+speed*0.014;
      const fx=Math.sin(this.camYaw), fz=Math.cos(this.camYaw);
      this.wantPos.set(car.x-fx*back, car.y+up, car.z-fz*back);
      this.wantLook.set(car.x+fx*(6+speed*0.12), car.y+1.15, car.z+fz*(6+speed*0.12));
    } else {
      /* cinematic: slow high orbit */
      const t=performance.now()*0.00022;
      const R=20;
      this.wantPos.set(car.x+Math.sin(t)*R, car.y+13+Math.sin(t*1.7)*3, car.z+Math.cos(t)*R);
      this.wantLook.set(car.x,car.y+1.2,car.z);
    }
    const pRate=this.mode===0?9:3.2, lRate=this.mode===0?12:4;
    this.pos.x=U.damp(this.pos.x,this.wantPos.x,pRate,dt);
    this.pos.y=U.damp(this.pos.y,this.wantPos.y,pRate*1.1,dt);
    this.pos.z=U.damp(this.pos.z,this.wantPos.z,pRate,dt);
    this.look.x=U.damp(this.look.x,this.wantLook.x,lRate,dt);
    this.look.y=U.damp(this.look.y,this.wantLook.y,lRate,dt);
    this.look.z=U.damp(this.look.z,this.wantLook.z,lRate,dt);

    /* impact shake */
    this.shake=Math.max(0,this.shake-dt*1.9);
    const sh=this.shake*this.shake;
    this.cam.position.copy(this.pos);
    if(sh>0.0001){
      this.cam.position.x+=(Math.random()-0.5)*sh*1.5;
      this.cam.position.y+=(Math.random()-0.5)*sh*1.1;
      this.cam.position.z+=(Math.random()-0.5)*sh*1.5;
    }
    /* keep the car in frame, slightly under the horizon */
    this.cam.lookAt(this.look.x,this.look.y+(sh*(Math.random()-0.5)*1.2),this.look.z);
    const fov=this.mode===0? this.baseFov+U.clamp(speed*0.26,0,14) : 52;
    this.cam.fov=U.damp(this.cam.fov,fov,3,dt);
    this.cam.updateProjectionMatrix();
  }
};
window.Rally.CameraRig = CameraRig;

/* =================================================================== GAME
   State machine, race/lap logic, the main loop and event wiring.
   ======================================================================== */
const Game = {
  renderer:null, scene:null, camera:null,
  state:"menu", prelim:null,
  prevIdx:6, skidTimer:0, dustTimer:0, cdShown:null, cdT:0,
  race:null, started:false,

  init(){
    const canvas=document.getElementById("viewport");
    if(typeof THREE==="undefined"){
      document.getElementById("bootErr").textContent =
        "Three.js failed to load. This game streams the library from a CDN — connect to the internet and reload.";
      document.getElementById("btnStart").disabled=true;
      document.getElementById("btnStart").style.opacity=.4;
      return;
    }
    const r=new THREE.WebGLRenderer({canvas,antialias:true,powerPreference:"high-performance"});
    r.setPixelRatio(Math.min(window.devicePixelRatio||1,2));
    r.setSize(window.innerWidth,window.innerHeight,false);
    r.shadowMap.enabled=true;
    r.shadowMap.type=THREE.PCFSoftShadowMap;
    this.renderer=r;

    const scene=new THREE.Scene();
    scene.fog=new THREE.Fog(0xbdd8ea,150,680);
    this.scene=scene;

    this.camera=new THREE.PerspectiveCamera(62,window.innerWidth/window.innerHeight,0.35,1800);
    this.camera.position.set(0,6,-14);

    /* build the world */
    Track.build();
    scene.add(Track.group);
    World.build(scene);
    Car.build(scene);
    FX.init(scene,r.getPixelRatio());

    HUD.init();
    CameraRig.init(this.camera);
    Input.init();
    Input.onAction=k=>this.onKey(k);
    addEventListener("resize",()=>this.resize());
    document.addEventListener("visibilitychange",()=>{ if(document.hidden&&this.state==="race") this.pause(); });

    /* UI wiring */
    const $=id=>document.getElementById(id);
    $("btnStart").addEventListener("click",()=>this.startRace());
    $("btnResume").addEventListener("click",()=>this.resume());
    $("btnRestartPause").addEventListener("click",()=>this.startRace());
    $("btnRestartFin").addEventListener("click",()=>this.startRace());

    Car.reset();
    this.race=this.freshRace();
    CameraRig.camYaw=Car.yaw;
    CameraRig.pos.set(Car.x-Math.sin(Car.yaw)*8,Car.y+3,Car.z-Math.cos(Car.yaw)*8);
    CameraRig.look.set(Car.x,Car.y+1,Car.z);

    this.started=true;
    this.lastT=performance.now();
    this.loop();
  },

  freshRace(){
    return {lap:1,laps:CFG.LAPS,time:0,lapStart:0,last:null,best:null,times:[],finished:false};
  },
  resetGates(){ Track.gates.forEach(g=>g.hit=false); },

  /* ---------------- state transitions ---------------- */
  startRace(){
    Audio.init(); Audio.resume();
    Car.reset();
    this.race=this.freshRace();
    this.resetGates();
    this.prevIdx=Car.trackIdx;
    this.state="countdown"; this.cdT=3.35; this.cdShown=null;
    this.skidTimer=0; this.dustTimer=0;
    CameraRig.camYaw=Car.yaw;
    CameraRig.pos.set(Car.x-Math.sin(Car.yaw)*8,Car.y+3,Car.z-Math.cos(Car.yaw)*8);
    CameraRig.look.set(Car.x,Car.y+1,Car.z);
    CameraRig.shake=0;
    HUD.hideCountdown();
    document.getElementById("overlayStart").classList.add("hidden");
    document.getElementById("overlayFinish").classList.add("hidden");
    document.getElementById("overlayPause").classList.add("hidden");
  },
  pause(){
    if(this.state!=="race"&&this.state!=="countdown") return;
    this.prelim=this.state; this.state="paused";
    document.getElementById("overlayPause").classList.remove("hidden");
    Audio.quiet();
  },
  resume(){
    if(this.state!=="paused") return;
    this.state=this.prelim||"race";
    document.getElementById("overlayPause").classList.add("hidden");
  },
  finishRace(){
    this.state="finish";
    const r=this.race; r.finished=true;
    Audio.quiet(); Audio.finishJingle();
    document.getElementById("finTotal").textContent=U.fmt(r.time);
    document.getElementById("finBest").textContent=U.fmt(r.best);
    document.getElementById("finishSub").textContent="TOTAL RACE TIME  ·  "+r.times.length+" LAPS";
    const list=document.getElementById("lapList");
    list.innerHTML="";
    r.times.forEach((t,i)=>{
      const d=document.createElement("div"); d.className="li";
      const isBest=(t===r.best);
      d.innerHTML="<span>LAP "+(i+1)+"</span><span style='color:"+(isBest?"#8ef0a8":"#dfe9f3")+"'>"+
        U.fmt(t)+(isBest?"  ★":"")+"</span>";
      list.appendChild(d);
    });
    document.getElementById("overlayFinish").classList.remove("hidden");
  },
  onKey(k){
    if(k==="r"){ if(this.state!=="menu") this.startRace(); }
    else if(k==="p"||k==="escape"){ if(this.state==="paused") this.resume(); else this.pause(); }
    else if(k==="c"){ CameraRig.toggle(); }
    else if(k==="m"){ Audio.init(); Audio.setMuted(!Audio.muted); HUD.toast(Audio.muted?"AUDIO MUTED":"AUDIO ON"); }
  },
  resize(){
    if(!this.renderer) return;
    const w=window.innerWidth,h=window.innerHeight;
    this.camera.aspect=w/h; this.camera.updateProjectionMatrix();
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio||1,2));
    this.renderer.setSize(w,h,false);
    FX.systems.forEach(s=>{ s.pts.material.uniforms.uPR.value=this.renderer.getPixelRatio(); });
  },

  /* ---------------- lap / checkpoint logic ---------------- */
  checkProgress(dt){
    const N=Track.samples.length, idx=Car.trackIdx, r=this.race;
    for(let k=1;k<Track.gates.length;k++){
      const g=Track.gates[k];
      let dd=Math.abs(idx-g.idx); dd=Math.min(dd,N-dd);
      if(dd<13) g.hit=true;
    }
    const prev=this.prevIdx;
    if(prev>N*0.72 && idx<N*0.28){
      const allPassed=Track.gates.slice(1).every(g=>g.hit);
      if(allPassed && !r.finished && r.time>3000){
        const lapTime=r.time-r.lapStart;
        r.lapStart=r.time; r.last=lapTime; r.times.push(lapTime);
        let isBest=false;
        if(r.best==null||lapTime<r.best){ r.best=lapTime; isBest=true; }
        if(r.lap>=r.laps){ this.finishRace(); }
        else{
          HUD.toast("LAP "+r.lap+"  ·  "+U.fmt(lapTime)+(isBest?"   ★ BEST":""),isBest?"fast":"");
          r.lap++; this.resetGates();
        }
      }
    }
    this.prevIdx=idx;
  },

  /* ---------------- particles / skid marks ---------------- */
  updateEffects(dt){
    const car=Car, speed=Math.hypot(car.vx,car.vz);
    if(speed<2.5) return;
    const slipping=Math.abs(car.slip)>3.0 || (car.inp&&car.inp.handbrake&&speed>5);
    const offRoad=!car.onTrack;
    const onWater=car.surf==="water";
    this.skidTimer-=dt; this.dustTimer-=dt;

    if((slipping||offRoad) && this.skidTimer<=0 && !onWater){
      this.skidTimer=0.026;
      const f=car.forward();
      for(const i of [2,3]){
        const w=car.worldWheel(i);
        const gy=car.surfY+0.035;
        FX.addSkid(w.x,gy,w.z,f.x,f.z,onWater?0.3:0.34);
      }
    }
    if((slipping||offRoad) && this.dustTimer<=0){
      this.dustTimer=0.05;
      const col=car.dustColor(car.surf);
      const spread=offRoad?1.5:0.9;
      for(const i of [2,3]){
        const w=car.worldWheel(i);
        const f=car.forward();
        FX.spawn(FX.dust,w.x,car.surfY+0.25,w.z,
          -f.x*speed*0.15+(Math.random()-0.5)*spread, 0.6+Math.random()*0.8,
          -f.z*speed*0.15+(Math.random()-0.5)*spread,
          (0.40+car.drift*0.65+speed*0.008), 0.9+Math.random()*0.7, col, 1.5, 0.6);
      }
    }
    if(onWater && speed>6 && this.dustTimer<=0){
      FX.splash(car.x,car.surfY,car.z,4);
    }
  },

  /* ---------------- main loop ---------------- */
  loop(){
    requestAnimationFrame(()=>this.loop());
    if(!this.started) return;
    const now=performance.now();
    let dt=(now-this.lastT)/1000;
    this.lastT=now;
    if(!isFinite(dt)||dt<0) dt=0;
    dt=Math.min(dt,0.05);
    this.update(dt);
    this.render();
  },

  update(dt){
    const st=this.state;
    /* --- input --- */
    const inp=Input.read();
    if(st==="countdown"){
      inp.throttle=0; inp.brake=0; inp.handbrake=false;
    } else if(st!=="race"){
      inp.throttle=0; inp.brake=0; inp.steer=0; inp.handbrake=false;
    }
    Car.inp=inp;

    /* --- countdown --- */
    if(st==="countdown"){
      this.cdT-=dt;
      const n=Math.max(0,Math.min(3,Math.ceil(this.cdT)));
      if(this.cdShown!==n){
        this.cdShown=n;
        if(n>0){ HUD.showCountdown(String(n),false); Audio.beep(660,0.16,0.24,"square"); }
        else { HUD.showCountdown("GO!",true); Audio.beep(1040,0.5,0.26,"triangle"); }
      }
      if(this.cdT<=0){
        this.state="race"; this.race.time=0; this.race.lapStart=0; this.prevIdx=Car.trackIdx;
      }
      Audio.engine(0.35,0.6,0);
    }

    /* --- physics (fixed-ish sub steps) --- */
    if(st==="race"){
      let remaining=dt;
      let guard=0;
      while(remaining>0.0001 && guard<4){
        const step=Math.min(remaining,1/60);
        Car.update(step,inp,false);
        remaining-=step; guard++;
      }
      this.race.time+=dt*1000;              // race clock kept in milliseconds
      this.checkProgress(dt);
      this.updateEffects(dt);
    } else if(st==="countdown"){
      Car.update(dt,{throttle:0,brake:0,steer:inp.steer,handbrake:false},true);
    } else {
      Car.updateTransform(0.016,null,0);
    }

    FX.update(dt);

    /* --- camera --- */
    CameraRig.update(dt,Car);
    if(Car.impact>0){ CameraRig.addShake(Car.impact*0.85); Car.impact=0; }

    /* --- audio --- */
    if(st==="race"){
      const load=(inp.throttle>0?1:0)*0.8;
      Audio.engine(Car.rpm,load,Math.hypot(Car.vx,Car.vz));
      const spd=Math.hypot(Car.vx,Car.vz);
      const slipAmt=U.clamp(Math.abs(Car.slip)/14,0,1);
      const offAmt=!Car.onTrack?U.clamp(spd/26,0,1):0;
      let kind = !Car.onTrack? Car.surf : (slipAmt>0.25? "skid":"none");
      if(Car.surf==="water") kind="water";
      Audio.tyres(Math.max(slipAmt*0.8,offAmt),kind,spd);
    }

    /* --- world follows --- */
    const car=Car;
    World.sky.position.copy(this.camera.position);
    World.sun.position.set(car.x+62,car.y+108,car.z+38);
    World.sun.target.position.set(car.x,car.y,car.z);
    World.sun.target.updateMatrixWorld();

    /* --- HUD --- */
    const r=this.race;
    HUD.setTiming(r.lap,r.laps,
      st==="race"? r.time-r.lapStart : (st==="finish"? r.best : 0),
      r.last,r.best);
    HUD.drawGauge(car.speedKmh(),car.rpm,car.gear,
      (car.onTrack?"":"OFF TRACK · ")+SURFACES[car.surf].name, !car.onTrack);
    HUD.drawMinimap(car);
    HUD.updateToast(dt);
  },

  render(){
    this.renderer.render(this.scene,this.camera);
  }
};
window.Rally.Game = Game;

/* boot once the first paint has happened so the menu shows instantly */
requestAnimationFrame(()=>Game.init());
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"36b7d832-671d-4188-9e34-b0791122602e","tokensIn":13809635,"tokensOut":108809,"tokensTotal":13918444,"cost":0.144981786,"turns":84,"toolCalls":83,"failedToolCalls":2,"timestamp":"2026-09-11T00:09:51.485Z"} -->