← Rally results

Rally

Qwen 3.6 35b a3b · full

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 57,122 bytes · SHA-256 5931ab5e664e
<!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 Racing 3D</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: 'Segoe UI', Arial, sans-serif; }
#gameCanvas { display: block; width: 100%; height: 100%; }

/* HUD Overlay */
#hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }

/* Speedometer */
#speedometer { position: absolute; bottom: 20px; right: 20px; width: 180px; height: 180px; }
#speedometer canvas { width: 100%; height: 100%; }

/* Lap info */
#lapInfo { position: absolute; top: 15px; left: 50%; transform: translateX(-50%); text-align: center; color: #fff; text-shadow: 1px 1px 3px #000; }
#lapInfo .lap { font-size: 28px; font-weight: bold; letter-spacing: 2px; }
#lapInfo .time { font-size: 22px; font-family: 'Courier New', monospace; margin-top: 4px; }
#lapInfo .best { font-size: 16px; color: #ffd700; margin-top: 2px; }

/* Minimap */
#minimap { position: absolute; top: 15px; right: 15px; width: 160px; height: 160px; border: 2px solid rgba(255,255,255,0.5); border-radius: 8px; overflow: hidden; background: rgba(0,0,0,0.4); }
#minimap canvas { width: 100%; height: 100%; }

/* Gear display */
#gearDisplay { position: absolute; bottom: 20px; right: 210px; text-align: center; color: #fff; text-shadow: 1px 1px 3px #000; }
#gearDisplay .gear { font-size: 48px; font-weight: bold; color: #ff4444; }
#gearDisplay .rpm { font-size: 14px; color: #aaa; }

/* Countdown */
#countdown { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 120px; font-weight: bold; color: #fff; text-shadow: 0 0 30px rgba(255,100,0,0.8), 2px 2px 6px #000; display: none; z-index: 20; }

/* Pause menu */
#pauseMenu { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); display: none; z-index: 30; justify-content: center; align-items: center; flex-direction: column; }
#pauseMenu .title { font-size: 60px; color: #fff; font-weight: bold; text-shadow: 0 0 20px rgba(255,100,0,0.6); margin-bottom: 30px; }
#pauseMenu button { padding: 15px 50px; font-size: 22px; margin: 8px; border: 2px solid #ff4444; background: rgba(255,68,68,0.2); color: #fff; cursor: pointer; border-radius: 8px; pointer-events: auto; transition: background 0.2s; }
#pauseMenu button:hover { background: rgba(255,68,68,0.5); }

/* Finish screen */
#finishScreen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); display: none; z-index: 30; justify-content: center; align-items: center; flex-direction: column; }
#finishScreen .title { font-size: 50px; color: #ffd700; font-weight: bold; margin-bottom: 20px; }
#finishScreen .stats { color: #fff; font-size: 22px; margin: 8px 0; }
#finishScreen button { padding: 15px 50px; font-size: 22px; margin-top: 25px; border: 2px solid #ffd700; background: rgba(255,215,0,0.2); color: #ffd700; cursor: pointer; border-radius: 8px; pointer-events: auto; }
#finishScreen button:hover { background: rgba(255,215,0,0.4); }

