← Rally results

Rally

muse spark 1.1 · composer

Initial view of Rally

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 44,355 bytes · SHA-256 0a38ca750fb3
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rally Rush — 3D Procedural Racer</title>
<script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
<style>
/* ── css: layout ── */
@import url('https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@600;700&family=JetBrains+Mono:wght@600&display=swap');
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#12161c;font-family:'Chakra Petch',system-ui,sans-serif}
#app{position:relative;width:100vw;height:100vh;overflow:hidden}
#gameCanvas{display:block;width:100%;height:100%}
#hud{position:absolute;inset:0;pointer-events:none}
#topbar{display:flex;gap:10px;padding:14px 18px;flex-wrap:wrap}
.hud-block{background:rgba(0,0,0,.55);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.12);padding:8px 14px;border-radius:12px;min-width:110px;display:flex;flex-direction:column}
.hud-block .label{font-size:9px;letter-spacing:.18em;color:#9aa0a8}
.hud-block span:last-child{font-family:'JetBrains Mono',monospace;font-size:16px;color:#fff;line-height:1.1;margin-top:2px}
#gearBox{position:absolute;left:18px;bottom:18px;background:rgba(0,0,0,.55);border:1px solid rgba(255,255,255,.12);backdrop-filter:blur(10px);padding:10px 14px;border-radius:14px;display:flex;align-items:center;gap:10px;min-width:150px}
#gearTxt{font-family:'JetBrains Mono';font-size:28px;font-weight:800;color:#ffec44;width:36px;text-align:center;text-shadow:0 0 12px #ffec44}
#rpmBar{width:86px;height:8px;background:rgba(255,255,255,.15);border-radius:99px;overflow:hidden}
#rpmFill{height:100%;width:10%;background:linear-gradient(90deg,#4cf5a7,#ffec44 60%,#ff3b30);transition:width .05s linear}
#minimap{position:absolute;right:18px;bottom:18px;width:200px;height:200px;border-radius:50%;border:2px solid rgba(255,255,255,.15);background:radial-gradient(circle at center,#1e2a33 0%,#0f151a 70%);box-shadow:0 8px 30px rgba(0,0,0,.6), inset 0 0 0 4px rgba(0,0,0,.4)}
#surfaceFlag{position:absolute;right:18px;bottom:230px;padding:6px 10px;border-radius:999px;font-size:11px;letter-spacing:.12em;background:#ff3b30;color:#fff;opacity:0;transform:translateY(6px);transition:.2s}
#surfaceFlag.show{opacity:1;transform:translateY(0)}
#countdown{position:absolute;left:50%;top:38%;transform:translate(-50%,-50%);font-size:16vw;font-weight:800;color:#fff;line-height:1;text-shadow:0 8px 30px rgba(0,0,0,.8),0 0 30px rgba(255,236,68,.6);pointer-events:none;opacity:0;transition:.2s}
#countdown.pop{opacity:1;transform:translate(-50%,-50%) scale(1.08)}
#overlay{position:absolute;inset:0;background:radial-gradient(120% 120% at 30% 10%,rgba(76,245,167,.18),transparent 40%),radial-gradient(120% 120% at 90% 90%,rgba(255,236,68,.18),transparent 40%),rgba(8,10,12,.78);backdrop-filter:blur(18px);display:flex;align-items:center;justify-content:center;transition:.35s}
#overlay.hidden{opacity:0;pointer-events:none}
#overlayCard{background:linear-gradient(180deg,rgba(255,255,255,.08),rgba(255,255,255,.02));border:1px solid rgba(255,255,255,.14);border-radius:24px;padding:28px 30px;max-width:460px;width:92%;text-align:center;box-shadow:0 20px 80px rgba(0,0,0,.6)}
#overlayTitle{font-size:44px;letter-spacing:-.02em;color:#fff;line-height:.9}
#overlayMsg{color:#b8c0c8;margin:12px 0 18px;font-size:14px;line-height:1.5}
#resultStats{font-family:'JetBrains Mono';color:#d8e0e8;font-size:13px;line-height:1.6;margin-bottom:18px;white-space:pre-line}
.btnRow{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
button.primary,button#btnResume{appearance:none;border:0;border-radius:999px;padding:12px 22px;font-weight:700;letter-spacing:.04em;cursor:pointer;transition:.18s}
button.primary{background:#ffec44;color:#111;box-shadow:0 8px 24px rgba(255,236,68,.35)}
button.primary:hover{transform:translateY(-1px) scale(1.02);box-shadow:0 12px 28px rgba(255,236,68,.5)}
button#btnResume{background:rgba(255,255,255,.12);color:#fff;border:1px solid rgba(255,255,255,.18)}
.hint{margin-top:18px;color:#7f8a93;font-size:11px;line-height:1.5;letter-spacing:.02em}
.hidden{display:none !important}
#touchControls{position:absolute;inset:0;pointer-events:none;display:none}
@media (max-width:900px){#touchControls{display:block}#topbar{gap:6px;padding:10px}#hud-block{min-width:86px}#minimap{width:140px;height:140px;right:10px;bottom:10px}#gearBox{left:10px;bottom:10px}}
#joyArea{position:absolute;left:14px;bottom:14px;width:140px;height:140px;border-radius:50%;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.12);pointer-events:auto;touch-action:none}
#joyStick{position:absolute;left:50%;top:50%;width:56px;height:56px;margin:-28px 0 0 -28px;border-radius:50%;background:radial-gradient(circle at 35% 30%,#fff,#c8d0d8);box-shadow:0 6px 18px rgba(0,0,0,.5)}
#actionArea{position:absolute;right:10px;bottom:14px;display:flex;flex-direction:column;gap:10px;pointer-events:auto}
.tBtn{width:92px;height:54px;border-radius:14px;border:1px solid rgba(255,255,255,.18);background:rgba(0,0,0,.42);color:#fff;font-weight:800;letter-spacing:.08em;backdrop-filter:blur(8px)}
.tBtn:active{transform:scale(.97);background:rgba(255,236,68,.9);color:#111}
</style>
</head>
<body>
<div id="app">
  <canvas id="gameCanvas"></canvas>
  <div id="hud">
    <div id="topbar">
      <div class="hud-block"><span class="label">LAP</span><span id="lapTxt">1 / 3</span></div>
      <div class="hud-block"><span class="label">TIME</span><span id="timeTxt">00:00.000</span></div>
      <div class="hud-block"><span class="label">BEST</span><span id="bestTxt">--:--.---</span></div>
      <div class="hud-block"><span class="label">SPEED</span><span id="speedTxt">0 km/h</span></div>
    </div>
    <div id="gearBox"><span id="gearTxt">N</span><div id="rpmBar"><div id="rpmFill"></div></div></div>
    <canvas id="minimap" width="200" height="200"></canvas>
    <div id="surfaceFlag"></div>
  </div>
  <div id="countdown"></div>
  <div id="overlay" class="hidden">
    <div id="overlayCard">
      <h1 id="overlayTitle">RALLY RUSH</h1>
      <p id="overlayMsg">Complete 3 laps. Stay on track, drift through mud.</p>
      <div id="resultStats"></div>
      <div class="btnRow">
        <button id="btnGo" class="primary">START RACE</button>
        <button id="btnResume" class="hidden">RESUME [P]</button>
      </div>
      <div class="hint">WASD / Arrows • SPACE handbrake • C camera • R restart • P pause<br>Touch: left pad steer, right buttons gas/brake/drift</div>
    </div>
  </div>
  <div id="touchControls">
    <div id="joyArea"><div id="joyStick"></div></div>
    <div id="actionArea">
      <button class="tBtn" id="tBrake">BRAKE</button>
      <button class="tBtn" id="tGas">GAS</button>
      <button class="tBtn" id="tHand">DRIFT</button>
    </div>
  </div>
</div>
<script>
'use strict';

// ── const: CONFIG ──
const CONFIG = {
  laps: 3,
  trackSegments: 200,
  trackWidth: 12,
  offWidth: 28,
  maxSpeed: 62, // m/s ~ 223 km/h
  gravity: -18,
  fogColor: 0xcfe4f0,
  worldSize: 600,
  checkpoints: 16
};

// ── const: SURFACE ──
const SURFACE = { ASPHALT:0, GRASS:1, MUD:2, WATER:3 };

// ── const: SURFACE_CFG ──
const SURFACE_CFG = {
  [SURFACE.ASPHALT]: { grip:1.0, drag:0.02, maxF:1.0, label:'', color:'#ffcc00' },
  [SURFACE.GRASS]:   { grip:0.55, drag:0.10, maxF:0.62, label:'GRASS', color:'#4cf5a7' },
  [SURFACE.MUD]:     { grip:0.32, drag:0.18, maxF:0.45, label:'MUD', color:'#8a5a2b' },
  [SURFACE.WATER]:   { grip:0.22, drag:0.28, maxF:0.35, label:'WATER', color:'#3aa0ff' }
};

// ── function: lerp ──
function lerp(a,b,t){return a+(b-a)*t}

// ── function: clamp ──
function clamp(v,a,b){return Math.max(a,Math.min(b,v))}

// ── function: smoothstep ──
function smoothstep(t){return t*t*(3-2*t)}

// ── function: fmtTime ──
function fmtTime(ms){
  if(!isFinite(ms)||ms<=0) return '--:--.---';
  let m=Math.floor(ms/60000), s=Math.floor((ms%60000)/1000), mm=Math.floor(ms%1000);
  return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}.${String(mm).padStart(3,'0')}`;
}

// ── class: Track ──
class Track {
  constructor(){
    this.points=[];
    this.length=0;
    this.total=0;
    this.checkGates=[];
    this.generate();
  }
  generate(){
    const N=CONFIG.trackSegments;
    const controls=[];
    const R=120;
    for(let i=0;i<8;i++){
      let ang = (i/8)*Math.PI*2 + Math.sin(i*1.3)*0.35;
      let rad = R * (0.8 + Math.sin(i*2.1)*0.25 + (i%2?0.15:0));
      let x = Math.cos(ang)*rad + Math.sin(ang*2)*18;
      let z = Math.sin(ang)*rad*0.9 + Math.cos(ang*1.7)*22;
      let y = Math.sin(i*1.8)*12 + Math.cos(i*0.9)*6;
      controls.push(new THREE.Vector3(x,y,z));
    }
    const pts=[];
    const get = (i)=> controls[(i+controls.length)%controls.length];
    for(let i=0;i<controls.length;i++){
      for(let j=0;j<N/controls.length;j++){
        let t=j/(N/controls.length);
        let p0=get(i-1),p1=get(i),p2=get(i+1),p3=get(i+2);
        let tt=t*t, ttt=tt*t;
        let x=0.5*((2*p1.x)+(-p0.x+p2.x)*t+(2*p0.x-5*p1.x+4*p2.x-p3.x)*tt+(-p0.x+3*p1.x-3*p2.x+p3.x)*ttt);
        let y=0.5*((2*p1.y)+(-p0.y+p2.y)*t+(2*p0.y-5*p1.y+4*p2.y-p3.y)*tt+(-p0.y+3*p1.y-3*p2.y+p3.y)*ttt);
        let z=0.5*((2*p1.z)+(-p0.z+p2.z)*t+(2*p0.z-5*p1.z+4*p2.z-p3.z)*tt+(-p0.z+3*p1.z-3*p2.z+p3.z)*ttt);
        y = y + Math.sin(t*6 + i)*1.2 + Math.sin((i+t)*0.8)*4;
        pts.push(new THREE.Vector3(x,y,z));
      }
    }
    let acc=0;
    const arr=[];
    for(let i=0;i<pts.length;i++){
      let cur=pts[i], nxt=pts[(i+1)%pts.length];
      let dir=new THREE.Vector3().subVectors(nxt,cur);
      let seg=dir.length(); dir.normalize();
      let right=new THREE.Vector3(-dir.z,0,dir.x).normalize();
      acc+=seg;
      arr.push({pos:cur.clone(), dir:dir, right:right, seg:seg, accum:acc, width:CONFIG.trackWidth*(0.9+0.18*Math.sin(i*0.17))});
    }
    this.points=arr; this.total=acc;
    for(let i=0;i<CONFIG.checkpoints;i++){
      let idx=Math.floor((i/CONFIG.checkpoints)*arr.length);
      this.checkGates.push({idx, accum:arr[idx].accum});
    }
  }
  closestParam(worldPos){
    let best=0,bestD=1e9;
    for(let i=0;i<this.points.length;i++){
      let d=this.points[i].pos.distanceToSquared(worldPos);
      if(d<bestD){bestD=d;best=i;}
    }
    let idx=best;
    let a=this.points[idx].pos, b=this.points[(idx+1)%this.points.length].pos;
    let ab=new THREE.Vector3().subVectors(b,a);
    let ap=new THREE.Vector3().subVectors(worldPos,a);
    let t=clamp(ap.dot(ab)/ab.lengthSq(),0,1);
    let proj=new THREE.Vector3().addVectors(a, ab.clone().multiplyScalar(t));
    let lateral=new THREE.Vector3().subVectors(worldPos, proj);
    let right=this.points[idx].right;
    let lat = lateral.dot(right);
    let accum = this.points[idx].accum + t*this.points[idx].seg;
    let off = Math.abs(lat);
    let surf=SURFACE.ASPHALT;
    if(off>this.points[idx].width*0.5+1) surf=SURFACE.GRASS;
    if(off>this.points[idx].width*0.5+8) surf=SURFACE.MUD;
    if(off>this.points[idx].width*0.5+18) surf=SURFACE.WATER;
    return {idx, t, proj, lat, accum: accum % this.total, accumRaw: this.points[idx].accum + t*this.points[idx].seg, surf, dist: Math.sqrt(bestD), right, seg:this.points[idx]};
  }
  buildMeshes(scene){
    const N=this.points.length;
    const verts=[], uvs=[], indices=[];
    for(let i=0;i<N;i++){
      let p=this.points[i];
      let half=p.width*0.5;
      let left=new THREE.Vector3().addVectors(p.pos, p.right.clone().multiplyScalar(-half));
      let right=new THREE.Vector3().addVectors(p.pos, p.right.clone().multiplyScalar(half));
      left.y+=0.02; right.y+=0.02;
      verts.push(left.x,left.y,left.z, right.x,right.y,right.z);
      uvs.push(0,i/N*10, 1,i/N*10);
    }
    for(let i=0;i<N;i++){
      let i0=i*2, i1=i*2+1, j0=((i+1)%N)*2, j1=((i+1)%N)*2+1;
      indices.push(i0,j0,i1, i1,j0,j1);
    }
    const geo=new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.Float32BufferAttribute(verts,3));
    geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs,2));
    geo.setIndex(indices);
    geo.computeVertexNormals();
    const stripeCanvas=document.createElement('canvas'); stripeCanvas.width=256; stripeCanvas.height=32;
    const sctx=stripeCanvas.getContext('2d');
    sctx.fillStyle='#2e3338'; sctx.fillRect(0,0,256,32);
    sctx.fillStyle='#444a50'; sctx.fillRect(0,0,64,32);
    const tex=new THREE.CanvasTexture(stripeCanvas); tex.wrapS=THREE.RepeatWrapping; tex.wrapT=THREE.RepeatWrapping; tex.repeat.set(1,10);
    const mat=new THREE.MeshStandardMaterial({map:tex, roughness:0.9, metalness:0.02});
    const mesh=new THREE.Mesh(geo,mat); mesh.receiveShadow=true; scene.add(mesh);
    const shoulderVerts=[], sIndices=[];
    for(let i=0;i<N;i++){
      let p=this.points[i];
      let innerHalf=p.width*0.5, outerHalf=p.width*0.5+3.5;
      let l1=new THREE.Vector3().addVectors(p.pos, p.right.clone().multiplyScalar(-innerHalf));
      let l2=new THREE.Vector3().addVectors(p.pos, p.right.clone().multiplyScalar(-outerHalf));
      let r1=new THREE.Vector3().addVectors(p.pos, p.right.clone().multiplyScalar(innerHalf));
      let r2=new THREE.Vector3().addVectors(p.pos, p.right.clone().multiplyScalar(outerHalf));
      [l1,l2,r1,r2].forEach(v=>v.y+=0.005);
      shoulderVerts.push(l1.x,l1.y,l1.z, l2.x,l2.y,l2.z, r1.x,r1.y,r1.z, r2.x,r2.y,r2.z);
    }
    for(let i=0;i<N;i++){
      let b=i*4, nb=((i+1)%N)*4;
      sIndices.push(b, nb, b+1, b+1, nb, nb+1);
      sIndices.push(b+2, b+3, nb+2, b+3, nb+3, nb+2);
    }
    const sGeo=new THREE.BufferGeometry();
    sGeo.setAttribute('position', new THREE.Float32BufferAttribute(shoulderVerts,3));
    sGeo.setIndex(sIndices); sGeo.computeVertexNormals();
    const sMat=new THREE.MeshStandardMaterial({color:0x8a6040, roughness:1});
    const sMesh=new THREE.Mesh(sGeo,sMat); sMesh.receiveShadow=true; scene.add(sMesh);
    const ground=new THREE.Mesh(new THREE.PlaneGeometry(800,800,20,20), new THREE.MeshStandardMaterial({color:0x4a7a3f, roughness:1}));
    ground.rotation.x=-Math.PI/2; ground.position.y=-1.5; ground.receiveShadow=true; scene.add(ground);
    const gateGroup=new THREE.Group();
    for(let g=0; g<this.checkGates.length; g++){
      let idx=this.checkGates[g].idx;
      let p=this.points[idx];
      let arch = new THREE.Mesh(new THREE.BoxGeometry(p.width+1,4,0.4), new THREE.MeshStandardMaterial({color: g===0?0xffec44:0xffffff, transparent:true, opacity: g===0?0.22:0.06}));
      arch.position.copy(p.pos); arch.position.y+=2;
      let look = this.points[(idx+2)%N].pos;
      arch.lookAt(look); gateGroup.add(arch);
    }
    scene.add(gateGroup);
    return {road:mesh, shoulder:sMesh, ground};
  }
}

// ── class: Car ──
class Car {
  constructor(){
    this.pos=new THREE.Vector3(0,0,0);
    this.vel=new THREE.Vector3(0,0,0);
    this.yaw=0; this.pitch=0;
    this.speed=0;
    this.steer=0; this.steerTarget=0;
    this.rpm=0; this.gear=1;
    this.slip=0;
    this.onGround=true;
    this.surface=SURFACE.ASPHALT;
    this.wheelSpin=0;
    this.angularVel=0;
    this.mesh=null;
    this.shadow=null;
  }
  buildModel(){
    const g=new THREE.Group();
    // body low-poly
    const bodyMat=new THREE.MeshStandardMaterial({color:0xff3b30, roughness:0.4, metalness:0.2});
    const body=new THREE.Mesh(new THREE.BoxGeometry(1.9,0.7,4.2), bodyMat); body.position.y=0.7; body.castShadow=true; g.add(body);
    const roof=new THREE.Mesh(new THREE.BoxGeometry(1.7,0.55,2.2), new THREE.MeshStandardMaterial({color:0xffffff, roughness:0.5})); roof.position.set(0,1.25, -0.2); roof.castShadow=true; g.add(roof);
    const spoiler=new THREE.Mesh(new THREE.BoxGeometry(1.8,0.1,0.45), new THREE.MeshStandardMaterial({color:0x111111})); spoiler.position.set(0,1.1, -2.0); g.add(spoiler);
    const spoiler2=new THREE.Mesh(new THREE.BoxGeometry(1.8,0.45,0.1), new THREE.MeshStandardMaterial({color:0x111111})); spoiler2.position.set(0,1.35, -2.0); g.add(spoiler2);
    // lights
    const headL=new THREE.Mesh(new THREE.BoxGeometry(0.35,0.18,0.06), new THREE.MeshStandardMaterial({color:0xffffcc, emissive:0xffffaa, emissiveIntensity:1.5})); headL.position.set(0.7,0.7,2.11); g.add(headL);
    const headR=headL.clone(); headR.position.x=-0.7; g.add(headR);
    const tailL=new THREE.Mesh(new THREE.BoxGeometry(0.35,0.18,0.06), new THREE.MeshStandardMaterial({color:0xff1111, emissive:0xff1111, emissiveIntensity:2})); tailL.position.set(0.7,0.7,-2.11); g.add(tailL);
    const tailR=tailL.clone(); tailR.position.x=-0.7; g.add(tailR);
    // wheels
    const whGeo=new THREE.CylinderGeometry(0.38,0.38,0.42,14); whGeo.rotateZ(Math.PI/2);
    const whMat=new THREE.MeshStandardMaterial({color:0x1a1a1a, roughness:0.9});
    for(let x of [-1,1]) for(let z of [-1.2,1.2]){
      let w=new THREE.Mesh(whGeo,whMat); w.position.set(x*1.05,0.38, z*1.1); w.castShadow=true; g.add(w);
    }
    // number plate
    const plate=new THREE.Mesh(new THREE.PlaneGeometry(0.7,0.28), new THREE.MeshStandardMaterial({color:0xffec44})); plate.position.set(0,0.72,-2.12); plate.rotation.y=Math.PI; g.add(plate);
    this.mesh=g;
    return g;
  }
  reset(track){
    let start=track.points[0];
    let next=track.points[4];
    let dir=new THREE.Vector3().subVectors(next.pos, start.pos).normalize();
    this.yaw=Math.atan2(dir.x, dir.z);
    this.pos.copy(start.pos); this.pos.y+=0.4;
    this.vel.set(0,0,0); this.speed=0; this.steer=0; this.slip=0; this.rpm=0.1; this.gear=1;
  }
  update(dt, input, trackInfo){
    const cfg=SURFACE_CFG[trackInfo.surf];
    this.surface=trackInfo.surf;
    // input: accel [-1,1], steer [-1,1], handbrake bool
    let accel = input.accel;
    let steerIn = input.steer;
    // steering target with speed dependency
    let speed01 = clamp(this.speed/CONFIG.maxSpeed,0,1);
    let steerGrip = lerp(1.0,0.35,speed01);
    this.steerTarget = steerIn * (0.9*steerGrip + (input.handbrake?0.6:0));
    this.steer = lerp(this.steer, this.steerTarget, dt*6);
    // engine force
    let forward = new THREE.Vector3(Math.sin(this.yaw),0,Math.cos(this.yaw));
    let engine = accel * (input.handbrake?0.2:1) * (580 * cfg.maxF);
    // special mud drag
    let drag = cfg.drag + speed01*0.06;
    // lateral slip
    let lateralVel = this.vel.clone().sub(forward.clone().multiplyScalar(this.vel.dot(forward)));
    let latSpeed = lateralVel.length();
    let grip = cfg.grip * (input.handbrake?0.35:1);
    // drift condition
    let wantSlip = Math.abs(this.steer)*speed01*1.5 + (input.handbrake?0.8:0) + latSpeed*0.02;
    this.slip = lerp(this.slip, clamp(wantSlip,0,1.4), dt* (wantSlip>this.slip?8:4));
    let effectiveGrip = grip * lerp(1, 0.45, this.slip*0.6);
    // apply forces
    let accelVec = forward.clone().multiplyScalar(engine*dt);
    // remove some lateral velocity via grip
    let lateralCorr = lateralVel.clone().multiplyScalar(-effectiveGrip*dt*4.5);
    this.vel.add(accelVec).add(lateralCorr);
    // drag
    this.vel.multiplyScalar(1 - drag*dt);
    // gravity / ground clamp
    this.vel.y += CONFIG.gravity*dt*0.35;
    if(this.pos.y < trackInfo.proj.y+0.38){
      this.pos.y = trackInfo.proj.y+0.38;
      if(this.vel.y<0) this.vel.y*= -0.1;
    }
    // move
    this.pos.add(this.vel.clone().multiplyScalar(dt));
    this.speed = this.vel.length();
    // yaw from steer and slip
    let yawRate = this.steer * lerp(2.2,1.0,speed01) * (1+this.slip*0.7) * (this.speed>2?1:0);
    this.yaw += yawRate*dt + this.angularVel*dt;
    this.angularVel = lerp(this.angularVel, -latSpeed*0.06*Math.sign(this.steer), dt*5);
    this.angularVel *= (1 - dt*2);
    // rpm simulation
    let targetRpm = 0.15 + speed01*0.85 + Math.abs(this.slip)*0.15;
    targetRpm = clamp(targetRpm + (accel>0?0.05: -0.05),0,1);
    this.rpm = lerp(this.rpm, targetRpm, dt* (targetRpm>this.rpm?10:4));
    // gear
    if(this.rpm>0.82 && this.gear<6){ this.gear++; this.rpm=0.45; }
    if(this.rpm<0.28 && this.gear>1 && accel<0.2){ this.gear--; this.rpm=0.6; }
    if(this.speed<0.5) this.gear=1;
    this.wheelSpin+= this.speed*dt*6;
    // collision with track sides (bounce)
    let off = trackInfo.lat;
    let half = trackInfo.seg.width*0.5+2.5;
    if(Math.abs(off) > half){
      // wall
      let push = Math.sign(off)* (Math.abs(off)-half);
      let right = trackInfo.right;
      this.pos.add(right.clone().multiplyScalar(-push*0.9));
      this.vel.add(right.clone().multiplyScalar(-push*8));
      this.speed*=0.85;
      this.slip=Math.max(this.slip,0.6);
      return true; // hit
    }
    return false;
  }
  syncMesh(){
    if(!this.mesh) return;
    this.mesh.position.copy(this.pos);
    this.mesh.rotation.set(this.pitch*0.3, this.yaw, Math.sin(this.yaw*2)*0 + this.steer*0.3 + this.angularVel*0.25);
    // slight roll on steer
    this.mesh.rotation.z = -this.steer*0.45 - this.slip*0.25* Math.sign(this.steer||1);
  }
}

// ── class: AudioSynth ──
class AudioSynth {
  constructor(){
    this.ctx=null; this.engOsc=null; this.engGain=null; this.mudGain=null;
    this.enabled=false; this.time=0;
  }
  init(){
    try{
      const AC = window.AudioContext||window.webkitAudioContext;
      this.ctx=new AC(); this.enabled=true;
      // engine chain
      this.engGain=this.ctx.createGain(); this.engGain.gain.value=0;
      this.engFilter=this.ctx.createBiquadFilter(); this.engFilter.type='lowpass'; this.engFilter.frequency.value=800;
      this.engOsc=this.ctx.createOscillator(); this.engOsc.type='sawtooth'; this.engOsc.frequency.value=60;
      this.engOsc.connect(this.engFilter); this.engFilter.connect(this.engGain); this.engGain.connect(this.ctx.destination);
      this.engOsc.start();
      // extra noise for gravel buzz
      this.noiseGain=this.ctx.createGain(); this.noiseGain.gain.value=0; this.noiseGain.connect(this.ctx.destination);
      // buffer noise
      let len=this.ctx.sampleRate*1; let buf=this.ctx.createBuffer(1,len,this.ctx.sampleRate);
      let ch=buf.getChannelData(0); for(let i=0;i<len;i++) ch[i]=(Math.random()*2-1)*0.4;
      this.noiseSrc=this.ctx.createBufferSource(); this.noiseSrc.buffer=buf; this.noiseSrc.loop=true;
      this.noiseFilter=this.ctx.createBiquadFilter(); this.noiseFilter.type='bandpass'; this.noiseFilter.frequency.value=1200;
      this.noiseSrc.connect(this.noiseFilter); this.noiseFilter.connect(this.noiseGain); this.noiseSrc.start();
    }catch(e){this.enabled=false}
  }
  resume(){ if(this.ctx && this.ctx.state==='suspended') this.ctx.resume(); }
  setEngine(rpm,speed,slip,surf){
    if(!this.enabled) return;
    let freq = 40 + rpm*280 + slip*90 + speed*1.2;
    if(this.engOsc) this.engOsc.frequency.setTargetAtTime(freq, this.ctx.currentTime, 0.06);
    if(this.engGain) this.engGain.gain.setTargetAtTime(0.12 + rpm*0.18 + speed*0.002, this.ctx.currentTime, 0.1);
    if(this.engFilter) this.engFilter.frequency.setTargetAtTime(400 + rpm*1200, this.ctx.currentTime, 0.08);
    // gravel rumble
    if(this.noiseGain){
      let g=(surf!==0?0.08+slip*0.12: slip*0.05);
      this.noiseGain.gain.setTargetAtTime(g, this.ctx.currentTime, 0.12);
    }
  }
  beep(count){
    if(!this.enabled) return;
    let o=this.ctx.createOscillator(), g=this.ctx.createGain();
    o.type='sine'; o.frequency.value= count<=0? 900: 480;
    g.gain.value=0.3; o.connect(g); g.connect(this.ctx.destination);
    o.start(); g.gain.exponentialRampToValueAtTime(0.0001, this.ctx.currentTime+0.25); o.stop(this.ctx.currentTime+0.3);
  }
  crash(intensity){
    if(!this.enabled) return;
    let o=this.ctx.createOscillator(), g=this.ctx.createGain(), f=this.ctx.createBiquadFilter();
    o.type='square'; o.frequency.value= 120+ intensity*200;
    f.type='lowpass'; f.frequency.value= 1800;
    g.gain.value= clamp(intensity,0,1)*0.5;
    o.connect(f); f.connect(g); g.connect(this.ctx.destination);
    o.start(); g.gain.exponentialRampToValueAtTime(0.0001, this.ctx.currentTime+0.3); o.stop(this.ctx.currentTime+0.35);
  }
  finish(){
    if(!this.enabled) return;
    for(let i=0;i<3;i++){
      let o=this.ctx.createOscillator(), g=this.ctx.createGain();
      o.type='sine'; o.frequency.value= 400 + i*250;
      g.gain.value=0.2; o.connect(g); g.connect(this.ctx.destination);
      o.start(this.ctx.currentTime+i*0.18); g.gain.exponentialRampToValueAtTime(0.0001, this.ctx.currentTime+i*0.18+0.4);
      o.stop(this.ctx.currentTime+i*0.18+0.45);
    }
  }
}

// ── class: Particles ──
class Particles {
  constructor(scene){
    this.scene=scene;
    this.pool=[]; this.active=[];
    this.skidCanvas=document.createElement('canvas'); this.skidCanvas.width=1024; this.skidCanvas.height=1024;
    this.skidCtx=this.skidCanvas.getContext('2d');
    this.skidTex=new THREE.CanvasTexture(this.skidCanvas); this.skidTex.minFilter=THREE.LinearFilter;
    this.skidMesh=null;
    this.initGroundDecal();
  }
  initGroundDecal(){
    // large plane for skid marks using canvas texture projected onto ground shader? simplified: use plane near ground with texture.
    // We'll add small quads for particles instead.
  }
  spawnDust(pos, vel, count, color){
    for(let i=0;i<count;i++){
      let m=new THREE.Mesh(new THREE.SphereGeometry(0.12+Math.random()*0.22,5,5), new THREE.MeshBasicMaterial({color: color||0xaa9977, transparent:true, opacity:0.55}));
      m.position.copy(pos).add(new THREE.Vector3((Math.random()-0.5)*0.6, Math.random()*0.5, (Math.random()-0.5)*0.6));
      m.userData={vel:new THREE.Vector3((Math.random()-0.5)*2+vel.x*0.2, Math.random()*1.5, (Math.random()-0.5)*2+vel.z*0.2), life:0.8+Math.random()*0.6, age:0};
      this.scene.add(m); this.active.push(m);
    }
  }
  spawnSkidMark(p1,p2){
    // draw line on skid canvas? Needs world to UV mapping — approximate by drawing near center with jitter
    // Instead spawn small thin boxes for instant visual
    let mid=new THREE.Vector3().addVectors(p1,p2).multiplyScalar(0.5);
    let dir=new THREE.Vector3().subVectors(p2,p1); let len=dir.length();
    if(len<0.05) return;
    dir.normalize();
    const geo=new THREE.BoxGeometry(0.12,len,0.02);
    const mat=new THREE.MeshBasicMaterial({color:0x111111, transparent:true, opacity:0.5});
    const mesh=new THREE.Mesh(geo,mat);
    mesh.position.copy(mid); mesh.position.y+=0.03;
    mesh.rotation.y=Math.atan2(dir.x,dir.z);
    this.scene.add(mesh);
    mesh.userData={life:7, age:0, fade:true};
    this.active.push(mesh);
  }
  spawnSparks(pos){
    for(let i=0;i<6;i++){
      let m=new THREE.Mesh(new THREE.SphereGeometry(0.05,4,4), new THREE.MeshBasicMaterial({color:0xffec44}));
      m.position.copy(pos); m.position.y+=0.3;
      m.userData={vel:new THREE.Vector3((Math.random()-0.5)*6, Math.random()*3, (Math.random()-0.5)*6), life:0.35, age:0, sparks:true};
      this.scene.add(m); this.active.push(m);
    }
  }
  update(dt){
    for(let i=this.active.length-1;i>=0;i--){
      let m=this.active[i]; let d=m.userData;
      d.age+=dt;
      if(d.vel){ m.position.add(d.vel.clone().multiplyScalar(dt)); d.vel.y-=9*dt; d.vel.multiplyScalar(1-dt*1.2); }
      if(d.fade){ /* stay */ } else if(m.material.opacity!==undefined){ m.material.opacity = 1 - d.age/d.life; m.scale.multiplyScalar(1+dt*0.6); }
      if(d.age> d.life){ this.scene.remove(m); this.active.splice(i,1); }
    }
  }
}

// ── function: buildScenery ──
function buildScenery(track, scene){
  const group=new THREE.Group();
  // trees
  const trunkGeo=new THREE.CylinderGeometry(0.18,0.26,2.2,6);
  const foliageGeo=new THREE.ConeGeometry(1.2,3.5,7);
  const trunkMat=new THREE.MeshStandardMaterial({color:0x5a3a22});
  const foliageMats=[new THREE.MeshStandardMaterial({color:0x2e7d32}), new THREE.MeshStandardMaterial({color:0x3d8b3d}), new THREE.MeshStandardMaterial({color:0x1e5a28})];
  const rockGeo=new THREE.DodecahedronGeometry(1,0);
  const rockMat=new THREE.MeshStandardMaterial({color:0x7a7a7a, roughness:0.9});
  // place along track sides
  for(let i=0;i<track.points.length;i+=2){
    let p=track.points[i];
    // skip some if too close to crowd?
    let r = (Math.random()*2+1);
    for(let side of [-1,1]){
      if(Math.random()<0.72){
        let off = p.width*0.5 + 4 + Math.random()*28;
        let pos=new THREE.Vector3().addVectors(p.pos, p.right.clone().multiplyScalar(side*off));
        pos.y = p.pos.y -0.5;
        // tree
        if(Math.random()<0.82){
          let trunk=new THREE.Mesh(trunkGeo,trunkMat); trunk.position.copy(pos); trunk.position.y+=1.1; trunk.castShadow=true; trunk.receiveShadow=true;
          group.add(trunk);
          let foliage=new THREE.Mesh(foliageGeo, foliageMats[Math.floor(Math.random()*3)]); foliage.position.copy(pos); foliage.position.y+=2.2+1.7; foliage.castShadow=true;
          foliage.rotation.y=Math.random()*Math.PI; group.add(foliage);
        } else {
          let rock=new THREE.Mesh(rockGeo, rockMat); rock.position.copy(pos); rock.position.y+=0.5; rock.scale.setScalar(0.6+Math.random()*1.2); rock.rotation.set(Math.random()*Math.PI,Math.random()*Math.PI,0); rock.castShadow=true; group.add(rock);
        }
      }
    }
  }
  // banners / guard rail posts
  const postGeo=new THREE.BoxGeometry(0.18,1.2,0.18);
  const railGeo=new THREE.BoxGeometry(0.12,0.12,4);
  const postMat=new THREE.MeshStandardMaterial({color:0xdddddd});
  const railMat=new THREE.MeshStandardMaterial({color:0xbbbbbb});
  for(let i=0;i<track.points.length;i+=8){
    let p=track.points[i];
    for(let side of [-1,1]){
      if(Math.random()<0.25) continue;
      let off=p.width*0.5+0.6;
      let base=new THREE.Vector3().addVectors(p.pos, p.right.clone().multiplyScalar(side*off));
      let post=new THREE.Mesh(postGeo,postMat); post.position.copy(base); post.position.y+=0.6; group.add(post);
      // rail
      if(i%16===0){
        let rail=new THREE.Mesh(railGeo,railMat); rail.position.copy(base); rail.position.y+=0.9;
        let nxt=track.points[(i+3)%track.points.length]; rail.lookAt(nxt.pos); group.add(rail);
      }
    }
  }
  // start/finish banner
  {
    let s=track.points[0];
    let bannerG=new THREE.Group();
    const pole=new THREE.Mesh(new THREE.BoxGeometry(0.3,5,0.3), new THREE.MeshStandardMaterial({color:0xeeeeee}));
    for(let side of [-1,1]){
      let pos=new THREE.Vector3().addVectors(s.pos, s.right.clone().multiplyScalar(side*(s.width*0.5+0.4)));
      let pl=pole.clone(); pl.position.copy(pos); pl.position.y+=2.5; bannerG.add(pl);
    }
    const topBar=new THREE.Mesh(new THREE.BoxGeometry(s.width+2,0.5,0.35), new THREE.MeshStandardMaterial({color:0xffec44}));
    topBar.position.copy(s.pos); topBar.position.y+=4.6; topBar.lookAt(track.points[2].pos); bannerG.add(topBar);
    group.add(bannerG);
  }
  scene.add(group);
  return group;
}

// ── class: Input ──
class Input {
  constructor(){
    this.keys={}; this.accel=0; this.brake=0; this.steer=0; this.hand=false;
    this.joyX=0;
    this.bind();
  }
  bind(){
    window.addEventListener('keydown', e=>{
      this.keys[e.code]=true;
      if(['ArrowUp','ArrowDown','Space'].includes(e.code)) e.preventDefault();
    });
    window.addEventListener('keyup', e=>{ this.keys[e.code]=false; });
    // touch joystick
    const joyArea=document.getElementById('joyArea'), stick=document.getElementById('joyStick');
    let joyActive=false, startX=0;
    const handle=(x)=>{
      let dx=x-startX; let max=50; let nx=clamp(dx/max,-1,1);
      this.joyX=nx; stick.style.transform=`translate(${nx*40}px,0)`;
    };
    joyArea.addEventListener('pointerdown', e=>{
      joyActive=true; startX=e.clientX; joyArea.setPointerCapture(e.pointerId);
    });
    joyArea.addEventListener('pointermove', e=>{ if(joyActive) handle(e.clientX); });
    const end=()=>{
      joyActive=false; this.joyX=0; stick.style.transform=`translate(0,0)`;
    };
    joyArea.addEventListener('pointerup', end); joyArea.addEventListener('pointercancel', end);
    // action buttons
    const gas=document.getElementById('tGas'), brake=document.getElementById('tBrake'), hand=document.getElementById('tHand');
    const setHold=(el, cb)=>{ 
      let on = (e)=>{ e.preventDefault(); cb(true); }; let off=()=>cb(false);
      el.addEventListener('pointerdown', on); el.addEventListener('pointerup', off); el.addEventListener('pointerleave', off);
      el.addEventListener('touchstart', on, {passive:false}); el.addEventListener('touchend', off);
    };
    setHold(gas, v=> this.keys['TouchGas']=v);
    setHold(brake, v=> this.keys['TouchBrake']=v);
    setHold(hand, v=> this.keys['TouchHand']=v);
  }
  read(){
    let accel=0, brake=0, steer=0;
    if(this.keys['KeyW']||this.keys['ArrowUp']||this.keys['TouchGas']) accel+=1;
    if(this.keys['KeyS']||this.keys['ArrowDown']||this.keys['TouchBrake']) brake+=1;
    if(this.keys['KeyA']||this.keys['ArrowLeft']) steer-=1;
    if(this.keys['KeyD']||this.keys['ArrowRight']) steer+=1;
    if(Math.abs(this.joyX)>0.08) steer+=this.joyX;
    let hand = this.keys['Space']||this.keys['TouchHand'];
    // combine accel/brake
    let a = accel - brake*0.7;
    if(brake && !accel) a = -0.7;
    return {accel: clamp(a,-1,1), steer: clamp(steer,-1,1), handbrake: hand};
  }
}

// ── class: Game ──
class Game {
  constructor(){
    this.canvas=document.getElementById('gameCanvas');
    this.scene=new THREE.Scene();
    this.scene.background=new THREE.Color(CONFIG.fogColor);
    this.renderer=new THREE.WebGLRenderer({canvas:this.canvas, antialias:true});
    this.renderer.setPixelRatio(Math.min(devicePixelRatio,2));
    this.renderer.shadowMap.enabled=true; this.renderer.shadowMap.type=THREE.PCFSoftShadowMap;
    this.camera=new THREE.PerspectiveCamera(68, innerWidth/innerHeight, 0.1, 900);
    this.track=new Track();
    this.car=new Car();
    this.input=new Input();
    this.audio=new AudioSynth();
    this.particles=new Particles(this.scene);
    this.time=0; this.raceTime=0; this.lapTimes=[]; this.best=Infinity; this.currentLap=1; this.raceStarted=false; this.raceOver=false;
    this.checkIdx=1; this.lapStartAccum=0; this.progress=0;
    this.camMode=0;
    this.camPos=new THREE.Vector3(); this.camTarget=new THREE.Vector3();
    this.countdownState=0;
    this.lapFlash=0;
    this.minimap=document.getElementById('minimap'); this.mmCtx=this.minimap.getContext('2d');
    this.raf=null; this.paused=false;
    this.lastSkidPos=null;
    this.lastRawAccum=0;
  }
  init(){
    const scene=this.scene;
    scene.fog=new THREE.Fog(CONFIG.fogColor, 70, 320);
    const amb=new THREE.AmbientLight(0xffffff,0.55); scene.add(amb);
    const sun=new THREE.DirectionalLight(0xfff6e0,1.2); sun.position.set(120,160,60); sun.castShadow=true;
    sun.shadow.mapSize.set(2048,2048);
    sun.shadow.camera.left=-150; sun.shadow.camera.right=150; sun.shadow.camera.top=150; sun.shadow.camera.bottom=-150;
    sun.shadow.camera.near=10; sun.shadow.camera.far=400;
    scene.add(sun);
    this.track.buildMeshes(scene);
    buildScenery(this.track, scene);
    scene.add(this.car.buildModel());
    this.car.reset(this.track);
    this.camPos.copy(this.car.pos).add(new THREE.Vector3(0,4,-10));
    this.camTarget.copy(this.car.pos);
    this.resize(); window.addEventListener('resize', ()=>this.resize());
    document.getElementById('btnGo').addEventListener('click', ()=>{ if(!this.audio.enabled) this.audio.init(); this.startCountdown(); });
    document.getElementById('btnResume').addEventListener('click', ()=> this.togglePause(false));
    window.addEventListener('keydown', e=>{
      if(e.code==='KeyR') this.restart();
      if(e.code==='KeyP') this.togglePause();
      if(e.code==='KeyC'){ this.camMode=1-this.camMode; }
      if(e.code==='Enter' && this.raceOver) this.restart();
    });
    document.getElementById('overlay').classList.remove('hidden');
    this.loop(0);
    this.drawMinimap();
  }
  resize(){
    this.renderer.setSize(innerWidth, innerHeight, false);
    this.camera.aspect=innerWidth/innerHeight; this.camera.updateProjectionMatrix();
  }
  startCountdown(){
    this.audio.resume();
    this.car.reset(this.track);
    this.raceTime=0; this.currentLap=1; this.lapTimes=[]; this.best=Infinity;
    this.raceStarted=false; this.raceOver=false; this.checkIdx=1; this.lapFlash=0;
    this.countdownState=3; this.paused=false; this.lastRawAccum=this.track.points[0].accum;
    document.getElementById('overlay').classList.add('hidden');
    document.getElementById('btnResume').classList.add('hidden');
    this.doCount();
  }
  doCount(){
    const el=document.getElementById('countdown');
    if(this.countdownState<0){
      el.textContent=''; el.classList.remove('pop');
      this.raceStarted=true; this.raceTime=0; this.audio.beep(0);
      return;
    }
    let txt = this.countdownState===0? 'GO!': String(this.countdownState);
    el.textContent=txt; el.classList.remove('pop'); void el.offsetWidth; el.classList.add('pop');
    this.audio.beep(this.countdownState);
    this.countdownState--;
    setTimeout(()=>this.doCount(), this.countdownState===0? 650: 900);
  }
  restart(){
    document.getElementById('overlay').classList.add('hidden');
    this.startCountdown();
  }
  togglePause(force){
    if(this.raceOver) return;
    let to = typeof force==='boolean'? force: !this.paused;
    if(!this.raceStarted && to) return;
    this.paused=to;
    let ov=document.getElementById('overlay');
    if(this.paused){ ov.classList.remove('hidden'); document.getElementById('overlayTitle').textContent='PAUSED'; document.getElementById('overlayMsg').textContent='Take a breath. The mud isn\'t going anywhere.'; document.getElementById('btnGo').textContent='RESTART'; document.getElementById('btnResume').classList.remove('hidden'); }
    else { ov.classList.add('hidden'); }
  }
  update(dt){
    if(this.paused || !this.raceStarted || this.raceOver) return;
    let inp=this.input.read();
    let info=this.track.closestParam(this.car.pos);
    this.progress=info.accumRaw;
    let curRaw = info.accumRaw;
    // handle wrap detection for lap validation
    let deltaRaw = curRaw - this.lastRawAccum;
    if(deltaRaw < -this.track.total*0.5) curRaw+= this.track.total; // crossed start line forward
    // checkpoint advancing using circular forward diff
    let gate = this.track.checkGates[this.checkIdx % this.track.checkGates.length];
    let diff = (info.accum - gate.accum + this.track.total) % this.track.total;
    // if we are just after gate (small diff), pass it
    if(diff < 40){
      this.checkIdx++;
      if(this.checkIdx>=this.track.checkGates.length){
        let lapT = this.raceTime;
        this.lapTimes.push(lapT);
        if(lapT<this.best) this.best=lapT;
        if(this.currentLap>=CONFIG.laps){
          this.finish();
        }else{
          this.currentLap++;
          this.lapFlash=1;
          this.raceTime=0;
          this.checkIdx=1;
        }
      }
    }
    this.lastRawAccum = info.accumRaw;
    let hit=this.car.update(dt, {accel: inp.accel, steer: inp.steer, handbrake: inp.handbrake}, info);
    this.car.syncMesh();
    this.audio.setEngine(this.car.rpm, this.car.speed, this.car.slip, this.car.surface);
    if(hit){ this.audio.crash(clamp(this.car.speed/25,0,1)); this.particles.spawnSparks(this.car.pos); this.particles.spawnDust(this.car.pos, this.car.vel, 10, 0xcccccc); }
    if(this.car.slip>0.55 || this.car.surface!==SURFACE.ASPHALT){
      let c = this.car.surface===SURFACE.MUD? 2: this.car.surface===SURFACE.WATER? 3: Math.floor(this.car.slip*3);
      if(c>0) this.particles.spawnDust(this.car.pos, this.car.vel, c, this.car.surface===SURFACE.MUD?0x6b4a30: this.car.surface===SURFACE.GRASS?0x5a7a4a:0xb8a88a);
    }
    if(Math.abs(this.car.steer)>0.2 && this.car.speed>6){
      let rear = this.car.pos.clone().add(new THREE.Vector3(Math.sin(this.car.yaw+Math.PI)*1.6,0, Math.cos(this.car.yaw+Math.PI)*1.6));
      if(this.lastSkidPos && rear.distanceTo(this.lastSkidPos)<3) this.particles.spawnSkidMark(this.lastSkidPos, rear);
      this.lastSkidPos=rear;
    } else { this.lastSkidPos=null; }
    this.raceTime+=dt*1000;
    this.updateCamera(dt);
    this.particles.update(dt);
  }
  updateCamera(dt){
    let car=this.car;
    if(this.camMode===0){
      let back = new THREE.Vector3(Math.sin(car.yaw),0,Math.cos(car.yaw)).multiplyScalar(-9 - car.speed*0.06 - car.slip*1.2);
      let up = new THREE.Vector3(0, 2.8 + car.speed*0.015, 0);
      let desired = car.pos.clone().add(back).add(up);
      let target = car.pos.clone().add(new THREE.Vector3(0,0.8,0)).add(new THREE.Vector3(Math.sin(car.yaw),0,Math.cos(car.yaw)).multiplyScalar(2 + car.speed*0.02));
      this.camTarget.lerp(target, dt*5);
      let lag = 1 - clamp(car.speed/CONFIG.maxSpeed,0,1)*0.25;
      this.camPos.lerp(desired, dt*(4*lag));
    } else {
      let high = car.pos.clone().add(new THREE.Vector3(0,22, -18));
      this.camPos.lerp(high, dt*3);
      this.camTarget.lerp(car.pos, dt*3);
    }
    this.camera.position.copy(this.camPos);
    this.camera.lookAt(this.camTarget);
  }
  drawMinimap(){
    let ctx=this.mmCtx, W=200,H=200;
    ctx.clearRect(0,0,W,H);
    ctx.save(); ctx.translate(W/2,H/2); ctx.scale(0.62,0.62);
    ctx.beginPath();
    for(let i=0;i<this.track.points.length;i++){
      let p=this.track.points[i].pos;
      let x=p.x, y=p.z;
      if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
    }
    ctx.closePath(); ctx.strokeStyle='rgba(255,255,255,.18)'; ctx.lineWidth=12; ctx.stroke();
    ctx.strokeStyle='#2a323a'; ctx.lineWidth=8; ctx.stroke();
    // gates dots
    for(let g of this.track.checkGates){
      let p=this.track.points[g.idx].pos;
      ctx.fillStyle= g.idx===0? '#ffec44' : 'rgba(255,255,255,.3)';
      ctx.beginPath(); ctx.arc(p.x,p.z,1.2,0,Math.PI*2); ctx.fill();
    }
    let cp=this.car.pos;
    ctx.fillStyle='#ffec44'; ctx.shadowColor='#ffec44'; ctx.shadowBlur=12;
    ctx.beginPath(); ctx.arc(cp.x, cp.z, 3.2,0,Math.PI*2); ctx.fill();
    ctx.restore();
  }
  updateHUD(){
    document.getElementById('lapTxt').textContent=`${this.currentLap} / ${CONFIG.laps}`;
    document.getElementById('timeTxt').textContent=fmtTime(this.raceStarted? this.raceTime:0);
    document.getElementById('bestTxt').textContent=fmtTime(this.best);
    let kmh=this.car.speed*3.6; document.getElementById('speedTxt').textContent=`${Math.round(kmh)} km/h`;
    let gearTxt=document.getElementById('gearTxt');
    gearTxt.textContent = this.car.speed<2? 'N': this.car.gear;
    document.getElementById('rpmFill').style.width=`${this.car.rpm*100}%`;
    let flag=document.getElementById('surfaceFlag');
    let lab=SURFACE_CFG[this.car.surface].label;
    if(lab){ flag.textContent=lab; flag.classList.add('show'); flag.style.background=SURFACE_CFG[this.car.surface].color; }
    else flag.classList.remove('show');
    if(this.lapFlash>0){
      this.lapFlash-=0.02; document.getElementById('lapTxt').style.color=`rgba(255,236,68,${0.5+this.lapFlash*0.5})`;
    } else {
      document.getElementById('lapTxt').style.color='';
    }
  }
  finish(){
    this.raceOver=true;
    this.audio.finish();
    const ov=document.getElementById('overlay');
    ov.classList.remove('hidden');
    document.getElementById('overlayTitle').textContent='FINISHED!';
    document.getElementById('overlayMsg').textContent='Great run. The track is chewing that time.';
    let total=this.lapTimes.reduce((a,b)=>a+b,0);
    let best=Math.min(...this.lapTimes);
    document.getElementById('resultStats').textContent=`Total: ${fmtTime(total)}\nLaps: ${this.lapTimes.map((t,i)=>`L${i+1} ${fmtTime(t)}`).join('  ')}\nBest: ${fmtTime(best)}`;
    document.getElementById('btnGo').textContent='RACE AGAIN';
    document.getElementById('btnResume').classList.add('hidden');
  }
  loop(t){
    requestAnimationFrame((nt)=>this.loop(nt));
    let dt=Math.min(0.033, (t-(this._last||t))/1000); this._last=t;
    if(!this._last) dt=0.016;
    this.time+=dt;
    if(!this.paused) this.update(dt);
    this.updateHUD();
    this.drawMinimap();
    this.renderer.render(this.scene, this.camera);
  }
}

// ── main ──
const game = new Game();
game.init();
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.1","provider":"openrouter","persona":"composer","sessionId":"98bbe548-9ba7-4a36-b3b5-222e7bd14d1e","tokensIn":316676,"tokensOut":35118,"tokensTotal":351794,"turns":18,"toolCalls":18,"failedToolCalls":0,"timestamp":"2026-07-17T04:35:26.947Z"} -->