← Rally results

Rally

muse spark 1.2 · basic

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 40,593 bytes · SHA-256 0dece41d9f7c
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>RALLY FURY — 3D Procedural</title>
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:'Segoe UI',system-ui,Arial,sans-serif}
body{background:#0a0e12;overflow:hidden;color:#fff;user-select:none}
#c{position:fixed;inset:0;width:100%;height:100%;display:block}
#hud{position:fixed;inset:0;pointer-events:none}
.topbar{position:absolute;top:0;left:0;right:0;height:64px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;background:linear-gradient(180deg,rgba(0,0,0,.75),rgba(0,0,0,0));}
.logo{font-weight:900;letter-spacing:2px;font-size:22px;line-height:1}
.logo b{color:#ff3b30}
.logo span{font-weight:600;font-size:11px;letter-spacing:4px;color:#ffd23f;display:block}
.top-center{display:flex;gap:10px;align-items:center}
.pill{background:rgba(0,0,0,.6);border:1px solid rgba(255,255,255,.15);backdrop-filter:blur(8px);border-radius:999px;padding:7px 14px;display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700}
.pill b{color:#ffd23f;font-size:16px}
.pill small{opacity:.7;font-weight:600}
.btn{pointer-events:auto;cursor:pointer;background:#ff3b30;color:#fff;border:none;padding:9px 16px;border-radius:8px;font-weight:800;letter-spacing:.5px;box-shadow:0 4px 14px rgba(255,59,48,.4)}
.btn:active{transform:scale(.97)}
.bottom{position:absolute;bottom:0;left:0;right:0;padding:14px 16px;display:flex;justify-content:space-between;align-items:end;gap:14px;background:linear-gradient(0deg,rgba(0,0,0,.75),transparent)}
.left-hud{display:flex;gap:14px;align-items:end}
.speedo{width:150px;background:rgba(10,14,18,.85);border:1px solid rgba(255,255,255,.12);border-radius:18px;padding:12px 14px;backdrop-filter:blur(10px)}
.speedo .label{font-size:10px;letter-spacing:2px;opacity:.6;font-weight:800}
.speedo .speed{font-size:42px;font-weight:900;line-height:1;letter-spacing:-2px}
.speedo .speed i{font-style:normal;font-size:14px;opacity:.6;letter-spacing:0}
.bar{height:6px;background:rgba(255,255,255,.15);border-radius:999px;overflow:hidden;margin-top:8px}
.bar>div{height:100%;background:linear-gradient(90deg,#ff3b30,#ffd23f);width:0%;transition:width .1s}
.gear{font-size:11px;font-weight:800;margin-top:6px;letter-spacing:1px}
.gear b{color:#ffd23f}
.lapbox{background:rgba(10,14,18,.85);border:1px solid rgba(255,255,255,.12);border-radius:18px;padding:12px 14px;min-width:180px;backdrop-filter:blur(10px)}
.lapbox .row{display:flex;justify-content:space-between;font-size:11px;font-weight:700;opacity:.9;margin:3px 0}
.lapbox .row b{color:#fff}
.lapbox .big{font-size:18px;font-weight:900;letter-spacing:-.5px}
#minimap{width:170px;height:170px;background:rgba(10,14,18,.92);border:1px solid rgba(255,255,255,.18);border-radius:16px;overflow:hidden;position:relative;box-shadow:0 10px 30px rgba(0,0,0,.5)}
#minimap canvas{width:100%;height:100%;display:block}
#minimap .m-label{position:absolute;top:8px;left:8px;font-size:9px;letter-spacing:2px;font-weight:900;opacity:.7}
.controls-hint{position:absolute;left:50%;top:72px;transform:translateX(-50%);background:rgba(0,0,0,.55);border:1px solid rgba(255,255,255,.12);padding:6px 12px;border-radius:999px;font-size:11px;font-weight:600;display:flex;gap:12px}
.controls-hint kbd{background:rgba(255,255,255,.15);padding:2px 6px;border-radius:4px;font-size:10px}
.center{position:absolute;inset:0;display:grid;place-items:center;background:rgba(6,10,14,.78);backdrop-filter:blur(6px);pointer-events:auto}
.card{background:#11161c;border:1px solid rgba(255,255,255,.12);border-radius:20px;padding:22px;width:min(520px,92vw);text-align:center;box-shadow:0 20px 60px rgba(0,0,0,.6)}
.card h1{font-size:32px;font-weight:950;letter-spacing:-1px}
.card h1 i{color:#ff3b30;font-style:normal}
.card p{opacity:.7;font-size:13px;line-height:1.5;margin:10px 0 16px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:12px 0}
.stat{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:10px}
.stat b{display:block;font-size:18px}
.stat span{font-size:10px;letter-spacing:1px;opacity:.6;font-weight:800}
.rowBtns{display:flex;gap:10px;justify-content:center;margin-top:14px}
.btn-ghost{background:transparent;border:1px solid rgba(255,255,255,.2);color:#fff}
.countdown{position:absolute;inset:0;display:grid;place-items:center;pointer-events:none}
.countdown span{font-size:140px;font-weight:950;line-height:1;text-shadow:0 10px 40px rgba(0,0,0,.8);transform:scale(.8);opacity:0;transition:.2s}
.countdown.show span{transform:scale(1);opacity:1}
#touch{position:fixed;inset:0;pointer-events:none;display:none}
@media(max-width:800px){#touch{display:block}.controls-hint{display:none}.speedo{width:120px}.speedo .speed{font-size:32px}#minimap{width:130px;height:130px}}
.touch-group{position:absolute;bottom:18px;display:flex;gap:10px;pointer-events:auto}
.touch-group.left{left:14px}
.touch-group.right{right:14px;flex-direction:column}
.tbtn{width:56px;height:56px;border-radius:50%;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);backdrop-filter:blur(8px);color:#fff;font-weight:900;display:grid;place-items:center;touch-action:none}
.tbtn:active{background:#ff3b30}
.tbtn.wide{width:84px;border-radius:28px;font-size:12px;letter-spacing:1px}
#toast{position:absolute;top:80px;left:50%;transform:translateX(-50%);background:#ffd23f;color:#111;padding:8px 14px;border-radius:999px;font-weight:900;font-size:12px;letter-spacing:1px;display:none}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud">
  <div class="topbar">
    <div class="logo"><div>RALLY <b>FURY</b></div><span>DIRT • GRAVEL • GLORY</span></div>
    <div class="top-center">
      <div class="pill"><small>LAP</small> <b id="hudLap">1 / 3</b></div>
      <div class="pill"><small>TIME</small> <b id="hudTime">00:00.00</b></div>
      <div class="pill"><small>BEST</small> <b id="hudBest">--:--.--</b></div>
    </div>
    <button class="btn" id="pauseBtn" style="pointer-events:auto">⏸ PAUSE [P]</button>
  </div>
  <div class="controls-hint"><span><kbd>W</kbd>/<kbd>↑</kbd> Gas</span><span><kbd>S</kbd>/<kbd>↓</kbd> Brake</span><span><kbd>A</kbd><kbd>D</kbd> Steer</span><span><kbd>SPACE</kbd> Handbrake</span><span><kbd>C</kbd> Camera</span><span><kbd>R</kbd> Restart</span></div>
  <div id="toast">CHECKPOINT MISSED!</div>
  <div class="bottom">
    <div class="left-hud">
      <div class="speedo">
        <div class="label">SPEED & RPM</div>
        <div class="speed"><span id="hudSpeed">0</span> <i>km/h</i></div>
        <div class="bar"><div id="rpmBar"></div></div>
        <div class="gear">GEAR <b id="hudGear">1</b> • <span id="hudSurface" style="opacity:.6">ASPHALT</span></div>
      </div>
      <div class="lapbox">
        <div class="row"><span>Current Lap</span><b id="lapTime">00:00.00</b></div>
        <div class="row"><span>Last Lap</span><b id="lastLap">--:--.--</b></div>
        <div class="row" style="margin-top:6px;border-top:1px solid rgba(255,255,255,.1);padding-top:6px"><span class="big">TOTAL</span><b class="big" id="totalTime">00:00.00</b></div>
      </div>
    </div>
    <div id="minimap"><div class="m-label">TRACK MAP</div><canvas id="mini" width="340" height="340"></canvas></div>
  </div>
  <div class="countdown" id="countdown"><span id="cdText">3</span></div>
</div>

<div id="touch">
  <div class="touch-group left">
    <div class="tbtn" id="tLeft">◀</div>
    <div class="tbtn" id="tRight">▶</div>
  </div>
  <div class="touch-group right">
    <div class="tbtn wide" id="tGas">GAS ▲</div>
    <div style="display:flex;gap:10px">
      <div class="tbtn" id="tBrake">▼</div>
      <div class="tbtn" style="background:rgba(255,59,48,.85)" id="tHand">HB</div>
    </div>
  </div>
</div>

<!-- START SCREEN -->
<div class="center" id="startScreen">
  <div class="card">
    <h1>RALLY <i>FURY</i></h1>
    <p>Procedural low-poly rally. Stay on track, hit every checkpoint, chase your best lap. Asphalt is fast — grass, gravel & mud will kill your grip. Use handbrake to drift hairpins!</p>
    <div class="grid2">
      <div class="stat"><b>3 LAPS</b><span>CLOSED CIRCUIT</span></div>
      <div class="stat"><b>~1.4 KM</b><span>DIRT & ASPHALT</span></div>
      <div class="stat"><b>DRIFT</b><span>HOLD SPACE</span></div>
      <div class="stat"><b>FOG + SHADOWS</b><span>60 FPS TARGET</span></div>
    </div>
    <div class="rowBtns">
      <button class="btn" id="playBtn" style="flex:1">▶ START ENGINE</button>
      <button class="btn btn-ghost" id="camBtn">CAM: CHASE</button>
    </div>
    <p style="font-size:11px;margin-top:12px;opacity:.5">No images, no samples — all geometry & audio synthesized live.</p>
  </div>
</div>

<!-- PAUSE -->
<div class="center" id="pauseScreen" style="display:none">
  <div class="card">
    <h1>PAUSED</h1>
    <p>Take a breather. Rally waits for no one.</p>
    <div class="rowBtns">
      <button class="btn" id="resumeBtn">RESUME</button>
      <button class="btn btn-ghost" id="restartBtn">RESTART [R]</button>
    </div>
  </div>
</div>

<!-- FINISH -->
<div class="center" id="finishScreen" style="display:none">
  <div class="card" style="width:min(560px,94vw)">
    <h1>FINISHED! 🏁</h1>
    <div class="grid2">
      <div class="stat"><b id="finTotal">00:00.00</b><span>TOTAL TIME</span></div>
      <div class="stat"><b id="finBest">00:00.00</b><span>BEST LAP</span></div>
    </div>
    <div id="lapList" style="text-align:left;background:rgba(255,255,255,.06);border-radius:12px;padding:12px;margin-top:12px;font-size:13px;line-height:1.6"></div>
    <div class="rowBtns">
      <button class="btn" id="againBtn">RACE AGAIN</button>
      <button class="btn btn-ghost" id="shareBtn">COPY TIME</button>
    </div>
  </div>
</div>

<script>
/* ========= RALLY FURY - Single file procedural ========= */
const T = THREE;
let renderer, scene, camera, ambient, sun, fog;
const canvas = document.getElementById('c');
let W = innerWidth, H = innerHeight;

// ——— Game State ———
const STATE = {
  running:false, paused:false, countdown:false, finished:false,
  lap:1, totalLaps:3,
  lapTime:0, totalTime:0, bestLap:null, lastLap:null, lapTimes:[],
  checkpointsPassed:0, nextCp:0,
  t:0.02, // track param 0-1
  surface:'ASPHALT',
  camMode:'chase' // chase | cinematic
};
let input = {f:0,b:0,steer:0,hand:false};

// ——— Audio ———
let audioCtx, engineOsc, engineGain, engineFilter;
function initAudio(){
  if(audioCtx) return;
  audioCtx = new (window.AudioContext||window.webkitAudioContext)();
  engineOsc = audioCtx.createOscillator();
  engineGain = audioCtx.createGain();
  engineFilter = audioCtx.createBiquadFilter();
  engineFilter.type='lowpass'; engineFilter.frequency.value=900;
  engineOsc.type='sawtooth'; engineOsc.frequency.value=60;
  engineGain.gain.value=0;
  engineOsc.connect(engineFilter).connect(engineGain).connect(audioCtx.destination);
  engineOsc.start();
}
function beep(freq,dur,vol=0.2){
  if(!audioCtx) return;
  const o=audioCtx.createOscillator(), g=audioCtx.createGain();
  o.type='square'; o.frequency.value=freq; g.gain.value=vol;
  o.connect(g).connect(audioCtx.destination); o.start();
  g.gain.exponentialRampToValueAtTime(0.0001,audioCtx.currentTime+dur);
  o.stop(audioCtx.currentTime+dur);
}
function crashSound(){
  if(!audioCtx) return;
  const o=audioCtx.createOscillator(), g=audioCtx.createGain(), f=audioCtx.createBiquadFilter();
  f.type='bandpass'; f.frequency.value=600;
  o.type='sawtooth'; o.frequency.setValueAtTime(300,audioCtx.currentTime);
  o.frequency.exponentialRampToValueAtTime(40,audioCtx.currentTime+0.25);
  g.gain.setValueAtTime(0.4,audioCtx.currentTime); g.gain.exponentialRampToValueAtTime(0.01,audioCtx.currentTime+0.3);
  o.connect(f).connect(g).connect(audioCtx.destination); o.start(); o.stop(audioCtx.currentTime+0.3);
}

// ——— Scene Setup ———
function init3D(){
  renderer = new T.WebGLRenderer({canvas, antialias:true});
  renderer.setPixelRatio(Math.min(devicePixelRatio,2));
  renderer.setSize(W,H);
  renderer.shadowMap.enabled=true; renderer.shadowMap.type=T.PCFSoftShadowMap;
  scene = new T.Scene();
  scene.background = new T.Color(0x87c4e8);
  fog = new T.Fog(0xd6e8f5, 180, 620);
  scene.fog = fog;

  camera = new T.PerspectiveCamera(72, W/H, 0.1, 1200);

  ambient = new T.HemisphereLight(0xffffff, 0x3a5a2a, 0.9);
  scene.add(ambient);
  sun = new T.DirectionalLight(0xfff3d0, 1.2);
  sun.position.set(120,180,60);
  sun.castShadow=true;
  sun.shadow.mapSize.set(2048,2048);
  sun.shadow.camera.near=10; sun.shadow.camera.far=600;
  sun.shadow.camera.left=-200; sun.shadow.camera.right=200; sun.shadow.camera.top=200; sun.shadow.camera.bottom=-200;
  scene.add(sun);

  // sky dome
  const skyGeo = new T.SphereGeometry(800,32,32);
  const skyMat = new T.MeshBasicMaterial({color:0x87c4e8, side:T.BackSide});
  const sky = new T.Mesh(skyGeo, skyMat);
  scene.add(sky);

  // distant mountains as ring
  const mtnGeo = new T.ConeGeometry(30,80,5);
  const mtnMat = new T.MeshLambertMaterial({color:0x5a7a8a});
  for(let i=0;i<28;i++){
    const a=i/28*Math.PI*2;
    const r=520+Math.random()*40;
    const m=new T.Mesh(mtnGeo,mtnMat);
    m.position.set(Math.cos(a)*r, -10+Math.random()*10, Math.sin(a)*r);
    m.scale.set(1+Math.random(),1,1+Math.random());
    scene.add(m);
  }
}
init3D();

// ——— Track ———
const trackPoints = [
  new T.Vector3(0,0,0),
  new T.Vector3(110,6, -40),
  new T.Vector3(190,10, 30),
  new T.Vector3(210,14, 120),
  new T.Vector3(150,9, 200),
  new T.Vector3(40,3, 210),
  new T.Vector3(-80,1, 150),
  new T.Vector3(-140,4, 40),
  new T.Vector3(-110,8, -80),
  new T.Vector3(-20,5, -140),
];
const curve = new T.CatmullRomCurve3(trackPoints,true,'catmullrom',0.25);
curve.closed=true;
const TRACK_SEG = 400;
const TRACK_W = 18; // width
const pts = curve.getPoints(TRACK_SEG);
const curveLengths = curve.getLengths(TRACK_SEG);
// precompute centerline and normals
let trackData=[]; // {p, tangent, left, right, elev}
for(let i=0;i<=TRACK_SEG;i++){
  const t=i/TRACK_SEG;
  const p=curve.getPointAt(t);
  const tang=curve.getTangentAt(t).normalize();
  const up=new T.Vector3(0,1,0);
  const left=new T.Vector3().crossVectors(up,tang).normalize(); // left dir
  // hills: elevation already in points, add micro dips
  const elev = p.y + Math.sin(t*Math.PI*6)*1.2;
  p.y=elev;
  trackData.push({p:p.clone(), tang, left, t});
}

// Build track mesh (ribbon)
function buildTrackMesh(){
  const geo = new T.BufferGeometry();
  const verts=[], uvs=[], indices=[];
  const roadMatCount = TRACK_SEG;
  for(let i=0;i<=TRACK_SEG;i++){
    const d=trackData[i%TRACK_SEG];
    const L = d.p.clone().addScaledVector(d.left, TRACK_W/2);
    const R = d.p.clone().addScaledVector(d.left, -TRACK_W/2);
    // slightly raise road to avoid z-fighting
    L.y+=0.3; R.y+=0.3;
    verts.push(L.x,L.y,L.z, R.x,R.y,R.z);
    uvs.push(0,i/TRACK_SEG*20, 1,i/TRACK_SEG*20);
  }
  for(let i=0;i<TRACK_SEG;i++){
    const a=i*2, b=a+1, c=a+2, d=a+3;
    indices.push(a,b,c, b,d,c);
  }
  geo.setAttribute('position', new T.Float32BufferAttribute(verts,3));
  geo.setAttribute('uv', new T.Float32BufferAttribute(uvs,2));
  geo.setIndex(indices);
  geo.computeVertexNormals();
  const mat = new T.MeshLambertMaterial({color:0x3b3a38}); // dirt asphalt
  const mesh = new T.Mesh(geo, mat);
  mesh.receiveShadow=true;
  scene.add(mesh);

  // shoulders (gravel)
  const sGeo=new T.BufferGeometry();
  const sVerts=[], sIdx=[];
  const shoulderW=6;
  for(let i=0;i<=TRACK_SEG;i++){
    const d=trackData[i%TRACK_SEG];
    const L1=d.p.clone().addScaledVector(d.left, TRACK_W/2);
    const L2=d.p.clone().addScaledVector(d.left, TRACK_W/2+shoulderW);
    const R1=d.p.clone().addScaledVector(d.left, -TRACK_W/2);
    const R2=d.p.clone().addScaledVector(d.left, -TRACK_W/2-shoulderW);
    L1.y+=0.15; L2.y+=0.12; R1.y+=0.15; R2.y+=0.12;
    sVerts.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<TRACK_SEG;i++){
    const o=i*4;
    // left
    sIdx.push(o,o+1,o+4, o+1,o+5,o+4);
    // right
    sIdx.push(o+2,o+6,o+3, o+3,o+6,o+7);
  }
  sGeo.setAttribute('position', new T.Float32BufferAttribute(sVerts,3));
  sGeo.setIndex(sIdx); sGeo.computeVertexNormals();
  const sMat=new T.MeshLambertMaterial({color:0x9a8c7a});
  const sMesh=new T.Mesh(sGeo,sMat); sMesh.receiveShadow=true; scene.add(sMesh);

  // center dashed line
  for(let i=0;i<TRACK_SEG;i+=8){
    const d=trackData[i];
    if(i%16===0){
      const box=new T.BoxGeometry(1.2,0.08,4);
      const m=new T.Mesh(box,new T.MeshLambertMaterial({color:0xffffff}));
      m.position.copy(d.p); m.position.y+=0.4;
      // orient along tangent
      const ang=Math.atan2(d.tang.x,d.tang.z);
      m.rotation.y=ang;
      scene.add(m);
    }
  }

  // ground plane huge
  const gGeo=new T.PlaneGeometry(1200,1200,20,20);
  const gMat=new T.MeshLambertMaterial({color:0x6a9a4a});
  const ground=new T.Mesh(gGeo,gMat);
  ground.rotation.x=-Math.PI/2; ground.position.y=-1.5;
  ground.receiveShadow=true; scene.add(ground);

  // checkpoint gates invisible debug (we show banners)
}
buildTrackMesh();

// ——— Scenery ———
const obstacles=[];
function addTree(pos,scale=1){
  const grp=new T.Group();
  const trunk=new T.Mesh(new T.CylinderGeometry(0.6*scale,0.9*scale,5*scale,6), new T.MeshLambertMaterial({color:0x6b4a2b}));
  trunk.position.y=2.5*scale; trunk.castShadow=true;
  const cone1=new T.Mesh(new T.ConeGeometry(4*scale,7*scale,6), new T.MeshLambertMaterial({color:0x1e4a22}));
  cone1.position.y=7*scale; cone1.castShadow=true;
  const cone2=cone1.clone(); cone2.scale.set(0.8,0.8,0.8); cone2.position.y=10*scale;
  grp.add(trunk,cone1,cone2);
  grp.position.copy(pos); scene.add(grp);
  obstacles.push({pos:grp.position.clone(), r:3.2*scale, type:'tree', mesh:grp});
}
function addRock(pos){
  const geo=new T.DodecahedronGeometry(1.8+Math.random()*1.2,0);
  const mat=new T.MeshLambertMaterial({color:0x8a8d93});
  const m=new T.Mesh(geo,mat); m.position.copy(pos); m.position.y+=0.8; m.rotation.set(Math.random(),Math.random(),Math.random());
  m.castShadow=true; scene.add(m);
  obstacles.push({pos:m.position.clone(), r:2.2, type:'rock', mesh:m});
}
function addBanner(pos, tang){
  const poleGeo=new T.CylinderGeometry(0.15,0.15,5,6);
  const poleMat=new T.MeshLambertMaterial({color:0xdddddd});
  const p1=new T.Mesh(poleGeo,poleMat); const p2=p1.clone();
  p1.position.set(-4,2.5,0); p2.position.set(4,2.5,0);
  const bannerGeo=new T.PlaneGeometry(8,2);
  const bannerMat=new T.MeshLambertMaterial({color: i=>0xff3b30, side:T.DoubleSide});
  // create canvas texture for banner text would be overkill — keep color blocks
  const colors=[0xff3b30,0xffd23f,0xffffff];
  const banner=new T.Mesh(bannerGeo,new T.MeshLambertMaterial({color:colors[Math.floor(Math.random()*3)], side:T.DoubleSide}));
  banner.position.y=4;
  const g=new T.Group(); g.add(p1,p2,banner);
  g.position.copy(pos); g.position.y+=0.2;
  const ang=Math.atan2(tang.x,tang.z); g.rotation.y=ang;
  scene.add(g);
}
function addBarrier(pos, tang){
  const geo=new T.BoxGeometry(6,1.2,0.6);
  const mat=new T.MeshLambertMaterial({color:0xd0d0d0});
  const m=new T.Mesh(geo,mat); m.position.copy(pos); m.position.y+=0.6;
  m.rotation.y=Math.atan2(tang.x,tang.z); m.castShadow=true; scene.add(m);
  obstacles.push({pos:m.position.clone(), r:3.5, type:'barrier', mesh:m});
}

// scatter
for(let i=0;i<TRACK_SEG;i+=6){
  const d=trackData[i];
  const off = (Math.random()>0.5?1:-1)*(TRACK_W/2+8+Math.random()*40);
  const p=d.p.clone().addScaledVector(d.left, off);
  p.y=-1.2;
  if(Math.random()<0.6) addTree(p, 0.8+Math.random()*0.7);
  else if(Math.random()<0.5) addRock(p);
  else addTree(p,0.6);
  // occasional roadside objects
  if(i%42===0){
    const side = TRACK_W/2+2;
    const bpos=d.p.clone().addScaledVector(d.left, side);
    bpos.y=0; addBarrier(bpos,d.tang);
    const bpos2=d.p.clone().addScaledVector(d.left, -side);
    bpos2.y=0; addBarrier(bpos2,d.tang);
  }
  if(i%70===0){
    const bp=d.p.clone().addScaledVector(d.left, TRACK_W/2+4);
    bp.y=0; addBanner(bp,d.tang);
  }
}

// ——— Car ———
const car = new T.Group();
scene.add(car);
const bodyGeo=new T.BoxGeometry(4.2,1.1,7.2);
const bodyMat=new T.MeshPhongMaterial({color:0xff2b2b, shininess:80});
const body=new T.Mesh(bodyGeo,bodyMat); body.position.y=1.2; body.castShadow=true; car.add(body);
const cabinGeo=new T.BoxGeometry(3.6,0.9,3.2);
const cabinMat=new T.MeshPhongMaterial({color:0x1a1f26});
const cabin=new T.Mesh(cabinGeo,cabinMat); cabin.position.set(0,1.95,-0.4); cabin.castShadow=true; car.add(cabin);
const spoilerGeo=new T.BoxGeometry(4.6,0.2,0.8);
const spoiler=new T.Mesh(spoilerGeo,new T.MeshPhongMaterial({color:0x111111})); spoiler.position.set(0,1.6,-3.2); car.add(spoiler);
const wheelGeo=new T.CylinderGeometry(0.7,0.7,0.9,12);
const wheelMat=new T.MeshLambertMaterial({color:0x111111});
const wheels=[];
[[-2,0.55,2.2],[2,0.55,2.2],[-2,0.55,-2.2],[2,0.55,-2.2]].forEach(p=>{
  const w=new T.Mesh(wheelGeo,wheelMat); w.rotation.z=Math.PI/2; w.position.set(p[0],p[1],p[2]); w.castShadow=true; car.add(w); wheels.push(w);
});
const headL=new T.Mesh(new T.SphereGeometry(0.35,8,8), new T.MeshBasicMaterial({color:0xffffcc}));
headL.position.set(-1.2,1.0,3.6); car.add(headL);
const headR=headL.clone(); headR.position.x=1.2; car.add(headR);
const tailL=new T.Mesh(new T.SphereGeometry(0.3,8,8), new T.MeshBasicMaterial({color:0xff1111}));
tailL.position.set(-1.3,1.0,-3.6); car.add(tailL);
const tailR=tailL.clone(); tailR.position.x=1.3; car.add(tailR);

// car physics vars
let carPos = trackData[0].p.clone(); carPos.y+=0.6;
let carYaw = Math.atan2(trackData[1].p.x - trackData[0].p.x, trackData[1].p.z - trackData[0].p.z);
let carSpeed=0, carVel = new T.Vector2(0,0); // x,z velocity
let drift=0, skidTime=0;
car.position.copy(carPos); car.rotation.y=carYaw;

// skid marks
const skidGroup=new T.Group(); scene.add(skidGroup);
function addSkid(pos, yaw){
  const g=new T.PlaneGeometry(0.7,1.6);
  const m=new T.Mesh(g,new T.MeshBasicMaterial({color:0x222222, transparent:true, opacity:0.65, side:T.DoubleSide}));
  m.rotation.x=-Math.PI/2; m.rotation.z=yaw; m.position.copy(pos); m.position.y=0.05; scene.add(m);
  setTimeout(()=>{m.material.opacity-=0.1; if(m.material.opacity<=0) scene.remove(m)}, 1800);
}
// dust particles
const dust=[]; const dustGeo=new T.SphereGeometry(0.25,6,6); const dustMat=new T.MeshBasicMaterial({color:0xc9b896, transparent:true, opacity:0.7});
function spawnDust(){
  const m=new T.Mesh(dustGeo,dustMat.clone()); m.position.copy(carPos); m.position.y+=0.4; m.position.x+=(Math.random()-0.5)*2; m.position.z+=(Math.random()-0.5)*2;
  m.userData={v:new T.Vector3((Math.random()-0.5)*2,1+Math.random()*2,(Math.random()-0.5)*2), life:0.6};
  scene.add(m); dust.push(m);
}

// ——— Minimap ———
const miniCanvas=document.getElementById('mini'), miniCtx=miniCanvas.getContext('2d');
function drawMini(){
  const w=340,h=340, pad=18;
  miniCtx.clearRect(0,0,w,h);
  // compute bounds
  let minX=Infinity,maxX=-Infinity,minZ=Infinity,maxZ=-Infinity;
  trackData.forEach(d=>{minX=Math.min(minX,d.p.x); maxX=Math.max(maxX,d.p.x); minZ=Math.min(minZ,d.p.z); maxZ=Math.max(maxZ,d.p.z);});
  const sx=(w-pad*2)/(maxX-minX), sz=(h-pad*2)/(maxZ-minZ), s=Math.min(sx,sz);
  const ox=(w-(maxX-minX)*s)/2 - minX*s, oz=(h-(maxZ-minZ)*s)/2 - minZ*s;
  const X=x=> x*s+ox, Z=z=> z*s+oz;
  // road
  miniCtx.lineWidth=14; miniCtx.strokeStyle='#2a2a2a'; miniCtx.lineCap='round'; miniCtx.lineJoin='round';
  miniCtx.beginPath();
  trackData.forEach((d,i)=>{ if(i===0) miniCtx.moveTo(X(d.p.x),Z(d.p.z)); else miniCtx.lineTo(X(d.p.x),Z(d.p.z));});
  miniCtx.closePath(); miniCtx.stroke();
  miniCtx.lineWidth=6; miniCtx.strokeStyle='#4a4a4a'; miniCtx.stroke();
  // center line dashes
  miniCtx.setLineDash([4,6]); miniCtx.strokeStyle='rgba(255,255,255,.5)'; miniCtx.lineWidth=1.2; miniCtx.stroke(); miniCtx.setLineDash([]);
  // start/finish
  const sp=trackData[0].p; miniCtx.fillStyle='#ffd23f'; miniCtx.fillRect(X(sp.x)-6,Z(sp.z)-2,12,4);
  // checkpoints
  const CP=12; for(let i=0;i<CP;i++){ const idx=Math.floor(i/CP*TRACK_SEG); const p=trackData[idx].p; miniCtx.fillStyle= i < STATE.nextCp ? 'rgba(255,255,255,.25)' : '#ff3b30'; miniCtx.beginPath(); miniCtx.arc(X(p.x),Z(p.z),3,0,Math.PI*2); miniCtx.fill();}
  // player
  miniCtx.fillStyle='#ff3b30'; miniCtx.strokeStyle='#fff'; miniCtx.lineWidth=2;
  miniCtx.beginPath(); miniCtx.arc(X(carPos.x),Z(carPos.z),6,0,Math.PI*2); miniCtx.fill(); miniCtx.stroke();
  // heading
  miniCtx.strokeStyle='#ff3b30'; miniCtx.lineWidth=2; miniCtx.beginPath(); miniCtx.moveTo(X(carPos.x),Z(carPos.z));
  miniCtx.lineTo(X(carPos.x+Math.sin(carYaw)*10), Z(carPos.z+Math.cos(carYaw)*10)); miniCtx.stroke();
}

// ——— Utils ———
function closestTrackParam(pos){
  let best=0, bestD=Infinity, bestIdx=0;
  for(let i=0;i<trackData.length;i++){
    const d=trackData[i].p.distanceTo(pos);
    if(d<bestD){bestD=d; best=i/trackData.length; bestIdx=i;}
  }
  // refine between neighbors
  return {t:best, dist:bestD, idx:bestIdx, data:trackData[bestIdx]};
}
function formatTime(s){
  if(s==null) return '--:--.--';
  const m=Math.floor(s/60), sec=s%60;
  return `${String(m).padStart(2,'0')}:${sec.toFixed(2).padStart(5,'0')}`;
}

// ——— Input ———
const keys={};
addEventListener('keydown',e=>{
  keys[e.key.toLowerCase()]=true;
  if(e.key==='p'||e.key==='P') togglePause();
  if(e.key==='r'||e.key==='R') restartRace();
  if(e.key==='c'||e.key==='C'){ STATE.camMode = STATE.camMode==='chase'?'cinematic':'chase'; document.getElementById('camBtn').textContent='CAM: '+STATE.camMode.toUpperCase(); beep(700,0.08,0.12);}
  if(e.code==='Space') e.preventDefault();
});
addEventListener('keyup',e=> keys[e.key.toLowerCase()]=false);
function pollInput(){
  const left = keys['arrowleft']||keys['a'], right=keys['arrowright']||keys['d'];
  const up = keys['arrowup']||keys['w'], down=keys['arrowsdown']||keys['s']||keys['arrowdown'];
  input.steer = (right?1:0)+(left?-1:0);
  input.f = up?1:0;
  input.b = down?1:0;
  input.hand = keys[' ']||false;
  // touch overrides
  if(touchState.left) input.steer=-1; else if(touchState.right) input.steer=1;
  if(touchState.gas) input.f=1;
  if(touchState.brake) input.b=1;
  if(touchState.hand) input.hand=true;
}
const touchState={left:false,right:false,gas:false,brake:false,hand:false};
function bindTouch(id,key){
  const el=document.getElementById(id);
  el.addEventListener('touchstart',e=>{e.preventDefault(); touchState[key]=true;});
  el.addEventListener('touchend',e=>{e.preventDefault(); touchState[key]=false;});
  el.addEventListener('mousedown',()=>touchState[key]=true);
  el.addEventListener('mouseup',()=>touchState[key]=false);
  el.addEventListener('mouseleave',()=>touchState[key]=false);
}
bindTouch('tLeft','left'); bindTouch('tRight','right'); bindTouch('tGas','gas'); bindTouch('tBrake','brake'); bindTouch('tHand','hand');

// ——— Physics ———
let prevClosestIdx=0;
function updatePhysics(dt){
  if(!STATE.running || STATE.paused || STATE.finished) return;
  pollInput();

  const accelInput = input.f - input.b*0.7;
  const steerInput = input.steer;

  // surface detection
  const ct = closestTrackParam(carPos);
  const lateralDist = ct.dist;
  const onTrack = lateralDist < TRACK_W/2;
  const onShoulder = lateralDist < TRACK_W/2+5;
  let grip=1, maxSpd=78, surf='ASPHALT';
  if(!onTrack){
    if(onShoulder){ grip=0.55; maxSpd=48; surf='GRAVEL';}
    else { grip=0.32; maxSpd=30; surf='MUD';}
  } else {
    // vary asphalt/dirt patches
    if(Math.sin(ct.t*Math.PI*8)>0.6){ grip=0.85; maxSpd=68; surf='DIRT';}
  }
  STATE.surface=surf;

  // engine & drag
  const enginePower = 62 * (input.hand?0.7:1);
  let targetAccel = accelInput * enginePower;
  // reverse
  if(input.b && carSpeed<5) targetAccel = -28;

  // drag & rolling
  const drag = 0.04 + (onTrack?0.01:0.08);
  const friction = onTrack? 2.2 : 5.5;

  // steering — speed dependent
  const speedFactor = Math.min(1, Math.abs(carSpeed)/50);
  const steerEff = (1 - speedFactor*0.55) * (input.hand?1.6:1);
  const steerAngle = steerInput * 1.05 * steerEff; // radians per sec factor

  // drifting
  let isDrifting = Math.abs(steerInput)>0.5 && Math.abs(carSpeed)>18 && ( !onTrack || input.hand);
  if(isDrifting){ grip*=0.55; drift = Math.max(drift, Math.abs(steerInput)); } else drift*=0.93;

  // integrate yaw
  const yawRate = steerAngle * (Math.abs(carSpeed)/12 + 0.6) * grip;
  carYaw += yawRate * dt;

  // forward vector
  const fwdX = Math.sin(carYaw), fwdZ=Math.cos(carYaw);
  // accelerate along forward
  let speedAlong = carVel.dot(new T.Vector2(fwdX,fwdZ));
  speedAlong += targetAccel * dt;
  // apply drag
  speedAlong -= speedAlong * drag * dt * 6;
  // friction braking when no input
  if(accelInput===0) speedAlong -= Math.sign(speedAlong)* friction * dt;
  // clamp
  if(surf==='MUD') speedAlong = Math.max(-18, Math.min(maxSpd, speedAlong));
  else speedAlong = Math.max(-20, Math.min(maxSpd, speedAlong));

  // lateral slip
  const velMag = carVel.length();
  const lateral = new T.Vector2(-fwdZ, fwdX); // left
  const latSpeed = carVel.dot(lateral);
  const latDamp = isDrifting? 2.2 : 7.5 * grip;
  const newLat = latSpeed - latSpeed * latDamp * dt;

  // recompose velocity
  const fwdVel = new T.Vector2(fwdX,fwdZ).multiplyScalar(speedAlong);
  const latVel = lateral.clone().multiplyScalar(newLat);
  carVel.copy(fwdVel).add(latVel);

  // position integrate
  carPos.x += carVel.x * dt;
  carPos.z += carVel.y * dt;
  // follow elevation
  const ct2 = closestTrackParam(carPos);
  const desiredY = ct2.data.p.y + 0.65;
  carPos.y += (desiredY - carPos.y)* 6 * dt;

  carSpeed = carVel.length() * 3.2; // scale to km/h feel
  skidTime = isDrifting? skidTime+dt : 0;

  // collisions
  for(const o of obstacles){
    const d = Math.hypot(carPos.x - o.pos.x, carPos.z - o.pos.z);
    if(d < o.r+2.0){
      const nx=(carPos.x - o.pos.x)/d, nz=(carPos.z - o.pos.z)/d;
      carPos.x = o.pos.x + nx*(o.r+2.1);
      carPos.z = o.pos.z + nz*(o.r+2.1);
      // bounce
      const dot = carVel.x*nx + carVel.y*nz;
      carVel.x -= 1.7*dot*nx;
      carVel.y -= 1.7*dot*nz;
      carVel.multiplyScalar(0.55);
      crashSound();
      // spark particle quickly
      spawnDust(); spawnDust();
      // flash
      document.getElementById('toast').textContent='CRASH!';
      document.getElementById('toast').style.display='block';
      setTimeout(()=>document.getElementById('toast').style.display='none',400);
    }
  }

  // checkpoint / lap logic - 12 checkpoints
  const CP_COUNT=12;
  const curCpT = STATE.nextCp / CP_COUNT;
  // compute track t nearest
  const t = ct2.t;
  // distance along track circular
  let diff = t - curCpT;
  if(diff < -0.5) diff+=1;
  if(diff > 0.5) diff-=1;
  if(Math.abs(diff) < 0.06 && ct2.dist < TRACK_W){ // passed gate
    STATE.nextCp++;
    if(STATE.nextCp >= CP_COUNT){
      // waiting for lap line
    }
    beep(900,0.06,0.12);
  }
  // lap crossing at t ~0
  const prevT = prevClosestIdx / trackData.length;
  // detect wrap
  if(prevT > 0.8 && t < 0.2){ // crossed start line forward
    if(STATE.nextCp >= CP_COUNT){
      // valid lap
      const lapTime = STATE.lapTime;
      STATE.lapTimes.push(lapTime);
      STATE.lastLap = lapTime;
      if(STATE.bestLap===null || lapTime < STATE.bestLap) STATE.bestLap = lapTime;
      STATE.lap++;
      STATE.lapTime=0;
      STATE.nextCp=0;
      if(STATE.lap > STATE.totalLaps){
        finishRace();
      } else {
        beep(1200,0.18,0.22);
      }
    } else {
      // cheat - show warning
      const toast=document.getElementById('toast');
      toast.textContent='MISSED CHECKPOINT!'; toast.style.display='block';
      setTimeout(()=>toast.style.display='none',900);
      beep(200,0.25,0.15);
    }
  }
  prevClosestIdx = ct2.idx;

  // update car mesh
  car.position.copy(carPos);
  car.rotation.y = carYaw;
  // lean
  car.rotation.z = -steerInput * (Math.abs(carSpeed)/70) * 0.35 - (isDrifting? steerInput*0.25:0);
  // wheel spin
  wheels.forEach((w,i)=>{
    w.rotation.x += carSpeed*0.08*dt * (i<2?1:1);
    if(i<2) w.rotation.y = steerInput*0.45;
  });

  // effects
  if(isDrifting && onTrack && Math.abs(carSpeed)>15){
    if(Math.random()<0.6) addSkid(new T.Vector3(carPos.x,0.06,carPos.z), carYaw);
    if(Math.random()<0.4) spawnDust();
  }
  if(!onTrack && Math.abs(carSpeed)>10 && Math.random()<0.5) spawnDust();

  // update timers
  STATE.lapTime += dt;
  STATE.totalTime += dt;

  // dust life
  for(let i=dust.length-1;i>=0;i--){
    const d=dust[i]; d.position.addScaledVector(d.userData.v, dt); d.userData.life-=dt; d.material.opacity = d.userData.life/0.6; d.scale.setScalar(1 + (0.6-d.userData.life)*2);
    if(d.userData.life<=0){ scene.remove(d); dust.splice(i,1); }
  }
}

// ——— Camera ———
let camTarget = new T.Vector3(), camPos = new T.Vector3();
function updateCamera(dt){
  const fwdX=Math.sin(carYaw), fwdZ=Math.cos(carYaw);
  let dist, height, lookH;
  if(STATE.camMode==='chase'){ dist=13; height=4.8; lookH=1.2; }
  else { dist=22; height=11; lookH=0.5; }
  // dynamic lag based on speed
  const speedLag = Math.min(1, Math.abs(carSpeed)/80);
  const targetPos = new T.Vector3(carPos.x - fwdX*(dist + speedLag*4), carPos.y + height, carPos.z - fwdZ*(dist + speedLag*4));
  // slight side offset when steering
  targetPos.x += Math.cos(carYaw)* input.steer * 2.2;
  targetPos.z -= Math.sin(carYaw)* input.steer * 2.2;
  camPos.lerp(targetPos, 1 - Math.pow(0.002, dt)); // smooth
  camTarget.lerp(new T.Vector3(carPos.x, carPos.y+lookH, carPos.z), 1 - Math.pow(0.002, dt));
  camera.position.copy(camPos);
  camera.lookAt(camTarget);
}

// ——— HUD ———
function updateHUD(){
  document.getElementById('hudSpeed').textContent=Math.round(Math.abs(carSpeed));
  document.getElementById('rpmBar').style.width= Math.min(100, (Math.abs(carSpeed)/78*100 + Math.abs(input.steer)*10))+'%';
  const gear = carSpeed<18?1: carSpeed<38?2: carSpeed<58?3: carSpeed<78?4:5;
  document.getElementById('hudGear').textContent= carSpeed<2? 'N' : gear;
  document.getElementById('hudSurface').textContent=STATE.surface;
  document.getElementById('hudLap').textContent= `${Math.min(STATE.lap,STATE.totalLaps)} / ${STATE.totalLaps}`;
  document.getElementById('hudTime').textContent=formatTime(STATE.totalTime);
  document.getElementById('hudBest').textContent=formatTime(STATE.bestLap);
  document.getElementById('lapTime').textContent=formatTime(STATE.lapTime);
  document.getElementById('lastLap').textContent=formatTime(STATE.lastLap);
  document.getElementById('totalTime').textContent=formatTime(STATE.totalTime);
}

// ——— Loop ———
let last=performance.now();
function animate(){
  requestAnimationFrame(animate);
  const now=performance.now(); let dt=(now-last)/1000; last=now;
  dt=Math.min(dt,0.033);
  if(STATE.running && !STATE.paused && !STATE.finished && !STATE.countdown){
    updatePhysics(dt);
    // engine audio
    if(audioCtx && engineOsc){
      const rpm = 0.25 + Math.abs(carSpeed)/78*0.9 + Math.abs(input.f)*0.2;
      engineOsc.frequency.setTargetAtTime(60 + rpm*340, audioCtx.currentTime, 0.08);
      engineGain.gain.setTargetAtTime(STATE.running? 0.14 + rpm*0.12 : 0, audioCtx.currentTime, 0.08);
      engineFilter.frequency.setTargetAtTime(500 + rpm*1800, audioCtx.currentTime, 0.08);
      // skid sound via gain modulation
      if(drift>0.3 && Math.abs(carSpeed)>20){
        if(!window.skidGain){
          window.skidOsc=audioCtx.createOscillator(); window.skidGain=audioCtx.createGain(); window.skidFilt=audioCtx.createBiquadFilter();
          window.skidOsc.type='sawtooth'; window.skidOsc.frequency.value=180; window.skidFilt.type='bandpass'; window.skidFilt.frequency.value=1400;
          window.skidOsc.connect(window.skidFilt).connect(window.skidGain).connect(audioCtx.destination); window.skidOsc.start(); window.skidGain.gain.value=0;
        }
        window.skidGain.gain.setTargetAtTime(0.12, audioCtx.currentTime, 0.05);
      } else if(window.skidGain) window.skidGain.gain.setTargetAtTime(0, audioCtx.currentTime, 0.1);
    }
  }
  updateCamera(dt);
  drawMini();
  updateHUD();
  renderer.render(scene, camera);
}
animate();

// ——— Countdown / Flow ———
function startCountdown(){
  STATE.countdown=true; STATE.running=false;
  const cdEl=document.getElementById('countdown'), txt=document.getElementById('cdText');
  cdEl.classList.add('show');
  let c=3; txt.textContent=c; beep(600,0.18,0.2);
  const iv=setInterval(()=>{
    c--;
    if(c>0){ txt.textContent=c; txt.style.color='#fff'; beep(600,0.18,0.2); cdEl.classList.remove('show'); void cdEl.offsetWidth; cdEl.classList.add('show'); }
    else if(c===0){ txt.textContent='GO!'; txt.style.color='#ffd23f'; beep(1000,0.32,0.25); cdEl.classList.remove('show'); void cdEl.offsetWidth; cdEl.classList.add('show'); }
    else { clearInterval(iv); cdEl.classList.remove('show'); STATE.countdown=false; STATE.running=true; STATE.paused=false; }
  }, 800);
}
function restartRace(){
  // reset
  STATE.lap=1; STATE.lapTime=0; STATE.totalTime=0; STATE.bestLap=null; STATE.lastLap=null; STATE.lapTimes=[]; STATE.nextCp=1; // require first cp? start at 1
  STATE.finished=false; STATE.paused=false;
  carPos.copy(trackData[0].p); carPos.y+=0.6;
  carYaw=Math.atan2(trackData[5].p.x - trackData[0].p.x, trackData[5].p.z - trackData[0].p.z);
  carVel.set(0,0); carSpeed=0;
  prevClosestIdx=0;
  document.getElementById('finishScreen').style.display='none';
  document.getElementById('pauseScreen').style.display='none';
  // clear dust/skids
  dust.forEach(d=>scene.remove(d)); dust.length=0;
  // remove skid meshes (they timeout)
  startCountdown();
}
function togglePause(){
  if(STATE.finished || STATE.countdown) return;
  if(!STATE.running) return;
  STATE.paused=!STATE.paused;
  document.getElementById('pauseScreen').style.display= STATE.paused?'grid':'none';
}
function finishRace(){
  STATE.finished=true; STATE.running=false;
  beep(800,0.12,0.2); setTimeout(()=>beep(1000,0.4,0.22),140); setTimeout(()=>beep(1200,0.6,0.22),320);
  document.getElementById('finTotal').textContent=formatTime(STATE.totalTime);
  document.getElementById('finBest').textContent=formatTime(STATE.bestLap);
  const list=document.getElementById('lapList');
  list.innerHTML = STATE.lapTimes.map((t,i)=>`<div style="display:flex;justify-content:space-between;border-bottom:1px solid rgba(255,255,255,.08);padding:4px 0"><span>Lap ${i+1}</span><b>${formatTime(t)} ${t===STATE.bestLap?'⭐ BEST':''}</b></div>`).join('');
  document.getElementById('finishScreen').style.display='grid';
}

// ——— Events ———
document.getElementById('playBtn').addEventListener('click',()=>{
  initAudio();
  document.getElementById('startScreen').style.display='none';
  restartRace();
});
document.getElementById('camBtn').addEventListener('click',()=>{
  STATE.camMode = STATE.camMode==='chase'?'cinematic':'chase';
  document.getElementById('camBtn').textContent='CAM: '+STATE.camMode.toUpperCase();
});
document.getElementById('pauseBtn').addEventListener('click',togglePause);
document.getElementById('resumeBtn').addEventListener('click',togglePause);
document.getElementById('restartBtn').addEventListener('click',restartRace);
document.getElementById('againBtn').addEventListener('click',restartRace);
document.getElementById('shareBtn').addEventListener('click',()=>{
  const txt=`RALLY FURY — Total ${formatTime(STATE.totalTime)} | Best ${formatTime(STATE.bestLap)}`;
  navigator.clipboard.writeText(txt); beep(900,0.1,0.15);
  const b=document.getElementById('shareBtn'); b.textContent='COPIED!'; setTimeout(()=>b.textContent='COPY TIME',1200);
});
addEventListener('resize',()=>{
  W=innerWidth; H=innerHeight;
  renderer.setSize(W,H); camera.aspect=W/H; camera.updateProjectionMatrix();
});
// init minimap once
drawMini();
// set initial cam
camPos.set(carPos.x, carPos.y+12, carPos.z-18);
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2","provider":"openrouter","persona":"basic","sessionId":"19d65899-205c-4b76-96f7-eaf161426659","tokensIn":1057,"tokensOut":13964,"tokensTotal":15021,"cost":0.06054395,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-08-06T15:30:58.987Z"} -->