← Rally results

Rally

Deepseek v4 Flash Vision Exp · 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 62,087 bytes · SHA-256 3e34215c7ff9
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Rally Rush 3D</title>
<style>
  :root{
    --accent:#ff5a1f;
    --accent2:#ffd21f;
    --dark:rgba(10,14,22,.72);
    --panel:rgba(12,18,28,.82);
  }
  *{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
  html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#0a0e16;
    font-family:'Segoe UI',system-ui,-apple-system,Helvetica,Arial,sans-serif;
    user-select:none;-webkit-user-select:none;touch-action:none;}
  #gameCanvas{position:fixed;inset:0;display:block;width:100%;height:100%;}

  /* ---------- HUD ---------- */
  #hud{position:fixed;inset:0;pointer-events:none;z-index:5;color:#eef3fa;
    text-shadow:0 2px 6px rgba(0,0,0,.55);}
  .hud-card{background:var(--panel);border:1px solid rgba(255,255,255,.08);
    border-radius:14px;backdrop-filter:blur(6px);box-shadow:0 6px 22px rgba(0,0,0,.35);}
  #raceInfo{position:absolute;top:14px;left:14px;padding:10px 14px;min-width:178px;}
  #raceInfo .row{display:flex;justify-content:space-between;align-items:baseline;gap:14px;}
  #raceInfo .label{font-size:11px;letter-spacing:1.2px;color:#9fb1c8;text-transform:uppercase;}
  #raceInfo .val{font-weight:700;font-variant-numeric:tabular-nums;}
  #lapVal{font-size:15px;}
  #timeVal{font-size:19px;color:#fff;}
  #bestVal{font-size:15px;color:var(--accent2);}
  #speedPanel{position:absolute;right:16px;bottom:16px;width:190px;padding:12px 14px;text-align:center;}
  #speedometer{height:64px;width:100%;}
  #speedVal{font-size:30px;font-weight:800;line-height:1;margin-top:2px;font-variant-numeric:tabular-nums;}
  #speedUnit{font-size:11px;letter-spacing:2px;color:#9fb1c8;margin-top:2px;}
  #gearBox{position:absolute;right:16px;top:14px;padding:8px 14px;text-align:center;min-width:86px;}
  #gearVal{font-size:30px;font-weight:900;color:var(--accent2);}
  #gearLbl{font-size:10px;letter-spacing:2px;color:#9fb1c8;}
  #rpmBar{height:8px;width:100%;margin-top:5px;border-radius:6px;background:rgba(255,255,255,.12);overflow:hidden;}
  #rpmFill{height:100%;width:0%;border-radius:6px;background:linear-gradient(90deg,#3ad26f,#ffd21f 65%,#ff4d3d);
    transition:width .06s linear;}
  #lapMsg{position:absolute;top:26%;left:50%;transform:translateX(-50%);font-size:38px;
    font-weight:900;color:var(--accent2);opacity:0;transition:opacity .4s,transform .4s;letter-spacing:2px;}
  #lapMsg.show{opacity:1;transform:translateX(-50%) scale(1.08);}

  /* ---------- minimap ---------- */
  #minimapWrap{position:absolute;top:14px;right:16px;text-align:right;pointer-events:none;}
  #minimapTitle{font-size:10px;letter-spacing:2px;color:#9fb1c8;margin-bottom:4px;text-transform:uppercase;}
  #minimap{border-radius:10px;border:1px solid rgba(255,255,255,.12);background:rgba(8,12,20,.5);}

  /* ---------- overlays ---------- */
  .overlay{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;
    background:rgba(3,6,12,.66);backdrop-filter:blur(5px);flex-direction:column;
    opacity:0;pointer-events:none;transition:opacity .3s;}
  .overlay.show{opacity:1;pointer-events:auto;}
  #countdown .cd{font-size:clamp(70px,22vw,200px);font-weight:900;color:#fff;letter-spacing:4px;
    text-shadow:0 0 40px rgba(255,255,255,.5);transform:scale(.6);opacity:0;}
  #countdown .cd.tick{animation:cdPop .9s ease forwards;}
  @keyframes cdPop{0%{transform:scale(.6);opacity:0;}18%{transform:scale(1.12);opacity:1;}
    70%{transform:scale(1);opacity:1;}100%{transform:scale(.85);opacity:0;}}
  #countdown .sub{font-size:clamp(16px,4vw,32px);font-weight:800;color:var(--accent);letter-spacing:3px;margin-top:6px;}

  #pauseMenu{flex-direction:column;gap:16px;}
  .menuTitle{font-size:clamp(30px,7vw,56px);font-weight:900;color:#fff;letter-spacing:3px;margin-bottom:6px;}
  .btn{pointer-events:auto;cursor:pointer;border:none;outline:none;font-weight:800;font-size:18px;
    padding:14px 34px;border-radius:12px;color:#fff;background:linear-gradient(135deg,var(--accent),#ff8a2e);
    box-shadow:0 8px 24px rgba(255,90,31,.4);transition:transform .12s,box-shadow .12s;min-width:220px;
    font-family:inherit;letter-spacing:1px;}
  .btn:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(255,90,31,.55);}
  .btn:active{transform:translateY(1px);}
  .btn.ghost{background:rgba(255,255,255,.1);box-shadow:none;border:1px solid rgba(255,255,255,.25);}
  .btn.ghost:hover{background:rgba(255,255,255,.2);}
  #finishScreen .finTitle{font-size:clamp(34px,8vw,68px);font-weight:900;color:var(--accent2);
    text-shadow:0 0 34px rgba(255,210,31,.5);letter-spacing:2px;margin-bottom:4px;}
  #finishScreen .finSub{font-size:clamp(15px,3vw,22px);color:#cdd9ea;margin-bottom:24px;}
  #finishStats{display:flex;gap:26px;margin-bottom:30px;flex-wrap:wrap;justify-content:center;}
  #finishStats .stat{text-align:center;background:var(--panel);padding:16px 24px;border-radius:14px;
    border:1px solid rgba(255,255,255,.1);min-width:150px;}
  #finishStats .stat .lbl{font-size:11px;letter-spacing:2px;color:#9fb1c8;text-transform:uppercase;}
  #finishStats .stat .num{font-size:30px;font-weight:900;color:#fff;font-variant-numeric:tabular-nums;margin-top:6px;}
  #finishStats .stat .num.gold{color:var(--accent2);}

  /* ---------- touch controls ---------- */
  #touch{position:fixed;inset:0;z-index:10;pointer-events:none;display:none;}
  @media (pointer:coarse),(max-width:820px){
    #touch{display:block;}
    #speedPanel,#gearBox,#raceInfo{transform:scale(.86);transform-origin:top right;}
  }
  .tbtn{position:absolute;pointer-events:auto;display:flex;align-items:center;justify-content:center;
    border-radius:50%;background:rgba(20,28,42,.55);border:2px solid rgba(255,255,255,.25);
    color:#fff;font-weight:900;font-size:22px;backdrop-filter:blur(4px);touch-action:none;
    box-shadow:0 6px 16px rgba(0,0,0,.35);}
  .tbtn:active{background:rgba(255,90,31,.65);}
  #steerLeft{left:16px;bottom:78px;width:86px;height:86px;}
  #steerRight{left:118px;bottom:78px;width:86px;height:86px;}
  #btnGas{right:16px;bottom:96px;width:96px;height:96px;background:rgba(70,170,60,.45);}
  #btnGas.active{background:rgba(90,200,70,.7);}
  #btnBrake{right:122px;bottom:70px;width:78px;height:78px;background:rgba(200,60,60,.45);}
  #btnHand{right:16px;bottom:210px;width:70px;height:70px;background:rgba(255,170,40,.4);font-size:15px;}
  #btnGas span,#btnBrake span,#btnHand span{font-size:12px;letter-spacing:1px;}
  #touch .txtHint{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);color:#fff;
    opacity:.0;font-size:13px;letter-spacing:2px;text-align:center;pointer-events:none;line-height:1.5;}
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>

<!-- Touch controls -->
<div id="touch">
  <div class="tbtn" id="steerLeft">◀</div>
  <div class="tbtn" id="steerRight">▶</div>
  <div class="tbtn" id="btnGas"><span>GAS</span></div>
  <div class="tbtn" id="btnBrake"><span>BRAKE</span></div>
  <div class="tbtn" id="btnHand"><span>DRIFT</span></div>
  <div class="txtHint">◀ ▶ steer &nbsp;•&nbsp; GAS / BRAKE / DRIFT</div>
</div>

<!-- HUD -->
<div id="hud">
  <div id="raceInfo" class="hud-card">
    <div class="row"><span class="label">Lap</span><span class="val" id="lapVal">1 / 3</span></div>
    <div class="row"><span class="label">Time</span><span class="val" id="timeVal">0:00.00</span></div>
    <div class="row"><span class="label">Best</span><span class="val" id="bestVal">--:--.--</span></div>
    <div class="row"><span class="label">Check</span><span class="val" id="checkVal">0 / 6</span></div>
  </div>
  <div id="gearBox" class="hud-card">
    <div id="gearVal">N</div>
    <div id="gearLbl">GEAR</div>
    <div id="rpmBar"><div id="rpmFill"></div></div>
  </div>
  <div id="speedPanel" class="hud-card">
    <canvas id="speedometer" width="160" height="64"></canvas>
    <div id="speedVal">0</div>
    <div id="speedUnit">KM/H</div>
  </div>
  <div id="minimapWrap">
    <div id="minimapTitle">Course</div>
    <canvas id="minimap" width="150" height="150"></canvas>
  </div>
  <div id="lapMsg"></div>
</div>

