← Rally results

Rally

Grok 4.5 · legacy

◈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 68,978 bytes · SHA-256 fe670d7d1a16
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>Rally Rush 3D</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body {
    width: 100%; height: 100%; overflow: hidden;
    background: #0a0e14; font-family: 'Segoe UI', system-ui, sans-serif;
    touch-action: none; user-select: none; -webkit-user-select: none;
  }
  #gameCanvas { display: block; width: 100%; height: 100%; }
  #hud {
    position: fixed; inset: 0; pointer-events: none;
    color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.8);
  }
  #hud .panel {
    position: absolute; background: rgba(0,0,0,.45);
    border: 1px solid rgba(255,255,255,.15); border-radius: 10px;
    padding: 10px 14px; backdrop-filter: blur(6px);
  }
  #speedPanel { bottom: 20px; left: 20px; min-width: 140px; }
  #speedVal { font-size: 42px; font-weight: 700; letter-spacing: 1px; line-height: 1; }
  #speedUnit { font-size: 14px; opacity: .7; margin-left: 4px; }
  #gearRpm { font-size: 13px; opacity: .85; margin-top: 4px; }
  #timePanel { top: 16px; left: 50%; transform: translateX(-50%); text-align: center; min-width: 200px; }
  #timer { font-size: 28px; font-weight: 700; font-variant-numeric: tabular-nums; }
  #lapInfo { font-size: 13px; opacity: .9; margin-top: 2px; }
  #bestLap { font-size: 12px; color: #7dffb3; margin-top: 2px; }
  #minimapWrap {
    position: absolute; top: 16px; right: 16px;
    width: 160px; height: 160px; border-radius: 12px;
    background: rgba(0,0,0,.5); border: 1px solid rgba(255,255,255,.2);
    overflow: hidden;
  }
  #minimap { width: 100%; height: 100%; display: block; }
  #surfaceHint {
    position: absolute; bottom: 100px; left: 20px;
    font-size: 12px; opacity: .7; text-transform: uppercase; letter-spacing: 1px;
  }
  #countdown {
    position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
    font-size: 120px; font-weight: 900; color: #fff;
    text-shadow: 0 0 40px rgba(255,120,0,.8), 0 4px 12px rgba(0,0,0,.9);
    pointer-events: none; opacity: 0; transition: opacity .15s;
    z-index: 20;
  }
  #countdown.show { opacity: 1; }
  #countdown.go { color: #5eff8a; font-size: 100px; }
  #overlay {
    position: fixed; inset: 0; background: rgba(0,0,0,.72);
    display: none; align-items: center; justify-content: center;
    z-index: 30; pointer-events: auto;
  }
  #overlay.visible { display: flex; }
  .menu-box {
    background: linear-gradient(160deg, #1a2332 0%, #0d141f 100%);
    border: 1px solid rgba(255,255,255,.12); border-radius: 16px;
    padding: 36px 48px; text-align: center; max-width: 90vw;
    box-shadow: 0 20px 60px rgba(0,0,0,.5);
  }
  .menu-box h1 {
    font-size: 36px; margin-bottom: 8px;
    background: linear-gradient(90deg, #ff6b2c, #ffd166);
    -webkit-background-clip: text; -webkit-text-fill-color: transparent;
    background-clip: text;
  }
  .menu-box p { opacity: .8; margin-bottom: 20px; line-height: 1.5; font-size: 14px; }
  .menu-box .stats { margin: 16px 0 24px; font-size: 16px; line-height: 1.8; }
  .menu-box .stats strong { color: #ffd166; }
  .btn {
    display: inline-block; margin: 6px; padding: 12px 28px;
    background: linear-gradient(180deg, #ff7a3d, #e04e12);
    color: #fff; border: none; border-radius: 8px; font-size: 16px;
    font-weight: 700; cursor: pointer; pointer-events: auto;
    box-shadow: 0 4px 0 #a0340a, 0 6px 16px rgba(0,0,0,.3);
    transition: transform .1s, box-shadow .1s;
  }
  .btn:hover { transform: translateY(-1px); }
  .btn:active { transform: translateY(2px); box-shadow: 0 2px 0 #a0340a; }
  .btn.secondary {
    background: linear-gradient(180deg, #3a4a5c, #2a3544);
    box-shadow: 0 4px 0 #1a2230, 0 6px 16px rgba(0,0,0,.3);
  }
  #titleBoot {
    position: fixed; inset: 0; background: radial-gradient(ellipse at center, #1a2838 0%, #060a10 70%);
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    z-index: 50; color: #fff;
  }
  #titleBoot h1 {
    font-size: clamp(36px, 8vw, 64px); margin-bottom: 12px;
    background: linear-gradient(90deg, #ff6b2c, #ffd166, #ff6b2c);
    background-size: 200% auto;
    -webkit-background-clip: text; -webkit-text-fill-color: transparent;
    background-clip: text; animation: shimmer 3s linear infinite;
  }
  @keyframes shimmer { to { background-position: 200% center; } }
  #titleBoot .sub { opacity: .7; margin-bottom: 28px; font-size: 15px; }
  #titleBoot .keys {
    font-size: 13px; opacity: .55; margin-top: 18px; line-height: 1.7; text-align: center;
  }
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<div id="hud">
  <div id="speedPanel" class="panel">
    <div><span id="speedVal">0</span><span id="speedUnit">km/h</span></div>
    <div id="gearRpm">N · 800 RPM</div>
  </div>
  <div id="timePanel" class="panel">
    <div id="timer">00:00.000</div>
    <div id="lapInfo">Lap 1 / 3</div>
    <div id="bestLap">Best: --:--.---</div>
  </div>
  <div id="minimapWrap"><canvas id="minimap" width="160" height="160"></canvas></div>
  <div id="surfaceHint">ASPHALT</div>
</div>
<div id="countdown"></div>
<div id="overlay">
  <div class="menu-box" id="menuBox"></div>
</div>
<div id="titleBoot">
  <h1>RALLY RUSH 3D</h1>
  <p class="sub">3 Laps · Winding Hills · Pure Speed</p>
  <button class="btn" id="startBtn">START RACE</button>
  <div class="keys">
    WASD / Arrows — Drive · Space — Handbrake<br>
    C — Camera · P — Pause · R — Restart
  </div>
</div>

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

  // ═══════════════════════════════════════════════════════════
  // CONFIG
  // ═══════════════════════════════════════════════════════════
  const CFG = {
    TOTAL_LAPS: 3,
    GRAVITY: 28,
    CAR: {
      ACCEL: 42,
      BRAKE: 55,
      REVERSE_ACCEL: 24,
      MAX_SPEED: 54,
      MAX_REVERSE: 14,
      DRAG: 0.5,
      ROLLING: 1.1,
      STEER_RATE: 2.6,        // max yaw rate influence
      STEER_RESP: 12,         // how quickly wheels respond
      STEER_SPEED_FALL: 0.55, // high-speed stability
      GRIP: 11,
      DRIFT_GRIP: 3.5,
      HANDBRAKE_GRIP: 1.6,
      WHEEL_BASE: 2.4,
      COLLISION_RADIUS: 1.5
    },
    SURFACE: {
      asphalt: { grip: 1.0,  drag: 1.0,  topMul: 1.0,  name: 'ASPHALT' },
      dirt:    { grip: 0.72, drag: 1.15, topMul: 0.9,  name: 'DIRT' },
      grass:   { grip: 0.48, drag: 1.5,  topMul: 0.7,  name: 'GRASS' },
      mud:     { grip: 0.32, drag: 2.2,  topMul: 0.45, name: 'MUD' }
    },
    TRACK_HALF_WIDTH: 7.5,
    SHOULDER: 3.5
  };

  // ═══════════════════════════════════════════════════════════
  // UTIL
  // ═══════════════════════════════════════════════════════════
  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
  const lerp = (a, b, t) => a + (b - a) * t;
  const fmtTime = (s) => {
    if (!isFinite(s) || s < 0) return '--:--.---';
    const m = Math.floor(s / 60);
    const sec = s - m * 60;
    return String(m).padStart(2, '0') + ':' + sec.toFixed(3).padStart(6, '0');
  };

  // ═══════════════════════════════════════════════════════════
  // AUDIO (Web Audio API only)
  // ═══════════════════════════════════════════════════════════
  const AudioSys = {
    ctx: null, master: null,
    engineOsc: null, engineOsc2: null, engineGain: null, engineFilter: null,
    skidOsc: null, skidGain: null, noiseNode: null, noiseGain: null,
    started: false,

    init() {
      if (this.started) return;
      const AC = window.AudioContext || window.webkitAudioContext;
      if (!AC) return;
      this.ctx = new AC();
      this.master = this.ctx.createGain();
      this.master.gain.value = 0.35;
      this.master.connect(this.ctx.destination);

      this.engineOsc = this.ctx.createOscillator();
      this.engineOsc.type = 'sawtooth';
      this.engineOsc.frequency.value = 60;
      this.engineOsc2 = this.ctx.createOscillator();
      this.engineOsc2.type = 'square';
      this.engineOsc2.frequency.value = 90;
      this.engineFilter = this.ctx.createBiquadFilter();
      this.engineFilter.type = 'lowpass';
      this.engineFilter.frequency.value = 400;
      this.engineFilter.Q.value = 4;
      this.engineGain = this.ctx.createGain();
      this.engineGain.gain.value = 0;
      const eg2 = this.ctx.createGain();
      eg2.gain.value = 0.3;
      this.engineOsc.connect(this.engineFilter);
      this.engineOsc2.connect(eg2);
      eg2.connect(this.engineFilter);
      this.engineFilter.connect(this.engineGain);
      this.engineGain.connect(this.master);
      this.engineOsc.start();
      this.engineOsc2.start();

      const bufLen = this.ctx.sampleRate * 2;
      const noiseBuf = this.ctx.createBuffer(1, bufLen, this.ctx.sampleRate);
      const data = noiseBuf.getChannelData(0);
      for (let i = 0; i < bufLen; i++) data[i] = Math.random() * 2 - 1;
      this.noiseNode = this.ctx.createBufferSource();
      this.noiseNode.buffer = noiseBuf;
      this.noiseNode.loop = true;
      const nFilter = this.ctx.createBiquadFilter();
      nFilter.type = 'bandpass';
      nFilter.frequency.value = 900;
      nFilter.Q.value = 0.8;
      this.noiseGain = this.ctx.createGain();
      this.noiseGain.gain.value = 0;
      this.noiseNode.connect(nFilter);
      nFilter.connect(this.noiseGain);
      this.noiseGain.connect(this.master);
      this.noiseNode.start();

      this.skidOsc = this.ctx.createOscillator();
      this.skidOsc.type = 'sawtooth';
      this.skidOsc.frequency.value = 180;
      this.skidGain = this.ctx.createGain();
      this.skidGain.gain.value = 0;
      const sf = this.ctx.createBiquadFilter();
      sf.type = 'highpass';
      sf.frequency.value = 400;
      this.skidOsc.connect(sf);
      sf.connect(this.skidGain);
      this.skidGain.connect(this.master);
      this.skidOsc.start();

      this.started = true;
    },

    resume() {
      if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume();
    },

    updateEngine(speed, throttle, gear, rpm) {
      if (!this.started) return;
      const sp = Math.abs(speed);
      const base = 55 + rpm * 0.045 + sp * 1.8;
      this.engineOsc.frequency.setTargetAtTime(base, this.ctx.currentTime, 0.05);
      this.engineOsc2.frequency.setTargetAtTime(base * 1.48 + gear * 8, this.ctx.currentTime, 0.05);
      this.engineFilter.frequency.setTargetAtTime(300 + sp * 12 + throttle * 400, this.ctx.currentTime, 0.08);
      const vol = 0.04 + Math.min(0.22, sp * 0.004) + throttle * 0.12;
      this.engineGain.gain.setTargetAtTime(vol, this.ctx.currentTime, 0.08);
    },

    updateSkid(amount, offroad) {
      if (!this.started) return;
      const a = clamp(amount, 0, 1);
      this.skidGain.gain.setTargetAtTime(a * 0.08, this.ctx.currentTime, 0.06);
      this.noiseGain.gain.setTargetAtTime(a * 0.1 + (offroad ? 0.05 : 0) * Math.min(1, a + 0.3), this.ctx.currentTime, 0.08);
      this.skidOsc.frequency.setTargetAtTime(140 + a * 220, this.ctx.currentTime, 0.05);
    },

    beep(freq, dur, type) {
      if (!this.started) return;
      const t = this.ctx.currentTime;
      const o = this.ctx.createOscillator();
      const g = this.ctx.createGain();
      o.type = type || 'square';
      o.frequency.value = freq;
      g.gain.setValueAtTime(0.25, t);
      g.gain.exponentialRampToValueAtTime(0.001, t + dur);
      o.connect(g); g.connect(this.master);
      o.start(t); o.stop(t + dur + 0.02);
    },

    countdownBeep(n) {
      if (n > 0) this.beep(440, 0.15, 'square');
      else this.beep(880, 0.4, 'square');
    },

    finishTone() {
      if (!this.started) return;
      [523, 659, 784, 1046].forEach((f, i) => setTimeout(() => this.beep(f, 0.25, 'triangle'), i * 120));
    },

    crash() {
      if (!this.started) return;
      const t = this.ctx.currentTime;
      const o = this.ctx.createOscillator();
      const g = this.ctx.createGain();
      o.type = 'sine';
      o.frequency.setValueAtTime(120, t);
      o.frequency.exponentialRampToValueAtTime(30, t + 0.3);
      g.gain.setValueAtTime(0.5, t);
      g.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
      o.connect(g); g.connect(this.master);
      o.start(t); o.stop(t + 0.4);

      const bufLen = Math.floor(this.ctx.sampleRate * 0.25);
      const buf = this.ctx.createBuffer(1, bufLen, this.ctx.sampleRate);
      const d = buf.getChannelData(0);
      for (let i = 0; i < bufLen; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / bufLen);
      const src = this.ctx.createBufferSource();
      src.buffer = buf;
      const ng = this.ctx.createGain();
      ng.gain.value = 0.3;
      const f = this.ctx.createBiquadFilter();
      f.type = 'lowpass'; f.frequency.value = 800;
      src.connect(f); f.connect(ng); ng.connect(this.master);
      src.start(t);
    }
  };

  // ═══════════════════════════════════════════════════════════
  // INPUT (keyboard only)
  // ═══════════════════════════════════════════════════════════
  const Input = {
    keys: {},
    steer: 0,
    throttle: 0,
    brake: 0,
    handbrake: false,

    init() {
      window.addEventListener('keydown', (e) => {
        this.keys[e.code] = true;
        if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) {
          e.preventDefault();
        }
      });
      window.addEventListener('keyup', (e) => {
        this.keys[e.code] = false;
      });
      // Avoid stuck keys when tabbing away
      window.addEventListener('blur', () => { this.keys = {}; });
    },

    poll() {
      let s = 0, th = 0, br = 0;
      if (this.keys['ArrowLeft']  || this.keys['KeyA']) s -= 1;
      if (this.keys['ArrowRight'] || this.keys['KeyD']) s += 1;
      if (this.keys['ArrowUp']    || this.keys['KeyW']) th = 1;
      if (this.keys['ArrowDown']  || this.keys['KeyS']) br = 1;

      this.steer = s;
      this.throttle = th;
      this.brake = br;
      this.handbrake = !!this.keys['Space'];
    }
  };

  // ═══════════════════════════════════════════════════════════
  // TRACK
  // ═══════════════════════════════════════════════════════════
  const Track = {
    points: [],
    samples: [],
    totalLength: 0,
    checkpoints: [],
    treePositions: [],
    rockPositions: [],
    barrierPositions: [],
    signPositions: [],
    bannerPositions: [],

    build() {
      // Wider, smoother rally loop — control points [x, y, z, width]
      const cp = [
        [  0,  0.0,   0, 15],
        [ 50,  0.4, -25, 15],
        [100,  1.0, -20, 14],
        [145,  2.2,  25, 14],
        [165,  4.0,  80, 13],
        [145,  5.5, 140, 13],
        [ 90,  5.0, 175, 14],
        [ 25,  3.5, 175, 15],
        [-35,  2.5, 145, 15],
        [-80,  2.0, 105, 14],
        [-110, 3.5,  55, 13],
        [-105, 5.5,   5, 13],
        [-75,  7.0, -45, 14],
        [-25,  4.5, -80, 15],
        [ 30,  2.0, -95, 15],
        [ 70,  1.0, -70, 15],
        [ 55,  0.5, -35, 15],
        [ 20,  0.2, -10, 15]
      ];

      this.points = cp.map(p => ({ x: p[0], y: p[1], z: p[2], width: p[3] }));

      const pts = this.points;
      const n = pts.length;
      const samples = [];
      const segs = 28;
      let totalLen = 0;
      let prev = null;

      for (let i = 0; i < n; i++) {
        const p0 = pts[(i - 1 + n) % n];
        const p1 = pts[i];
        const p2 = pts[(i + 1) % n];
        const p3 = pts[(i + 2) % n];
        for (let j = 0; j < segs; j++) {
          const t = j / segs;
          const t2 = t * t, t3 = t2 * t;
          const x = 0.5 * ((2*p1.x) + (-p0.x+p2.x)*t + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t2 + (-p0.x+3*p1.x-3*p2.x+p3.x)*t3);
          const y = 0.5 * ((2*p1.y) + (-p0.y+p2.y)*t + (2*p0.y-5*p1.y+4*p2.y-p3.y)*t2 + (-p0.y+3*p1.y-3*p2.y+p3.y)*t3);
          const z = 0.5 * ((2*p1.z) + (-p0.z+p2.z)*t + (2*p0.z-5*p1.z+4*p2.z-p3.z)*t2 + (-p0.z+3*p1.z-3*p2.z+p3.z)*t3);
          const w = lerp(p1.width, p2.width, t);
          const s = { x, y, z, width: w, dist: 0 };
          if (prev) {
            totalLen += Math.hypot(x - prev.x, y - prev.y, z - prev.z);
            s.dist = totalLen;
          }
          samples.push(s);
          prev = s;
        }
      }

      this.samples = samples;
      this.totalLength = totalLen;

      for (let i = 0; i < samples.length; i++) {
        const a = samples[i];
        const b = samples[(i + 1) % samples.length];
        const dx = b.x - a.x, dz = b.z - a.z;
        const len = Math.hypot(dx, dz) || 1;
        a.tx = dx / len;
        a.tz = dz / len;
        a.nx = -a.tz; // left normal in XZ
        a.nz =  a.tx;
        const dy = b.y - a.y;
        a.ty = dy / (Math.hypot(dx, dy, dz) || 1);
      }

      // Checkpoints for lap validation
      this.checkpoints = [];
      const cpCount = 12;
      for (let i = 0; i < cpCount; i++) {
        const dist = (i / cpCount) * this.totalLength;
        const s = this.sampleAtDistance(dist);
        this.checkpoints.push({
          dist, x: s.x, y: s.y, z: s.z,
          tx: s.tx, tz: s.tz, index: i,
          halfW: s.width * 0.55
        });
      }

      this.scatterScenery();
    },

    sampleAtDistance(d) {
      const samples = this.samples;
      const L = this.totalLength;
      d = ((d % L) + L) % L;
      let lo = 0, hi = samples.length - 1;
      while (lo < hi) {
        const mid = (lo + hi) >> 1;
        if (samples[mid].dist < d) lo = mid + 1;
        else hi = mid;
      }
      const i1 = lo % samples.length;
      const i0 = (i1 - 1 + samples.length) % samples.length;
      const a = samples[i0], b = samples[i1];
      let segLen = b.dist - a.dist;
      if (segLen <= 0) segLen = 1;
      let t = (d - a.dist) / segLen;
      if (t < 0 || t > 1) t = 0;
      return {
        x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t), z: lerp(a.z, b.z, t),
        tx: lerp(a.tx, b.tx, t), tz: lerp(a.tz, b.tz, t),
        nx: lerp(a.nx, b.nx, t), nz: lerp(a.nz, b.nz, t),
        width: lerp(a.width, b.width, t),
        ty: lerp(a.ty || 0, b.ty || 0, t),
        dist: d
      };
    },

    query(px, pz) {
      let best = 0, bestD = Infinity;
      const samples = this.samples;
      const step = 4;
      for (let i = 0; i < samples.length; i += step) {
        const s = samples[i];
        const d = (s.x - px) * (s.x - px) + (s.z - pz) * (s.z - pz);
        if (d < bestD) { bestD = d; best = i; }
      }
      const r0 = Math.max(0, best - step);
      const r1 = Math.min(samples.length - 1, best + step);
      for (let i = r0; i <= r1; i++) {
        const s = samples[i];
        const d = (s.x - px) * (s.x - px) + (s.z - pz) * (s.z - pz);
        if (d < bestD) { bestD = d; best = i; }
      }
      const s = samples[best];
      const lx = px - s.x, lz = pz - s.z;
      const lateral = lx * s.nx + lz * s.nz;
      const halfW = s.width * 0.5;
      return {
        sample: s, lateral, halfW, elev: s.y, dist: s.dist,
        onTrack: Math.abs(lateral) < halfW,
        onShoulder: Math.abs(lateral) < halfW + CFG.SHOULDER && Math.abs(lateral) >= halfW
      };
    },

    surfaceAt(px, pz) {
      const q = this.query(px, pz);
      const absL = Math.abs(q.lateral);
      if (absL < q.halfW * 0.85) return { type: 'asphalt', q };
      if (absL < q.halfW) return { type: 'dirt', q };
      if (absL < q.halfW + CFG.SHOULDER * 0.6) return { type: 'grass', q };
      const h = Math.sin(px * 0.17) * Math.cos(pz * 0.13);
      if (h > 0.55) return { type: 'mud', q };
      return { type: 'grass', q };
    },

    scatterScenery() {
      const rng = (seed) => {
        let s = seed;
        return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; };
      };
      const r = rng(42);

      this.treePositions = [];
      this.rockPositions = [];
      this.barrierPositions = [];
      this.signPositions = [];
      this.bannerPositions = [];

      for (let i = 0; i < this.samples.length; i += 2) {
        const s = this.samples[i];
        const halfW = s.width * 0.5;

        // Trees well clear of the road
        for (let side = -1; side <= 1; side += 2) {
          if (r() > 0.42) continue;
          const off = halfW + 7 + r() * 22;
          this.treePositions.push({
            x: s.x + s.nx * side * off + (r() - 0.5) * 3,
            y: s.y,
            z: s.z + s.nz * side * off + (r() - 0.5) * 3,
            scale: 0.85 + r() * 1.3,
            type: r() > 0.35 ? 0 : 1
          });
        }

        // Rocks near but not on the road
        if (r() > 0.82) {
          const side = r() > 0.5 ? 1 : -1;
          const off = halfW + 3.5 + r() * 5;
          this.rockPositions.push({
            x: s.x + s.nx * side * off,
            y: s.y,
            z: s.z + s.nz * side * off,
            scale: 0.5 + r() * 1.0
          });
        }

        // Guard rails only on outer edge of sharp turns
        if (i % 6 === 0) {
          const next = this.samples[(i + 3) % this.samples.length];
          const cross = s.tx * next.tz - s.tz * next.tx;
          const turn = Math.abs(cross);
          if (turn > 0.06) {
            // Place on the outside of the corner
            const side = cross > 0 ? -1 : 1;
            for (let k = 0; k < 3; k++) {
              const ss = this.samples[(i + k) % this.samples.length];
              this.barrierPositions.push({
                x: ss.x + ss.nx * side * (ss.width * 0.5 + 1.0),
                y: ss.y,
                z: ss.z + ss.nz * side * (ss.width * 0.5 + 1.0),
                // rail runs along the track direction
                angle: Math.atan2(ss.tx, ss.tz)
              });
            }
          }
        }
      }

      // Rally signs (clear of roadway)
      for (let i = 0; i < 12; i++) {
        const dist = ((i + 0.3) / 12) * this.totalLength;
        const s = this.sampleAtDistance(dist);
        const side = i % 2 === 0 ? 1 : -1;
        this.signPositions.push({
          x: s.x + s.nx * side * (s.width * 0.5 + 3.2),
          y: s.y,
          z: s.z + s.nz * side * (s.width * 0.5 + 3.2),
          angle: Math.atan2(s.tx, s.tz),
          kind: i % 3
        });
      }

      // Overhead banners / gates (span the road, tall enough clearance)
      for (let i = 0; i < 5; i++) {
        const dist = ((i + 0.5) / 5) * this.totalLength;
        // Skip very start so we don't double with the finish arch
        if (dist < 20 || dist > this.totalLength - 20) continue;
        const s = this.sampleAtDistance(dist);
        this.bannerPositions.push({
          x: s.x, y: s.y, z: s.z,
          angle: Math.atan2(s.tx, s.tz),
          width: s.width + 3
        });
      }
    }
  };

  // ═══════════════════════════════════════════════════════════
  // PARTICLES
  // ═══════════════════════════════════════════════════════════
  const Particles = {
    list: [], mesh: null, max: 400, geo: null, positions: null, colors: null,

    init(scene) {
      this.positions = new Float32Array(this.max * 3);
      this.colors = new Float32Array(this.max * 3);
      this.geo = new THREE.BufferGeometry();
      this.geo.setAttribute('position', new THREE.BufferAttribute(this.positions, 3));
      this.geo.setAttribute('color', new THREE.BufferAttribute(this.colors, 3));
      const mat = new THREE.PointsMaterial({
        size: 0.55, vertexColors: true, transparent: true,
        opacity: 0.85, depthWrite: false, depthWrite: false, sizeAttenuation: true
      });
      this.mesh = new THREE.Points(this.geo, mat);
      this.mesh.frustumCulled = false;
      scene.add(this.mesh);
      this.list = [];
    },

    spawn(x, y, z, vx, vy, vz, life, r, g, b) {
      if (this.list.length >= this.max) this.list.shift();
      this.list.push({ x, y, z, vx, vy, vz, life, maxLife: life, r, g, b });
    },

    dust(x, y, z, speed, amt) {
      for (let i = 0; i < amt; i++) {
        const a = Math.random() * Math.PI * 2;
        const sp = 1 + Math.random() * 3;
        this.spawn(
          x + (Math.random() - 0.5) * 1.2, y + 0.1, z + (Math.random() - 0.5) * 1.2,
          Math.cos(a) * sp * 0.3 - speed * 0.12,
          1 + Math.random() * 2,
          Math.sin(a) * sp * 0.3,
          0.4 + Math.random() * 0.6,
          0.55 + Math.random() * 0.2, 0.45 + Math.random() * 0.15, 0.3 + Math.random() * 0.15
        );
      }
    },

    sparks(x, y, z, n) {
      for (let i = 0; i < n; i++) {
        this.spawn(x, y + 0.3, z,
          (Math.random() - 0.5) * 12, 2 + Math.random() * 8, (Math.random() - 0.5) * 12,
          0.25 + Math.random() * 0.3, 1, 0.7 + Math.random() * 0.3, 0.2);
      }
    },

    skid(x, y, z) {
      this.spawn(x, y + 0.05, z, (Math.random()-0.5)*0.5, 0, (Math.random()-0.5)*0.5, 1.2, 0.12, 0.1, 0.08);
    },

    update(dt) {
      let i = 0;
      while (i < this.list.length) {
        const p = this.list[i];
        p.life -= dt;
        if (p.life <= 0) { this.list.splice(i, 1); continue; }
        p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
        p.vy -= 6 * dt;
        p.vx *= (1 - 1.5 * dt); p.vz *= (1 - 1.5 * dt);
        i++;
      }
      for (let j = 0; j < this.max; j++) {
        if (j < this.list.length) {
          const p = this.list[j];
          const t = p.life / p.maxLife;
          this.positions[j*3] = p.x; this.positions[j*3+1] = p.y; this.positions[j*3+2] = p.z;
          this.colors[j*3] = p.r * t; this.colors[j*3+1] = p.g * t; this.colors[j*3+2] = p.b * t;
        } else {
          this.positions[j*3+1] = -999;
        }
      }
      this.geo.attributes.position.needsUpdate = true;
      this.geo.attributes.color.needsUpdate = true;
    }
  };

  // ═══════════════════════════════════════════════════════════
  // CAR
  // ═══════════════════════════════════════════════════════════
  const Car = {
    x: 0, y: 0, z: 0,
    yaw: 0, pitch: 0, roll: 0,
    speed: 0, vx: 0, vz: 0,
    steerAngle: 0,
    gear: 1, rpm: 800,
    sliding: 0,
    mesh: null, wheels: [], headlights: [], taillights: [],
    lastSurface: 'asphalt',
    collisionCool: 0,
    skidTimer: 0,

    buildMesh(scene) {
      const g = new THREE.Group();

      const bodyMat = new THREE.MeshStandardMaterial({
        color: 0xd63031, roughness: 0.45, metalness: 0.35, flatShading: true
      });
      const cabinMat = new THREE.MeshStandardMaterial({
        color: 0x1e272e, roughness: 0.3, metalness: 0.5, flatShading: true
      });
      const glassMat = new THREE.MeshStandardMaterial({
        color: 0x74b9ff, roughness: 0.2, metalness: 0.8, transparent: true, opacity: 0.55, flatShading: true
      });
      const bumperMat = new THREE.MeshStandardMaterial({ color: 0x2d3436, flatShading: true });

      // Body elongated along local +Z (forward)
      const body = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.55, 3.6), bodyMat);
      body.position.y = 0.55;
      body.castShadow = true;
      g.add(body);

      const cabin = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.5, 1.6), cabinMat);
      cabin.position.set(0, 1.0, -0.15);
      cabin.castShadow = true;
      g.add(cabin);

      const windshield = new THREE.Mesh(new THREE.BoxGeometry(1.35, 0.35, 0.08), glassMat);
      windshield.position.set(0, 1.05, 0.7);
      windshield.rotation.x = -0.25;
      g.add(windshield);

      const scoop = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.12, 0.7), bodyMat);
      scoop.position.set(0, 0.88, 1.0);
      g.add(scoop);

      // Rear spoiler
      const spoilerPoleL = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.35, 0.08), cabinMat);
      spoilerPoleL.position.set(-0.6, 0.95, -1.65);
      const spoilerPoleR = spoilerPoleL.clone(); spoilerPoleR.position.x = 0.6;
      const spoiler = new THREE.Mesh(new THREE.BoxGeometry(1.7, 0.08, 0.4), bodyMat);
      spoiler.position.set(0, 1.15, -1.65);
      g.add(spoilerPoleL, spoilerPoleR, spoiler);

      const fBump = new THREE.Mesh(new THREE.BoxGeometry(1.85, 0.25, 0.3), bumperMat);
      fBump.position.set(0, 0.35, 1.9);
      const rBump = new THREE.Mesh(new THREE.BoxGeometry(1.85, 0.25, 0.25), bumperMat);
      rBump.position.set(0, 0.35, -1.9);
      g.add(fBump, rBump);

      const hlMat = new THREE.MeshStandardMaterial({
        color: 0xfff3c4, emissive: 0xffeeaa, emissiveIntensity: 0.9, flatShading: true
      });
      const hlL = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.18, 0.1), hlMat);
      hlL.position.set(-0.55, 0.55, 1.95);
      const hlR = hlL.clone(); hlR.position.x = 0.55;
      g.add(hlL, hlR);
      this.headlights = [hlL, hlR];

      const tlMat = new THREE.MeshStandardMaterial({
        color: 0xff0000, emissive: 0xff0000, emissiveIntensity: 0.6, flatShading: true
      });
      const tlL = new THREE.Mesh(new THREE.BoxGeometry(0.35, 0.14, 0.08), tlMat);
      tlL.position.set(-0.55, 0.55, -1.95);
      const tlR = tlL.clone(); tlR.position.x = 0.55;
      g.add(tlL, tlR);
      this.taillights = [tlL, tlR];

      const wheelMat = new THREE.MeshStandardMaterial({ color: 0x111111, flatShading: true, roughness: 0.9 });
      const rimMat = new THREE.MeshStandardMaterial({ color: 0xb2bec3, metalness: 0.7, roughness: 0.3, flatShading: true });
      this.wheels = [];
      const wheelPos = [
        [-0.85, 0.32,  1.15],
        [ 0.85, 0.32,  1.15],
        [-0.85, 0.32, -1.15],
        [ 0.85, 0.32, -1.15]
      ];
      wheelPos.forEach((p, idx) => {
        const wg = new THREE.Group();
        const tire = new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.35, 0.28, 10), wheelMat);
        tire.rotation.z = Math.PI / 2;
        tire.castShadow = true;
        const rim = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.18, 0.3, 8), rimMat);
        rim.rotation.z = Math.PI / 2;
        wg.add(tire, rim);
        wg.position.set(p[0], p[1], p[2]);
        g.add(wg);
        this.wheels.push({ mesh: wg, front: idx < 2 });
      });

      const shadow = new THREE.Mesh(
        new THREE.CircleGeometry(1.3, 16),
        new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.35, depthWrite: false })
      );
      shadow.rotation.x = -Math.PI / 2;
      shadow.position.y = 0.05;
      g.add(shadow);

      this.mesh = g;
      scene.add(g);
    },

    reset() {
      // Start a little past the finish line so the arch is in front of the car
      const start = Track.sampleAtDistance(8);
      this.x = start.x;
      this.y = start.y;
      this.z = start.z;
      // Face along track forward (+Z comes from sin/cos of yaw)
      this.yaw = Math.atan2(start.tx, start.tz);
      this.pitch = 0;
      this.roll = 0;
      this.speed = 0;
      this.vx = 0;
      this.vz = 0;
      this.steerAngle = 0;
      this.gear = 1;
      this.rpm = 800;
      this.sliding = 0;
      this.collisionCool = 0;
      if (this.mesh) {
        this.mesh.position.set(this.x, this.y, this.z);
        this.mesh.rotation.order = 'YXZ';
        this.mesh.rotation.y = this.yaw;
        this.mesh.rotation.x = 0;
        this.mesh.rotation.z = 0;
      }
    },

    update(dt) {
      Input.poll();
      const c = CFG.CAR;

      const surf = Track.surfaceAt(this.x, this.z);
      this.lastSurface = surf.type;
      const S = CFG.SURFACE[surf.type];
      const q = surf.q;

      // --- Steering (works while moving; gentle kick even crawling) ---
      const spAbs = Math.abs(this.speed);
      const speedFactor = 1 - clamp(spAbs / c.MAX_SPEED, 0, 1) * c.STEER_SPEED_FALL;
      const targetSteer = Input.steer * c.STEER_RATE * speedFactor;
      this.steerAngle = lerp(this.steerAngle, targetSteer, 1 - Math.exp(-c.STEER_RESP * dt));

      // --- Longitudinal force ---
      let accel = 0;
      if (Input.throttle > 0) {
        const top = c.MAX_SPEED * S.topMul;
        const power = 1 - clamp(this.speed / Math.max(top, 0.01), 0, 1);
        accel += c.ACCEL * Input.throttle * power * (0.55 + 0.45 * S.grip);
      }
      if (Input.brake > 0) {
        if (this.speed > 0.5) accel -= c.BRAKE * Input.brake;
        else accel -= c.REVERSE_ACCEL * Input.brake;
      }

      const drag = (c.DRAG * S.drag) * this.speed * Math.abs(this.speed) * 0.015;
      const roll = c.ROLLING * S.drag * Math.sign(this.speed) * (spAbs > 0.1 ? 1 : 0);
      accel -= drag + roll;

      this.speed += accel * dt;
      this.speed = clamp(this.speed, -c.MAX_REVERSE, c.MAX_SPEED * S.topMul * 1.05);

      if (Input.handbrake && this.speed > 3) {
        this.speed *= (1 - 1.9 * dt);
      }

      // --- Apply yaw from steering FIRST so velocity follows new heading ---
      // Bicycle-style: turn rate ~ steer * speed / wheelbase
      if (spAbs > 0.15 || Math.abs(Input.steer) > 0) {
        const dir = this.speed >= -0.5 ? 1 : -1;
        // Minimum effective for low-speed turning
        const steerScale = clamp(spAbs / 6, 0.35, 1.0);
        const hbBoost = Input.handbrake ? 1.45 : 1;
        this.yaw += this.steerAngle * steerScale * dir * hbBoost * dt;
      }

      const forwardX = Math.sin(this.yaw);
      const forwardZ = Math.cos(this.yaw);
      const rightX = Math.cos(this.yaw);
      const rightZ = -Math.sin(this.yaw);

      // Desired velocity purely along forward axis
      const targetFx = forwardX * this.speed;
      const targetFz = forwardZ * this.speed;

      // Grip blend — lower grip keeps more of previous velocity (slide/drift)
      let grip = c.GRIP * S.grip;
      if (Input.handbrake) grip = c.HANDBRAKE_GRIP * S.grip;
      if (spAbs > 18 && Math.abs(this.steerAngle) > 0.5 && !Input.handbrake) {
        grip = lerp(grip, c.DRIFT_GRIP * S.grip, clamp((spAbs - 18) / 28, 0, 1));
      }

      const blend = 1 - Math.exp(-grip * dt);
      this.vx = lerp(this.vx, targetFx, blend);
      this.vz = lerp(this.vz, targetFz, blend);

      const fVel = this.vx * forwardX + this.vz * forwardZ;
      const lVel = this.vx * rightX + this.vz * rightZ;
      this.sliding = Math.abs(lVel);

      // Soft pull signed speed toward forward velocity component
      if (Math.abs(fVel) > 0.01) {
        this.speed = lerp(this.speed, fVel, 0.2);
      }

      // Drift continuation yaw
      if (this.sliding > 2.5) {
        this.yaw += lVel * 0.025 * dt;
      }

      this.x += this.vx * dt;
      this.z += this.vz * dt;

      // Follow elevation
      const targetY = q.elev;
      this.y = lerp(this.y, targetY, 1 - Math.exp(-14 * dt));

      const targetPitch = -Math.atan2(q.sample.ty || 0, 1) * 0.9;
      this.pitch = lerp(this.pitch, targetPitch, 1 - Math.exp(-6 * dt));
      const targetRoll = -this.steerAngle * 0.2 - lVel * 0.018;
      this.roll = lerp(this.roll, clamp(targetRoll, -0.35, 0.35), 1 - Math.exp(-5 * dt));

      // Soft outer boundary
      const absL = Math.abs(q.lateral);
      const limit = q.halfW + CFG.SHOULDER + 3;
      if (absL > limit) {
        const pushDir = q.lateral > 0 ? -1 : 1;
        const pen = absL - limit;
        this.x += q.sample.nx * pushDir * pen;
        this.z += q.sample.nz * pushDir * pen;
        const into = this.vx * q.sample.nx + this.vz * q.sample.nz;
        if ((q.lateral > 0 && into > 0) || (q.lateral < 0 && into < 0)) {
          this.vx -= q.sample.nx * into * 1.5;
          this.vz -= q.sample.nz * into * 1.5;
          this.speed *= 0.72;
          if (this.collisionCool <= 0 && spAbs > 8) {
            AudioSys.crash();
            Particles.sparks(this.x, this.y + 0.5, this.z, 12);
            this.collisionCool = 0.5;
          }
        }
      }

      this.collisionCool -= dt;
      this.collideObstacles(spAbs);
      this.simGear(dt);
      this.syncMesh(dt);

      // FX
      if (this.sliding > 4 || (Input.handbrake && spAbs > 8)) {
        this.skidTimer -= dt;
        if (this.skidTimer <= 0) {
          Particles.skid(this.x - forwardX * 1.1 + rightX * 0.7, this.y, this.z - forwardZ * 1.1 + rightZ * 0.7);
          Particles.skid(this.x - forwardX * 1.1 - rightX * 0.7, this.y, this.z - forwardZ * 1.1 - rightZ * 0.7);
          this.skidTimer = 0.04;
        }
        Particles.dust(this.x - forwardX * 1.5, this.y, this.z - forwardZ * 1.5, spAbs, 2);
      }
      if (surf.type !== 'asphalt' && spAbs > 5) {
        Particles.dust(this.x, this.y, this.z, spAbs * 0.5, surf.type === 'mud' ? 3 : 1);
      }

      const driftAmt = clamp(this.sliding / 12, 0, 1) + (Input.handbrake && spAbs > 6 ? 0.5 : 0);
      AudioSys.updateEngine(this.speed, Input.throttle, this.gear, this.rpm);
      AudioSys.updateSkid(
        driftAmt + (surf.type !== 'asphalt' ? 0.25 * clamp(spAbs / 30, 0, 1) : 0),
        surf.type !== 'asphalt'
      );
    },

    collideObstacles(spAbs) {
      const r = CFG.CAR.COLLISION_RADIUS;
      const bounce = (ox, oz, rad) => {
        const dx = this.x - ox, dz = this.z - oz;
        const d2 = dx * dx + dz * dz;
        const minD = r + rad;
        if (d2 < minD * minD && d2 > 0.0001) {
          const d = Math.sqrt(d2);
          const nx = dx / d, nz = dz / d;
          const pen = minD - d;
          this.x += nx * pen;
          this.z += nz * pen;
          const vn = this.vx * nx + this.vz * nz;
          if (vn < 0) {
            this.vx -= nx * vn * 1.6;
            this.vz -= nz * vn * 1.6;
          }
          this.speed *= 0.5;
          if (this.collisionCool <= 0 && spAbs > 5) {
            AudioSys.crash();
            Particles.sparks(this.x, this.y + 0.4, this.z, 14);
            this.collisionCool = 0.45;
          }
          return true;
        }
        return false;
      };
      for (let i = 0; i < Track.rockPositions.length; i++) {
        const o = Track.rockPositions[i];
        bounce(o.x, o.z, 0.9 * (o.scale || 1));
      }
      for (let i = 0; i < Track.barrierPositions.length; i++) {
        const o = Track.barrierPositions[i];
        bounce(o.x, o.z, 0.7);
      }
      for (let i = 0; i < Track.treePositions.length; i++) {
        const o = Track.treePositions[i];
        bounce(o.x, o.z, 0.55);
      }
    },

    simGear(dt) {
      const sp = Math.abs(this.speed);
      const ratios = [0, 10, 20, 30, 40, 50];
      let g = 1;
      for (let i = 1; i < ratios.length; i++) {
        if (sp > ratios[i] * 0.92) g = i;
      }
      if (sp < 1) g = 0;
      this.gear = g;
      const lo = g <= 1 ? 0 : ratios[g - 1];
      const hi = g === 0 ? 10 : (ratios[g] || 55);
      const t = clamp((sp - lo) / (hi - lo + 0.01), 0, 1);
      const targetRpm = g === 0 ? 800 : 900 + t * 5500 + Input.throttle * 400;
      this.rpm = lerp(this.rpm, targetRpm, 1 - Math.exp(-4 * dt));
    },

    syncMesh(dt) {
      if (!this.mesh) return;
      this.mesh.position.set(this.x, this.y, this.z);
      this.mesh.rotation.order = 'YXZ';
      this.mesh.rotation.y = this.yaw;
      this.mesh.rotation.x = this.pitch;
      this.mesh.rotation.z = this.roll;

      const spin = (this.speed * dt) / 0.35;
      this.wheels.forEach((w) => {
        w.mesh.rotation.x += spin;
        if (w.front) w.mesh.rotation.y = this.steerAngle * 0.5;
      });

      const brakeOn = Input.brake > 0 || Input.handbrake;
      this.taillights.forEach((tl) => {
        tl.material.emissiveIntensity = brakeOn ? 1.4 : 0.5;
      });
    },

    speedKmh() {
      return Math.abs(this.speed) * 3.6 * 1.8;
    }
  };

  // ═══════════════════════════════════════════════════════════
  // WORLD / RENDER
  // ═══════════════════════════════════════════════════════════
  const World = {
    scene: null, camera: null, renderer: null, sun: null,
    camMode: 0,
    camPos: new THREE.Vector3(),
    camLook: new THREE.Vector3(),

    init() {
      const canvas = document.getElementById('gameCanvas');
      this.renderer = new THREE.WebGLRenderer({
        canvas, antialias: true, powerPreference: 'high-performance'
      });
      this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
      this.renderer.setSize(window.innerWidth, window.innerHeight);
      this.renderer.shadowMap.enabled = true;
      this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
      this.renderer.setClearColor(0x87b5d9);

      this.scene = new THREE.Scene();
      this.scene.fog = new THREE.Fog(0x9ec8e6, 70, 300);

      this.camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.3, 500);
      this.camPos.set(0, 8, 12);
      this.camLook.set(0, 0, 0);

      this.scene.add(new THREE.AmbientLight(0x9bb4c8, 0.55));
      this.sun = new THREE.DirectionalLight(0xfff0d5, 0.95);
      this.sun.position.set(60, 100, 40);
      this.sun.castShadow = true;
      this.sun.shadow.mapSize.set(2048, 2048);
      this.sun.shadow.camera.near = 10;
      this.sun.shadow.camera.far = 300;
      this.sun.shadow.camera.left = -90;
      this.sun.shadow.camera.right = 90;
      this.sun.shadow.camera.top = 90;
      this.sun.shadow.camera.bottom = -90;
      this.sun.shadow.bias = -0.0005;
      this.scene.add(this.sun);
      this.scene.add(this.sun.target);
      this.scene.add(new THREE.HemisphereLight(0x87ceeb, 0x3d5c2e, 0.35));

      // Sky dome
      const skyGeo = new THREE.SphereGeometry(420, 24, 16);
      const skyMat = new THREE.ShaderMaterial({
        side: THREE.BackSide,
        depthWrite: false,
        uniforms: {
          topColor: { value: new THREE.Color(0x4a90c8) },
          bottomColor: { value: new THREE.Color(0xc8dff0) },
          offset: { value: 20 },
          exponent: { value: 0.5 }
        },
        vertexShader: `
          varying vec3 vWorldPos;
          void main() {
            vec4 wp = modelMatrix * vec4(position, 1.0);
            vWorldPos = wp.xyz;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
          }
        `,
        fragmentShader: `
          uniform vec3 topColor;
          uniform vec3 bottomColor;
          uniform float offset;
          uniform float exponent;
          varying vec3 vWorldPos;
          void main() {
            float h = normalize(vWorldPos + vec3(0.0, offset, 0.0)).y;
            float t = max(pow(max(h, 0.0), exponent), 0.0);
            gl_FragColor = vec4(mix(bottomColor, topColor, t), 1.0);
          }
        `
      });
      this.scene.add(new THREE.Mesh(skyGeo, skyMat));

      // Grass ground tree surfaces
      const ground = new THREE.Mesh(
        new THREE.PlaneGeometry(700, 700, 48, 48),
        new THREE.MeshStandardMaterial({ color: 0x3d6b2e, roughness: 1, flatShading: true })
      );
      ground.rotation.x = -Math.PI / 2;
      ground.position.y = -0.6;
      ground.receiveShadow = true;
      const gpos = ground.geometry.attributes.position;
      for (let i = 0; i < gpos.count; i++) {
        const x = gpos.getX(i), y = gpos.getY(i);
        gpos.setZ(i, Math.sin(x * 0.04) * Math.cos(y * 0.035) * 1.8);
      }
      gpos.needsUpdate = true;
      ground.geometry.computeVertexNormals();
      this.scene.add(ground);

      this.buildTrackMesh();
      this.buildScenery();

      window.addEventListener('resize', () => this.onResize());
    },

    // Build a ribbon ribbon mesh where edges are left/right of centerline
    makeRibbon(innerMul, outerMul, yOff, color, roughness) {
      const samples = Track.samples;
      const n = samples.length;
      const positions = [];
      const indices = [];
      for (let i = 0; i < n; i++) {
        const s = samples[i];
        const hw = s.width * 0.5;
        // Vertex 0 = left edge, 1 = right edge
        positions.push(
          s.x + s.nx * hw * innerMul, s.y + yOff, s.z + s.nz * hw * innerMul,
          s.x - s.nx * hw * outerMul, s.y + yOff, s.z - s.nz * hw * outerMul
        );
      }
      for (let i = 0; i < n; i++) {
        const a = i * 2;
        const b = a + 1;
        const c = ((i + 1) % n) * 2;
        const d = c + 1;
        indices.push(a, c, b, b, c, d);
      }
      const geo = new THREE.BufferGeometry();
      geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
      geo.setIndex(indices);
      geo.computeVertexNormals();
      const mat = new THREE.MeshStandardMaterial({
        color, roughness: roughness || 0.85, metalness: 0.05
      });
      const mesh = new THREE.Mesh(geo, mat);
      mesh.receiveShadow = true;
      return mesh;
    },

    buildTrackMesh() {
      // Asphalt (main road)
      this.scene.add(this.makeRibbon(1, 1, 0.06, 0x3a3a40, 0.9));
      // Dirt shoulder — left strip (beyond left edge)
      this.scene.add(this.buildShoulderStrip(1.0, 1.35, 0.03, 0x6b5b3a, true));
      // Dirt shoulder — right strip
      this.scene.add(this.buildShoulderStrip(1.0, 1.35, 0.03, 0x6b5b3a, false));

      // Center line dashed
      const lineMat = new THREE.MeshBasicMaterial({ color: 0xf0e68c });
      const edgeMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
      const samples = Track.samples;
      for (let i = 0; i < samples.length; i += 4) {
        const s = samples[i];
        const s2 = samples[(i + 1) % samples.length];
        const len = Math.min(Math.hypot(s2.x - s.x, s2.z - s.z) * 1.3, 2.0);
        const line = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.04, len), lineMat);
        line.position.set(s.x, s.y + 0.09, s.z);
        line.rotation.y = Math.atan2(s.tx, s.tz);
        this.scene.add(line);
      }
      // Edge dashes
      for (let i = 0; i < samples.length; i += 3) {
        const s = samples[i];
        const hw = s.width * 0.5;
        for (const side of [-1, 1]) {
          const e = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.05, 1.4), edgeMat);
          e.position.set(
            s.x + s.nx * side * (hw - 0.15),
            s.y + 0.1,
            s.z + s.nz * side * (hw - 0.15)
          );
          e.rotation.y = Math.atan2(s.tx, s.tz);
          this.scene.add(e);
        }
      }
    },

    buildShoulderStrip(fromMul, toMul, yOff, color, leftSide) {
      // Strip between fromMul and toMul of half-width on one side
      const samples = Track.samples;
      const n = samples.length;
      const positions = [];
      const indices = [];
      for (let i = 0; i < n; i++) {
        const s = samples[i];
        const hw = s.width * 0.5;
        const sign = leftSide ? 1 : -1;
        const nxl = s.nx * sign, nzl = s.nz * sign;
        positions.push(
          s.x + nxl * hw * fromMul, s.y + yOff, s.z + nzl * hw * fromMul,
          s.x + nxl * hw * toMul,   s.y + yOff, s.z + nzl * hw * toMul
        );
      }
      for (let i = 0; i < n; i++) {
        const a = i * 2, b = a + 1;
        const c = ((i + 1) % n) * 2, d = c + 1;
        indices.push(a, c, b, b, c, d);
      }
      const geo = new THREE.BufferGeometry();
      geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
      geo.setIndex(indices);
      geo.computeVertexNormals();
      const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
        color, roughness: 1, metalness: 0
      }));
      mesh.receiveShadow = true;
      return mesh;
    },

    buildScenery() {
      // Trees
      Track.treePositions.forEach((t) => {
        const g = new THREE.Group();
        const trunk = new THREE.Mesh(
          new THREE.CylinderGeometry(0.15 * t.scale, 0.22 * t.scale, 1.2 * t.scale, 5),
          new THREE.MeshStandardMaterial({ color: 0x5d4037, flatShading: true })
        );
        trunk.position.y = 0.6 * t.scale;
        trunk.castShadow = true;
        g.add(trunk);
        const leafColor = t.type === 0 ? 0x2d6a2e : 0x1b5e20;
        const leaves = new THREE.Mesh(
          new THREE.ConeGeometry(1.1 * t.scale, 2.2 * t.scale, 6),
          new THREE.MeshStandardMaterial({ color: leafColor, flatShading: true })
        );
        leaves.position.y = 1.9 * t.scale;
        leaves.castShadow = true;
        g.add(leaves);
        if (t.type === 0) {
          const leaves2 = new THREE.Mesh(
            new THREE.ConeGeometry(0.85 * t.scale, 1.5 * t.scale, 6),
            new THREE.MeshStandardMaterial({ color: 0x388e3c, flatShading: true })
          );
          leaves2.position.y = 2.8 * t.scale;
          leaves2.castShadow = true;
          g.add(leaves2);
        }
        g.position.set(t.x, t.y, t.z);
        this.scene.add(g);
      });

      // Rocks
      const rockMat = new THREE.MeshStandardMaterial({ color: 0x6d6d6d, flatShading: true, roughness: 1 });
      Track.rockPositions.forEach((r) => {
        const mesh = new THREE.Mesh(new THREE.DodecahedronGeometry(0.55 * r.scale, 0), rockMat);
        mesh.position.set(r.x, r.y + 0.28 * r.scale, r.z);
        mesh.rotation.set(r.x * 0.3, r.z * 0.2, 0.4);
        mesh.scale.set(1, 0.7 + (r.scale % 0.5), 1);
        mesh.castShadow = true;
        mesh.receiveShadow = true;
        this.scene.add(mesh);
      });

      // Guard rails — oriented along the road
      const barMat = new THREE.MeshStandardMaterial({ color: 0xb0b0b0, metalness: 0.6, roughness: 0.4, flatShading: true });
      const postMat = new THREE.MeshStandardMaterial({ color: 0x555555, flatShading: true });
      Track.barrierPositions.forEach((b) => {
        const g = new THREE.Group();
        const rail = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.32, 2.2), barMat);
        rail.position.y = 0.55;
        rail.castShadow = true;
        g.add(rail);
        for (let i = -1; i <= 1; i++) {
          const post = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.7, 0.1), postMat);
          post.position.set(0, 0.35, i * 0.85);
          g.add(post);
        }
        g.position.set(b.x, b.y, b.z);
        g.rotation.y = b.angle; // runs along track tangent
        this.scene.add(g);
      });

      // Signs
      Track.signPositions.forEach((s) => {
        const g = new THREE.Group();
        const pole = new THREE.Mesh(
          new THREE.CylinderGeometry(0.05, 0.05, 2.0, 5),
          new THREE.MeshStandardMaterial({ color: 0x444444, flatShading: true })
        );
        pole.position.y = 1.0;
        g.add(pole);
        const colors = [0xff6b00, 0xffd166, 0xe74c3c];
        const board = new THREE.Mesh(
          new THREE.BoxGeometry(1.1, 0.7, 0.08),
          new THREE.MeshStandardMaterial({ color: colors[s.kind], flatShading: true })
        );
        board.position.y = 1.85;
        g.add(board);
        g.position.set(s.x, s.y, s.z);
        // Face toward the track center (approx opposite side wall)
        g.rotation.y = s.angle;
        this.scene.add(g);
      });

      // Overhead banners — poles on shoulders, cloth high enough to clear the car
      Track.bannerPositions.forEach((b) => {
        const g = new THREE.Group();
        const poleMat = new THREE.MeshStandardMaterial({ color: 0x333333, flatShading: true });
        const half = b.width * 0.5;
        const poleH = 6.5;
        const pL = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.12, poleH, 6), poleMat);
        pL.position.set(-half, poleH * 0.5, 0);
        const pR = pL.clone();
        pR.position.x = half;
        g.add(pL, pR);

        // Crossbeam + banner fabric
        const beam = new THREE.Mesh(
          new THREE.BoxGeometry(b.width + 0.2, 0.18, 0.18),
          poleMat
        );
        beam.position.y = poleH - 0.2;
        g.add(beam);

        const banner = new THREE.Mesh(
          new THREE.BoxGeometry(b.width - 0.4, 1.0, 0.06),
          new THREE.MeshStandardMaterial({ color: 0xe67e22, flatShading: true, side: THREE.DoubleSide })
        );
        banner.position.y = poleH - 0.85;
        g.add(banner);

        const stripe = new THREE.Mesh(
          new THREE.BoxGeometry(b.width - 0.4, 0.18, 0.07),
          new THREE.MeshBasicMaterial({ color: 0xffffff })
        );
        stripe.position.y = poleH - 0.85;
        g.add(stripe);

        g.position.set(b.x, b.y, b.z);
        // Rotate so poles sit left/right of track (span the width on X after rotation)
        g.rotation.y = b.angle;
        this.scene.add(g);
      });

      // Start / finish arch — tall, spans full width, at dist=0
      const start = Track.sampleAtDistance(0);
      const arch = new THREE.Group();
      const aMat = new THREE.MeshStandardMaterial({ color: 0x2c3e50, flatShading: true });
      const aW = start.width + 1.5;
      const archH = 6.0;
      const colL = new THREE.Mesh(new THREE.BoxGeometry(0.45, archH, 0.45), aMat);
      colL.position.set(-aW / 2, archH / 2, 0);
      const colR = colL.clone();
      colR.position.x = aW / 2;
      const top = new THREE.Mesh(new THREE.BoxGeometry(aW + 0.45, 0.4, 0.5), aMat);
      top.position.y = archH + 0.1;
      arch.add(colL, colR, top);

      // Chequered banner under crossbeam
      const checkH = 0.9;
      const checkY = archH - 0.55;
      const checkBg = new THREE.Mesh(
        new THREE.BoxGeometry(aW, checkH, 0.1),
        new THREE.MeshBasicMaterial({ color: 0xffffff })
      );
      checkBg.position.y = checkY;
      arch.add(checkBg);
      const sqW = aW / 10;
      for (let row = 0; row < 2; row++) {
        for (let col = 0; col < 10; col++) {
          if ((row + col) % 2 === 0) continue;
          const sq = new THREE.Mesh(
            new THREE.BoxGeometry(sqW, checkH / 2, 0.12),
            new THREE.MeshBasicMaterial({ color: 0x111111 })
          );
          sq.position.set(-aW / 2 + sqW / 2 + col * sqW, checkY + (row === 0 ? checkH / 4 : -checkH / 4), 0);
          arch.add(sq);
        }
      }

      arch.position.set(start.x, start.y, start.z);
      // Span left–right across track
      arch.rotation.y = Math.atan2(start.tx, start.tz);
      this.scene.add(arch);

      // Spectators (clustered off the road, not floating mid-track)
      for (let i = 0; i < 28; i++) {
        const dist = (0.08 + Math.random() * 0.84) * Track.totalLength;
        const s = Track.sampleAtDistance(dist);
        const side = Math.random() > 0.5 ? 1 : -1;
        const off = s.width * 0.5 + 9 + Math.random() * 5;
        const person = new THREE.Group();
        const bodyC = new THREE.Mesh(
          new THREE.BoxGeometry(0.35, 0.55, 0.25),
          new THREE.MeshStandardMaterial({
            color: new THREE.Color().setHSL(Math.random(), 0.55, 0.48),
            flatShading: true
          })
        );
        bodyC.position.y = 0.85;
        const head = new THREE.Mesh(
          new THREE.BoxGeometry(0.24, 0.24, 0.24),
          new THREE.MeshStandardMaterial({ color: 0xffccaa, flatShading: true })
        );
        head.position.y = 1.28;
        person.add(bodyC, head);
        person.position.set(s.x + s.nx * side * off, s.y, s.z + s.nz * side * off);
        this.scene.add(person);
      }
    },

    updateCamera(dt) {
      const fx = Math.sin(Car.yaw);
      const fz = Math.cos(Car.yaw);
      let back, up, lookAhead;
      if (this.camMode === 0) {
        back = 8.5; up = 3.2; lookAhead = 12;
      } else {
        back = 16; up = 10; lookAhead = 6;
      }
      const sp = Math.abs(Car.speed);
      back += clamp(sp * 0.08, 0, 4);

      const ideal = new THREE.Vector3(
        Car.x - fx * back,
        Car.y + up,
        Car.z - fz * back
      );
      const lag = this.camMode === 0 ? 5.5 : 2.5;
      this.camPos.lerp(ideal, 1 - Math.exp(-lag * dt));

      const look = new THREE.Vector3(
        Car.x + fx * lookAhead,
        Car.y + 1.2,
        Car.z + fz * lookAhead
      );
      this.camLook.lerp(look, 1 - Math.exp(-7 * dt));

      this.camera.position.copy(this.camPos);
      this.camera.lookAt(this.camLook);

      const targetFov = 58 + clamp(sp * 0.35, 0, 14);
      this.camera.fov = lerp(this.camera.fov, targetFov, 1 - Math.exp(-3 * dt));
      this.camera.updateProjectionMatrix();

      this.sun.position.set(Car.x + 60, 100, Car.z + 40);
      this.sun.target.position.set(Car.x, Car.y, Car.z);
      this.sun.target.updateMatrixWorld();
    },

    onResize() {
      const w = window.innerWidth, h = window.innerHeight;
      this.camera.aspect = w / h;
      this.camera.updateProjectionMatrix();
      this.renderer.setSize(w, h);
    },

    render() {
      this.renderer.render(this.scene, this.camera);
    }
  };

  // ═══════════════════════════════════════════════════════════
  // RACE LOGIC
  // ═══════════════════════════════════════════════════════════
  const Race = {
    state: 'title',
    lap: 1,
    checkpoint: 0,
    raceTime: 0,
    lapTime: 0,
    bestLap: Infinity,
    lapTimes: [],
    countdown: 3,
    countdownTimer: 0,

    start() {
      Car.reset();
      this.lap = 1;
      this.checkpoint = 0;
      this.raceTime = 0;
      this.lapTime = 0;
      this.bestLap = Infinity;
      this.lapTimes = [];
      this.countdown = 3;
      this.countdownTimer = 1;
      this.state = 'countdown';
      HUD.hideOverlay();
      HUD.showCountdown('3');
      AudioSys.countdownBeep(3);
    },

    update(dt) {
      if (this.state === 'countdown') {
        this.countdownTimer -= dt;
        if (this.countdownTimer <= 0) {
          this.countdown--;
          if (this.countdown > 0) {
            this.countdownTimer = 1;
            HUD.showCountdown(String(this.countdown));
            AudioSys.countdownBeep(this.countdown);
          } else if (this.countdown === 0) {
            this.countdownTimer = 0.8;
            HUD.showCountdown('GO!', true);
            AudioSys.countdownBeep(0);
            this.state = 'racing';
          }
        }
        Car.syncMesh(dt);
        return;
      }

      if (this.state === 'racing') {
        Car.update(dt);
        this.raceTime += dt;
        this.lapTime += dt;
        this.checkGates();
        if (this.countdownTimer > 0) {
          this.countdownTimer -= dt;
          if (this.countdownTimer <= 0) HUD.hideCountdown();
        }
      } else if (this.state === 'finished') {
        Car.speed *= (1 - 2 * dt);
        Car.vx *= (1 - 2 * dt);
        Car.vz *= (1 - 2 * dt);
        Car.syncMesh(dt);
      }
    },

    checkGates() {
      const cps = Track.checkpoints;
      const next = cps[this.checkpoint];
      const dx = Car.x - next.x;
      const dz = Car.z - next.z;
      const along = dx * next.tx + dz * next.tz;
      const lateral = Math.abs(-dx * next.tz + dz * next.tx);
      if (lateral < next.halfW + 5 && along > -4 && along < 10) {
        const fwd = Math.sin(Car.yaw) * next.tx + Math.cos(Car.yaw) * next.tz;
        if (fwd > -0.3 || Math.abs(Car.speed) < 2) {
          this.checkpoint++;
          if (this.checkpoint >= cps.length) {
            this.checkpoint = 0;
            this.onLapComplete();
          }
        }
      }
    },

    onLapComplete() {
      this.lapTimes.push(this.lapTime);
      if (this.lapTime < this.bestLap) this.bestLap = this.lapTime;
      this.lapTime = 0;
      if (this.lap >= CFG.TOTAL_LAPS) {
        this.state = 'finished';
        HUD.hideCountdown();
        AudioSys.finishTone();
        HUD.showFinish(this.raceTime, this.bestLap, this.lapTimes);
      } else {
        this.lap++;
      }
    },

    togglePause() {
      if (this.state === 'racing') {
        this.state = 'paused';
        HUD.showPause();
      } else if (this.state === 'paused') {
        this.state = 'racing';
        HUD.hideOverlay();
      }
    }
  };

  // ═══════════════════════════════════════════════════════════
  // HUD / MINIMAP
  // ═══════════════════════════════════════════════════════════
  const HUD = {
    speedVal: null, gearRpm: null, timer: null, lapInfo: null, bestLap: null,
    surfaceHint: null, countdownEl: null, overlay: null, menuBox: null,
    mm: null, mmCtx: null, mmPts: [], mmMap: null,

    init() {
      this.speedVal = document.getElementById('speedVal');
      this.gearRpm = document.getElementById('gearRpm');
      this.timer = document.getElementById('timer');
      this.lapInfo = document.getElementById('lapInfo');
      this.bestLap = document.getElementById('bestLap');
      this.surfaceHint = document.getElementById('surfaceHint');
      this.countdownEl = document.getElementById('countdown');
      this.overlay = document.getElementById('overlay');
      this.menuBox = document.getElementById('menuBox');
      this.mm = document.getElementById('minimap');
      this.mmCtx = this.mm.getContext('2d');
      this.prepMinimap();
    },

    prepMinimap() {
      let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
      Track.samples.forEach((s) => {
        minX = Math.min(minX, s.x); maxX = Math.max(maxX, s.x);
        minZ = Math.min(minZ, s.z); maxZ = Math.max(maxZ, s.z);
      });
      const pad = 12;
      const w = 160 - pad * 2, h = 160 - pad * 2;
      const sc = Math.min(w / (maxX - minX || 1), h / (maxZ - minZ || 1));
      this.mmMap = {
        minX, minZ, sc, pad,
        offX: (w - (maxX - minX) * sc) / 2,
        offZ: (h - (maxZ - minZ) * sc) / 2
      };
      this.mmPts = Track.samples.map((s) => ({
        x: pad + this.mmMap.offX + (s.x - minX) * sc,
        y: pad + this.mmMap.offZ + (s.z - minZ) * sc
      }));
    },

    worldToMm(x, z) {
      const m = this.mmMap;
      return {
        x: m.pad + m.offX + (x - m.minX) * m.sc,
        y: m.pad + m.offZ + (z - m.minZ) * m.sc
      };
    },

    update() {
      this.speedVal.textContent = String(Math.round(Car.speedKmh()));
      const gearLabel = Car.gear === 0 ? 'N' : String(Car.gear);
      this.gearRpm.textContent = gearLabel + ' · ' + Math.round(Car.rpm) + ' RPM';
      this.timer.textContent = fmtTime(Race.raceTime);
      this.lapInfo.textContent = 'Lap ' + Race.lap + ' / ' + CFG.TOTAL_LAPS;
      this.bestLap.textContent = 'Best: ' + fmtTime(Race.bestLap === Infinity ? -1 : Race.bestLap);
      this.surfaceHint.textContent = CFG.SURFACE[Car.lastSurface].name;
      this.drawMinimap();
    },

    drawMinimap() {
      const ctx = this.mmCtx;
      ctx.clearRect(0, 0, 160, 160);
      ctx.fillStyle = 'rgba(10,20,10,0.5)';
      ctx.fillRect(0, 0, 160, 160);

      ctx.beginPath();
      this.mmPts.forEach((p, i) => { if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); });
      ctx.closePath();
      ctx.strokeStyle = 'rgba(255,255,255,0.25)';
      ctx.lineWidth = 6;
      ctx.stroke();
      ctx.strokeStyle = '#888';
      ctx.lineWidth = 2.5;
      ctx.stroke();

      const st = this.mmPts[0];
      ctx.fillStyle = '#fff';
      ctx.fillRect(st.x - 2, st.y - 2, 4, 4);

      Track.checkpoints.forEach((cp, i) => {
        const p = this.worldToMm(cp.x, cp.z);
        ctx.fillStyle = i === Race.checkpoint ? '#ffd166' : 'rgba(255,255,255,0.2)';
        ctx.beginPath();
        ctx.arc(p.x, p.y, i === Race.checkpoint ? 3 : 1.5, 0, Math.PI * 2);
        ctx.fill();
      });

      const pp = this.worldToMm(Car.x, Car.z);
      ctx.save();
      ctx.translate(pp.x, pp.y);
      ctx.rotate(Car.yaw);
      ctx.fillStyle = '#ff4d4d';
      ctx.beginPath();
      ctx.moveTo(0, -5);
      ctx.lineTo(3.5, 4);
      ctx.lineTo(-3.5, 4);
      ctx.closePath();
      ctx.fill();
      ctx.restore();
    },

    showCountdown(text, isGo) {
      this.countdownEl.textContent = text;
      this.countdownEl.classList.add('show');
      this.countdownEl.classList.toggle('go', !!isGo);
    },
    hideCountdown() { this.countdownEl.classList.remove('show'); },

    showPause() {
      this.menuBox.innerHTML = `
        <h1>PAUSED</h1>
        <p>Take a breath — the stage is waiting.</p>
        <button class="btn" id="btnResume">RESUME</button>
        <button class="btn secondary" id="btnRestart">RESTART</button>
      `;
      this.overlay.classList.add('visible');
      document.getElementById('btnResume').onclick = () => Race.togglePause();
      document.getElementById('btnRestart').onclick = () => Race.start();
    },

    showFinish(total, best, laps) {
      const rows = laps.map((t, i) => `Lap ${i + 1}: <strong>${fmtTime(t)}</strong>`).join('<br>');
      this.menuBox.innerHTML = `
        <h1>FINISH!</h1>
        <div class="stats">
          Total: <strong>${fmtTime(total)}</strong><br>
          Best lap: <strong>${fmtTime(best)}</strong><br>
          ${rows}
        </div>
        <button class="btn" id="btnAgain">RACE AGAIN</button>
      `;
      this.overlay.classList.add('visible');
      document.getElementById('btnAgain').onclick = () => Race.start();
    },

    hideOverlay() { this.overlay.classList.remove('visible'); }
  };

  // ═══════════════════════════════════════════════════════════
  // MAIN LOOP
  // ═══════════════════════════════════════════════════════════
  const Game = {
    last: 0,
    running: false,

    init() {
      Track.build();
      World.init();
      Car.buildMesh(World.scene);
      Particles.init(World.scene);
      Input.init();
      HUD.init();
      Car.reset();

      document.getElementById('startBtn').addEventListener('click', () => {
        document.getElementById('titleBoot').style.display = 'none';
        AudioSys.init();
        AudioSys.resume();
        Race.start();
        this.running = true;
        this.last = performance.now();
        requestAnimationFrame((t) => this.loop(t));
      });

      window.addEventListener('keydown', (e) => {
        if (e.code === 'KeyP') {
          if (Race.state === 'racing' || Race.state === 'paused') Race.togglePause();
        }
        if (e.code === 'KeyR') {
          if (Race.state !== 'title') Race.start();
        }
        if (e.code === 'KeyC') {
          World.camMode = World.camMode === 0 ? 1 : 0;
        }
        AudioSys.resume();
      });

      World.updateCamera(0.016);
      World.render();
      HUD.update();
      this.idleTitle();
    },

    idleTitle() {
      if (this.running) return;
      const t = performance.now() * 0.00015;
      const s = Track.sampleAtDistance((t * 40) % Track.totalLength);
      World.camera.position.set(
        s.x - s.tx * 14 + s.nx * 7,
        s.y + 7,
        s.z - s.tz * 14 + s.nz * 7
      );
      World.camera.lookAt(s.x, s.y + 1, s.z);
      World.render();
      requestAnimationFrame(() => this.idleTitle());
    },

    loop(now) {
      if (!this.running) return;
      let dt = (now - this.last) / 1000;
      this.last = now;
      dt = Math.min(dt, 0.05);

      if (Race.state !== 'paused' && Race.state !== 'title') {
        Race.update(dt);
        Particles.update(dt);
        World.updateCamera(dt);
      }

      HUD.update();
      World.render();
      requestAnimationFrame((t) => this.loop(t));
    }
  };

  if (typeof THREE === 'undefined') {
    document.body.innerHTML = '<p style="color:#fff;padding:40px;font-family:sans-serif">Failed to load Three.js from CDN. Check your internet connection.</p>';
  } else {
    Game.init();
  }
})();
</script>
</body>
</html>