/* Touch controls */
#touchControls { position: absolute; bottom: 0; left: 0; width: 100%; height: 160px; display: none; z-index: 15; pointer-events: none; }
.touch-btn { position: absolute; width: 64px; height: 64px; border-radius: 50%; background: rgba(255,255,255,0.2); border: 2px solid rgba(255,255,255,0.4); color: #fff; font-size: 24px; display: flex; align-items: center; justify-content: center; pointer-events: auto; user-select: none; -webkit-user-select: none; touch-action: none; }
.touch-btn:active { background: rgba(255,255,255,0.4); }
#btnLeft { bottom: 20px; left: 20px; }
#btnRight { bottom: 20px; left: 100px; }
#btnUp { bottom: 100px; left: 60px; }
#btnDown { bottom: 0px; left: 60px; }
#btnGas { bottom: 20px; right: 20px; width: 80px; height: 80px; background: rgba(0,255,0,0.15); border-color: rgba(0,255,0,0.4); font-size: 16px; }
#btnBrake { bottom: 20px; right: 115px; width: 70px; height: 70px; background: rgba(255,0,0,0.15); border-color: rgba(255,0,0,0.4); font-size: 14px; }
#btnHandbrake { bottom: 100px; right: 65px; width: 60px; height: 60px; background: rgba(255,165,0,0.15); border-color: rgba(255,165,0,0.4); font-size: 11px; }

/* Start screen */
#startScreen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); display: flex; z-index: 40; justify-content: center; align-items: center; flex-direction: column; }
#startScreen .title { font-size: 64px; color: #ff4444; font-weight: bold; text-shadow: 0 0 30px rgba(255,68,68,0.6); margin-bottom: 10px; }
#startScreen .subtitle { font-size: 20px; color: #aaa; margin-bottom: 30px; }
#startScreen .controls { font-size: 14px; color: #888; margin-bottom: 30px; text-align: center; line-height: 1.8; }
#startScreen button { padding: 18px 60px; font-size: 26px; border: 3px solid #ff4444; background: rgba(255,68,68,0.15); color: #ff4444; cursor: pointer; border-radius: 10px; pointer-events: auto; font-weight: bold; }
#startScreen button:hover { background: rgba(255,68,68,0.35); }

@media (max-width: 768px) {
  #speedometer { width: 120px; height: 120px; bottom: 10px; right: 10px; }
  #gearDisplay { right: 140px; bottom: 10px; }
  #gearDisplay .gear { font-size: 32px; }
  #minimap { width: 100px; height: 100px; top: 10px; right: 10px; }
  #lapInfo .lap { font-size: 20px; }
  #lapInfo .time { font-size: 16px; }
  #lapInfo .best { font-size: 12px; }
  #touchControls { display: flex; }
}
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>

<div id="hud">
  <div id="lapInfo">
    <div class="lap" id="lapText">LAP 1 / 3</div>
    <div class="time" id="timeText">0:00.000</div>
    <div class="best" id="bestText">BEST: --:--.--</div>
  </div>
  <div id="minimap"><canvas id="minimapCanvas"></canvas></div>
  <div id="speedometer"><canvas id="speedoCanvas"></canvas></div>
  <div id="gearDisplay">
    <div class="gear" id="gearText">N</div>
    <div class="rpm" id="rpmText">0 RPM</div>
  </div>
</div>

<div id="countdown"></div>

<div id="pauseMenu">
  <div class="title">PAUSED</div>
  <button id="resumeBtn">Resume</button>
  <button id="restartBtn">Restart</button>
</div>

<div id="finishScreen">
  <div class="title">RACE COMPLETE!</div>
  <div class="stats" id="finalTime">Final Time: --:--.---</div>
  <div class="stats" id="finalBest">Best Lap: --:--.--</div>
  <div class="stats" id="finalLaps">Laps Completed: 0/3</div>
  <button id="finishRestartBtn">Race Again</button>
</div>

<div id="startScreen">
  <div class="title">RALLY RACER 3D</div>
  <div class="subtitle">Procedural Rally Racing</div>
  <div class="controls">
    Arrow Keys / WASD — Drive<br>
    Space — Handbrake<br>
    R — Restart &nbsp;|&nbsp; P — Pause &nbsp;|&nbsp; C — Camera
  </div>
  <button id="startBtn">START RACE</button>
</div>

<div id="touchControls">
  <div class="touch-btn" id="btnLeft">◀</div>
  <div class="touch-btn" id="btnRight">▶</div>
  <div class="touch-btn" id="btnUp">▲</div>
  <div class="touch-btn" id="btnDown">▼</div>
  <div class="touch-btn" id="btnGas">GAS</div>
  <div class="touch-btn" id="btnBrake">BRK</div>
  <div class="touch-btn" id="btnHandbrake">HB</div>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ============================================================
// RALLY RACING 3D - Main Game Module
// ============================================================
const RallyGame = (function() {
  'use strict';

  // ---- CONFIGURATION ----
  const CFG = {
    totalLaps: 3,
    trackWidth: 18,
    trackSegments: 600,
    fogNear: 80,
    fogFar: 350,
    cameraHeight: 5,
    cameraDistance: 12,
    cameraLag: 0.04,
    cinematicHeight: 25,
    cinematicDistance: 35,
    cinematicLag: 0.02,
    maxSpeed: 55,
    accelRate: 28,
    brakeRate: 35,
    reverseAccel: 12,
    steeringSpeed: 2.8,
    maxSteerAngle: 0.55,
    driftThreshold: 12,
    driftFactor: 0.92,
    gripFactor: 0.88,
    offRoadGrip: 0.55,
    offRoadSpeedReduction: 0.4,
    mudSpeedReduction: 0.2,
    mudGripReduction: 0.35,
    obstacleBounceForce: 8,
    obstacleSlowFactor: 0.5,
    particleCount: 80,
    treeCount: 300,
    rockCount: 80,
    bannerCount: 20,
    signCount: 15,
  };

  // ---- TRACK CONTROL POINTS ----
  // A winding loop with hills, curves, and elevation changes
  const TRACK_POINTS = [
    [0, 0, 0], [30, 2, -40], [80, 5, -70], [130, 1, -60],
    [160, -2, -30], [170, 3, 20], [155, 6, 60], [120, 4, 90],
    [70, 0, 110], [20, -3, 100], [-30, 1, 80], [-70, 4, 50],
    [-100, 7, 10], [-105, 5, -40], [-90, 2, -80], [-60, -1, -110],
    [-20, 0, -120], [20, -2, -110], [40, 3, -80], [45, 1, -45],
  ];

  // ---- GAME STATE ----
  let state = {
    phase: 'menu', // menu, countdown, racing, paused, finished
    lap: 1,
    lapTimes: [],
    bestLap: Infinity,
    lapStartTime: 0,
    raceStartTime: 0,
    raceTime: 0,
    countdownValue: 3,
    countdownTimer: 0,
    cameraMode: 0, // 0=chase, 1=cinematic
  };

  // ---- CAR STATE ----
  const car = {
    position: new THREE.Vector3(),
    velocity: new THREE.Vector3(),
    heading: 0, // radians
    speed: 0,
    gear: 0, // -1=reverse, 0=neutral, 1-5=fwd
    rpm: 0,
    wheelAngle: 0,
    isDrifting: false,
    driftAngle: 0,
    onTrack: true,
    surfaceType: 'asphalt', // asphalt, dirt, grass, mud
    width: 1.8,
    length: 4.2,
    height: 1.2,
    mesh: null,
    wheels: [],
    particles: [],
    skidMarks: [],
  };

  // ---- INPUT ----
  const input = {
    up: false, down: false, left: false, right: false,
    brake: false, handbrake: false,
  };

  // ---- THREE.JS GLOBALS ----
  let scene, camera, renderer, clock;
  let sunLight, ambientLight;
  let trackCurve, trackPositions = [];
  let trackMesh, groundMesh;
  let minimapCtx, speedoCtx, minimapCanvas, speedoCanvas;
  let audioCtx = null;
  let engineOsc = null, engineGain = null, engineFilter = null;
  let skidNoise = null, skidGain = null;
  let treeMeshes = [], rockMeshes = [], bannerMeshes = [], signMeshes = [];

  // ---- UTILITY FUNCTIONS ----
  function lerp(a, b, t) { return a + (b - a) * t; }
  function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
  function formatTime(ms) {
    const s = ms / 1000;
    const m = Math.floor(s / 60);
    const sec = Math.floor(s % 60);
    const frac = Math.floor((ms % 1000));
    return `${m}:${sec.toString().padStart(2,'0')}.${frac.toString().padStart(3,'0')}`;
  }

  // ---- TRACK HELPERS ----
  function getTrackPoint(t) {
    // t is 0..1 along the track
    const pts = TRACK_POINTS;
    const n = pts.length;
    const idx = ((t % 1) + 1) % 1 * (n - 1);
    const i = Math.floor(idx);
    const f = idx - i;
    const p0 = pts[(i - 1 + n) % n];
    const p1 = pts[i % n];
    const p2 = pts[(i + 1) % n];
    const p3 = pts[(i + 2) % n];
    // Catmull-Rom spline
    const f2 = f * f, f3 = f2 * f;
    const x = 0.5 * ((2*p1[0]) + (-p0[0]+p2[0])*f + (2*p0[0]-5*p1[0]+4*p2[0]-p3[0])*f2 + (-p0[0]+3*p1[0]-3*p2[0]+p3[0])*f3);
    const y = 0.5 * ((2*p1[1]) + (-p0[1]+p2[1])*f + (2*p0[1]-5*p1[1]+4*p2[1]-p3[1])*f2 + (-p0[1]+3*p1[1]-3*p2[1]+p3[1])*f3);
    const z = 0.5 * ((2*p1[2]) + (-p0[2]+p2[2])*f + (2*p0[2]-5*p1[2]+4*p2[2]-p3[2])*f2 + (-p0[2]+3*p1[2]-3*p2[2]+p3[2])*f3);
    return new THREE.Vector3(x, y, z);
  }

  function getTrackTangent(t) {
    const eps = 0.001;
    const p1 = getTrackPoint(t);
    const p2 = getTrackPoint(t + eps);
    const dir = new THREE.Vector3().subVectors(p2, p1).normalize();
    return dir;
  }

  function getClosestTrackInfo(pos) {
    let minDist = Infinity;
    let bestT = 0;
    const steps = 200;
    for (let i = 0; i <= steps; i++) {
      const t = i / steps;
      const p = getTrackPoint(t);
      const d = pos.distanceTo(p);
      if (d < minDist) { minDist = d; bestT = t; }
    }
    // Refine
    for (let j = 0; j < 20; j++) {
      const eps = 0.001 / steps * (j + 1);
      const t1 = clamp(bestT - eps, 0, 1);
      const t2 = clamp(bestT + eps, 0, 1);
      const d1 = pos.distanceTo(getTrackPoint(t1));
      const d2 = pos.distanceTo(getTrackPoint(t2));
      if (d1 < d2) bestT = t1; else bestT = t2;
      if (Math.abs(d1 - d2) < 0.01) break;
    }
    // Determine surface type based on track section
    let surface = 'asphalt';
    // First half is asphalt/dirt, second half has grass/mud sections
    const t = ((bestT % 1) + 1) % 1;
    if (t > 0.3 && t < 0.35) surface = 'dirt';
    else if (t > 0.55 && t < 0.65) surface = 'grass';
    else if (t > 0.75 && t < 0.82) surface = 'mud';
    else if (t > 0.9 && t < 0.95) surface = 'dirt';
    // Check if player is off-track
    const onTrack = minDist < CFG.trackWidth / 2;
    const surfaceAtPos = onTrack ? surface : (minDist < CFG.trackWidth ? 'grass' : 'grass');
    return { t: bestT, distance: minDist, surface: surfaceAtPos, onTrack };
  }

  // Checkpoint system
  const CHECKPOINTS = [0, 0.25, 0.5, 0.75];
  let checkpointPassed = [false, false, false, false];

  function checkCheckpoints() {
    const info = getClosestTrackInfo(car.position);
    const t = ((info.t % 1) + 1) % 1;
    for (let i = 0; i < CHECKPOINTS.length; i++) {
      const cp = CHECKPOINTS[i];
      const diff = Math.abs(t - cp);
      if (diff < 0.02 || (cp === 0 && (diff > 0.98))) {
        checkpointPassed[i] = true;
      }
    }
  }

  function checkLapProgress() {
    const info = getClosestTrackInfo(car.position);
    const t = ((info.t % 1) + 1) % 1;
    // Crossing the start line (t near 0 going from high to low)
    if (t > 0.98 && checkpointPassed[1] && checkpointPassed[2] && checkpointPassed[3]) {
      const now = performance.now();
      const lapTime = now - state.lapStartTime;
      state.lapTimes.push(lapTime);
      if (lapTime < state.bestLap) state.bestLap = lapTime;
      checkpointPassed = [false, false, false, false];
      state.lap++;
      state.lapStartTime = now;
      if (state.lap > CFG.totalLaps) {
        state.phase = 'finished';
        showFinishScreen();
      }
    }
    // Reset if went backwards past a checkpoint
    if (t < 0.02) {
      checkpointPassed = [false, false, false, false];
    }
  }

  // ---- AUDIO SYSTEM ----
  function initAudio() {
    if (audioCtx) return;
    audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    // Engine sound
    engineOsc = audioCtx.createOscillator();
    engineGain = audioCtx.createGain();
    engineFilter = audioCtx.createBiquadFilter();
    engineOsc.type = 'sawtooth';
    engineOsc.frequency.value = 80;
    engineFilter.type = 'lowpass';
    engineFilter.frequency.value = 400;
    engineFilter.Q.value = 2;
    engineGain.gain.value = 0;
    engineOsc.connect(engineFilter);
    engineFilter.connect(engineGain);
    engineGain.connect(audioCtx.destination);
    engineOsc.start();
    // Skid/gravel noise
    const bufferSize = audioCtx.sampleRate * 2;
    const noiseBuffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
    const data = noiseBuffer.getChannelData(0);
    for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
    skidNoise = audioCtx.createBufferSource();
    skidNoise.buffer = noiseBuffer;
    skidNoise.loop = true;
    skidGain = audioCtx.createGain();
    skidGain.gain.value = 0;
    const skidFilter = audioCtx.createBiquadFilter();
    skidFilter.type = 'bandpass';
    skidFilter.frequency.value = 800;
    skidFilter.Q.value = 0.5;
    skidNoise.connect(skidFilter);
    skidFilter.connect(skidGain);
    skidGain.connect(audioCtx.destination);
    skidNoise.start();
  }

  function playCountdownBeep(high) {
    if (!audioCtx) return;
    const osc = audioCtx.createOscillator();
    const gain = audioCtx.createGain();
    osc.type = 'sine';
    osc.frequency.value = high ? 880 : 440;
    gain.gain.value = 0.3;
    gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.3);
    osc.connect(gain);
    gain.connect(audioCtx.destination);
    osc.start();
    osc.stop(audioCtx.currentTime + 0.3);
  }

  function playFinishTone() {
    if (!audioCtx) return;
    [523, 659, 784].forEach((freq, i) => {
      const osc = audioCtx.createOscillator();
      const gain = audioCtx.createGain();
      osc.type = 'sine';
      osc.frequency.value = freq;
      gain.gain.value = 0.2;
      gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.5 + i * 0.15);
      osc.connect(gain);
      gain.connect(audioCtx.destination);
      osc.start(audioCtx.currentTime + i * 0.15);
      osc.stop(audioCtx.currentTime + 0.5 + i * 0.15);
    });
  }

  function playCrashSound() {
    if (!audioCtx) return;
    const osc = audioCtx.createOscillator();
    const gain = audioCtx.createGain();
    osc.type = 'square';
    osc.frequency.value = 100;
    osc.frequency.exponentialRampToValueAtTime(30, audioCtx.currentTime + 0.15);
    gain.gain.value = 0.25;
    gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.15);
    osc.connect(gain);
    gain.connect(audioCtx.destination);
    osc.start();
    osc.stop(audioCtx.currentTime + 0.15);
  }

  function updateEngineAudio() {
    if (!audioCtx) return;
    const speedRatio = Math.abs(car.speed) / CFG.maxSpeed;
    const baseFreq = 60 + speedRatio * 200;
    const gearMult = Math.max(1, car.gear) * 0.8 + 0.2;
    engineOsc.frequency.setTargetAtTime(baseFreq * gearMult, audioCtx.currentTime, 0.05);
    engineGain.gain.setTargetAtTime(0.04 + speedRatio * 0.08, audioCtx.currentTime, 0.05);
    engineFilter.frequency.setTargetAtTime(300 + speedRatio * 1500, audioCtx.currentTime, 0.05);
    // Skid sound
    const skidVol = (car.isDrifting || (!car.onTrack && Math.abs(car.speed) > 5)) ? 0.06 : 0;
    skidGain.gain.setTargetAtTime(skidVol, audioCtx.currentTime, 0.05);
  }

  // ---- THREE.JS SCENE SETUP ----
  function initScene() {
    const canvas = document.getElementById('gameCanvas');
    renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1.1;
    renderer.setSize(window.innerWidth, window.innerHeight);

    scene = new THREE.Scene();
    scene.background = new THREE.Color(0x87CEEB);
    scene.fog = new THREE.Fog(0x87CEEB, CFG.fogNear, CFG.fogFar);

    camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.5, 500);

    clock = new THREE.Clock();

    // Lighting
    ambientLight = new THREE.AmbientLight(0x6688aa, 0.5);
    scene.add(ambientLight);

    sunLight = new THREE.DirectionalLight(0xfff4e0, 1.2);
    sunLight.position.set(50, 80, 30);
    sunLight.castShadow = true;
    sunLight.shadow.mapSize.width = 2048;
    sunLight.shadow.mapSize.height = 2048;
    sunLight.shadow.camera.near = 1;
    sunLight.shadow.camera.far = 200;
    sunLight.shadow.camera.left = -60;
    sunLight.shadow.camera.right = 60;
    sunLight.shadow.camera.top = 60;
    sunLight.shadow.camera.bottom = -60;
    sunLight.shadow.bias = -0.001;
    scene.add(sunLight);

    const hemiLight = new THREE.HemisphereLight(0x87CEEB, 0x556633, 0.4);
    scene.add(hemiLight);

    // Ground plane
    const groundGeo = new THREE.PlaneGeometry(800, 800, 1, 1);
    groundGeo.computeVertexNormals();
    const groundMat = new THREE.MeshLambertMaterial({ color: 0x4a7a3a });
    groundMesh = new THREE.Mesh(groundGeo, groundMat);
    groundMesh.rotation.x = -Math.PI / 2;
    groundMesh.position.y = -0.5;
    groundMesh.receiveShadow = true;
    scene.add(groundMesh);

    // Sky dome
    const skyGeo = new THREE.SphereGeometry(400, 32, 16);
    const skyMat = new THREE.MeshBasicMaterial({
      color: 0x87CEEB, side: THREE.BackSide
    });
    const sky = new THREE.Mesh(skyGeo, skyMat);
    scene.add(sky);

    // Build track
    buildTrack();
    // Build scenery
    buildScenery();
    // Build car
    buildCar();
    // Init particles
    initParticles();

    // Minimap canvas
    minimapCanvas = document.getElementById('minimapCanvas');
    minimapCanvas.width = 160;
    minimapCanvas.height = 160;
    minimapCtx = minimapCanvas.getContext('2d');

    // Speedometer canvas
    speedoCanvas = document.getElementById('speedoCanvas');
    speedoCanvas.width = 180;
    speedoCanvas.height = 180;
    speedoCtx = speedoCanvas.getContext('2d');

    window.addEventListener('resize', onResize);
  }

  function buildTrack() {
    // Create track geometry using a tube-like approach along the spline
    trackCurve = new THREE.CatmullRomCurve3(
      TRACK_POINTS.map(p => new THREE.Vector3(p[0], p[1], p[2])),
      true
    );

    // Generate track positions for sampling
    for (let i = 0; i <= CFG.trackSegments; i++) {
      const t = (i / CFG.trackSegments) * 0.99999;
      const p = trackCurve.getPointAt(t);
      if (p.isVector3 && isFinite(p.x) && isFinite(p.y) && isFinite(p.z)) {
        trackPositions.push(p);
      } else {
        trackPositions.push(new THREE.Vector3(0, 0, 0));
      }
    }

    // Road surface using custom geometry
    const roadGeo = createRoadGeometry();
    const roadMat = new THREE.MeshLambertMaterial({ color: 0x333333 });
    trackMesh = new THREE.Mesh(roadGeo, roadMat);
    trackMesh.receiveShadow = true;
    scene.add(trackMesh);

    // Road shoulders (gravel strips)
    const shoulderGeo = createShoulderGeometry();
    const shoulderMat = new THREE.MeshLambertMaterial({ color: 0x8B7355 });
    const shoulderMesh = new THREE.Mesh(shoulderGeo, shoulderMat);
    shoulderMesh.receiveShadow = true;
    scene.add(shoulderMesh);

    // Suppress THREE.js bounding sphere NaN warnings
    const origWarn = console.warn;
    console.warn = function(...args) {
      if (args[0] && typeof args[0] === 'string' && args[0].includes('computeBoundingSphere') && args[0].includes('NaN')) return;
      origWarn.apply(console, args);
    };

    // Pre-compute tangents for edge lines
    const edgeTangents = trackPositions.map((p, idx) => {
      const prevIdx = (idx - 1 + trackPositions.length) % trackPositions.length;
      const nextIdx = (idx + 1) % trackPositions.length;
      return new THREE.Vector3().subVectors(trackPositions[nextIdx], trackPositions[prevIdx]).normalize();
    });

    // Track edge markers (white lines)
    for (let side = -1; side <= 1; side += 2) {
      const edgePoints = trackPositions.map((p, idx) => {
        const tangent = edgeTangents[idx];
        const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
        return p.clone().add(normal.multiplyScalar(CFG.trackWidth / 2 * side));
      });
      const edgeGeo = new THREE.BufferGeometry().setFromPoints(edgePoints);
      const edgeLine = new THREE.Line(edgeGeo, new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.6 }));
      edgeLine.position.y = 0.05;
      scene.add(edgeLine);
    }

    // Center dashed line
    const centerPoints = trackPositions.map(p => p.clone().add(new THREE.Vector3(0, 0.06, 0)));
    // Create dashed line
    for (let i = 0; i < trackPositions.length; i += 4) {
      const p1 = centerPoints[i];
      const p2 = centerPoints[Math.min(i + 2, trackPositions.length - 1)];
      const lineGeo = new THREE.BufferGeometry().setFromPoints([p1, p2]);
      const line = new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: 0xffff00, transparent: true, opacity: 0.5 }));
      line.position.y = 0.02;
      scene.add(line);
    }
  }

  function createRoadGeometry() {
    const width = CFG.trackWidth;
    const segments = CFG.trackSegments;
    const vertices = [];
    const normals = [];
    const uvs = [];
    const indices = [];

    for (let i = 0; i <= segments; i++) {
      const point = trackPositions[i];
      if (!point || !isFinite(point.x) || !isFinite(point.y) || !isFinite(point.z)) continue;
      // Compute tangent from adjacent pre-computed positions
      const prevIdx = (i - 1 + trackPositions.length) % trackPositions.length;
      const nextIdx = (i + 1) % trackPositions.length;
      const prevP = trackPositions[prevIdx];
      const nextP = trackPositions[nextIdx];
      if (!prevP || !nextP || !isFinite(prevP.x) || !isFinite(nextP.x)) continue;
      const tangent = new THREE.Vector3().subVectors(nextP, prevP).normalize();
      const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();

      const left = point.clone().add(normal.clone().multiplyScalar(-width / 2));
      const right = point.clone().add(normal.clone().multiplyScalar(width / 2));

      left.y = Math.max(left.y, point.y - 0.1);
      right.y = Math.max(right.y, point.y - 0.1);

      if (!isFinite(left.x) || !isFinite(right.x)) continue;

      vertices.push(left.x, left.y, left.z);
      vertices.push(right.x, right.y, right.z);
      normals.push(0, 1, 0);
      normals.push(0, 1, 0);
      uvs.push(0, i / segments * 20);
      uvs.push(1, i / segments * 20);

      if (i < segments) {
        const base = i * 2;
        indices.push(base, base + 1, base + 2);
        indices.push(base + 1, base + 3, base + 2);
      }
    }

    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
    geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
    geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
    geo.setIndex(indices);
    geo.computeBoundingSphere();
    geo.computeVertexNormals();
    return geo;
  }

  function createShoulderGeometry() {
    const width = CFG.trackWidth + 6;
    const segments = CFG.trackSegments;
    const vertices = [];
    const normals = [];
    const uvs = [];
    const indices = [];

    for (let i = 0; i <= segments; i++) {
      const point = trackPositions[i];
      const prevIdx = (i - 1 + trackPositions.length) % trackPositions.length;
      const nextIdx = (i + 1) % trackPositions.length;
      const tangent = new THREE.Vector3().subVectors(trackPositions[nextIdx], trackPositions[prevIdx]).normalize();
      const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();

      const left = point.clone().add(normal.clone().multiplyScalar(-width / 2));
      const right = point.clone().add(normal.clone().multiplyScalar(width / 2));

      vertices.push(left.x, left.y - 0.05, left.z);
      vertices.push(right.x, right.y - 0.05, right.z);
      normals.push(0, 1, 0);
      normals.push(0, 1, 0);
      uvs.push(0, i / segments * 20);
      uvs.push(1, i / segments * 20);

      if (i < segments) {
        const base = i * 2;
        indices.push(base, base + 1, base + 2);
        indices.push(base + 1, base + 3, base + 2);
      }
    }

    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
    geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
    geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
    geo.setIndex(indices);
    geo.computeVertexNormals();
    return geo;
  }

  // ---- PROCEDURAL SCENERY ----
  function buildScenery() {
    // Trees
    const treeGeo = createTreeGeometry();
    const treeMat = new THREE.MeshLambertMaterial({ color: 0x2d5a1e });
    const trunkGeo = new THREE.CylinderGeometry(0.2, 0.3, 2, 6);
    const trunkMat = new THREE.MeshLambertMaterial({ color: 0x6b4226 });

    for (let i = 0; i < CFG.treeCount; i++) {
      const t = Math.random();
      const trackPoint = trackCurve.getPointAt(t);
      const tangent = trackCurve.getTangentAt(t);
      const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
      const offset = (CFG.trackWidth / 2 + 5 + Math.random() * 40) * (Math.random() > 0.5 ? 1 : -1);
      const pos = trackPoint.clone().add(normal.multiplyScalar(offset));
      pos.y = trackPoint.y + 0.5;

      const group = new THREE.Group();

      // Trunk
      const trunk = new THREE.Mesh(trunkGeo, trunkMat);
      trunk.position.y = 1;
      trunk.castShadow = true;
      group.add(trunk);

      // Foliage layers
      const foliageSize = 1.5 + Math.random() * 2;
      const foliage = new THREE.Mesh(
        new THREE.ConeGeometry(foliageSize, foliageSize * 2.5, 6),
        new THREE.MeshLambertMaterial({
          color: new THREE.Color().setHSL(0.25 + Math.random() * 0.1, 0.6 + Math.random() * 0.3, 0.2 + Math.random() * 0.15)
        })
      );
      foliage.position.y = foliageSize * 1.5 + 1;
      foliage.castShadow = true;
      group.add(foliage);

      group.position.copy(pos);
      group.scale.setScalar(0.7 + Math.random() * 0.8);
      scene.add(group);
      treeMeshes.push(group);
    }

    // Rocks
    const rockGeo = new THREE.DodecahedronGeometry(1, 0);
    const rockMat = new THREE.MeshLambertMaterial({ color: 0x777777 });

    for (let i = 0; i < CFG.rockCount; i++) {
      const t = Math.random();
      const trackPoint = trackCurve.getPointAt(t);
      const tangent = trackCurve.getTangentAt(t);
      const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
      const offset = CFG.trackWidth / 2 + 3 + Math.random() * 25;
      const pos = trackPoint.clone().add(normal.multiplyScalar(offset * (Math.random() > 0.5 ? 1 : -1)));
      pos.y = trackPoint.y;

      const rock = new THREE.Mesh(rockGeo, rockMat.clone());
      rock.material.color.setHSL(0, 0, 0.3 + Math.random() * 0.3);
      rock.position.copy(pos);
      rock.scale.setScalar(0.5 + Math.random() * 2);
      rock.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
      rock.castShadow = true;
      rock.receiveShadow = true;
      scene.add(rock);
      rockMeshes.push(rock);
    }

    // Banners along track
    const bannerColors = [0xff0000, 0x00ff00, 0x0000ff, 0xffff00, 0xff00ff, 0x00ffff];
    for (let i = 0; i < CFG.bannerCount; i++) {
      const t = i / CFG.bannerCount;
      const trackPoint = trackCurve.getPointAt(t);
      const tangent = trackCurve.getTangentAt(t);
      const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
      const offset = CFG.trackWidth / 2 + 2;
      const side = (i % 2 === 0) ? 1 : -1;
      const pos = trackPoint.clone().add(normal.multiplyScalar(offset * side));
      pos.y = trackPoint.y;

      const group = new THREE.Group();

      // Pole
      const pole = new THREE.Mesh(
        new THREE.CylinderGeometry(0.05, 0.05, 4, 6),
        new THREE.MeshLambertMaterial({ color: 0xcccccc })
      );
      pole.position.y = 2;
      group.add(pole);

      // Banner
      const banner = new THREE.Mesh(
        new THREE.PlaneGeometry(2, 1),
        new THREE.MeshLambertMaterial({
          color: bannerColors[i % bannerColors.length],
          side: THREE.DoubleSide
        })
      );
      banner.position.set(side * 1.05, 3.2, 0);
      group.add(banner);

      group.position.copy(pos);
      group.rotation.y = Math.atan2(tangent.x, tangent.z);
      scene.add(group);
      bannerMeshes.push(group);
    }

    // Signs / barriers near track
    for (let i = 0; i < CFG.signCount; i++) {
      const t = (i + 0.5) / CFG.signCount;
      const trackPoint = trackCurve.getPointAt(t);
      const tangent = trackCurve.getTangentAt(t);
      const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
      const offset = CFG.trackWidth / 2 + 1;
      const side = (i % 2 === 0) ? 1 : -1;
      const pos = trackPoint.clone().add(normal.multiplyScalar(offset * side));
      pos.y = trackPoint.y;

      // Barrier
      const barrier = new THREE.Mesh(
        new THREE.BoxGeometry(2, 0.8, 0.3),
        new THREE.MeshLambertMaterial({ color: 0xff4400 })
      );
      barrier.position.set(pos.x, pos.y + 0.4, pos.z);
      barrier.rotation.y = Math.atan2(tangent.x, tangent.z);
      barrier.castShadow = true;
      scene.add(barrier);
    }
  }

  function createTreeGeometry() {
    return new THREE.ConeGeometry(1, 3, 6);
  }

  // ---- BUILD CAR ----
  function buildCar() {
    const group = new THREE.Group();

    // Car body
    const bodyMat = new THREE.MeshLambertMaterial({ color: 0xcc2222 });
    const bodyGeo = new THREE.BoxGeometry(CFG.width, 0.6, CFG.length);
    const body = new THREE.Mesh(bodyGeo, bodyMat);
    body.position.y = 0.5;
    body.castShadow = true;
    group.add(body);

    // Cabin
    const cabinMat = new THREE.MeshLambertMaterial({ color: 0x222222 });
    const cabinGeo = new THREE.BoxGeometry(CFG.width - 0.3, 0.5, 1.5);
    const cabin = new THREE.Mesh(cabinGeo, cabinMat);
    cabin.position.set(0, 1.05, -0.2);
    cabin.castShadow = true;
    group.add(cabin);

    // Windshield
    const windMat = new THREE.MeshLambertMaterial({ color: 0x88aacc, transparent: true, opacity: 0.5 });
    const windGeo = new THREE.PlaneGeometry(CFG.width - 0.4, 0.45);
    const windshield = new THREE.Mesh(windGeo, windMat);
    windshield.position.set(0, 1.05, 0.55);
    windshield.rotation.x = -0.3;
    group.add(windshield);

    // Hood
    const hoodMat = new THREE.MeshLambertMaterial({ color: 0xcc2222 });
    const hoodGeo = new THREE.BoxGeometry(CFG.width - 0.2, 0.15, 1.2);
    const hood = new THREE.Mesh(hoodGeo, hoodMat);
    hood.position.set(0, 0.85, 1.5);
    hood.castShadow = true;
    group.add(hood);

    // Wheels
    const wheelGeo = new THREE.CylinderGeometry(0.35, 0.35, 0.3, 12);
    const wheelMat = new THREE.MeshLambertMaterial({ color: 0x111111 });
    const wheelPositions = [
      [-CFG.width / 2 - 0.1, 0.35, 1.2],
      [CFG.width / 2 + 0.1, 0.35, 1.2],
      [-CFG.width / 2 - 0.1, 0.35, -1.2],
      [CFG.width / 2 + 0.1, 0.35, -1.2],
    ];
    car.wheels = [];
    wheelPositions.forEach(pos => {
      const wheel = new THREE.Mesh(wheelGeo, wheelMat);
      wheel.position.set(pos[0], pos[1], pos[2]);
      wheel.rotation.z = Math.PI / 2;
      wheel.castShadow = true;
      group.add(wheel);
      car.wheels.push(wheel);
    });

    // Spoiler
    const spoilerMat = new THREE.MeshLambertMaterial({ color: 0x111111 });
    const spoilerPlate = new THREE.Mesh(new THREE.BoxGeometry(CFG.width + 0.4, 0.08, 0.4), spoilerMat);
    spoilerPlate.position.set(0, 1.3, -1.8);
    group.add(spoilerPlate);
    const spoilerLegs = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.3, 0.1), spoilerMat);
    const leg1 = spoilerLegs.clone(); leg1.position.set(-0.5, 1.15, -1.8); group.add(leg1);
    const leg2 = spoilerLegs.clone(); leg2.position.set(0.5, 1.15, -1.8); group.add(leg2);

    // Headlights
    const headlightMat = new THREE.MeshBasicMaterial({ color: 0xffffcc });
    for (let side = -1; side <= 1; side += 2) {
      const hl = new THREE.Mesh(new THREE.SphereGeometry(0.12, 8, 8), headlightMat);
      hl.position.set(side * 0.5, 0.55, CFG.length / 2 + 0.05);
      group.add(hl);
    }

    // Taillights
    const taillightMat = new THREE.MeshBasicMaterial({ color: 0xff0000 });
    for (let side = -1; side <= 1; side += 2) {
      const tl = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.15, 0.05), taillightMat);
      tl.position.set(side * 0.5, 0.55, -CFG.length / 2 - 0.02);
      group.add(tl);
    }

    // Rally decals (stripes)
    const stripeMat = new THREE.MeshLambertMaterial({ color: 0xffffff });
    const stripe = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.62, CFG.length - 0.3), stripeMat);
    stripe.position.set(0, 0.5, 0);
    group.add(stripe);

    car.mesh = group;
    scene.add(group);
  }

  // ---- PARTICLE SYSTEM ----
  function initParticles() {
    // Dust particles
    const dustGeo = new THREE.SphereGeometry(0.15, 4, 4);
    const dustMat = new THREE.MeshBasicMaterial({ color: 0xc8b898, transparent: true, opacity: 0.6 });

    for (let i = 0; i < CFG.particleCount; i++) {
      const dust = new THREE.Mesh(dustGeo, dustMat.clone());
      dust.visible = false;
      dust.userData = { life: 0, maxLife: 0, vel: new THREE.Vector3() };
      scene.add(dust);
      car.particles.push(dust);
    }

    // Spark particles
    const sparkGeo = new THREE.SphereGeometry(0.05, 4, 4);
    const sparkMat = new THREE.MeshBasicMaterial({ color: 0xffaa00 });

    for (let i = 0; i < 30; i++) {
      const spark = new THREE.Mesh(sparkGeo, sparkMat.clone());
      spark.visible = false;
      spark.userData = { life: 0, maxLife: 0, vel: new THREE.Vector3() };
      scene.add(spark);
      car.particles.push(spark);
    }
  }

  function emitDust(pos, speed) {
    for (let i = 0; i < car.particles.length; i++) {
      const p = car.particles[i];
      if (!p.visible) {
        p.visible = true;
        p.position.copy(pos);
        p.position.y += 0.2;
        p.userData.life = 0.5 + Math.random() * 0.5;
        p.userData.maxLife = p.userData.life;
        const dir = new THREE.Vector3(-Math.sin(car.heading), 0, -Math.cos(car.heading));
        p.userData.vel.set(
          (Math.random() - 0.5) * 3 + dir.x * speed * 0.3,
          Math.random() * 2 + 1,
          (Math.random() - 0.5) * 3 + dir.z * speed * 0.3
        );
        p.scale.setScalar(0.5 + Math.random());
        return;
      }
    }
  }

  function emitSparks(pos) {
    for (let i = 0; i < car.particles.length; i++) {
      const p = car.particles[i];
      if (!p.visible && p.material.color.getHex() === 0xffaa00) {
        p.visible = true;
        p.position.copy(pos);
        p.userData.life = 0.3 + Math.random() * 0.2;
        p.userData.maxLife = p.userData.life;
        p.userData.vel.set(
          (Math.random() - 0.5) * 10,
          Math.random() * 8 + 3,
          (Math.random() - 0.5) * 10
        );
        return;
      }
    }
  }

  function updateParticles(dt) {
    for (let i = 0; i < car.particles.length; i++) {
      const p = car.particles[i];
      if (!p.visible) continue;
      p.userData.life -= dt;
      if (p.userData.life <= 0) {
        p.visible = false;
        continue;
      }
      const lifeRatio = p.userData.life / p.userData.maxLife;
      p.position.add(p.userData.vel.clone().multiplyScalar(dt));
      p.userData.vel.y -= 5 * dt;
      const alpha = lifeRatio * 0.6;
      p.material.opacity = alpha;
      const s = (1 - lifeRatio) * 2 + 0.5;
      p.scale.setScalar(s);
    }
  }

  // ---- PHYSICS UPDATE ----
  function updatePhysics(dt) {
    if (state.phase !== 'racing') return;

    const info = getClosestTrackInfo(car.position);
    car.onTrack = info.onTrack;
    car.surfaceType = info.surface;

    const tangent = getTrackTangent(info.t);
    const forward = new THREE.Vector3(Math.sin(car.heading), 0, Math.cos(car.heading));

    // Surface effects
    let grip = 1.0;
    let speedMult = 1.0;
    if (!car.onTrack) {
      grip = CFG.offRoadGrip;
      speedMult = CFG.offRoadSpeedReduction;
    } else if (info.surface === 'dirt') {
      grip = 0.85;
      speedMult = 0.85;
    } else if (info.surface === 'grass') {
      grip = CFG.offRoadGrip;
      speedMult = CFG.offRoadSpeedReduction;
    } else if (info.surface === 'mud') {
      grip = CFG.mudGripReduction;
      speedMult = CFG.mudSpeedReduction;
    }

    // Acceleration
    let accel = 0;
    if (input.up) accel = CFG.accelRate * speedMult;
    if (input.down) accel = -CFG.brakeRate;
    if (input.down && car.speed > 1) accel = -CFG.brakeRate;
    if (car.speed < -2 && input.down) accel = CFG.reverseAccel;

    car.speed += accel * dt;
    car.speed *= (1 - 0.5 * dt); // Drag
    car.speed = clamp(car.speed, -8, CFG.maxSpeed * speedMult);

    // Steering
    const speedRatio = Math.abs(car.speed) / CFG.maxSpeed;
    const steerFactor = (1 - speedRatio * 0.4) * CFG.steeringSpeed;
    const steerAmount = CFG.maxSteerAngle * speedRatio;

    if (input.left) car.wheelAngle = lerp(car.wheelAngle, steerAmount, dt * 5);
    else if (input.right) car.wheelAngle = lerp(car.wheelAngle, -steerAmount, dt * 5);
    else car.wheelAngle *= (1 - dt * 8);

    let turnRate = car.wheelAngle * steerFactor * Math.abs(car.speed) * 0.08;
    if (Math.abs(car.speed) < 0.5) turnRate = 0;

    car.heading += turnRate * dt;

    // Drift mechanics
    const lateralSpeed = forward.clone().cross(new THREE.Vector3(0, 1, 0)).dot(
      new THREE.Vector3(car.velocity.x, 0, car.velocity.z).normalize()
    ) * Math.abs(car.speed);

    car.isDrifting = (Math.abs(car.wheelAngle) > 0.2 && Math.abs(car.speed) > CFG.driftThreshold) ||
                      (!car.onTrack && Math.abs(car.speed) > 8) ||
                      (input.handbrake && Math.abs(car.speed) > 5);

    if (car.isDrifting) {
      car.driftAngle = lerp(car.driftAngle, car.wheelAngle * 0.5 * dt * 3, 0.1);
    } else {
      car.driftAngle *= 0.9;
    }

    // Apply velocity
    const moveDir = new THREE.Vector3(Math.sin(car.heading), 0, Math.cos(car.heading));
    car.velocity.x += moveDir.x * car.speed * dt;
    car.velocity.z += moveDir.z * car.speed * dt;

    // Lateral grip (reduce sideways velocity)
    const sideDir = new THREE.Vector3(-Math.cos(car.heading), 0, Math.sin(car.heading));
    const sideSpeed = sideDir.dot(new THREE.Vector3(car.velocity.x, 0, car.velocity.z));
    const gripForce = car.isDrifting ? CFG.driftFactor : CFG.gripFactor * grip;
    car.velocity.x -= sideDir.x * sideSpeed * (1 - gripForce) * dt * 5;
    car.velocity.z -= sideDir.z * sideSpeed * (1 - gripForce) * dt * 5;

    // Handbrake
    if (input.handbrake) {
      car.velocity.multiplyScalar(1 - 2 * dt);
    }

    // Update position
    car.position.x += car.velocity.x * dt;
    car.position.z += car.velocity.z * dt;
    car.position.y = getTrackHeightAt(car.position.x, car.position.z) + 0.3;

    // Obstacle collision
    for (const rock of rockMeshes) {
      const dx = car.position.x - rock.position.x;
      const dz = car.position.z - rock.position.z;
      const dist = Math.sqrt(dx * dx + dz * dz);
      if (dist < 1.5) {
        const nx = dx / dist, nz = dz / dist;
        car.position.x += nx * (1.5 - dist);
        car.position.z += nz * (1.5 - dist);
        car.velocity.x += nx * CFG.obstacleBounceForce * 0.3;
        car.velocity.z += nz * CFG.obstacleBounceForce * 0.3;
        car.speed *= CFG.obstacleSlowFactor;
        playCrashSound();
        emitSparks(car.position.clone());
        break;
      }
    }

    // Track boundary bounce
    const trackInfo = getClosestTrackInfo(car.position);
    if (!trackInfo.onTrack) {
      const trackPoint = getTrackPoint(trackInfo.t);
      const toTrack = new THREE.Vector3().subVectors(trackPoint, car.position);
      toTrack.y = 0;
      const dist = toTrack.length();
      if (dist > CFG.trackWidth / 2) {
        toTrack.normalize();
        car.position.x += toTrack.x * (dist - CFG.trackWidth / 2) * 0.5;
        car.position.z += toTrack.z * (dist - CFG.trackWidth / 2) * 0.5;
        car.velocity.x *= 0.7;
        car.velocity.z *= 0.7;
        car.speed *= 0.7;
      }
    }

    // Update car mesh
    car.mesh.position.copy(car.position);
    car.mesh.rotation.y = car.heading;

    // Wheel rotation
    const wheelRot = car.speed * dt * 3;
    car.wheels.forEach(w => w.rotation.x += wheelRot);

    // Gear and RPM
    const absSpeed = Math.abs(car.speed);
    if (car.speed < -1) { car.gear = -1; }
    else if (absSpeed < 2) { car.gear = 0; }
    else if (absSpeed < 12) { car.gear = 1; }
    else if (absSpeed < 22) { car.gear = 2; }
    else if (absSpeed < 35) { car.gear = 3; }
    else if (absSpeed < 45) { car.gear = 4; }
    else { car.gear = 5; }
    car.rpm = 800 + (absSpeed / CFG.maxSpeed) * 6000 + (car.gear > 0 ? (1 - absSpeed / (CFG.maxSpeed * (car.gear / 5 + 0.6))) * 1000 : 0);
    car.rpm = clamp(car.rpm, 800, 7500);

    // Dust emission
    if ((car.isDrifting || (!car.onTrack && absSpeed > 5)) && Math.random() < 0.3) {
      emitDust(car.position.clone(), absSpeed);
    }

    // Check checkpoints and lap progress
    checkCheckpoints();
    checkLapProgress();
  }

  function getTrackHeightAt(x, z) {
    const info = getClosestTrackInfo(new THREE.Vector3(x, 0, z));
    const point = getTrackPoint(info.t);
    return point.y;
  }

  // ---- CAMERA ----
  function updateCamera(dt) {
    const targetPos = car.position.clone();
    let camHeight, camDist, camLag;

    if (state.cameraMode === 0) {
      camHeight = CFG.cameraHeight;
      camDist = CFG.cameraDistance;
      camLag = CFG.cameraLag;
    } else {
      camHeight = CFG.cinematicHeight;
      camDist = CFG.cinematicDistance;
      camLag = CFG.cinematicLag;
    }

    const idealPos = new THREE.Vector3(
      targetPos.x - Math.sin(car.heading) * camDist,
      targetPos.y + camHeight,
      targetPos.z - Math.cos(car.heading) * camDist
    );

    camera.position.lerp(idealPos, camLag + dt * 3);
    const lookTarget = new THREE.Vector3(
      targetPos.x + Math.sin(car.heading) * 5,
      targetPos.y + 1,
      targetPos.z + Math.cos(car.heading) * 5
    );
    camera.lookAt(lookTarget);
  }

  // ---- HUD UPDATE ----
  function updateHUD() {
    // Lap text
    document.getElementById('lapText').textContent = `LAP ${Math.min(state.lap, CFG.totalLaps)} / ${CFG.totalLaps}`;

    // Time
    if (state.phase === 'racing' || state.phase === 'paused') {
      state.raceTime = performance.now() - state.raceStartTime;
      document.getElementById('timeText').textContent = formatTime(state.raceTime);
    }

    // Best lap
    if (state.bestLap < Infinity) {
      document.getElementById('bestText').textContent = `BEST: ${formatTime(state.bestLap)}`;
    }

    // Gear and RPM
    const gearNames = ['R', 'N', '1', '2', '3', '4', '5'];
    document.getElementById('gearText').textContent = gearNames[car.gear + 1];
    document.getElementById('rpmText').textContent = `${Math.round(car.rpm)} RPM`;

    // Speedometer
    drawSpeedometer();

    // Minimap
    drawMinimap();
  }

  function drawSpeedometer() {
    const ctx = speedoCtx;
    const w = speedoCanvas.width, h = speedoCanvas.height;
    const cx = w / 2, cy = h / 2, r = w / 2 - 10;
    ctx.clearRect(0, 0, w, h);

    // Background
    ctx.beginPath();
    ctx.arc(cx, cy, r + 5, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(0,0,0,0.5)';
    ctx.fill();

    // Scale arc
    const startAngle = Math.PI * 0.75;
    const endAngle = Math.PI * 2.25;
    const speedRatio = Math.abs(car.speed) / CFG.maxSpeed;

    // Outer arc
    ctx.beginPath();
    ctx.arc(cx, cy, r, startAngle, endAngle);
    ctx.strokeStyle = '#333';
    ctx.lineWidth = 8;
    ctx.stroke();

    // Speed arc
    if (speedRatio > 0) {
      const color = speedRatio > 0.8 ? '#ff4444' : speedRatio > 0.5 ? '#ffaa00' : '#44ff44';
      ctx.beginPath();
      ctx.arc(cx, cy, r, startAngle, startAngle + (endAngle - startAngle) * speedRatio);
      ctx.strokeStyle = color;
      ctx.lineWidth = 8;
      ctx.stroke();
    }

    // Ticks
    for (let i = 0; i <= 10; i++) {
      const angle = startAngle + (endAngle - startAngle) * (i / 10);
      const inner = r - 20;
      const outer = r - 10;
      ctx.beginPath();
      ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner);
      ctx.lineTo(cx + Math.cos(angle) * outer, cy + Math.sin(angle) * outer);
      ctx.strokeStyle = '#aaa';
      ctx.lineWidth = 2;
      ctx.stroke();

      const labelR = r - 30;
      ctx.fillStyle = '#888';
      ctx.font = '10px monospace';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText(Math.round(CFG.maxSpeed * i / 10), cx + Math.cos(angle) * labelR, cy + Math.sin(angle) * labelR);
    }

    // Speed text
    ctx.fillStyle = '#fff';
    ctx.font = 'bold 28px monospace';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(Math.round(Math.abs(car.speed)), cx, cy - 5);
    ctx.fillStyle = '#aaa';
    ctx.font = '10px sans-serif';
    ctx.fillText('km/h', cx, cy + 18);
  }

  function drawMinimap() {
    const ctx = minimapCtx;
    const w = minimapCanvas.width, h = minimapCanvas.height;
    ctx.clearRect(0, 0, w, h);

    // Find bounds of track
    let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
    for (let i = 0; i <= 100; i++) {
      const p = getTrackPoint(i / 100);
      minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
      minZ = Math.min(minZ, p.z); maxZ = Math.max(maxZ, p.z);
    }
    const pad = 20;
    const scaleX = (w - pad * 2) / (maxX - minX);
    const scaleZ = (h - pad * 2) / (maxZ - minZ);
    const scale = Math.min(scaleX, scaleZ);
    const offsetX = (w - (maxX - minX) * scale) / 2;
    const offsetZ = (h - (maxZ - minZ) * scale) / 2;

    function toMap(x, z) {
      return [(x - minX) * scale + offsetX, (z - minZ) * scale + offsetZ];
    }

    // Draw track
    ctx.beginPath();
    for (let i = 0; i <= 100; i++) {
      const p = getTrackPoint(i / 100);
      const [mx, mz] = toMap(p.x, p.z);
      if (i === 0) ctx.moveTo(mx, mz);
      else ctx.lineTo(mx, mz);
    }
    ctx.closePath();
    ctx.strokeStyle = 'rgba(255,255,255,0.6)';
    ctx.lineWidth = 4;
    ctx.stroke();
    ctx.fillStyle = 'rgba(255,255,255,0.15)';
    ctx.fill();

    // Checkpoint markers
    CHECKPOINTS.forEach((cp, i) => {
      const p = getTrackPoint(cp);
      const [mx, mz] = toMap(p.x, p.z);
      ctx.beginPath();
      ctx.arc(mx, mz, 3, 0, Math.PI * 2);
      ctx.fillStyle = checkpointPassed[i] ? '#44ff44' : '#ff4444';
      ctx.fill();
    });

    // Start line
    const sp = getTrackPoint(0);
    const [sx, sz] = toMap(sp.x, sp.z);
    ctx.beginPath();
    ctx.arc(sx, sz, 5, 0, Math.PI * 2);
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = 2;
    ctx.stroke();

    // Player position
    const [px, pz] = toMap(car.position.x, car.position.z);
    ctx.beginPath();
    ctx.arc(px, pz, 4, 0, Math.PI * 2);
    ctx.fillStyle = '#ff4444';
    ctx.fill();
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = 1.5;
    ctx.stroke();

    // Direction indicator
    const dirLen = 8;
    ctx.beginPath();
    ctx.moveTo(px, pz);
    ctx.lineTo(px + Math.sin(car.heading) * dirLen, pz + Math.cos(car.heading) * dirLen);
    ctx.strokeStyle = '#ff4444';
    ctx.lineWidth = 2;
    ctx.stroke();
  }

  // ---- COUNTDOWN ----
  function startCountdown() {
    state.phase = 'countdown';
    state.countdownValue = 3;
    state.countdownTimer = 0;
    const el = document.getElementById('countdown');
    el.style.display = 'block';
    el.textContent = '3';
    playCountdownBeep(false);
  }

  function updateCountdown(dt) {
    if (state.phase !== 'countdown') return;
    state.countdownTimer += dt;
    const newVal = 3 - Math.floor(state.countdownTimer);
    if (newVal !== state.countdownValue && newVal >= 0) {
      state.countdownValue = newVal;
      const el = document.getElementById('countdown');
      if (newVal > 0) {
        el.textContent = newVal.toString();
        playCountdownBeep(false);
      } else {
        el.textContent = 'GO!';
        el.style.color = '#44ff44';
        playCountdownBeep(true);
        state.phase = 'racing';
        state.raceStartTime = performance.now();
        state.lapStartTime = state.raceStartTime;
        setTimeout(() => { el.style.display = 'none'; el.style.color = '#fff'; }, 800);
      }
    }
  }

  // ---- UI FUNCTIONS ----
  function showPauseMenu() {
    state.phase = 'paused';
    document.getElementById('pauseMenu').style.display = 'flex';
  }

  function hidePauseMenu() {
    state.phase = 'racing';
    state.raceStartTime += performance.now() - (state.raceStartTime + (state.phase === 'paused' ? 0 : 0));
    document.getElementById('pauseMenu').style.display = 'none';
  }

  function showFinishScreen() {
    playFinishTone();
    document.getElementById('finishScreen').style.display = 'flex';
    document.getElementById('finalTime').textContent = `Final Time: ${formatTime(state.raceTime)}`;
    document.getElementById('finalBest').textContent = `Best Lap: ${state.bestLap < Infinity ? formatTime(state.bestLap) : '--:--.--'}`;
    document.getElementById('finalLaps').textContent = `Laps Completed: ${CFG.totalLaps}/${CFG.totalLaps}`;
  }

  function resetGame() {
    state.phase = 'menu';
    state.lap = 1;
    state.lapTimes = [];
    state.bestLap = Infinity;
    state.lapStartTime = 0;
    state.raceTime = 0;
    state.countdownValue = 3;
    state.countdownTimer = 0;
    state.cameraMode = 0;

    car.position.set(0, 0.3, 0);
    car.velocity.set(0, 0, 0);
    car.heading = 0;
    car.speed = 0;
    car.gear = 0;
    car.rpm = 0;
    car.wheelAngle = 0;
    car.isDrifting = false;
    car.driftAngle = 0;

    checkpointPassed = [false, false, false, false];

    document.getElementById('countdown').style.display = 'none';
    document.getElementById('countdown').style.color = '#fff';
    document.getElementById('pauseMenu').style.display = 'none';
    document.getElementById('finishScreen').style.display = 'none';
    document.getElementById('startScreen').style.display = 'flex';
  }

  // ---- INPUT HANDLING ----
  function setupInput() {
    // Keyboard
    window.addEventListener('keydown', (e) => {
      if (state.phase === 'menu') return;

      switch(e.code) {
        case 'ArrowUp': case 'KeyW': input.up = true; break;
        case 'ArrowDown': case 'KeyS': input.down = true; break;
        case 'ArrowLeft': case 'KeyA': input.left = true; break;
        case 'ArrowRight': case 'KeyD': input.right = true; break;
        case 'Space': input.handbrake = true; e.preventDefault(); break;
        case 'KeyP':
          if (state.phase === 'racing') showPauseMenu();
          else if (state.phase === 'paused') { hidePauseMenu(); }
          break;
        case 'KeyC': state.cameraMode = (state.cameraMode + 1) % 2; break;
        case 'KeyR': resetGame(); break;
      }
    });

    window.addEventListener('keyup', (e) => {
      switch(e.code) {
        case 'ArrowUp': case 'KeyW': input.up = false; break;
        case 'ArrowDown': case 'KeyS': input.down = false; break;
        case 'ArrowLeft': case 'KeyA': input.left = false; break;
        case 'ArrowRight': case 'KeyD': input.right = false; break;
        case 'Space': input.handbrake = false; break;
      }
    });

    // Touch controls
    const isTouchDevice = 'ontouchstart' in window;
    if (isTouchDevice) {
      document.getElementById('touchControls').style.display = 'flex';
    }

    function setupTouchBtn(id, key) {
      const el = document.getElementById(id);
      el.addEventListener('touchstart', (e) => { e.preventDefault(); input[key] = true; });
      el.addEventListener('touchend', (e) => { e.preventDefault(); input[key] = false; });
      el.addEventListener('touchcancel', (e) => { input[key] = false; });
    }
    setupTouchBtn('btnUp', 'up');
    setupTouchBtn('btnDown', 'down');
    setupTouchBtn('btnLeft', 'left');
    setupTouchBtn('btnRight', 'right');
    setupTouchBtn('btnGas', 'up');
    setupTouchBtn('btnBrake', 'down');
    setupTouchBtn('btnHandbrake', 'handbrake');

    // UI buttons
    document.getElementById('startBtn').addEventListener('click', () => {
      initAudio();
      document.getElementById('startScreen').style.display = 'none';
      state.phase = 'countdown';
      startCountdown();
    });

    document.getElementById('resumeBtn').addEventListener('click', () => {
      hidePauseMenu();
    });

    document.getElementById('restartBtn').addEventListener('click', () => {
      resetGame();
      setTimeout(() => {
        state.phase = 'countdown';
        startCountdown();
      }, 100);
    });

    document.getElementById('finishRestartBtn').addEventListener('click', () => {
      resetGame();
      setTimeout(() => {
        state.phase = 'countdown';
        startCountdown();
      }, 100);
    });
  }

  // ---- RESIZE ----
  function onResize() {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  }

  // ---- MAIN LOOP ----
  function gameLoop() {
    requestAnimationFrame(gameLoop);

    const dt = Math.min(clock.getDelta(), 0.05);

    if (state.phase === 'countdown') {
      updateCountdown(dt);
    }

    if (state.phase === 'racing') {
      updatePhysics(dt);
    }

    updateCamera(dt);
    updateParticles(dt);
    updateEngineAudio();
    updateHUD();

    // Update sun shadow to follow car
    sunLight.position.set(car.position.x + 50, 80, car.position.z + 30);
    sunLight.target.position.copy(car.position);
    sunLight.target.updateMatrixWorld();

    renderer.render(scene, camera);
  }

  // ---- INIT ----
  function init() {
    initScene();
    setupInput();

    // Set initial car position
    const startPos = getTrackPoint(0);
    car.position.copy(startPos);
    car.position.y += 0.3;
    car.heading = Math.atan2(
      getTrackTangent(0.001).x,
      getTrackTangent(0.001).z
    );

    gameLoop();
  }

  // Start
  init();

  // Public API
  return {
    getState: () => state,
    getCar: () => car,
  };
})();
</script>
</body>
</html>