<!-- Countdown -->
<div id="countdown" class="overlay show">
  <div class="cd" id="cdText">3</div>
  <div class="sub" id="cdSub">GET READY</div>
</div>

<!-- Pause menu -->
<div id="pauseMenu" class="overlay">
  <div class="menuTitle">PAUSED</div>
  <button class="btn" id="btnResume">Resume</button>
  <button class="btn ghost" id="btnRestart2">Restart</button>
</div>

<!-- Finish screen -->
<div id="finishScreen" class="overlay">
  <div class="finTitle">FINISH!</div>
  <div class="finSub">Great run, driver.</div>
  <div id="finishStats">
    <div class="stat"><div class="lbl">Total Time</div><div class="num" id="finTotal">0:00.00</div></div>
    <div class="stat"><div class="lbl">Best Lap</div><div class="num gold" id="finBest">0:00.00</div></div>
  </div>
  <button class="btn" id="btnRestartF">Restart Race</button>
</div>

<!-- Three.js from CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
/* ============================================================
   RALLY RUSH 3D — a single-file procedural rally racing game
   Structure: Config -> Utils -> Audio -> Track -> Scene ->
   Car -> Particles -> Input -> Camera -> HUD -> Main loop
   ============================================================ */
(function(){
'use strict';

/* ------------------------------------------------------------
   CONFIG / TUNING
------------------------------------------------------------ */
const CFG = {
  trackSample: 720,        // sample points around the loop
  trackHalf: 6.5,          // half width of asphalt
  shoulder: 1.9,          // extra shoulder width (gravel)
  boundaryMargin: 34,      // lateral distance before hard bounce wall
  laps: 3,
  gates: 6,                // checkpoint gates (start also acts as a gate)
  cars: {                  // per-surface physics
    tarmac:{ grip:1.00, accel:13.5, maxSpeed:27.0, drag:0.9 },
    dirt:  { grip:0.70, accel:11.0, maxSpeed:22.0, drag:0.9 },
    grass: { grip:0.40, accel: 6.5, maxSpeed:13.5, drag:1.6 },
    mud:   { grip:0.22, accel: 3.5, maxSpeed: 8.5, drag:3.2 }
  },
  reverseMax: 8,
  carRadius: 1.5,
  wheelBase: 2.6,
};

/* ------------------------------------------------------------
   SHORT UTILS
------------------------------------------------------------ */
const U = {
  clamp:(v,a,b)=>v<a?a:(v>b?b:v),
  lerp:(a,b,t)=>a+(b-a)*t,
  rand:(a,b)=>a+Math.random()*(b-a),
  randInt:(a,b)=>Math.floor(U.rand(a,b+1)),
  // signed distance from point to segment, returns t in [0,1]
  nearestParam(px,pz,ax,az,bx,bz){
    const dx=bx-ax, dz=bz-az, L2=dx*dx+dz*dz;
    let t = L2>1e-9 ? ((px-ax)*dx+(pz-az)*dz)/L2 : 0;
    t=U.clamp(t,0,1);
    const cx=ax+dx*t, cz=az+dz*t;
    return { t, d2:(px-cx)*(px-cx)+(pz-cz)*(pz-cz), cx, cz };
  },
  fmtTime(s){
    if(s==null||!isFinite(s)) return '--:--.--';
    s=Math.max(0,s);
    const m=Math.floor(s/60), sec=s-m*60;
    const secStr=sec<10?'0':'';
    return m+':'+secStr+sec.toFixed(2);
  }
};

/* DOM shorthand */
const $=id=>document.getElementById(id);
const canvasEl=$('gameCanvas');
const touchEl=$('touch');

/* ------------------------------------------------------------
   AUDIO — fully synthesized with Web Audio API
------------------------------------------------------------ */
const AudioSys = {
  ctx:null, master:null, engineGain:null, engineOsc1:null, engineOsc2:null,
  engineFilter:null, skidGain:null, skidNoise:null, started:false, muted:false,
  init(){
    if(this.ctx) return;
    const AC = window.AudioContext||window.webkitAudioContext;
    if(!AC) return;
    this.ctx = new AC();
    this.master = this.ctx.createGain();
    this.master.gain.value = 0.85;
    this.master.connect(this.ctx.destination);
    // engine: two detuned saws -> lowpass -> gain
    this.engineGain = this.ctx.createGain(); this.engineGain.gain.value=0.0;
    this.engineFilter = this.ctx.createBiquadFilter(); this.engineFilter.type='lowpass';
    this.engineFilter.frequency.value=520; this.engineFilter.Q.value=1.2;
    this.engineOsc1=this.ctx.createOscillator(); this.engineOsc1.type='sawtooth';
    this.engineOsc2=this.ctx.createOscillator(); this.engineOsc2.type='square';
    this.engineOsc2.detune.value=8;
    this.engineOsc1.connect(this.engineFilter); this.engineOsc2.connect(this.engineFilter);
    this.engineFilter.connect(this.engineGain); this.engineGain.connect(this.master);
    this.engineOsc1.start(); this.engineOsc2.start();
    // skid / gravel loop (filtered noise)
    const len=this.ctx.sampleRate*1.0;
    const buf=this.ctx.createBuffer(1,len,this.ctx.sampleRate);
    const d=buf.getChannelData(0);
    for(let i=0;i<len;i++) d[i]=Math.random()*2-1;
    const src=this.ctx.createBufferSource(); src.buffer=buf; src.loop=true;
    const bp=this.ctx.createBiquadFilter(); bp.type='bandpass'; bp.frequency.value=700; bp.Q.value=0.7;
    this.skidGain=this.ctx.createGain(); this.skidGain.gain.value=0.0;
    src.connect(bp); bp.connect(this.skidGain); this.skidGain.connect(this.master);
    src.start();
  },
  resume(){ if(this.ctx&&this.ctx.state==='suspended') this.ctx.resume(); },
  engine(speed,gearPitch,throttle){
    if(!this.ctx) return;
    const rpm=U.clamp(gearPitch,0,1);
    const freq=40 + rpm*180 + speed*2.2;
    this.engineOsc1.frequency.setTargetAtTime(freq,this.ctx.currentTime,0.05);
    this.engineOsc2.frequency.setTargetAtTime(freq*0.5,this.ctx.currentTime,0.05);
    this.engineFilter.frequency.setTargetAtTime(300+rpm*1400,this.ctx.currentTime,0.05);
    const vol=(0.12+0.28*throttle+0.18*(speed>2?1:0))* (this.started?1:0);
    this.engineGain.gain.setTargetAtTime(vol,this.ctx.currentTime,0.06);
  },
  setSkid(level){
    if(!this.ctx) return;
    this.skidGain.gain.setTargetAtTime(level*0.5,this.ctx.currentTime,0.05);
  },
  beep(freq,dur,type='square',vol=0.3,when=0){
    if(!this.ctx) return;
    const t=this.ctx.currentTime+(when||0);
    const o=this.ctx.createOscillator(); o.type=type; o.frequency.value=freq;
    const g=this.ctx.createGain();
    g.gain.setValueAtTime(0,t); g.gain.linearRampToValueAtTime(vol,t+0.01);
    g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
    o.connect(g); g.connect(this.master); o.start(t); o.stop(t+dur+0.02);
  },
  toggle(on){ this.muted=!on; if(this.master)this.master.gain.value=on?0.85:0.0; }
};

/* ------------------------------------------------------------
   TRACK — procedural closed loop w/ hills, curves, dips
   Builds a dense centreline, per-point surface type, gates
------------------------------------------------------------ */
const Track = {
  pts:[],        // dense sample points {x,y,z, tanX,tanZ, leftX,leftZ, half, surf, dist}
  length:0,
  gates:[{frac:0}],  // fractions (first is start/finish)
  // control points (x,z,y)
  ctrl:[
    [  0,  90, 4],[ 42, 70, 8],[ 70, 30,10],[ 70,-30, 8],[ 40,-70, 5],
    [  0,-90, 4],[-40,-70, 6],[-70,-28, 9],[-70, 28, 8],[-42, 70, 6]
  ],
  build(){
    const c=this.ctrl.map(p=>new THREE.Vector3(p[0],p[2],p[1]));
    const curve=new THREE.CatmullRomCurve3(c,true,'catmullrom',0.5);
    const N=CFG.trackSample;
    this.pts.length=0;
    let dist=0;
    for(let i=0;i<N;i++){
      const t=i/N;
      const p=curve.getPointAt(t);
      const tan=curve.getTangentAt(t);
      // elevation: gentle rolling hills via signed distance from centre
      const elev=p.y;
      // per-point surface — make two "dirt" stretches, rest asphalt
      const surf=this.surfaceAt(i/N);
      // half width slightly variable
      const half=CFG.trackHalf + Math.sin(t*Math.PI*4+1.3)*0.8 + 0.6;
      const tx=tan.x, tz=tan.z;
      const len=Math.hypot(tx,tz)||1;
      const tnx=tx/len, tnz=tz/len;
      this.pts.push({
        x:p.x, y:elev, z:p.z,
        tanX:tnx, tanZ:tnz,
        leftX:tnz, leftZ:-tnx,   // left normal
        half, surf,
        dist:dist
      });
      if(i>0){ const q=this.pts[i-1]; dist+=Math.hypot(p.x-q.x,p.z-q.z); }
      this.pts[i].dist=dist;
    }
    this.length=dist;
    // fix cumulative dist interpolation continuity
    let run=0;
    for(let i=0;i<N;i++){ if(i>0){ const q=this.pts[i-1]; run+=Math.hypot(this.pts[i].x-q.x,this.pts[i].z-q.z);} this.pts[i].dist=run; }
    this.length=run;

    // checkpoint gates spread around loop (index 0 is start)
    this.gates=[];
    for(let g=0;g<CFG.gates;g++) this.gates.push({frac:g/CFG.gates});
  },
  surfaceAt(frac){
    // two dirt sections, otherwise asphalt — smooth-ish switches
    const a= Math.sin(frac*Math.PI*2);
    return a>0.35 ? (a>0.75?'dirt':'tarmac') : (frac<0.5?'tarmac':'tarmac');
  },
  // centre height at a sample
  sampleY(i){ return this.pts[i % this.pts.length].y; },
  // find nearest sample to a world pos, returns {idx, lat, progress, surf}
  locate(x,z){
    const P=this.pts, N=P.length;
    let best=0, bestD=Infinity;
    for(let i=0;i<N;i++){
      const dx=x-P[i].x, dz=z-P[i].z;
      const d=dx*dx+dz*dz;
      if(d<bestD){ bestD=d; best=i; }
    }
    const p=P[best];
    const dx=x-p.x, dz=z-p.z;
    const lat=dx*p.leftX + dz*p.leftZ;       // signed lateral offset
    const along=dx*p.tanX + dz*p.tanZ;       // along-track component
    const progress=(p.dist+along)/this.length;
    const pr=(progress%1+1)%1;
    return { idx:best, lat, absLat:Math.abs(lat), progress:pr, surf:p.surf };
  },
  // lateral surface: tarmac on, shoulder gravel just off, grass further
  surfaceFor(loc){
    const half=this.pts[loc.idx].half;
    if(loc.absLat<=half) return loc.surf;         // on asphalt/dirt
    if(loc.absLat<=half+CFG.shoulder) return 'dirt'; // shoulder gravel
    return 'grass';
  },
  // world-space mud pools (very slow water)
  mudPools:[
    {x:-14, z:0,  r:10},
    {x: 30, z:34, r:8 }
  ],
  inMud(x,z,margin){
    for(const m of this.mudPools){
      const dx=x-m.x, dz=z-m.z;
      if(dx*dx+dz*dz < (m.r+margin)*(m.r+margin)) return true;
    }
    return false;
  }
};

/* ------------------------------------------------------------
   RENDERER / WORLD — renderer, lights, sky, terrain, track,
   scenery, and distant decorations
------------------------------------------------------------ */
const World = {
  renderer:null, scene:null, camera:null,
  sun:null, hemi:null, sky:null,
  trackMesh:null,
  obstacles:[],          // {x,z,r,type} for collisions
  trees:null, rocks:null, // InstancedMesh
  bound:120,
  init(){
    const rnd=this.renderer=new THREE.WebGLRenderer({canvas:canvasEl,antialias:true,powerPreference:'high-performance'});
    rnd.setPixelRatio(Math.min(window.devicePixelRatio||1,2));
    rnd.setSize(window.innerWidth,window.innerHeight);
    rnd.shadowMap.enabled=true;
    rnd.shadowMap.type=THREE.PCFSoftShadowMap;
    rnd.outputEncoding=THREE.sRGBEncoding;

    this.scene=new THREE.Scene();
    this.scene.background=new THREE.Color(0x9fd7ff);
    this.scene.fog=new THREE.Fog(0xa9d8ef,120,360);

    this.camera=new THREE.PerspectiveCamera(62,window.innerWidth/window.innerHeight,0.1,900);
    this.camera.position.set(0,30,140);

    this._lights();
    this._sky();
    this._buildTrack();
    this._buildTerrain();
    this._buildScenery();
  },
  _lights(){
    const hemi=new THREE.HemisphereLight(0xcfe8ff,0x5a6a4a,0.75);
    this.scene.add(hemi);
    const sun=new THREE.DirectionalLight(0xfff0d8,1.15);
    sun.position.set(120,160,80);
    sun.castShadow=true;
    sun.shadow.mapSize.set(2048,2048);
    sun.shadow.camera.near=10; sun.shadow.camera.far=520;
    const S=70; sun.shadow.camera.left=-S; sun.shadow.camera.right=S;
    sun.shadow.camera.top=S; sun.shadow.camera.bottom=-S;
    sun.shadow.bias=-0.0004;
    this.scene.add(sun); this.scene.add(sun.target);
    this.sun=sun; this.hemi=hemi;
    this._amb=new THREE.AmbientLight(0x304050,0.35);
    this.scene.add(this._amb);
  },
  _sky(){
    const geo=new THREE.SphereGeometry(560,24,16);
    const cols=[]; const pos=geo.attributes.position;
    const top=new THREE.Color(0x3b79c4), mid=new THREE.Color(0x9fd7ff), bot=new THREE.Color(0xf3e6c8);
    for(let i=0;i<pos.count;i++){
      const y=pos.getY(i)/560;
      const c=new THREE.Color();
      if(y>0){ c.copy(mid).lerp(top,Math.min(1,y*1.3)); }
      else c.copy(mid).lerp(bot,Math.min(1,-y*2.2));
      cols.push(c.r,c.g,c.b);
    }
    geo.setAttribute('color',new THREE.Float32BufferAttribute(cols,3));
    const mat=new THREE.MeshBasicMaterial({vertexColors:true,side:THREE.BackSide,fog:false});
    this.sky=new THREE.Mesh(geo,mat); this.scene.add(this.sky);
    // a few cartoon clouds
    const cg=new THREE.Group();
    const cm=new THREE.MeshBasicMaterial({color:0xffffff,transparent:true,opacity:.9,fog:false});
    for(let i=0;i<9;i++){
      const cl=new THREE.Group();
      for(let j=0;j<4;j++){
        const m=new THREE.Mesh(new THREE.SphereGeometry(U.rand(4,9),8,6),cm);
        m.position.set(j*7-U.rand(2,8),U.rand(-1,2),U.rand(-2,2));
        m.scale.y=0.55; cl.add(m);
      }
      cl.position.set(U.rand(-260,260),U.rand(70,130),U.rand(-260,260));
      cg.add(cl);
    }
    this.scene.add(cg);
  },
  /* --- track ribbon with vertex colours + shoulders --- */
  _buildTrack(){
    const P=Track.pts, N=P.length;
    // helper to build a ribbon given half-width function
    const buildRibbon=(halfFn, extraY, colorFn)=>{
      const posA=[], colA=[], uvA=[], idxA=[];
      for(let i=0;i<N;i++){
        const p=P[i];
        const half=halfFn(p,i);
        const y=p.y+extraY;
        const lx=p.x+p.leftX*half, lz=p.z+p.leftZ*half;
        const rx=p.x-p.leftX*half, rz=p.z-p.leftZ*half;
        posA.push(lx,y,lz, rx,y,rz);
        const cl=colorFn(p,i,1), cr=colorFn(p,i,-1);
        colA.push(cl.r,cl.g,cl.b, cr.r,cr.g,cr.b);
        uvA.push(0,i/6, 1,i/6);
      }
      for(let i=0;i<N;i++){
        const a=i*2, b=i*2+1, c=((i+1)%N)*2, d=((i+1)%N)*2+1;
        idxA.push(a,b,c, b,d,c);
      }
      const g=new THREE.BufferGeometry();
      g.setIndex(idxA);
      g.setAttribute('position',new THREE.Float32BufferAttribute(posA,3));
      g.setAttribute('color',new THREE.Float32BufferAttribute(colA,3));
      g.setAttribute('uv',new THREE.Float32BufferAttribute(uvA,2));
      const n=[]; for(let i=0;i<posA.length/3;i++) n.push(0,1,0);
      g.setAttribute('normal',new THREE.Float32BufferAttribute(n,3));
      return g;
    };
    // shoulder (gravel) strip below
    const shoulderG=buildRibbon((p)=>p.half+CFG.shoulder, -0.12, ()=>new THREE.Color(0x8d6b47));
    const shoulderM=new THREE.Mesh(shoulderG,new THREE.MeshStandardMaterial({vertexColors:true,roughness:1,metalness:0}));
    shoulderM.receiveShadow=true; this.scene.add(shoulderM);
    // asphalt/dirt surface with per-surface colour
    const trackG=buildRibbon((p)=>p.half+0.05, 0, (p)=>{
      if(p.surf==='dirt') return new THREE.Color(0x9a6f43);
      return new THREE.Color(0x3c3f45);
    });
    const trackM=new THREE.Mesh(trackG,new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.85,metalness:0}));
    trackM.receiveShadow=true; this.scene.add(trackM);
    this.trackMesh=trackM;
    // dashed centre line
    this._buildCentreLine();
  },
  _buildCentreLine(){
    const P=Track.pts, N=P.length;
    const cmat=new THREE.MeshBasicMaterial({color:0xffe28a});
    const cg=new THREE.BufferGeometry();
    const pos=[], idx=[];
    let vi=0;
    for(let i=0;i<N;i+=4){
      const p0=P[i], p1=P[(i+2)%N];
      const y=U.lerp(p0.y,p1.y,0.5)+0.06;
      pos.push(p0.x,y,p0.z, p1.x,y,p1.z);
      idx.push(vi*2,vi*2+1); vi++;
    }
    const line=new THREE.LineSegments(cg.setAttribute('position',new THREE.Float32BufferAttribute(pos,3)),cmat);
    line.geometry.setIndex(idx);
    this.scene.add(line);
  },
  /* --- rolling terrain carved under the track --- */
  _buildTerrain(){
    const size=1000, seg=140;
    const g=new THREE.PlaneGeometry(size,size,seg,seg);
    g.rotateX(-Math.PI/2);
    const pos=g.attributes.position;
    const cols=[];
    const grassA=new THREE.Color(0x5f8f3f), grassB=new THREE.Color(0x79a852), grassC=new THREE.Color(0x4a7a34);
    for(let i=0;i<pos.count;i++){
      const x=pos.getX(i), z=pos.getZ(i);
      // nearest track sample (coarse)
      let best=0,bd=Infinity;
      const P=Track.pts;
      for(let j=0;j<P.length;j++){ const dx=x-P[j].x,dz=z-P[j].z,d=dx*dx+dz*dz; if(d<bd){bd=d;best=j;} }
      const p=P[best];
      const lat=Math.hypot(x-p.x,z-p.z);
      const roadDist=lat-(p.half+1.2);
      let hill=1.8+2.0*Math.sin(x*0.02+1.0)*Math.sin(z*0.021)+1.2*Math.cos((x+z)*0.012);
      let y;
      if(roadDist<0){ y=p.y-0.35; }
      else{ const fade=U.clamp(roadDist/26,0,1); y=U.lerp(p.y-0.35,hill,fade); }
      pos.setY(i,y);
      const n=0.5+0.5*Math.sin(x*0.05)*Math.cos(z*0.05);
      const c=grassA.clone().lerp(grassB,n).lerp(grassC,U.clamp((y-2)/8,0,1));
      cols.push(c.r,c.g,c.b);
    }
    g.setAttribute('color',new THREE.Float32BufferAttribute(cols,3));
    g.computeVertexNormals();
    const m=new THREE.Mesh(g,new THREE.MeshStandardMaterial({vertexColors:true,roughness:1,metalness:0}));
    m.receiveShadow=true; this.scene.add(m);
    this.terrain=m;
  },
  /* --- trees, rocks, signs, barriers, banners (instanced + meshes) --- */
  _buildScenery(){
    const N=Track.pts.length;
    // collision obstacles + instanced decorations
    const trees=[], rocks=[];
    // helpers to push a world point at given lateral offset from a sample
    const pointAt=(i,lat)=>{
      const p=Track.pts[((i%N)+N)%N];
      return {x:p.x+p.leftX*lat, z:p.z+p.leftZ*lat, y:p.y};
    };
    // trees: every few samples on both sides, at reachable & far offsets
    for(let i=0;i<N;i+=6){
      const side=(i%2?1:-1);
      const lat=side*U.rand(CFG.trackHalf+8,CFG.boundaryMargin+8);
      const pt=pointAt(i,lat);
      const h=U.rand(5,11);
      trees.push({x:pt.x,z:pt.z,y:pt.y,h,seed:U.rand(0,1)});
    }
    // extra far forest
    for(let k=0;k<60;k++){
      const i=U.randInt(0,N-1), lat=(k%2?1:-1)*U.rand(CFG.boundaryMargin+12,70);
      const pt=pointAt(i,lat);
      trees.push({x:pt.x,z:pt.z,y:pt.y,h:U.rand(6,12),seed:U.rand(0,1)});
    }
    // trees within reachable band become collision obstacles
    for(const t of trees){
      const l=Track.locate(t.x,t.z);
      if(l.absLat<CFG.boundaryMargin-2) this.obstacles.push({x:t.x,z:t.z,r:1.1,type:'tree',h:t.h});
    }
    // rocks
    for(let i=0;i<N;i+=11){
      const side=(i%2?1:-1);
      const lat=side*U.rand(CFG.trackHalf+4,CFG.boundaryMargin+4);
      const pt=pointAt(i,lat);
      rocks.push({x:pt.x,z:pt.z,y:pt.y,r:U.rand(0.8,1.9),seed:U.rand(0,1)});
    }
    for(const r of rocks){
      const l=Track.locate(r.x,r.z);
      if(l.absLat<CFG.boundaryMargin-2) this.obstacles.push({x:r.x,z:r.z,r:r.r,type:'rock'});
    }
    this._makeTrees(trees);
    this._makeRocks(rocks);
    this._makeSignsAndBarriers();
    this._makeBanners();
  },
  _makeTrees(trees){
    const geom=new THREE.ConeGeometry(1,1,6);
    const mat=new THREE.MeshStandardMaterial({color:0x2f6b34,roughness:1});
    const trunkG=new THREE.CylinderGeometry(0.22,0.3,1,5);
    const trunkM=new THREE.MeshStandardMaterial({color:0x6b4a2a,roughness:1});
    const ins=new THREE.InstancedMesh(geom,mat,trees.length);
    const tr=new THREE.InstancedMesh(trunkG,trunkM,trees.length);
    const m=new THREE.Matrix4(), q=new THREE.Quaternion(), s=new THREE.Vector3();
    // darker / lighter foliage via vertex colour not easy on instanced; vary scale & some via material clone
    for(let i=0;i<trees.length;i++){
      const t=trees[i];
      const h=t.h;
      q.setFromAxisAngle(new THREE.Vector3(0,1,0),t.seed*6.28);
      s.set(h*0.42,h*0.85,h*0.42);
      m.compose(new THREE.Vector3(t.x,t.y+h*0.42+h*0.42,t.z),q,s);
      ins.setMatrixAt(i,m);
      s.set(1,h*0.42,1);
      m.compose(new THREE.Vector3(t.x,t.y+h*0.21,t.z),q,s);
      tr.setMatrixAt(i,m);
    }
    ins.castShadow=true; tr.castShadow=true;
    ins.instanceMatrix.needsUpdate=true; tr.instanceMatrix.needsUpdate=true;
    this.scene.add(ins); this.scene.add(tr);
    this.trees=ins;
  },
  _makeRocks(rocks){
    const geom=new THREE.IcosahedronGeometry(1,0);
    const mat=new THREE.MeshStandardMaterial({color:0x8a8f96,roughness:1,flatShading:true});
    const ins=new THREE.InstancedMesh(geom,mat,rocks.length);
    const m=new THREE.Matrix4();
    for(let i=0;i<rocks.length;i++){
      const r=rocks[i];
      const q=new THREE.Quaternion().setFromEuler(new THREE.Euler(U.rand(-.4,.4),U.rand(0,6.28),U.rand(-.4,.4)));
      m.compose(new THREE.Vector3(r.x,r.y+r.r*0.4,r.z),q,new THREE.Vector3(r.r,r.r*0.8,r.r));
      ins.setMatrixAt(i,m);
    }
    ins.castShadow=true; ins.receiveShadow=true;
    ins.instanceMatrix.needsUpdate=true;
    this.scene.add(ins); this.rocks=ins;
  },
  _makeSignsAndBarriers(){
    const N=Track.pts.length;
    // billboard / warning signs near sharp corners (high curvature zones)
    const signMat=new THREE.MeshStandardMaterial({color:0xdd3333,roughness:.7});
    const boardCols=[0xffd21f,0x2f6bff,0xffffff,0x33c46b];
    for(let i=0;i<N;i+=26){
      const p=Track.pts[i];
      const side=(i%2?1:-1);
      const lat=side*(p.half+2.6);
      const x=p.x+p.leftX*lat, z=p.z+p.leftZ*lat, y=p.y;
      const dirIdx=(i+3)%N; const d=Track.pts[dirIdx];
      const ang=Math.atan2(d.x-p.x,d.z-p.z);
      const grp=new THREE.Group();
      const pole=new THREE.Mesh(new THREE.CylinderGeometry(0.12,0.12,2.6,5),new THREE.MeshStandardMaterial({color:0x999999,roughness:.6}));
      pole.position.y=1.3; grp.add(pole);
      const col=new THREE.Color(boardCols[i%boardCols.length]);
      const bd=new THREE.Mesh(new THREE.PlaneGeometry(2.8,1.4),new THREE.MeshStandardMaterial({color:col,roughness:.6,side:THREE.DoubleSide}));
      bd.position.y=2.4; grp.add(bd);
      // black stripe
      const st=new THREE.Mesh(new THREE.PlaneGeometry(2.8,0.35),new THREE.MeshStandardMaterial({color:0x111111,side:THREE.DoubleSide}));
      st.position.set(0,2.4,0.02); grp.add(st);
      grp.position.set(x,y,z); grp.rotation.y=ang+Math.PI/2;
      grp.traverse(o=>{if(o.isMesh){o.castShadow=true;}});
      this.scene.add(grp);
      this.obstacles.push({x,z,r:0.5,type:'sign'});
    }
    // guard rails / barriers on the outside of a few high-curve stretches
    const rails=[];
    for(let r=0;r<5;r++){
      const start=Math.floor(U.rand(0,N));
      const len=14+U.randInt(0,20);
      const side=(r%2?1:-1);
      for(let k=0;k<len;k++){
        const p=Track.pts[(start+k)%N];
        const lat=side*(p.half+0.7);
        rails.push({x:p.x+p.leftX*lat,z:p.z+p.leftZ*lat,y:p.y});
      }
      // index-k barrier collision approximated by thin obstacle row
    }
    // barrier meshes: repurpose as a few long low walls along chosen arcs
    this._makeGuardRail();
    // barriers as chunky blocks
    for(let r=0;r<4;r++){
      const i=Math.floor(U.rand(0,N)), side=(r%2?1:-1);
      const p=Track.pts[i], lat=side*(p.half+1.4);
      this.obstacles.push({x:p.x+p.leftX*lat,z:p.z+p.leftZ*lat,r:0.9,type:'barrier'});
    }
  },
  _makeGuardRail(){
    // a few rows of simple rail posts+beam along outer edge in fixed arcs
    const mats=new THREE.MeshStandardMaterial({color:0xc8ccd2,roughness:.4,metalness:.3});
    const rails=[{a:0.10,b:0.20},{a:0.42,b:0.55},{a:0.75,b:0.86}];
    for(const seg of rails){
      for(let k=Math.floor(seg.a*Track.pts.length);k<Math.floor(seg.b*Track.pts.length);k+=3){
        const p=Track.pts[k];
        const pole=new THREE.Mesh(new THREE.CylinderGeometry(0.1,0.1,1.4,4),mats);
        pole.position.set(p.x+p.leftX*(p.half+0.5),p.y+0.7,p.z+p.leftZ*(p.half+0.5));
        pole.castShadow=true; this.scene.add(pole);
      }
      // beam
      const bg=new THREE.BufferGeometry(); const pos=[];
      for(let k=Math.floor(seg.a*Track.pts.length);k<=Math.floor(seg.b*Track.pts.length);k+=4){
        const p=Track.pts[k%Track.pts.length];
        pos.push(p.x+p.leftX*(p.half+0.5),p.y+1.25,p.z+p.leftZ*(p.half+0.5));
      }
      bg.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));
      const line=new THREE.Line(bg,mats); this.scene.add(line);
    }
  },
  _makeBanners(){
    // overhead finish gantry at the start gate (thin, non-blocking)
    const p=Track.pts[0]; const ahead=Track.pts[10];
    const ang=Math.atan2(ahead.x-p.x,ahead.z-p.z);
    const grp=new THREE.Group();
    const side=new THREE.Vector3(Math.cos(ang),0,-Math.sin(ang));
    const half=p.half+1.6;
    const postM=new THREE.MeshStandardMaterial({color:0xeeeded,roughness:.5});
    for(const s of [-1,1]){
      const post=new THREE.Mesh(new THREE.CylinderGeometry(0.22,0.28,7.5,6),postM);
      post.position.copy(side.clone().multiplyScalar(s*half)).y+=3.75;
      post.castShadow=true; grp.add(post);
    }
    // top gantry beam
    const beam=new THREE.Mesh(new THREE.BoxGeometry(half*2,0.8,0.8),
      new THREE.MeshStandardMaterial({color:0xffffff,roughness:.6}));
    beam.position.y=7.2; beam.castShadow=true; grp.add(beam);
    // FINISH text as a transparent overlay (no solid fill)
    const txtC=document.createElement('canvas'); txtC.width=1024; txtC.height=128;
    const cx=txtC.getContext('2d');
    cx.clearRect(0,0,1024,128);
    cx.fillStyle='#ff5a1f'; cx.font='900 96px sans-serif'; cx.textAlign='center'; cx.textBaseline='middle';
    cx.lineWidth=10; cx.strokeStyle='#ffffff'; cx.strokeText('FINISH',512,64); cx.fillText('FINISH',512,64);
    const tex=new THREE.CanvasTexture(txtC);
    const txt=new THREE.Mesh(new THREE.PlaneGeometry(half*1.8,1.4),
      new THREE.MeshBasicMaterial({map:tex,transparent:true,side:THREE.DoubleSide}));
    txt.position.y=6.7; grp.add(txt);
    // small checkered strip under the text
    const st=new THREE.Mesh(new THREE.PlaneGeometry(half*1.8,0.4),
      new THREE.MeshStandardMaterial({color:0xcaa23a,roughness:.5,side:THREE.DoubleSide}));
    st.position.y=5.9; grp.add(st);
    grp.position.set(p.x,p.y,p.z); grp.rotation.y=Math.PI/2+ang;
    this.scene.add(grp);
  },

  /* per-frame: update shadow + sky follow the car */
  follow(carPos){
    this.sun.position.set(carPos.x+90,carPos.y+140,carPos.z+60);
    this.sun.target.position.copy(carPos);
    this.sun.target.updateMatrixWorld();
    this.sky.position.copy(carPos);
  },
  resize(w,h){ this.camera.aspect=w/h; this.camera.updateProjectionMatrix();
    this.renderer.setSize(w,h); }
};

