← Rally results

Rally

ox alpha · 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 43,287 bytes · SHA-256 6cb02af3ce89
<!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>Dust Rally 3D</title>
<style>
  /* ---------- Global layout ---------- */
  html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000;
    font-family:"Segoe UI",Arial,sans-serif;color:#fff;-webkit-user-select:none;user-select:none;}
  #game{position:fixed;inset:0;}
  canvas#three{display:block;width:100%;height:100%;}

  /* ---------- HUD ---------- */
  #hud{position:fixed;inset:0;pointer-events:none;z-index:5;}
  .panel{background:rgba(10,14,20,.55);border:1px solid rgba(255,255,255,.15);
    border-radius:10px;padding:8px 12px;backdrop-filter:blur(3px);}
  #topLeft{position:absolute;top:12px;left:12px;font-size:14px;line-height:1.5;text-shadow:0 1px 2px #000;}
  #topLeft b{color:#ffd24a;}
  #timerBox{position:absolute;top:12px;left:50%;transform:translateX(-50%);text-align:center;font-size:20px;
    font-weight:700;letter-spacing:1px;text-shadow:0 2px 4px #000;}
  #timerBox .sub{font-size:12px;color:#9fd0ff;font-weight:400;}
  /* Speedometer / gear */
  #speedo{position:absolute;right:16px;bottom:16px;text-align:center;}
  #speedVal{font-size:44px;font-weight:800;line-height:1;text-shadow:0 2px 6px #000;}
  #speedUnit{font-size:11px;color:#bbb;letter-spacing:2px;}
  #gearVal{font-size:22px;color:#ffd24a;font-weight:700;margin-top:2px;}
  #rpmBar{width:150px;height:8px;background:#222;border-radius:4px;margin:6px auto 0;overflow:hidden;border:1px solid #444;}
  #rpmFill{height:100%;width:0%;background:linear-gradient(90deg,#6f6,#ff4,#f43);transition:width .05s linear;}

  /* Minimap */
  #minimapWrap{position:absolute;left:12px;bottom:12px;padding:6px;}
  #minimap{display:block;}

  /* Countdown */
  #countdown{position:absolute;top:38%;left:50%;transform:translate(-50%,-50%);
    font-size:120px;font-weight:900;color:#ffd24a;text-shadow:0 0 30px rgba(255,180,40,.8),0 4px 10px #000;
    display:none;}
  #msgFlash{position:absolute;top:52%;left:50%;transform:translateX(-50%);font-size:26px;font-weight:700;
    color:#ff5b5b;text-shadow:0 2px 6px #000;display:none;}

  /* Overlays (pause / finish) */
  .overlay{position:fixed;inset:0;background:rgba(4,8,14,.78);display:none;z-index:20;
    align-items:center;justify-content:center;flex-direction:column;gap:18px;}
  .overlay h1{margin:0;font-size:42px;letter-spacing:3px;color:#ffd24a;text-shadow:0 3px 8px #000;}
  .overlay .stats{font-size:19px;line-height:1.9;text-align:center;}
  .overlay .stats b{color:#9fd0ff;}
  .btn{pointer-events:auto;cursor:pointer;background:linear-gradient(180deg,#ffcf4a,#f0a020);color:#20160a;
    border:none;border-radius:8px;padding:12px 34px;font-size:18px;font-weight:800;letter-spacing:1px;
    box-shadow:0 4px 0 #9a6510;}
  .btn:active{transform:translateY(3px);box-shadow:0 1px 0 #9a6510;}
  .btn.secondary{background:linear-gradient(180deg,#7fa8c9,#4d7699);color:#eef6ff;box-shadow:0 4px 0 #2c4a63;}

  /* Touch controls */
  #touchUI{position:fixed;inset:0;display:none;z-index:10;pointer-events:none;}
  .tbtn{position:absolute;width:74px;height:74px;border-radius:50%;background:rgba(255,255,255,.13);
    border:2px solid rgba(255,255,255,.35);color:#fff;font-size:26px;font-weight:800;
    display:flex;align-items:center;justify-content:center;pointer-events:auto;touch-action:none;}
  .tbtn.active{background:rgba(255,210,74,.45);}
  #tLeft{left:18px;bottom:110px;} #tRight{left:110px;bottom:110px;}
  #tGas{right:18px;bottom:170px;} #tBrake{right:18px;bottom:80px;} #tHand{right:106px;bottom:125px;}
  #helpTip{position:absolute;bottom:14px;left:50%;transform:translateX(-50%);font-size:12px;color:#cfd8e3;
    text-shadow:0 1px 2px #000;opacity:.85;}
</style>
</head>
<body>
<div id="game"><canvas id="three"></canvas></div>

<!-- HUD -->
<div id="hud">
  <div id="topLeft" class="panel">
    LAP <b id="lapNow">1</b>/3<br>
    BEST <b id="bestLap">--:--.--</b><br>
    LAST <b id="lastLap">--:--.--</b>
  </div>
  <div id="timerBox" class="panel">
    <span id="raceTime">00:00.00</span><br><span class="sub" id="camMode">CHASE CAM · C to toggle</span>
  </div>
  <div id="speedo">
    <div id="rpmBar"><div id="rpmFill"></div></div>
    <div id="speedVal">0</div><div id="speedUnit">KM/H</div>
    <div id="gearVal">N</div>
  </div>
  <div id="minimapWrap" class="panel"><canvas id="minimap" width="150" height="150"></canvas></div>
  <div id="countdown">3</div>
  <div id="msgFlash"></div>
  <div id="helpTip">WASD / Arrows drive · SPACE handbrake · R restart · P pause · C camera</div>
</div>

<!-- Pause -->
<div class="overlay" id="pauseMenu">
  <h1>PAUSED</h1>
  <button class="btn" id="btnResume">RESUME</button>
  <button class="btn secondary" id="btnRestartP">RESTART</button>
</div>

<!-- Finish -->
<div class="overlay" id="finishScreen">
  <h1>FINISH!</h1>
  <div class="stats" id="finishStats"></div>
  <button class="btn" id="btnRestartF">RACE AGAIN</button>
</div>

<!-- Touch controls -->
<div id="touchUI">
  <div class="tbtn" id="tLeft">◀</div><div class="tbtn" id="tRight">▶</div>
  <div class="tbtn" id="tGas">▲</div><div class="tbtn" id="tBrake">▼</div><div class="tbtn" id="tHand">✋</div>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
/* =====================================================================
   DUST RALLY 3D — single-file rally racer
   Modules: Input, AudioSynth, TrackBuilder, Scenery, CarPhysics,
            Particles, CameraRig, HUD, Minimap, Game (main loop)
   ===================================================================== */
(function () {
"use strict";

/* ---------------- Utilities ---------------- */
const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
const lerp  = (a,b,t)=>a+(b-a)*t;
const rand  = (a,b)=>a+Math.random()*(b-a);
function fmtTime(t){ if(t==null) return "--:--.--";
  const m=Math.floor(t/60), s=Math.floor(t%60), c=Math.floor((t*100)%100);
  return String(m).padStart(2,"0")+":"+String(s).padStart(2,"0")+"."+String(c).padStart(2,"0"); }

/* ---------------- Input (keyboard + touch) ---------------- */
const Input = {
  keys:{}, touch:{left:false,right:false,gas:false,brake:false,hand:false},
  init(game){
    addEventListener("keydown",e=>{
      this.keys[e.code]=true;
      if(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Space"].includes(e.code)) e.preventDefault();
      if(e.code==="KeyP") game.togglePause();
      if(e.code==="KeyR") game.restart();
      if(e.code==="KeyC") game.toggleCam();
    });
    addEventListener("keyup",e=>this.keys[e.code]=false);
    // touch
    const isTouch = ("ontouchstart" in window);
    if(isTouch) document.getElementById("touchUI").style.display="block";
    const bind=(id,prop)=>{
      const el=document.getElementById(id);
      const on=e=>{e.preventDefault();this.touch[prop]=true;el.classList.add("active");};
      const off=e=>{e.preventDefault();this.touch[prop]=false;el.classList.remove("active");};
      el.addEventListener("touchstart",on,{passive:false});
      el.addEventListener("touchend",off);el.addEventListener("touchcancel",off);
      el.addEventListener("mousedown",on);el.addEventListener("mouseup",off);el.addEventListener("mouseleave",off);
    };
    bind("tLeft","left");bind("tRight","right");bind("tGas","gas");bind("tBrake","brake");bind("tHand","hand");
  },
  get throttle(){ return (this.keys.ArrowUp||this.keys.KeyW||this.touch.gas)?1:0; },
  get brake(){ return (this.keys.ArrowDown||this.keys.KeyS||this.touch.brake)?1:0; },
  get steer(){ let s=0;
    if(this.keys.ArrowLeft||this.keys.KeyA||this.touch.left)s-=1;
    if(this.keys.ArrowRight||this.keys.KeyD||this.touch.right)s+=1; return s; },
  get handbrake(){ return !!(this.keys.Space||this.touch.hand); }
};

/* ---------------- Synthesized audio (Web Audio API) ---------------- */
const AudioSynth = {
  ctx:null, started:false,
  init(){
    try{ this.ctx=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){ return; }
    const c=this.ctx;
    this.master=c.createGain(); this.master.gain.value=.5; this.master.connect(c.destination);
    // Engine: two detuned saw oscillators through lowpass -> gain
    this.engGain=c.createGain(); this.engGain.gain.value=0;
    this.engFilter=c.createBiquadFilter(); this.engFilter.type="lowpass"; this.engFilter.frequency.value=600;
    this.oscA=c.createOscillator(); this.oscA.type="sawtooth";
    this.oscB=c.createOscillator(); this.oscB.type="square";
    const bGain=c.createGain(); bGain.gain.value=.35;
    this.oscA.connect(this.engFilter); this.oscB.connect(bGain); bGain.connect(this.engFilter);
    this.engFilter.connect(this.engGain); this.engGain.connect(this.master);
    this.oscA.start(); this.oscB.start();
    // Noise buffer for skid/gravel
    const len=c.sampleRate*1.5, buf=c.createBuffer(1,len,c.sampleRate), d=buf.getChannelData(0);
    for(let i=0;i<len;i++) d[i]=Math.random()*2-1;
    this.noiseBuf=buf;
    this.skidSrc=c.createBufferSource(); this.skidSrc.buffer=buf; this.skidSrc.loop=true;
    this.skidFilter=c.createBiquadFilter(); this.skidFilter.type="bandpass"; this.skidFilter.frequency.value=2200; this.skidFilter.Q.value=.7;
    this.skidGain=c.createGain(); this.skidGain.gain.value=0;
    this.skidSrc.connect(this.skidFilter); this.skidFilter.connect(this.skidGain); this.skidGain.connect(this.master);
    this.skidSrc.start();
    this.started=true;
  },
  resume(){ if(this.ctx&&this.ctx.state==="suspended") this.ctx.resume(); },
  /** rpm 0..1, load 0..1 */
  engine(rpm,load){
    if(!this.started)return;
    const f=55+rpm*160;
    this.oscA.frequency.setTargetAtTime(f,this.ctx.currentTime,.03);
    this.oscB.frequency.setTargetAtTime(f*.5,this.ctx.currentTime,.03);
    this.engFilter.frequency.setTargetAtTime(300+rpm*1800,this.ctx.currentTime,.05);
    this.engGain.gain.setTargetAtTime(.06+load*.12+rpm*.08,this.ctx.currentTime,.08);
  },
  skid(amount,gravel){
    if(!this.started)return;
    this.skidGain.gain.setTargetAtTime(clamp(amount,0,1)*.22,this.ctx.currentTime,.05);
    this.skidFilter.frequency.setTargetAtTime(gravel?900:2400,this.ctx.currentTime,.1);
  },
  beep(freq,dur,vol){
    if(!this.started)return; const c=this.ctx,t=c.currentTime;
    const o=c.createOscillator(),g=c.createGain();
    o.type="square"; o.frequency.value=freq;
    g.gain.setValueAtTime(vol||.25,t); g.gain.exponentialRampToValueAtTime(.001,t+dur);
    o.connect(g);g.connect(this.master); o.start(t); o.stop(t+dur+.02);
  },
  crash(intensity){
    if(!this.started)return; const c=this.ctx,t=c.currentTime;
    const src=c.createBufferSource(); src.buffer=this.noiseBuf;
    const f=c.createBiquadFilter(); f.type="lowpass"; f.frequency.value=400;
    const g=c.createGain();
    g.gain.setValueAtTime(clamp(intensity,.1,.6),t); g.gain.exponentialRampToValueAtTime(.001,t+.25);
    src.connect(f);f.connect(g);g.connect(this.master); src.start(t); src.stop(t+.3);
  },
  finishTone(){
    if(!this.started)return;
    [523,659,784,1047].forEach((f,i)=>setTimeout(()=>this.beep(f,.28,.28),i*160));
  }
};

/* ---------------- Track definition & builder ---------------- */
const TRACK_W = 11;               // half width of drivable road
const SAMPLES = 500;              // resolution of the centerline

const TrackBuilder = {
  curve:null, samples:[], surfaces:[], length:0,
  build(scene){
    // Control points: [x, z, elevation] — winding loop with hills & dips
    const CP=[
      [0,0,0],[60,-8,2],[120,10,6],[170,60,10],[180,130,14],[150,190,10],
      [90,215,6],[30,195,3],[-30,215,0],[-95,205,-2],[-150,165,-4],[-175,105,-6],
      [-160,45,-4],[-110,15,-2],[-70,-40,0],[-90,-100,-3],[-60,-155,-5],[0,-175,-3],
      [70,-160,0],[110,-110,2],[80,-60,1],[30,-45,0]
    ].map(p=>new THREE.Vector3(p[0],p[2],p[1]));
    this.curve=new THREE.CatmullRomCurve3(CP,true,"catmullrom",.5);
    this.length=this.curve.getLength();

    // Sample centerline: position, tangent, side vector, surface type
    const S=this.samples=[];
    for(let i=0;i<SAMPLES;i++){
      const t=i/SAMPLES;
      const p=this.curve.getPointAt(t);
      const tan=this.curve.getTangentAt(t).normalize();
      const side=new THREE.Vector3(-tan.z,0,tan.x).normalize(); // left normal in XZ
      S.push({p,tan,side,t});
    }
    // Surface map around the loop: 0=asphalt(fast) 1=dirt(fast) 2=mud(water/very slow)
    const surf=this.surfaces=[];
    for(let i=0;i<SAMPLES;i++){
      const f=i/SAMPLES;
      let s = (f<.35)?0 : (f<.62)?1 : (f<.72)?2 : (f<.86)?1 : 0;
      surf.push(s);
    }

    /* --- Road ribbon mesh --- */
    const pos=[],col=[],idx=[];
    const surfColors=[new THREE.Color(0x3a3d42),new THREE.Color(0x8a6b42),new THREE.Color(0x5e4a33)];
    for(let i=0;i<=SAMPLES;i++){
      const j=i%SAMPLES, s=S[j];
      const c=surfColors[surf[j]];
      const l=s.p.clone().addScaledVector(s.side,TRACK_W);
      const r=s.p.clone().addScaledVector(s.side,-TRACK_W);
      pos.push(l.x,l.y+.05,l.z, r.x,r.y+.05,r.z);
      col.push(c.r,c.g,c.b, c.r,c.g,c.b);
      if(i<SAMPLES){ const a=i*2;
        idx.push(a,a+1,a+2, a+1,a+3,a+2); }
    }
    const geo=new THREE.BufferGeometry();
    geo.setAttribute("position",new THREE.Float32BufferAttribute(pos,3));
    geo.setAttribute("color",new THREE.Float32BufferAttribute(col,3));
    geo.setIndex(idx); geo.computeVertexNormals();
    const road=new THREE.Mesh(geo,new THREE.MeshLambertMaterial({vertexColors:true}));
    road.receiveShadow=true; scene.add(road);

    /* --- Gravel shoulders (slightly wider ribbons, both sides) --- */
    const mkShoulder=(sign)=>{
      const p=[],ix=[];
      for(let i=0;i<=SAMPLES;i++){
        const j=i%SAMPLES,s=S[j];
        const inner=s.p.clone().addScaledVector(s.side,sign*(TRACK_W-.2));
        const outer=s.p.clone().addScaledVector(s.side,sign*(TRACK_W+3.2));
        p.push(inner.x,inner.y+.04,inner.z, outer.x,outer.y-.15,outer.z);
        if(i<SAMPLES){const a=i*2; ix.push(a,a+1,a+2, a+1,a+3,a+2);}
      }
      const g=new THREE.BufferGeometry();
      g.setAttribute("position",new THREE.Float32BufferAttribute(p,3));
      g.setIndex(ix); g.computeVertexNormals();
      scene.add(new THREE.Mesh(g,new THREE.MeshLambertMaterial({color:0x9a8752,side:THREE.DoubleSide})));
    };
    mkShoulder(1); mkShoulder(-1);

    /* --- Center dashes on asphalt stretches --- */
    const dashGeo=[],dashIdx=[];let vi=0;
    for(let i=0;i<SAMPLES;i+=8){
      if(surf[i]!==0)continue;
      const s=S[i],s2=S[(i+3)%SAMPLES];
      const a=s.p.clone().addScaledVector(s.side,.35),b=s.p.clone().addScaledVector(s.side,-.35);
      const cc=s2.p.clone().addScaledVector(s2.side,.35),d=s2.p.clone().addScaledVector(s2.side,-.35);
      dashGeo.push(a.x,a.y+.09,a.z,b.x,b.y+.09,b.z,cc.x,cc.y+.09,cc.z,d.x,d.y+.09,d.z);
      dashIdx.push(vi,vi+1,vi+2, vi+1,vi+3,vi+2); vi+=4;
    }
    const dg=new THREE.BufferGeometry();
    dg.setAttribute("position",new THREE.Float32BufferAttribute(dashGeo,3));
    dg.setIndex(dashIdx);
    scene.add(new THREE.Mesh(dg,new THREE.MeshBasicMaterial({color:0xf5f0d8})));

    /* --- Start/finish line checker strip --- */
    const s0=S[0], sl=s0.p.clone().addScaledVector(s0.side,TRACK_W), sr=s0.p.clone().addScaledVector(s0.side,-TRACK_W);
    const n0=S[4], nl=n0.p.clone().addScaledVector(n0.side,TRACK_W), nr=n0.p.clone().addScaledVector(n0.side,-TRACK_W);
    const fg=new THREE.BufferGeometry();
    fg.setAttribute("position",new THREE.Float32BufferAttribute(
      [sl.x,sl.y+.1,sl.z, sr.x,sr.y+.1,sr.z, nl.x,nl.y+.1,nl.z, nr.x,nr.y+.1,nr.z],3));
    fg.setIndex([0,1,2,1,3,2]); fg.computeVertexNormals();
    scene.add(new THREE.Mesh(fg,new THREE.MeshBasicMaterial({color:0xeeeeee})));
  },
  /** Nearest sample index to a position, searching near hint for speed */
  nearestIndex(p,hint){
    let best=-1,bd=1e9;
    const range=25;
    for(let k=-range;k<=range;k++){
      const i=((hint+k)%SAMPLES+SAMPLES)%SAMPLES, s=this.samples[i];
      const dx=p.x-s.p.x, dz=p.z-s.p.z, d=dx*dx+dz*dz;
      if(d<bd){bd=d;best=i;}
    }
    return best;
  },
  /** Full nearest search (used at init) */
  nearestIndexFull(p){
    let best=0,bd=1e9;
    for(let i=0;i<SAMPLES;i++){const s=this.samples[i];
      const dx=p.x-s.p.x,dz=p.z-s.p.z,d=dx*dx+dz*dz;if(d<bd){bd=d;best=i;}}
    return best;
  }
};

/* ---------------- Procedural textures (canvas-generated) ---------------- */
const Tex = {
  dust(){
    const c=document.createElement("canvas");c.width=c.height=64;
    const g=c.getContext("2d");
    const gr=g.createRadialGradient(32,32,2,32,32,30);
    gr.addColorStop(0,"rgba(210,190,160,.9)");gr.addColorStop(1,"rgba(210,190,160,0)");
    g.fillStyle=gr;g.fillRect(0,0,64,64);
    return new THREE.CanvasTexture(c);
  },
  spark(){
    const c=document.createElement("canvas");c.width=c.height=32;
    const g=c.getContext("2d");
    const gr=g.createRadialGradient(16,16,1,16,16,14);
    gr.addColorStop(0,"rgba(255,240,180,1)");gr.addColorStop(.4,"rgba(255,160,40,.8)");gr.addColorStop(1,"rgba(255,120,0,0)");
    g.fillStyle=gr;g.fillRect(0,0,32,32);
    return new THREE.CanvasTexture(c);
  }
};

/* ---------------- Scenery: trees, rocks, signs, barriers, crowd ---------------- */
const Scenery = {
  obstacles:[],   // {x,z,r} collision circles
  build(scene,track){
    const S=track.samples;
    const addObstacle=(x,z,r)=>this.obstacles.push({x,z,r});

    /* Trees — low-poly cone + trunk, scattered off-road */
    const treeGeoT=new THREE.CylinderGeometry(.25,.35,1.6,5);
    const treeGeoC=new THREE.ConeGeometry(1.6,4.2,6);
    const trunkMat=new THREE.MeshLambertMaterial({color:0x6b4a2a});
    const leafMats=[new THREE.MeshLambertMaterial({color:0x2e6b2f}),
                    new THREE.MeshLambertMaterial({color:0x3a7d33}),
                    new THREE.MeshLambertMaterial({color:0x27592a})];
    const treeGroup=new THREE.Group();
    for(let i=0;i<260;i++){
      const s=S[Math.floor(Math.random()*SAMPLES)];
      const off=(TRACK_W+6+Math.random()*45)*(Math.random()<.5?1:-1);
      const p=s.p.clone().addScaledVector(s.side,off);
      p.y+=rand(-1.5,2.5);
      const g=new THREE.Group();
      const trunk=new THREE.Mesh(treeGeoT,trunkMat);trunk.position.y=.8;
      const leaf=new THREE.Mesh(treeGeoC,leafMats[i%3]);leaf.position.y=3.2;
      const sc=rand(.7,1.6);g.scale.setScalar(sc);
      g.add(trunk,leaf);g.position.copy(p);
      g.rotation.y=Math.random()*Math.PI*2;
      treeGroup.add(g);
      if(off<TRACK_W+9) addObstacle(p.x,p.z,.9*sc);
    }
    scene.add(treeGroup);

    /* Rocks */
    const rockGeo=new THREE.DodecahedronGeometry(1,0);
    const rockMat=new THREE.MeshLambertMaterial({color:0x8b8b90});
    for(let i=0;i<70;i++){
      const s=S[Math.floor(Math.random()*SAMPLES)];
      const off=(TRACK_W+4+Math.random()*30)*(Math.random()<.5?1:-1);
      const p=s.p.clone().addScaledVector(s.side,off); p.y+=rand(-1,1);
      const m=new THREE.Mesh(rockGeo,rockMat);
      const sc=rand(.5,2);m.scale.set(sc,sc*.7,sc);
      m.position.copy(p);m.rotation.set(Math.random(),Math.random(),Math.random());
      m.castShadow=true;scene.add(m);
      if(off<TRACK_W+8) addObstacle(p.x,p.z,sc);
    }

    /* Distance markers / arrow signs on curves */
    const signMat=new THREE.MeshLambertMaterial({color:0xd8322a});
    const postMat=new THREE.MeshLambertMaterial({color:0xcccccc});
    for(let i=0;i<SAMPLES;i+=40){
      const a=S[i],b=S[(i+12)%SAMPLES];
      // curve direction hint: cross of tangents
      const cross=a.tan.x*b.tan.z-a.tan.z*b.tan.x;
      if(Math.abs(cross)<.05)continue;
      const dir=cross>0?-1:1; // place sign on outside of the corner
      const p=a.p.clone().addScaledVector(a.side,dir*(TRACK_W+3.5));
      const g=new THREE.Group();
      const post=new THREE.Mesh(new THREE.BoxGeometry(.15,1.6,.15),postMat);post.position.y=.8;
      const board=new THREE.Mesh(new THREE.BoxGeometry(1.8,.9,.1),signMat);board.position.y=1.9;
      g.add(post,board);g.position.copy(p);g.lookAt(p.clone().sub(a.side));
      scene.add(g);addObstacle(p.x,p.z,.7);
    }

    /* Guard-rail barriers along outer edges every so often */
    const railMat=new THREE.MeshLambertMaterial({color:0xd0d0d4});
    const railPost=new THREE.MeshLambertMaterial({color:0x555a60});
    for(let i=0;i<SAMPLES;i+=6){
      const s=S[i];
      const cross=s.tan.x*S[(i+10)%SAMPLES].tan.z-s.tan.z*S[(i+10)%SAMPLES].tan.x;
      const side=cross>0?-1:1;
      const p=s.p.clone().addScaledVector(s.side,side*(TRACK_W+3.8));
      p.y+=.5;
      const rail=new THREE.Mesh(new THREE.BoxGeometry(3.4,.28,.1),railMat);
      rail.position.copy(p);
      rail.rotation.y=Math.atan2(s.tan.x,s.tan.z)+Math.PI/2;
      scene.add(rail);
      if(i%18===0){
        const post=new THREE.Mesh(new THREE.BoxGeometry(.14,.9,.14),railPost);
        post.position.set(p.x,p.y-.45,p.z);scene.add(post);
      }
      addObstacle(p.x,p.z,.6);
    }

    /* Start-line banners + crowd */
    const s0=S[0];
    const bannerMat=new THREE.MeshLambertMaterial({color:0xffd24a});
    for(const dir of [1,-1]){
      const p=s0.p.clone().addScaledVector(s0.side,dir*(TRACK_W+2.5));
      const post=new THREE.Mesh(new THREE.CylinderGeometry(.15,.15,6,6),postMat);
      post.position.set(p.x,p.y+3,p.z);scene.add(post);
      addObstacle(p.x,p.z,.5);
    }
    const top=new THREE.Mesh(new THREE.BoxGeometry((TRACK_W+2.5)*2,.8,.5),bannerMat);
    top.position.set(s0.p.x,s0.p.y+6.2,s0.p.z);
    top.rotation.y=Math.atan2(s0.tan.x,s0.tan.z)+Math.PI/2;
    scene.add(top);
    // crowd blocks
    const crowdMat=new THREE.MeshLambertMaterial({color:0x4466aa});
    const crowdMat2=new THREE.MeshLambertMaterial({color:0xaa4466});
    for(let i=0;i<40;i++){
      const s=S[(i*3)%SAMPLES];
      const dir=i%2?1:-1;
      const p=s.p.clone().addScaledVector(s.side,dir*(TRACK_W+5.5));
      const m=new THREE.Mesh(new THREE.BoxGeometry(rand(.4,.7),rand(.9,1.4),rand(.4,.7)),
        i%2?crowdMat:crowdMat2);
      m.position.set(p.x,p.y+.5,p.z);m.rotation.y=Math.random()*Math.PI;
      scene.add(m);
    }
  }
};

/* ---------------- Rally car (procedural low-poly) ---------------- */
function buildCar(){
  const car=new THREE.Group();
  const bodyMat=new THREE.MeshLambertMaterial({color:0xe03a2a});
  const darkMat=new THREE.MeshLambertMaterial({color:0x22252a});
  const glassMat=new THREE.MeshLambertMaterial({color:0x9fd4ff});
  // main body
  const body=new THREE.Mesh(new THREE.BoxGeometry(2,.7,4.2),bodyMat);
  body.position.y=.65;body.castShadow=true;car.add(body);
  // cabin
  const cabin=new THREE.Mesh(new THREE.BoxGeometry(1.7,.6,2),bodyMat);
  cabin.position.set(0,1.25,.1);cabin.castShadow=true;car.add(cabin);
  const glass=new THREE.Mesh(new THREE.BoxGeometry(1.72,.42,1.7),glassMat);
  glass.position.set(0,1.28,.1);car.add(glass);
  // hood scoop + bumper
  const scoop=new THREE.Mesh(new THREE.BoxGeometry(.7,.2,.8),darkMat);
  scoop.position.set(0,1.05,-1.5);car.add(scoop);
  const bumper=new THREE.Mesh(new THREE.BoxGeometry(2.1,.3,.3),darkMat);
  bumper.position.set(0,.45,-2.15);car.add(bumper);
  // spoiler
  const wing=new THREE.Mesh(new THREE.BoxGeometry(2.1,.1,.55),darkMat);
  wing.position.set(0,1.35,1.95);car.add(wing);
  const wL=new THREE.Mesh(new THREE.BoxGeometry(.1,.4,.3),darkMat);
  wL.position.set(-.85,1.12,1.95);car.add(wL);
  const wR=wL.clone();wR.position.x=.85;car.add(wR);
  // headlights / taillights
  const hlMat=new THREE.MeshBasicMaterial({color:0xfff4c0});
  const tlMat=new THREE.MeshBasicMaterial({color:0xff2a1a});
  for(const sx of [-.65,.65]){
    const h=new THREE.Mesh(new THREE.BoxGeometry(.45,.18,.08),hlMat);
    h.position.set(sx,.75,-2.12);car.add(h);
    const t=new THREE.Mesh(new THREE.BoxGeometry(.45,.18,.08),tlMat);
    t.position.set(sx,.75,2.12);car.add(t);
  }
  // wheels (stored for spin/steer)
  const wheelGeo=new THREE.CylinderGeometry(.42,.42,.34,10);
  wheelGeo.rotateZ(Math.PI/2);
  const wheelMat=new THREE.MeshLambertMaterial({color:0x1a1a1c});
  const hubMat=new THREE.MeshLambertMaterial({color:0xb8b8b8});
  const wheels=[];
  for(const [x,z] of [[-1.05,-1.35],[1.05,-1.35],[-1.05,1.4],[1.05,1.4]]){
    const w=new THREE.Group();
    const tire=new THREE.Mesh(wheelGeo,wheelMat);tire.castShadow=true;
    const hub=new THREE.Mesh(new THREE.CylinderGeometry(.18,.18,.36,8),hubMat);
    hub.rotation.z=Math.PI/2;
    w.add(tire,hub);w.position.set(x,.42,z);car.add(w);wheels.push(w);
  }
  return {group:car,wheels};
}

/* ---------------- Particles: dust, sparks, skid marks ---------------- */
const Particles = {
  init(scene){
    this.scene=scene;
    this.dustTex=Tex.dust(); this.sparkTex=Tex.spark();
    // dust sprite pool
    this.dust=[];
    for(let i=0;i<90;i++){
      const sp=new THREE.Sprite(new THREE.SpriteMaterial({map:this.dustTex,transparent:true,opacity:0,depthWrite:false}));
      sp.visible=false;sp.userData={life:0};
      scene.add(sp);this.dust.push(sp);
    }
    this.dustIdx=0;
    // spark pool
    this.sparks=[];
    for(let i=0;i<40;i++){
      const sp=new THREE.Sprite(new THREE.SpriteMaterial({map:this.sparkTex,transparent:true,opacity:0,depthWrite:false}));
      sp.scale.setScalar(.5);sp.visible=false;sp.userData={life:0,vel:new THREE.Vector3()};
      scene.add(sp);this.sparks.push(sp);
    }
    this.sparkIdx=0;
    // skid marks: small dark quads laid on the road
    this.skids=[];
    const skGeo=new THREE.PlaneGeometry(.34,1.1);skGeo.rotateX(-Math.PI/2);
    const skMat=new THREE.MeshBasicMaterial({color:0x18140f,transparent:true,opacity:.55,depthWrite:false});
    for(let i=0;i<160;i++){
      const m=new THREE.Mesh(skGeo,skMat.clone());
      m.visible=false;m.userData={life:1};scene.add(m);this.skids.push(m);
    }
    this.skidIdx=0;
  },
  spawnDust(p,scale){
    const sp=this.dust[this.dustIdx=(this.dustIdx+1)%this.dust.length];
    sp.position.copy(p);sp.position.y+=.3;
    sp.scale.setScalar(scale||rand(.8,1.6));
    sp.material.opacity=.55;sp.visible=true;sp.userData.life=1;
  },
  spawnSpark(p){
    const sp=this.sparks[this.sparkIdx=(this.sparkIdx+1)%this.sparks.length];
    sp.position.copy(p);sp.material.opacity=1;sp.visible=true;
    sp.userData.life=1;
    sp.userData.vel.set(rand(-4,4),rand(2,6),rand(-4,4));
  },
  spawnSkid(p,angle){
    const m=this.skids[this.skidIdx=(this.skidIdx+1)%this.skids.length];
    m.position.copy(p);m.position.y+=.06;
    m.rotation.y=angle;m.visible=true;m.userData.life=1;m.material.opacity=.55;
  },
  update(dt){
    for(const sp of this.dust){ if(!sp.visible)continue;
      sp.userData.life-=dt*1.4;
      sp.material.opacity=Math.max(0,sp.userData.life*.55);
      sp.position.y+=dt*1.2; sp.scale.multiplyScalar(1+dt*1.5);
      if(sp.userData.life<=0)sp.visible=false;
    }
    for(const sp of this.sparks){ if(!sp.visible)continue;
      sp.userData.life-=dt*2.5;
      sp.userData.vel.y-=20*dt;
      sp.position.addScaledVector(sp.userData.vel,dt);
      sp.material.opacity=Math.max(0,sp.userData.life);
      if(sp.userData.life<=0)sp.visible=false;
    }
    for(const m of this.skids){ if(!m.visible)continue;
      m.userData.life-=dt*.12;
      m.material.opacity=Math.max(0,m.userData.life*.55);
      if(m.userData.life<=0)m.visible=false;
    }
  }
};

/* ---------------- Car physics (arcade rally model) ---------------- */
const Car = {
  pos:new THREE.Vector3(), heading:0,
  vel:new THREE.Vector3(),      // world-space velocity (XZ)
  yawRate:0, steerVis:0, wheelSpin:0,
  surface:0, onTrack:true, nearest:0, progress:0,
  MAXSPEED:46,                  // m/s ~165 km/h
  reset(track){
    const s=track.samples[2];
    this.pos.copy(s.p);this.pos.y+=.6;
    this.heading=Math.atan2(s.tan.x,s.tan.z); // face along tangent
    this.vel.set(0,0,0);this.yawRate=0;this.nearest=2;this.progress=0;
  },
  /** returns telemetry for audio/fx */
  update(dt,track){
    const th=Input.throttle, br=Input.brake, st=Input.steer, hb=Input.handbrake;
    // --- surface lookup ---
    this.nearest=track.nearestIndex(this.pos,this.nearest);
    const ns=track.samples[this.nearest];
    const dxz=this.pos.clone().sub(ns.p); dxz.y=0;
    const latDist=Math.abs(dxz.dot(ns.side));
    this.onTrack = latDist < TRACK_W+2.5;
    this.surface = this.onTrack ? track.surfaces[this.nearest] : 3; // 3=grass
    // grip & top speed per surface: asphalt, dirt, mud, grass
    const GRIP=[9.5,6.0,3.2,4.0][this.surface];
    const TOP  =[this.MAXSPEED,this.MAXSPEED*.92,10,14][this.surface];

    // --- decompose velocity ---
    const fwd=new THREE.Vector3(Math.sin(this.heading),0,Math.cos(this.heading));
    const right=new THREE.Vector3(fwd.z,0,-fwd.x);
    let vF=this.vel.dot(fwd), vR=this.vel.dot(right);

    // --- engine / brakes ---
    const power=26;
    if(th) vF += power*dt*(1-Math.max(0,vF)/TOP);
    if(br){ if(vF>1) vF-=34*dt; else vF-=10*dt; }         // brake then reverse
    vF=clamp(vF,-10,TOP*(this.onTrack?1:.55));
    // drag & rolling resistance
    vF -= vF*(this.onTrack?0.25:1.1)*dt;
    if(this.surface===2) vF-=vF*1.5*dt;                    // mud is glue

    // --- steering: speed-sensitive ---
    const speed=Math.abs(vF);
    const steerMax=lerp(.9,.32,clamp(speed/40,0,1));
    const steer=st*steerMax;
    this.steerVis=lerp(this.steerVis,steer,dt*8);
    this.yawRate=lerp(this.yawRate,steer*clamp(speed/8,0,1)*2.2,dt*6);
    this.heading+=this.yawRate*dt;

    // --- lateral grip / drift ---
    let grip=GRIP*(hb?.25:1);
    // centrifugal push in corners
    vR += this.yawRate*vF*dt;
    vR -= vR*Math.min(1,grip*dt);
    // handbrake kick
    if(hb&&speed>8) vR+=this.yawRate*vF*dt*.6;

    // recompose
    this.vel.copy(fwd).multiplyScalar(vF).addScaledVector(right,vR);
    this.pos.addScaledVector(this.vel,dt);
    this.pos.y=ns.p.y+.55+lerp(-.3,.3,clamp(latDist/TRACK_W,0,1)); // follow terrain

    // keep car inside world bounds
    const lim=230;
    this.pos.x=clamp(this.pos.x,-lim,lim);this.pos.z=clamp(this.pos.z,-lim,lim);

    this.wheelSpin+=vF*dt/.42;this.lastVF=vF;
    return {vF,vR,speed,drift:Math.abs(vR),surface:this.surface,onTrack:this.onTrack};
  }
};

/* ---------------- Lap / checkpoint logic ---------------- */
const Race = {
  state:"countdown",   // countdown | racing | paused | finished
  lap:1, totalLaps:3, raceTime:0, lapStart:0,
  lapTimes:[], best:null,
  nextCP:0, cpCount:12, lastProg:0,
  init(track){
    // evenly spaced checkpoints around the loop (fractions of the track)
    this.cps=[];for(let i=0;i<this.cpCount;i++)this.cps.push(i/this.cpCount);
    this.resetLapState();
  },
  resetLapState(){ this.nextCP=1; this.lastProg=0; },
  fullRestart(){
    this.state="countdown";this.lap=1;this.raceTime=0;this.lapStart=0;
    this.lapTimes=[];// keep best across restarts? no — fresh race
    this.resetLapState();
  },
  update(car,game){
    if(this.state!=="racing")return;
    this.raceTime+=game.dt;
    const prog=car.nearest/SAMPLES;
    const target=this.cps[this.nextCP];
    // detect passing the next checkpoint (within tolerance, forward direction)
    const d=Math.abs(prog-target);
    if(d<.04 && prog>this.lastProg-.5){
      this.nextCP++;
      if(this.nextCP>=this.cpCount){
        // all checkpoints cleared — next crossing of start line completes the lap
        this.nextCP=this.cpCount; // wait for wrap
      }
    }
    // lap complete when we wrap from high progress to low with all CPs hit
    if(this.nextCP>=this.cpCount && prog<this.lastProg-0.5){
      const lt=this.raceTime-this.lapStart;this.lapStart=this.raceTime;
      this.lapTimes.push(lt);
      if(this.best==null||lt<this.best){this.best=lt;game.hud.flash("NEW BEST LAP!");}
      else game.hud.flash("LAP "+fmtTime(lt));
      if(this.lap>=this.totalLaps){ game.finish(); return; }
      this.lap++;this.resetLapState();
      AudioSynth.beep(880,.15,.2);
    }
    // going backwards past start without CPs → guard against cheating
    this.lastProg=prog;
  }
};

/* ---------------- Camera rig ---------------- */
const CameraRig = {
  mode:0, pos:new THREE.Vector3(0,10,-20), look:new THREE.Vector3(),
  toggle(){ this.mode=(this.mode+1)%2; },
  update(dt,cam,car,speed){
    const fwd=new THREE.Vector3(Math.sin(car.heading),0,Math.cos(car.heading));
    let want,look;
    if(this.mode===0){ // chase cam
      want=car.pos.clone().addScaledVector(fwd,-9.5).add(new THREE.Vector3(0,4.4,0));
      look=car.pos.clone().addScaledVector(fwd,7).add(new THREE.Vector3(0,1.4,0));
    }else{ // cinematic high cam
      want=car.pos.clone().addScaledVector(fwd,-22).add(new THREE.Vector3(0,16,0));
      look=car.pos.clone().addScaledVector(fwd,10);
    }
    // lag: slower follow at speed for dynamic feel
    const k=1-Math.pow(.0018,dt)*(this.mode===0?1:.6);
    this.pos.lerp(want,k);
    cam.position.copy(this.pos);
    this.look.lerp(look,1-Math.pow(.0005,dt));
    cam.lookAt(this.look);
    // subtle FOV stretch with speed
    cam.fov=lerp(cam.fov,62+clamp(speed/46,0,1)*14,dt*3);
    cam.updateProjectionMatrix();
  }
};

/* ---------------- HUD + Minimap ---------------- */
const HUD = {
  els:{}, flashT:0,
  init(game){
    const id=s=>document.getElementById(s);
    this.els={lap:id("lapNow"),best:id("bestLap"),last:id("lastLap"),
      time:id("raceTime"),speed:id("speedVal"),gear:id("gearVal"),rpm:id("rpmFill"),
      cd:id("countdown"),flash:id("msgFlash"),camMode:id("camMode")};
    id("btnResume").onclick=()=>game.togglePause();
    id("btnRestartP").onclick=()=>{game.togglePause(true);game.restart();};
    id("btnRestartF").onclick=()=>game.restart();
    // minimap path precomputed
    const c=id("minimap"),g=c.getContext("2d");
    this.mmC=c;this.mmG=g;
    this.mmPath=TrackBuilder.samples.map(s=>({x:s.p.x,z:s.p.z}));
  },
  flash(msg){ this.els.flash.textContent=msg;this.els.flash.style.display="block";this.flashT=2; },
  showCountdown(txt){ const e=this.els.cd;e.style.display="block";e.textContent=txt; },
  hideCountdown(){ this.els.cd.style.display="none"; },
  setCam(mode){ this.els.camMode.textContent=mode===0?"CHASE CAM · C to toggle":"CINEMATIC CAM · C to toggle"; },
  update(dt,car,race,audioRpm){
    const e=this.els;
    e.lap.textContent=race.lap;e.best.textContent=fmtTime(race.best);
    e.last.textContent=fmtTime(race.lapTimes[race.lapTimes.length-1]||null);
    e.time.textContent=fmtTime(race.raceTime);
    const kmh=Math.round(Car.vel.length()*3.6);
    e.speed.textContent=kmh;
    // simulated gear from speed
    const gears=[0,20,45,75,110,150,999];let gi=0;
    while(kmh>gears[gi+1])gi++;
    const lo=gears[gi],hi=gears[gi+1];
    const rpm=.25+.75*clamp((kmh-lo)/(hi-lo),0,1);
    e.gear.textContent=Car.vel.length()<.5?"N":(Car.lastVF<-0.5?"R":Math.max(1,gi));
    e.rpm.style.width=(rpm*100)+"%";
    if(this.flashT>0){this.flashT-=dt;if(this.flashT<=0)this.els.flash.style.display="none";}
    // minimap
    const g=this.mmG,W=this.mmC.width;
    g.clearRect(0,0,W,W);
    g.fillStyle="rgba(0,0,0,.35)";g.fillRect(0,0,W,W);
    const map=(v)=>W/2+v/240*(W/2-8);
    g.strokeStyle="#666";g.lineWidth=4;g.lineJoin="round";g.beginPath();
    this.mmPath.forEach((p,i)=>{const x=map(p.x),y=map(p.z);i?g.lineTo(x,y):g.moveTo(x,y);});
    g.closePath();g.stroke();
    g.strokeStyle="#ddd";g.lineWidth=1.5;g.stroke();
    // player dot
    g.fillStyle="#ffd24a";g.beginPath();
    g.arc(map(car.pos.x),map(car.pos.z),4,0,7);g.fill();
  }
};

/* ---------------- Main game object ---------------- */
const Game = {
  dt:0,lastT:0,countdownT:-1,
  init(){
    // renderer
    this.renderer=new THREE.WebGLRenderer({canvas:document.getElementById("three"),antialias:true});
    this.renderer.setSize(innerWidth,innerHeight);
    this.renderer.setPixelRatio(Math.min(devicePixelRatio,2));
    this.renderer.shadowMap.enabled=true;
    this.renderer.shadowMap.type=THREE.PCFSoftShadowMap;

    this.scene=new THREE.Scene();
    this.scene.background=new THREE.Color(0x87b8e0);
    this.scene.fog=new THREE.Fog(0x9cc4e4,120,420);

    this.camera=new THREE.PerspectiveCamera(64,innerWidth/innerHeight,.1,600);

    // lights
    const sun=new THREE.DirectionalLight(0xfff2d8,1.05);
    sun.position.set(80,140,40);sun.castShadow=true;
    sun.shadow.mapSize.set(2048,2048);
    const sc=sun.shadow.camera;sc.left=-160;sc.right=160;sc.top=160;sc.bottom=-160;sc.far=400;
    this.scene.add(sun);
    this.scene.add(new THREE.AmbientLight(0x8899bb,.55));
    this.scene.add(new THREE.HemisphereLight(0xbfdcff,0x556b44,.35));

    // ground plane
    const ground=new THREE.Mesh(
      new THREE.PlaneGeometry(900,900,64,64),
      new THREE.MeshLambertMaterial({color:0x5d7a42}));
    ground.rotation.x=-Math.PI/2;ground.position.y=-.35;
    ground.receiveShadow=true;this.scene.add(ground);
    // gentle hills via vertex noise on the ground
    const gp=ground.geometry.attributes.position;
    for(let i=0;i<gp.count;i++){
      const x=gp.getX(i),y=gp.getY(i);
      gp.setZ(i,Math.sin(x*.02)*Math.cos(y*.03)*1.2+Math.sin(x*.008+y*.01)*2);
    }
    gp.needsUpdate=true;ground.geometry.computeVertexNormals();

    // world
    TrackBuilder.build(this.scene);
    Scenery.build(this.scene,TrackBuilder);
    Particles.init(this.scene);
    const carParts=buildCar();
    this.carMesh=carParts.group;this.carWheels=carParts.wheels;
    this.scene.add(this.carMesh);

    Input.init(this);
    HUD.init(this);
    Race.init(TrackBuilder);
    Car.reset(TrackBuilder);
    CameraRig.update(1,this.camera,Car,0);
    AudioSynth.init();
    addEventListener("resize",()=>this.onResize());
    // audio unlock on first interaction
    ["pointerdown","keydown"].forEach(ev=>addEventListener(ev,()=>AudioSynth.resume(),{once:true}));
    this.startCountdown();
    requestAnimationFrame(t=>this.loop(t));
  },
  onResize(){
    this.camera.aspect=innerWidth/innerHeight;this.camera.updateProjectionMatrix();
    this.renderer.setSize(innerWidth,innerHeight);
  },
  startCountdown(){
    Race.fullRestart();Car.reset(TrackBuilder);
    this.countdownT=3.0;this.cdShown=4;
    HUD.showCountdown("3");
  },
  restart(){ if(Race.state==="paused")this.togglePause(true); this.startCountdown(); },
  togglePause(forceOff){
    if(Race.state==="finished")return;
    const menu=document.getElementById("pauseMenu");
    if(Race.state==="paused"||forceOff){Race.state="racing";menu.style.display="none";}
    else if(Race.state==="racing"){Race.state="paused";menu.style.display="flex";}
  },
  toggleCam(){ CameraRig.toggle();HUD.setCam(CameraRig.mode); },
  finish(){
    Race.state="finished";
    document.getElementById("finishStats").innerHTML=
      "TOTAL TIME <b>"+fmtTime(Race.raceTime)+"</b><br>"+
      "BEST LAP <b>"+fmtTime(Race.best)+"</b><br>"+
      "LAPS "+Race.lapTimes.map(fmtTime).join(" · ");
    document.getElementById("finishScreen").style.display="flex";
    AudioSynth.finishTone();
    AudioSynth.engine(0,0);AudioSynth.skid(0,false);
  },
  /** collisions vs scenery obstacle circles */
  collide(fx){
    for(const o of Scenery.obstacles){
      const dx=Car.pos.x-o.x,dz=Car.pos.z-o.z;
      const rr=o.r+1.3,d2=dx*dx+dz*dz;
      if(d2<rr*rr&&d2>1e-6){
        const d=Math.sqrt(d2),nx=dx/d,nz=dz/d;
        Car.pos.x=o.x+nx*rr;Car.pos.z=o.z+nz*rr;
        const vn=Car.vel.x*nx+Car.vel.z*nz;
        if(vn<0){
          Car.vel.x-=1.6*vn*nx;Car.vel.z-=1.6*vn*nz; // reflect w/ bounce
          Car.vel.multiplyScalar(.45);
          const inten=Math.min(.6,-vn/25);
          if(inten>.08){AudioSynth.crash(inten);
            for(let i=0;i<6;i++)Particles.spawnSpark(Car.pos.clone().add(new THREE.Vector3(nx,0.4,nz)));}
        }
      }
    }
  },
  loop(t){
    requestAnimationFrame(tt=>this.loop(tt));
    const dt=Math.min((t-this.lastT)/1000||.016,.05);this.dt=dt;this.lastT=t;

    if(Race.state==="countdown"){
      this.countdownT-=dt;
      const n=Math.ceil(this.countdownT);
      if(n!==this.cdShown&&n>=1){this.cdShown=n;HUD.showCountdown(String(n));AudioSynth.beep(440,.18,.3);}
      if(this.countdownT<=0){
        Race.state="racing";HUD.hideCountdown();
        HUD.showCountdown("GO!");setTimeout(()=>HUD.hideCountdown(),700);
        AudioSynth.beep(880,.4,.35);
        Race.lapStart=0;Race.raceTime=0;
      }
    }

    const fx=Car.update(dt,TrackBuilder) || {speed:0,drift:0,vF:0,vR:0};
    if(Race.state==="racing"||Race.state==="finished"){ /* physics idle when finished */ }
    if(Race.state==="countdown"||Race.state==="paused"){
      // freeze car during countdown/pause
      Car.vel.multiplyScalar(Race.state==="paused"?0:1);
    } else {
      this.collide(fx);
      Race.update(Car,this);
    }

    /* --- visual FX driven by telemetry --- */
    const speedN=clamp(Math.abs(fx.vF)/Car.MAXSPEED,0,1);
    if(Race.state==="racing"){
      // wheels roll & steer visually
      this.carMesh.position.copy(Car.pos);
      this.carMesh.rotation.y=Car.heading;
      this.carMesh.rotation.z=lerp(this.carMesh.rotation.z,clamp(-fx.vR*.02,-.15,.15),dt*5);
      this.carMesh.rotation.x=lerp(this.carMesh.rotation.x,clamp(-(Input.throttle? .012:0)+(Input.brake?.018:0),-.03,.03),dt*5);
      for(let i=0;i<4;i++){
        const w=this.carWheels[i];
        w.children.forEach(m=>m.rotation.x=Car.wheelSpin);
        if(i<2)w.rotation.y=Car.steerVis*.5;
      }
      // dust & skids when drifting / off-road
      const drifting=fx.drift>4||(Input.handbrake&&speedN>.2)||(!fx.onTrack&&speedN>.25);
      if(drifting){
        const fwd=new THREE.Vector3(Math.sin(Car.heading),0,Math.cos(Car.heading));
        const right=new THREE.Vector3(fwd.z,0,-fwd.x);
        for(const s of [-1,1]){
          const wp=Car.pos.clone().addScaledVector(fwd,1.4).addScaledVector(right,s*1.05);
          Particles.spawnSkid(wp,Car.heading);
          if(Math.random()<.5)Particles.spawnDust(wp,rand(.8,1.8));
        }
        AudioSynth.skid(clamp(fx.drift/10+(fx.onTrack?0:.5),0,1),!fx.onTrack||fx.surface===1);
      }else{
        AudioSynth.skid(speedN*.15*(fx.onTrack?0:1),!fx.onTrack);
        if(!fx.onTrack&&Math.random()<.4)
          Particles.spawnDust(Car.pos.clone().addScaledVector(new THREE.Vector3(Math.sin(Car.heading),0,Math.cos(Car.heading)),-1.4),1.4);
      }
      // engine sound
      const kmh=Car.vel.length()*3.6;
      const gears=[0,20,45,75,110,150,999];let gi=0;while(kmh>gears[gi+1])gi++;
      const rpm=clamp(.25+.75*((kmh-gears[gi])/(gears[gi+1]-gears[gi])),0,1);
      AudioSynth.engine(gi===0&&Input.throttle? .5:rpm,(Input.throttle?1:0)*.6+speedN*.4);
    }else if(Race.state==="finished"){
      AudioSynth.engine(0,0);AudioSynth.skid(0,false);
      this.carMesh.position.copy(Car.pos);this.carMesh.rotation.y=Car.heading;
    }

    Particles.update(dt);
    CameraRig.update(dt,this.camera,Car,Car.vel.length());
    HUD.update(dt,Car,Race);
    this.renderer.render(this.scene,this.camera);
  }
};

/* boot */
window.addEventListener("load",()=>Game.init());
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"stealth/ox-alpha","provider":"openrouter","persona":"full","sessionId":"ff85cb80-3a82-47d7-bb63-5e907f468219","tokensIn":390750,"tokensOut":17456,"tokensTotal":408206,"cost":0,"turns":14,"toolCalls":15,"failedToolCalls":0,"timestamp":"2026-08-21T23:13:31.803Z"} -->