/* ------------------------------------------------------------
   CAR — procedural low-poly body + physics state
------------------------------------------------------------ */
const Car = {
  group:null, wheels:[], 
  pos:new THREE.Vector3(), heading:0, vel:new THREE.Vector2(0,0),
  steer:0, speed:0, gear:1, rpm:0,
  throttle:0, brake:0, handbrake:false,
  surface:'tarmac', grounded:true,
  wheelSpin:0,
  init(){
    const g=new THREE.Group();
    const bodyM=new THREE.MeshStandardMaterial({color:0xff5a1f,roughness:.45,metalness:.25});
    const darkM=new THREE.MeshStandardMaterial({color:0x1c1f26,roughness:.6,metalness:.3});
    const glassM=new THREE.MeshStandardMaterial({color:0x152030,roughness:.2,metalness:.4});
    // main body
    const body=new THREE.Mesh(new THREE.BoxGeometry(1.9,0.62,4.0),bodyM);
    body.position.y=0.62; body.castShadow=true; g.add(body);
    // cabin
    const cab=new THREE.Mesh(new THREE.BoxGeometry(1.5,0.6,1.8),bodyM);
    cab.position.set(0,1.05,-0.35); cab.castShadow=true; g.add(cab);
    // windshield / windows
    const win=new THREE.Mesh(new THREE.BoxGeometry(1.42,0.5,1.0),glassM);
    win.position.set(0,1.05,0.35); g.add(win);
    // front hood slope
    const hood=new THREE.Mesh(new THREE.BoxGeometry(1.7,0.2,1.2),bodyM);
    hood.position.set(0,0.72,1.25); g.add(hood);
    // spoiler
    const sp=new THREE.Group();
    const wing=new THREE.Mesh(new THREE.BoxGeometry(1.9,0.12,0.5),darkM);
    wing.position.y=1.35; wing.castShadow=true; sp.add(wing);
    const strut=new THREE.Mesh(new THREE.BoxGeometry(0.12,0.5,0.2),darkM);
    strut.position.set(0.5,1.1,-1.95); sp.add(strut);
    const strut2=strut.clone(); strut2.position.x=-0.5; sp.add(strut2);
    sp.position.z=-1.85; g.add(sp);
    // headlights (front, +Z)
    const hlM=new THREE.MeshStandardMaterial({color:0xffffff,emissive:0xfff2c0,emissiveIntensity:1});
    for(const s of [-1,1]){
      const hl=new THREE.Mesh(new THREE.BoxGeometry(0.34,0.24,0.1),hlM);
      hl.position.set(s*0.55,0.58,2.02); g.add(hl);
    }
    // taillights
    const tlM=new THREE.MeshStandardMaterial({color:0x330000,emissive:0xff2200,emissiveIntensity:1.6});
    for(const s of [-1,1]){
      const tl=new THREE.Mesh(new THREE.BoxGeometry(0.4,0.22,0.1),tlM);
      tl.position.set(s*0.6,0.66,-2.02); g.add(tl);
    }
    // bumper / skirts
    const skirt=new THREE.Mesh(new THREE.BoxGeometry(2.0,0.3,3.9),darkM);
    skirt.position.y=0.34; g.add(skirt);
    // wheels
    const wheelG=new THREE.CylinderGeometry(0.42,0.42,0.34,14); wheelG.rotateZ(Math.PI/2);
    const wheelM=new THREE.MeshStandardMaterial({color:0x14161a,roughness:.8});
    const hubM=new THREE.MeshStandardMaterial({color:0x9aa0a8,roughness:.3,metalness:.6});
    const wpos=[[-0.95,0.42,1.35],[0.95,0.42,1.35],[-0.95,0.42,-1.35],[0.95,0.42,-1.35]];
    for(const w of wpos){
      const wg=new THREE.Group();
      const tire=new THREE.Mesh(wheelG,wheelM); tire.castShadow=true;
      const hub=new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.18,0.36,8),hubM); hub.rotation.z=Math.PI/2;
      wg.add(tire); wg.add(hub); wg.position.set(w[0],w[1],w[2]);
      g.add(wg); this.wheels.push(wg);
    }
    this.group=g;
    this.scene=World.scene;
    World.scene.add(g);
    // spawn on start line
    const p=Track.pts[0];
    this.pos.set(p.x,p.y,p.z);
    this.heading=Math.atan2(p.tanX,p.tanZ);
    // face along forwards tangent
    this.group.position.copy(this.pos);
    this.group.rotation.y=this.heading;
  },

  surfaceCar(){
    // decide surface + grip for current location
    const loc=Track.locate(this.pos.x,this.pos.z);
    let surf=Track.surfaceFor(loc);
    if(Track.inMud(this.pos.x,this.pos.z,0)) surf='mud';
    return surf;
  },
  update(dt,input){
    const st=CFG.cars[this.surface]||CFG.cars.tarmac;
    const th=input.throttle?1:0;
    const brk=input.brake?1:0;
    const hand=!!input.handbrake;
    this.throttle=th; this.brake=brk; this.handbrake=hand;

    // heading / steering
    const f=Math.abs(this.vel.length());
    const spd=this.vel.x*Math.sin(this.heading)+this.vel.y*Math.cos(this.heading);
    const maxSp=st.maxSpeed;
    const steerMax=U.lerp(0.62,0.16,U.clamp(f/maxSp,0,1));
    const steerTarget=input.steer*steerMax;
    this.steer+= (steerTarget-this.steer)*Math.min(1,dt*8);
    // yaw rate depends on speed
    const spdFactor=U.clamp(Math.abs(spd)/7,0,1)*(1-0.55*U.clamp(Math.abs(spd)/maxSp,0,1));
    const steerRate=2.2;
    this.heading += this.steer*steerRate*spdFactor*dt*(spd<0?-1:1);

    // forward/right basis
    const fx=Math.sin(this.heading), fz=Math.cos(this.heading);
    const rx=fz, rz=-fx;
    let vF=this.vel.x*fx+this.vel.y*fz;
    let vL=this.vel.x*rx+this.vel.y*rz;

    // throttle & brake
    if(th>0){
      vF += st.accel*(1-0.45*U.clamp(vF/maxSp,0,1))*dt;
    }
    // drag / rolling resistance
    vF -= st.drag*vF*dt;
    // apply resist as fraction
    if(brk){
      const decel=18*dt;
      if(vF>0.1) vF=Math.max(0,vF-decel);
      else if(vF>-0.1) vF-=Math.min(decel,5)*dt*0.5, vF=Math.max(vF,-CFG.reverseMax);
      else vF=Math.max(vF-6*dt,-CFG.reverseMax);
    }
    // cap reverse & forward
    vF=U.clamp(vF,-CFG.reverseMax,maxSp);

    // grip on lateral velocity
    const lean=U.clamp(1.1-0.5*(Math.abs(vF)/maxSp),0.3,1);
    let grip=st.grip*lean;
    if(hand) grip*=0.32;
    vL*= Math.max(0,1-grip*dt*3.4);
    // recompose velocity
    this.vel.x=fx*vF+rx*vL;
    this.vel.y=fz*vF+rz*vL;

    this.pos.x+=this.vel.x*dt;
    this.pos.z+=this.vel.y*dt;

    // ground height from nearest track sample
    const loc=Track.locate(this.pos.x,this.pos.z);
    const gy=Track.pts[loc.idx].y;
    const carY=U.lerp(this.pos.y,gy,Math.min(1,dt*10));
    this.pos.y=carY;

    this.speed=vF;
    // gear & rpm simulation
    this._gear();
    // wheel spin
    this.wheelSpin+=(vF/0.42)*dt;
    for(const w of this.wheels){ w.rotation.x=this.wheelSpin; }

    this.group.position.copy(this.pos);
    this.group.rotation.y=this.heading;
    // slight body lean on steer / drift
    this.group.rotation.z=U.lerp(this.group.rotation.z,-this.steer*0.10-Math.sign(this.steer)*Math.min(0.14,Math.abs(vL)*0.012),0.2);
    this.group.rotation.x=U.lerp(this.group.rotation.x,0.02+ (vF<0?0.03:0)-Math.abs(this.steer)*0.03,0.2);

    return { loc, surf:this.surface, spd:Math.abs(spd) };
  },
  _gear(){
    // gear bands by speed (m/s)
    const bands=[0,4,9,15,21,27,34];
    let g=1; for(let i=1;i<bands.length;i++){ if(this.speed>=bands[i]) g=i+1; else break; }
    this.gear=g;
    const lo=bands[g-1], hi=bands[Math.min(g,bands.length-1)]||lo+5;
    this.rpm=U.clamp((Math.abs(this.speed)-lo)/Math.max(1,hi-lo),0.12,1.0);
  },
};

/* ------------------------------------------------------------
   PARTICLE SYSTEM — dust, sparks, skid marks (pooled meshes)
------------------------------------------------------------ */
const Particles = {
  pool:[], sparks:[], skids:[], max:220,
  dustGeo:null, dustMat:null, sparkGeo:null, sparkMat:null,
  skidGeo:null, skidMat:null, skidCount:0,
  init(){
    this.dustGeo=new THREE.IcosahedronGeometry(1,0);
    this.dustMat=new THREE.MeshBasicMaterial({color:0xcbb693,transparent:true,opacity:0.55,fog:true});
    this.sparkGeo=new THREE.BoxGeometry(0.22,0.22,0.22);
    this.sparkMat=new THREE.MeshBasicMaterial({color:0xffcc33});
    this.skidGeo=new THREE.PlaneGeometry(0.5,0.9);
    this.skidGeo.rotateX(-Math.PI/2);
    for(let i=0;i<this.max;i++){
      const m=new THREE.Mesh(this.dustGeo,this.dustMat.clone());
      m.visible=false; m.fog=true;
      World.scene.add(m);
      this.pool.push({mesh:m,life:0,max:1,vx:0,vy:0,vz:0,scale:0});
    }
  },
  emitDust(x,y,z,vx,vz,big){
    const p=this.pool.find(p=>p.life<=0);
    if(!p) return;
    p.life=p.max=U.rand(0.5,0.9);
    p.vx=vx*0.3+U.rand(-0.6,0.6); p.vz=vz*0.3+U.rand(-0.6,0.6); p.vy=U.rand(0.6,1.2);
    p.scale=big?U.rand(0.8,1.3):U.rand(0.5,0.9);
    p.mesh.position.set(x,y,z);
    p.mesh.scale.setScalar(p.scale*0.4);
    p.mesh.material.opacity=0.5;
    p.mesh.visible=true;
    p.wobble=U.rand(0,6.28);
  },
  emitSpark(x,y,z,dir){
    if(this.sparks.length>40) return;
    const m=new THREE.Mesh(this.sparkGeo,this.sparkMat.clone());
    m.position.set(x,y,z); World.scene.add(m);
    this.sparks.push({mesh:m,life:0.45,life0:0.45,
      vx:dir*U.rand(-1,1)+U.rand(-3,3),vy:U.rand(2,5),vz:U.rand(-3,3)});
  },
  skid(x,z,y){
    const m=new THREE.Mesh(this.skidGeo,this.skidMat||(this.skidMat=new THREE.MeshBasicMaterial({color:0x222222,transparent:true,opacity:0.5})));
    if(this.skids.length>=90){ const old=this.skids.shift(); World.scene.remove(old.mesh); }
    m.position.set(x,y+0.05,z);
    m.rotation.y=this._lastAng||0;
    World.scene.add(m);
    this.skids.push({mesh:m,life:2.5,life0:2.5});
  },
  update(dt){
    for(const p of this.pool){
      if(p.life>0){
        p.life-=dt;
        p.mesh.position.x+=p.vx*dt; p.mesh.position.y+=p.vy*dt; p.mesh.position.z+=p.vz*dt;
        p.vy+=0.8*dt; p.vx*=0.96; p.vz*=0.96;
        const t=1-p.life/p.max;
        p.mesh.scale.setScalar(p.scale*(0.3+t*1.4));
        p.mesh.material.opacity=0.5*(1-t);
        if(p.life<=0) p.mesh.visible=false;
      }
    }
    for(let i=this.sparks.length-1;i>=0;i--){
      const s=this.sparks[i]; s.life-=dt;
      s.mesh.position.x+=s.vx*dt; s.mesh.position.y+=s.vy*dt; s.mesh.position.z+=s.vz*dt;
      s.vy-=18*dt; s.mesh.scale.setScalar(Math.max(0.01,s.life/s.life0));
      if(s.life<=0){ World.scene.remove(s.mesh); this.sparks.splice(i,1); }
    }
    for(let i=this.skids.length-1;i>=0;i--){
      const s=this.skids[i]; s.life-=dt;
      s.mesh.material.opacity=0.5*(s.life/s.life0);
      if(s.life<=0){ World.scene.remove(s.mesh); this.skids.splice(i,1); }
    }
  }
};

/* ------------------------------------------------------------
   INPUT — keyboard + touch / on-screen pads
------------------------------------------------------------ */
const Input = {
  steer:0, throttle:false, brake:false, handbrake:false,
  cb:{},
  bind(cb){ this.cb=cb; 
    window.addEventListener('keydown',e=>this._key(e,true));
    window.addEventListener('keyup',e=>this._key(e,false));
    this._touch();
    window.addEventListener('blur',()=>{ this.steer=0; this.throttle=this.brake=this.handbrake=false; });
    // start on first gesture
    const start=()=>{ AudioSys.init(); AudioSys.resume(); if(this.cb.onStart)this.cb.onStart();
      window.removeEventListener('keydown',start); window.removeEventListener('pointerdown',start); };
    window.addEventListener('keydown',start); window.addEventListener('pointerdown',start);
  },
  _key(e,down){
    const k=e.key;
    const map={ArrowUp:'u',ArrowDown:'d',ArrowLeft:'l',ArrowRight:'r',w:'u',s:'d',a:'l',d:'r',
      W:'u',S:'d',A:'l',D:'r'};
    if(k===' '){ this.handbrake=down; e.preventDefault(); }
    if(map[k]){
      if(map[k]==='u')this.throttle=down;
      else if(map[k]==='d')this.brake=down;
      else if(map[k]==='l'){ if(down)this.steer=-1; else if(this.steer<0)this.steer=0; }
      else if(map[k]==='r'){ if(down)this.steer=1; else if(this.steer>0)this.steer=0; }
    }
    // commands (on keydown only)
    if(down){
      if(k.toLowerCase()==='r'){ if(this.cb.onRestart)this.cb.onRestart(); }
      if(k.toLowerCase()==='p'){ if(this.cb.onPause)this.cb.onPause(); }
      if(k.toLowerCase()==='c'){ if(this.cb.onCamera)this.cb.onCamera(); }
      if(k==='Escape'){ if(this.cb.onPause)this.cb.onPause(); }
    }
    if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(k)) e.preventDefault();
  },
  _touch(){
    const set=(sel,prop)=>{
      const el=$(sel);
      if(!el) return;
      el.addEventListener('pointerdown',e=>{ e.preventDefault(); el.setPointerCapture(e.pointerId); this[prop]=true; el.classList.add('active'); });
      const off=e=>{ this[prop]=false; el.classList.remove('active'); };
      el.addEventListener('pointerup',off); el.addEventListener('pointercancel',off);
      el.addEventListener('lostpointercapture',off);
    };
    set('btnGas','throttle'); set('btnBrake','brake'); set('btnHand','handbrake');
    const steer=(sel,dir)=>{
      const el=$(sel); if(!el)return;
      const on=e=>{ e.preventDefault(); this.steer=dir; };
      const off=()=>{ if((dir<0&&this.steer<0)||(dir>0&&this.steer>0))this.steer=0; };
      el.addEventListener('pointerdown',on); el.addEventListener('pointerup',off);
      el.addEventListener('pointercancel',off); el.addEventListener('pointerleave',off);
    };
    steer('steerLeft',-1); steer('steerRight',1);
  }
};

/* ------------------------------------------------------------
   CAMERA — chase cam with lag + optional cinematic cam
------------------------------------------------------------ */
const CameraSys = {
  mode:'chase',
  pos:new THREE.Vector3(), look:new THREE.Vector3(),
  shake:0,
  update(dt){
    const car=Car;
    const fx=Math.sin(car.heading), fz=Math.cos(car.heading);
    let dist=8.4, height=4.0, ahead=5.2;
    if(this.mode==='cine'){ dist=16.5; height=12.5; ahead=2.0; }
    // desired chase position behind the car
    const dx=car.pos.x - fx*dist, dz=car.pos.z - fz*dist;
    const dy=car.pos.y + height;
    const desired=new THREE.Vector3(dx,dy,dz);
    const lag=this.mode==='chase'?1-Math.pow(0.0006,dt):1-Math.pow(0.002,dt);
    this.pos.lerp(desired,lag);
    const lookDes=new THREE.Vector3(car.pos.x+fx*ahead, car.pos.y+1.4, car.pos.z+fz*ahead);
    this.look.lerp(lookDes,1-Math.pow(0.0002,dt));
    // shake
    if(this.shake>0){ this.shake-=dt*4;
      this.pos.x+=U.rand(-1,1)*this.shake; this.pos.y+=U.rand(-1,1)*this.shake; this.pos.z+=U.rand(-1,1)*this.shake; }
    World.camera.position.copy(this.pos);
    World.camera.lookAt(this.look);
  },
  reset(){
    const car=Car;
    const fx=Math.sin(car.heading), fz=Math.cos(car.heading);
    this.pos.set(car.pos.x-fx*9.5, car.pos.y+4.6, car.pos.z-fz*9.5);
    this.look.set(car.pos.x+fx*4.5,car.pos.y+1.4,car.pos.z+fz*4.5);
  }
};

/* ------------------------------------------------------------
   HUD — speedometer gauge, minimap, lap/time text
------------------------------------------------------------ */
const HUD = {
  spCtx:null, miniCtx:null, trackPts2d:[], miniBounds:null,
  lastLap:null,
  init(){
    this.spCtx=$('speedometer').getContext('2d');
    this.miniCtx=$('minimap').getContext('2d');
    this._prepMinimap();
  },
  _prepMinimap(){
    const W=$('minimap').width, H=$('minimap').height;
    let minx=1e9,maxx=-1e9,minz=1e9,maxz=-1e9;
    for(const p of Track.pts){ minx=Math.min(minx,p.x);maxx=Math.max(maxx,p.x);minz=Math.min(minz,p.z);maxz=Math.max(maxz,p.z); }
    const pad=10, span=Math.max(maxx-minx,maxz-minz);
    const sc=Math.min((W-pad*2)/span,(H-pad*2)/span);
    const cx=(minx+maxx)/2, cz=(minz+maxz)/2;
    this.miniBounds={minx,maxx,minz,maxz,sc,cx,cz};
    this.trackPts2d=Track.pts.map(p=>{
      return { x:(p.x-cx)*sc+W/2, y:(p.z-cz)*sc+H/2 };
    });
  },
  drawSpeedo(){
    const ctx=this.spCtx, W=160,H=64;
    ctx.clearRect(0,0,W,H);
    const cx=80, cy=58, r=48;
    const a0=Math.PI*1.02, a1=-0.02; // 180deg-ish arc
    const maxSpeed=0.95; // m/s -> frac
    const s=U.clamp(Math.abs(Car.speed)/CFG.cars.tarmac.maxSpeed,0,1);
    // track arc
    ctx.lineWidth=11; ctx.lineCap='round';
    ctx.strokeStyle='rgba(255,255,255,.12)';
    ctx.beginPath(); ctx.arc(cx,cy,r,a0,a1); ctx.stroke();
    // value arc
    const ang=a0+(a1-a0)*s;
    const grad=ctx.createLinearGradient(0,0,W,0);
    grad.addColorStop(0,'#3ad26f'); grad.addColorStop(.6,'#ffd21f'); grad.addColorStop(1,'#ff4d3d');
    ctx.strokeStyle=grad;
    ctx.beginPath(); ctx.arc(cx,cy,r,a0,ang); ctx.stroke();
    // needle
    const na=a0+(a1-a0)*s;
    ctx.strokeStyle='#fff'; ctx.lineWidth=3;
    ctx.beginPath(); ctx.moveTo(cx,cy);
    ctx.lineTo(cx+Math.cos(na)*(r+2), cy+Math.sin(na)*2); ctx.stroke();
  },
  drawMinimap(){
    const ctx=this.miniCtx, W=$('minimap').width, H=$('minimap').height;
    ctx.clearRect(0,0,W,H);
    // track path
    ctx.strokeStyle='rgba(255,255,255,.55)'; ctx.lineWidth=3; ctx.lineJoin='round';
    ctx.beginPath();
    const pts=this.trackPts2d;
    for(let i=0;i<pts.length;i++){ const p=pts[i]; if(i===0)ctx.moveTo(p.x,p.y); else ctx.lineTo(p.x,p.y); }
    ctx.closePath(); ctx.stroke();
    // gates
    ctx.fillStyle='#ffd21f';
    for(const g of Track.gates){
      const i=Math.floor(g.frac*Track.pts.length)%Track.pts.length;
      const p=pts[i]; ctx.beginPath(); ctx.arc(p.x,p.y,2.6,0,6.28); ctx.fill();
    }
    // start marker
    const sp=pts[0]; ctx.strokeStyle='#fff'; ctx.lineWidth=2;
    ctx.beginPath(); ctx.moveTo(sp.x-5,sp.y); ctx.lineTo(sp.x+5,sp.y); ctx.stroke();
    // player
    const loc=Track.locate(Car.pos.x,Car.pos.z);
    const i=Math.floor(loc.progress*Track.pts.length);
    const cp=pts[i];
    const jx=U.clamp((Car.pos.x-this.miniBounds.cx)*this.miniBounds.sc+W/2,4,W-4);
    const jy=U.clamp((Car.pos.z-this.miniBounds.cz)*this.miniBounds.sc+H/2,4,H-4);
    ctx.fillStyle='#ff5a1f';
    ctx.beginPath(); ctx.arc(jx,jy,4.2,0,6.28); ctx.fill();
    ctx.strokeStyle='#fff'; ctx.lineWidth=1.5; ctx.beginPath(); ctx.arc(jx,jy,4.2,0,6.28); ctx.stroke();
  },
  update(){
    const now=Game.raceTime();
    $('timeVal').textContent=U.fmtTime(Game.state==='racing'?now:0);
    $('lapVal').textContent=Game.lap+' / '+CFG.laps;
    $('bestVal').textContent=U.fmtTime(Game.bestLap);
    $('checkVal').textContent=Game.checksPassed+' / '+(CFG.gates-1);
    // speed
    const kmh=Math.abs(Car.speed)*3.6;
    $('speedVal').textContent=Math.round(kmh);
    $('gearVal').textContent=Car.gameLocked?'N':(Car.speed<-0.5?'R':' '+Car.gear);
    $('rpmFill').style.width=(Car.rpm*100)+'%';
    this.drawSpeedo();
    this.drawMinimap();
  },
  flashLap(msg){
    const el=$('lapMsg'); el.textContent=msg; el.classList.add('show');
    clearTimeout(this._lt); this._lt=setTimeout(()=>el.classList.remove('show'),1800);
  }
};

/* ------------------------------------------------------------
   GAME — state machine, lap logic, collisions, main loop
------------------------------------------------------------ */
const Game = {
  state:'idle',       // idle | countdown | racing | paused | finished
  locked:true,
  lap:1, bestLap:null, lastLap:null,
  lapStart:0, raceStart:0, now:0,
  expectedGate:1, checksPassed:0,
  countBegin:0, cdPhase:-1, lastHit:0, spdForAudio:0, drifting:0,
  carStand:null,

  init(){
    Track.build();
    World.init();
    Car.init();
    Particles.init();
    HUD.init();
    Input.bind({onStart:()=>this.beginCountdown(), onRestart:()=>this.restart(),
      onPause:()=>this.togglePause(), onCamera:()=>this.toggleCamera()});
    $('btnResume').addEventListener('click',()=>this.togglePause());
    $('btnRestart2').addEventListener('click',()=>this.restart());
    $('btnRestartF').addEventListener('click',()=>this.restart());
    window.addEventListener('resize',()=>{ World.resize(window.innerWidth,window.innerHeight); });
    this.showIdle();
    this.lastT=performance.now();
    requestAnimationFrame(()=>this.loop(performance.now()));
  },

  showIdle(){
    $('countdown').classList.add('show');
    $('cdText').textContent='▶';
    $('cdText').classList.remove('tick');
    $('cdSub').textContent='TAP OR PRESS ANY KEY TO START';
  },
  beginCountdown(){
    if(this.state!=='idle') return;
    this.state='countdown';
    this.countBegin=performance.now(); this.cdPhase=-1;
    $('cdText').textContent='3'; $('cdText').classList.add('tick');
    $('cdSub').textContent='GET READY';
  },

  raceTime(){ return this.state==='racing'?(this.now-this.raceStart)/1000:0; },

  restart(){
    // reset car
    const p=Track.pts[0];
    Car.pos.set(p.x,p.y,p.z);
    Car.heading=Math.atan2(p.tanX,p.tanZ);
    Car.vel.set(0,0); Car.speed=0; Car.steer=0; Car.gear=1; Car.rpm=0;
    Car.group.position.copy(Car.pos); Car.group.rotation.set(0,Car.heading,0);
    this.lap=1; this.bestLap=this.bestLap; // keep best across restarts? reset best
    this.bestLap=null; this.lastLap=null;
    this.expectedGate=1; this.checksPassed=0;
    this.state='idle'; this.locked=true;
    $('finishScreen').classList.remove('show');
    $('pauseMenu').classList.remove('show');
    $('countdown').classList.add('show');
    $('cdText').textContent='▶'; $('cdText').classList.remove('tick');
    $('cdSub').textContent='TAP OR PRESS ANY KEY TO START';
    CameraSys.reset();
    HUD.flashLap('RESTART');
  },
  togglePause(){
    if(this.state==='racing'){ this.state='paused'; $('pauseMenu').classList.add('show'); }
    else if(this.state==='paused'){ this.state='racing'; $('pauseMenu').classList.remove('show'); this.lastT=performance.now(); }
  },
  toggleCamera(){
    CameraSys.mode=CameraSys.mode==='chase'?'cine':'chase';
    HUD.flashLap(CameraSys.mode==='chase'?'CHASE CAM':'CINE CAM');
  },

  /* lap / checkpoint detection */
  _gates(loc){
    const G=Track.gates.length;
    const p=Track.pts[loc.idx];
    const onCourse=loc.absLat<p.half+2.0;
    const gate=Track.gates[this.expectedGate];
    if(!gate) return;
    // circular frac distance
    let d=Math.abs(loc.progress-gate.frac); d=Math.min(d,1-d);
    if(d<0.022 && onCourse && !this.locked){
      if(this.expectedGate===0){
        // completed a lap
        const lapT=(this.now-this.lapStart)/1000;
        this.lastLap=lapT;
        if(this.bestLap==null||lapT<this.bestLap) this.bestLap=lapT;
        HUD.flashLap('LAP '+this.lap+'  —  '+U.fmtTime(lapT));
        if(this.lap>=CFG.laps){ this.finish(); return; }
        this.lap++; this.lapStart=this.now; this.checksPassed=0;
        this.expectedGate=1;
      } else {
        this.checksPassed++;
        this.expectedGate++;
        if(this.expectedGate>=G) this.expectedGate=0;
        AudioSys.beep(880,0.12,'sine',0.12);
      }
    }
  },

  _collisions(loc){
    const car=Car; const now=performance.now();
    const cr=CFG.carRadius;
    const obs=World.obstacles;
    for(const o of obs){
      const dx=car.pos.x-o.x, dz=car.pos.z-o.z;
      const rr=o.r+cr;
      if(dx*dx+dz*dz<rr*rr){
        const dist=Math.sqrt(dx*dx+dz*dz)||0.001;
        const nx=dx/dist, nz=dz/dist;
        // push car out
        const push=rr-dist;
        car.pos.x+=nx*push; car.pos.z+=nz*push;
        // reflect velocity along normal (bounce) + kill momentum
        const vn=car.vel.x*nx+car.vel.y*nz;
        if(vn<0){ car.vel.x-=(1.4)*vn*nx; car.vel.y-=(1.4)*vn*nz; }
        car.vel.multiplyScalar(0.55);
        if(now-this.lastHit>200){
          this.lastHit=now;
          CameraSys.shake=1.4;
          AudioSys.beep(180,0.18,'sawtooth',0.5); // thud
          Car.pos.y+=Math.min(2,Math.abs(vn)*0.5);
        }
        const dir=Math.sign(car.vel.x*Math.cos(car.heading)-car.vel.y*Math.sin(car.heading))||1;
        Particles.emitSpark(car.pos.x,car.pos.y+0.6,car.pos.z,dir);
      }
    }
    // hard boundary (invisible wall) to keep car near course
    const wall=CFG.boundaryMargin + Track.pts[loc.idx].half;
    if(Math.abs(loc.lat)>wall){
      const side=Math.sign(loc.lat);
      const p=Track.pts[loc.idx];
      const nx=p.leftX*side, nz=p.leftZ*side;
      // snap to just inside the boundary
      car.pos.x=p.x+nx*wall;
      car.pos.z=p.z+nz*wall;
      const vn=car.vel.x*nx+car.vel.y*nz;
      if(vn>0){ car.vel.x-=1.4*vn*nx; car.vel.y-=1.4*vn*nz; }
      car.vel.multiplyScalar(0.6);
      CameraSys.shake=Math.max(CameraSys.shake,0.6);
    }
  },

  finish(){
    this.state='finished'; this.locked=true;
    const total=this.raceTime();
    $('finTotal').textContent=U.fmtTime(total);
    $('finBest').textContent=U.fmtTime(this.bestLap);
    $('finishScreen').classList.add('show');
    AudioSys.beep(660,0.2,'sine',0.35);
    AudioSys.beep(880,0.2,'sine',0.35,0.22);
    AudioSys.beep(1100,0.4,'sine',0.4,0.44);
  },

  /* surfaces + particle emission + audio per frame */
  _effects(loc,res,dt){
    const car=Car;
    const surf=res.surf;
    this.surfaceDetected=surf;
    const spd=Math.abs(car.speed);
    const offTrack=loc.absLat>Track.pts[loc.idx].half;
    const drifting=Math.abs(car.vel.x*Math.cos(car.heading)-car.vel.y*Math.sin(car.heading))>1.6;
    this.drifting=drifting;
    // dust
    const dustRate = (offTrack||drifting||surf!=='tarmac') ? 1:0;
    if(dustRate){
      if(Math.random()<Math.min(1,dt*40)){
        const fx=Math.sin(car.heading), fz=Math.cos(car.heading);
        Particles.emitDust(car.pos.x-fx*1.5,car.pos.y+0.3,car.pos.z-fz*1.5,-car.vel.x,-car.vel.y, surf==='mud'||offTrack);
      }
    }
    // skid marks
    if((drifting&&spd>6)||(surf==='mud'&&spd>2)){
      this._skidAcc=(this._skidAcc||0)+dt;
      if(this._skidAcc>0.06){ this._skidAcc=0;
        const fx=Math.sin(car.heading), fz=Math.cos(car.heading);
        Particles.skid(car.pos.x-fx*1.2,car.pos.z-fz*1.2,car.pos.y);
        Particles.skid(car.pos.x-fx*1.2+Math.cos(car.heading)*0.9,car.pos.z-fz*1.2-Math.sin(car.heading)*0.9,car.pos.y);
      }
    }
    // audio
    const surfAudio = (offTrack||surf==='mud'||drifting)?1:0;
    AudioSys.setSkid(surfAudio*U.clamp(spd/16,0,1));
    AudioSys.engine(spd,Car.rpm,this.locked?0:car.throttle);
  },

  loop(now){
    requestAnimationFrame(()=>this.loop(performance.now()));
    const dt=Math.min(0.05,(now-this.lastT)/1000); this.lastT=now; this.now=now;

    if(this.state==='countdown'){
      const el=(now-this.countBegin)/1000;
      let phase = el<1?3:el<2?2:el<3?1:0; // 0 => GO
      if(phase!==this.cdPhase){
        this.cdPhase=phase;
        if(phase===3||phase===2||phase===1){
          $('cdText').textContent=String(phase); $('cdText').classList.remove('tick');
          void $('cdText').offsetWidth; $('cdText').classList.add('tick');
          AudioSys.beep(660,0.22,'square',0.3);
        } else {
          $('cdText').textContent='GO!'; $('cdText').classList.remove('tick');
          void $('cdText').offsetWidth; $('cdText').classList.add('tick');
          $('cdSub').textContent='RACE!';
          AudioSys.beep(920,0.6,'square',0.35);
          this.state='racing'; this.locked=false;
          this.raceStart=now; this.lapStart=now;
          setTimeout(()=>{ $('countdown').classList.remove('show'); },650);
        }
      }
    }
    else if(this.state==='racing'){
      // car physics with locked guard
      const inp=this.locked? {steer:0,throttle:false,brake:false,handbrake:false} :
        {steer:Input.steer,throttle:Input.throttle,brake:Input.brake,handbrake:Input.handbrake};
      const res=Car.update(dt,inp);
      // surface recompute for effects/audio
      const surf=Car.surfaceCar();
      res.surf=surf;
      Car.surface=surf;
      const loc=Track.locate(Car.pos.x,Car.pos.z);
      this._gates(loc);
      this._collisions(loc);
      this._effects(loc,res,dt);
    }
    else if(this.state==='paused'){
      AudioSys.engine(0,0,0); AudioSys.setSkid(0);
    }
    else if(this.state==='idle'){
      AudioSys.engine(0,0,0);
    }

    // always update camera & render
    World.follow(Car.pos);
    CameraSys.update(dt);
    Particles.update(dt);
    World.renderer.render(World.scene,World.camera);
    HUD.update();
  }
};

/* boot */
Game.init();

/* single exposed handle for quick testing / debugging */
window.RallyGame={Car,Input,Game,Track,World,CameraSys,Particles,AudioSys};
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4-flash-vision-exp","provider":"openrouter","persona":"full","sessionId":"942e2552-0c96-4df4-9eab-09017b3ce2c0","tokensIn":3830612,"tokensOut":66791,"tokensTotal":3897403,"cost":0.16783832799999995,"turns":52,"toolCalls":51,"failedToolCalls":0,"timestamp":"2026-08-27T03:18:06.427Z"} -->