← Rally results

Rally

thinkingcap qwen3.6 27b · full

◈Preview unavailable — try playing the result

Browser check: Preview unavailable or timed out. This is a technical check, not a quality review.

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 62,118 bytes · SHA-256 ea26056c5260
<!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 Racer 3D</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; width: 100%; height: 100%; background: #000; font-family: 'Segoe UI', Arial, sans-serif; touch-action: none; }
canvas { display: block; }

/* HUD Overlay */
#hud { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
#speedometer { position: absolute; bottom: 20px; right: 20px; text-align: center; color: #fff; }
#speed-value { font-size: 48px; font-weight: bold; text-shadow: 2px 2px 4px rgba(0,0,0,0.8); }
#speed-unit { font-size: 16px; opacity: 0.7; }
#gear-display { font-size: 28px; color: #ffcc00; margin-top: 4px; text-shadow: 1px 1px 3px rgba(0,0,0,0.8); }
#rpm-bar-container { width: 160px; height: 10px; background: rgba(0,0,0,0.5); border-radius: 5px; margin-top: 4px; overflow: hidden; }
#rpm-bar { height: 100%; width: 0%; background: linear-gradient(90deg, #0f0, #ff0, #f00); transition: width 0.05s; border-radius: 5px; }

/* Lap info */
#lap-info { position: absolute; top: 20px; left: 20px; color: #fff; text-shadow: 1px 1px 3px rgba(0,0,0,0.8); }
#lap-count { font-size: 24px; font-weight: bold; }
#current-time { font-size: 20px; margin-top: 4px; }
#best-lap { font-size: 16px; color: #ffcc00; margin-top: 2px; }

/* Minimap */
#minimap-container { position: absolute; bottom: 20px; left: 20px; width: 180px; height: 180px; background: rgba(0,0,0,0.5); border-radius: 10px; border: 2px solid rgba(255,255,255,0.3); }
#minimap { width: 100%; height: 100%; }

/* Countdown */
#countdown-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; z-index: 20; pointer-events: none; }
#countdown-text { font-size: 120px; font-weight: bold; color: #fff; text-shadow: 4px 4px 8px rgba(0,0,0,0.8); opacity: 0; transition: opacity 0.3s; }

/* Pause menu */
#pause-menu { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); display: none; align-items: center; justify-content: center; z-index: 30; pointer-events: auto; }
#pause-menu.active { display: flex; }
.pause-box { text-align: center; color: #fff; }
.pause-box h2 { font-size: 48px; margin-bottom: 30px; }
.pause-btn { padding: 15px 40px; font-size: 20px; border: none; border-radius: 8px; cursor: pointer; margin: 10px; background: #ff6600; color: #fff; font-weight: bold; }
.pause-btn:hover { background: #ff8833; }

/* Finish screen */
#finish-screen { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); display: none; align-items: center; justify-content: center; z-index: 30; pointer-events: auto; }
#finish-screen.active { display: flex; }
.finish-box { text-align: center; color: #fff; }
.finish-box h2 { font-size: 48px; margin-bottom: 10px; color: #ffcc00; }
.finish-stat { font-size: 22px; margin: 8px 0; }

/* Touch controls */
#touch-controls { position: fixed; bottom: 0; left: 0; width: 100%; height: 200px; display: none; z-index: 15; pointer-events: auto; }
.touch-btn { position: absolute; 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; user-select: none; -webkit-user-select: none; }
.touch-btn:active { background: rgba(255,255,255,0.5); }
#touch-left { left: 20px; bottom: 80px; width: 70px; height: 70px; }
#touch-right { left: 110px; bottom: 80px; width: 70px; height: 70px; }
#touch-gas { right: 20px; bottom: 80px; width: 80px; height: 80px; background: rgba(0,255,0,0.3); border-color: rgba(0,255,0,0.6); }
#touch-brake { right: 120px; bottom: 80px; width: 70px; height: 70px; background: rgba(255,0,0,0.3); border-color: rgba(255,0,0,0.6); }
#touch-handbrake { right: 70px; bottom: 10px; width: 80px; height: 40px; border-radius: 20px; background: rgba(255,165,0,0.3); border-color: rgba(255,165,0,0.6); font-size: 14px; }
#touch-reverse { left: 70px; bottom: 10px; width: 70px; height: 40px; border-radius: 20px; background: rgba(200,200,200,0.3); border-color: rgba(200,200,200,0.6); font-size: 14px; }

/* Surface indicator */
#surface-indicator { position: absolute; bottom: 80px; right: 200px; color: #fff; font-size: 14px; text-shadow: 1px 1px 3px rgba(0,0,0,0.8); opacity: 0.7; }

/* Start screen */
#start-screen { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient(135deg, #1a1a2e, #16213e); display: flex; align-items: center; justify-content: center; z-index: 40; pointer-events: auto; }
.start-box { text-align: center; color: #fff; }
.start-box h1 { font-size: 64px; margin-bottom: 10px; background: linear-gradient(90deg, #ff6600, #ffcc00); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.start-box p { font-size: 18px; opacity: 0.7; margin-bottom: 30px; }
.start-btn { padding: 20px 60px; font-size: 24px; border: none; border-radius: 10px; cursor: pointer; background: linear-gradient(90deg, #ff6600, #ff8833); color: #fff; font-weight: bold; box-shadow: 0 4px 15px rgba(255,102,0,0.4); }
.start-btn:hover { transform: scale(1.05); }
.controls-info { margin-top: 30px; font-size: 14px; opacity: 0.6; line-height: 1.8; }

/* Warning flash */
#warning-flash { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 5; opacity: 0; background: radial-gradient(ellipse at center, rgba(255,0,0,0.3), transparent); transition: opacity 0.1s; }
</style>
</head>
<body>

<!-- Start Screen -->
<div id="start-screen">
  <div class="start-box">
    <h1>RALLY RACER 3D</h1>
    <p>Complete 3 laps as fast as possible!</p>
    <button class="start-btn" onclick="RallyGame.startRace()">START RACE</button>
    <div class="controls-info">
      WASD / Arrow Keys — Drive &nbsp;|&nbsp; Space — Handbrake<br>
      P — Pause &nbsp;|&nbsp; C — Camera Toggle &nbsp;|&nbsp; R — Restart
    </div>
  </div>
</div>

<!-- HUD -->
<div id="hud">
  <div id="lap-info">
    <div id="lap-count">LAP 1 / 3</div>
    <div id="current-time">00:00.000</div>
    <div id="best-lap">Best: --:--.---</div>
  </div>
  <div id="speedometer">
    <div id="speed-value">0</div>
    <div id="speed-unit">km/h</div>
    <div id="gear-display">N</div>
    <div id="rpm-bar-container"><div id="rpm-bar"></div></div>
  </div>
  <div id="surface-indicator">ASPHALT</div>
  <div id="minimap-container"><canvas id="minimap" width="180" height="180"></canvas></div>
</div>

<!-- Countdown -->
<div id="countdown-overlay"><div id="countdown-text"></div></div>

<!-- Pause Menu -->
<div id="pause-menu">
  <div class="pause-box">
    <h2>PAUSED</h2>
    <button class="pause-btn" onclick="RallyGame.resume()">RESUME</button><br>
    <button class="pause-btn" onclick="RallyGame.restart()">RESTART</button>
  </div>
</div>

<!-- Finish Screen -->
<div id="finish-screen">
  <div class="finish-box">
    <h2>RACE COMPLETE!</h2>
    <div class="finish-stat" id="final-time"></div>
    <div class="finish-stat" id="final-best-lap"></div>
    <button class="pause-btn" onclick="RallyGame.restart()">RACE AGAIN</button>
  </div>
</div>

<!-- Touch Controls -->
<div id="touch-controls">
  <div class="touch-btn" id="touch-left">◀</div>
  <div class="touch-btn" id="touch-right">▶</div>
  <div class="touch-btn" id="touch-gas">▲</div>
  <div class="touch-btn" id="touch-brake">▼</div>
  <div class="touch-btn" id="touch-handbrake">HBK</div>
  <div class="touch-btn" id="touch-reverse">REV</div>
</div>

<!-- Warning Flash -->
<div id="warning-flash"></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ============================================================
// RALLY GAME - Single-file 3D Rally Racing Game
// ============================================================

const RallyGame = (function() {
    'use strict';

    // ==================== CONFIGURATION ====================
    const CFG = {
        totalLaps: 3,
        trackWidth: 14,
        trackSegments: 500,
        carMaxSpeed: 85,       // m/s (~306 km/h)
        carAccel: 35,
        carBrakeForce: 50,
        carReverseMax: 20,
        carSteerSpeed: 2.8,
        driftFactor: 0.92,     // lower = more drift
        offRoadSlowdown: 0.4,
        mudSlowdown: 0.15,
        gravity: 20,
        cameraHeight: 6,
        cameraDistance: 14,
        cameraLag: 0.06,
        fogNear: 80,
        fogFar: 350,
        checkpointCount: 12,
        treeCount: 400,
        rockCount: 100,
    };

    // ==================== GAME STATE ====================
    let state = {
        phase: 'menu',       // menu | countdown | racing | paused | finished
        lap: 1,
        currentCheckpoint: 0,
        raceTime: 0,
        lapStartTime: 0,
        bestLap: Infinity,
        lapTimes: [],
    };

    // ==================== INPUT ====================
    const keys = {};
    let touchInput = { left: false, right: false, gas: false, brake: false, handbrake: false, reverse: false };

    function onKey(e, down) {
        keys[e.code] = !!down;
        if (down && e.code === 'KeyP' && state.phase === 'racing') pause();
        else if (down && e.code === 'KeyP' && state.phase === 'paused') resume();
        if (down && e.code === 'KeyR') restart();
        if (down && e.code === 'KeyC') toggleCamera();
    }

    document.addEventListener('keydown', e => onKey(e, true));
    document.addEventListener('keyup', e => onKey(e, false));

    // Touch setup
    function setupTouch() {
        const isMobile = /Mobi|Android/i.test(navigator.userAgent) || ('ontouchstart' in window);
        if (!isMobile) return;
        document.getElementById('touch-controls').style.display = 'block';
        const bindBtn = (id, prop) => {
            const el = document.getElementById(id);
            el.addEventListener('touchstart', e => { e.preventDefault(); touchInput[prop] = true; });
            el.addEventListener('touchend', e => { e.preventDefault(); touchInput[prop] = false; });
            el.addEventListener('touchcancel', e => { touchInput[prop] = false; });
        };
        bindBtn('touch-left', 'left');
        bindBtn('touch-right', 'right');
        bindBtn('touch-gas', 'gas');
        bindBtn('touch-brake', 'brake');
        bindBtn('touch-handbrake', 'handbrake');
        bindBtn('touch-reverse', 'reverse');
    }

    function input(action) {
        const t = touchInput;
        switch (action) {
            case 'accel': return keys['ArrowUp'] || keys['KeyW'] || t.gas;
            case 'brake': return keys['ArrowDown'] || keys['KeyS'] || t.brake;
            case 'left':  return keys['ArrowLeft'] || keys['KeyA'] || t.left;
            case 'right': return keys['ArrowRight'] || keys['KeyD'] || t.right;
            case 'handbrake': return keys['Space'] || t.handbrake;
            case 'reverse': return t.reverse;
        }
    }

    // ==================== THREE.JS SETUP ====================
    let scene, camera, renderer, clock;
    let carGroup, carBody, wheels = [];
    let trackCurve, checkpoints = [], checkpointMeshes = [];
    let sceneryObjects = [];
    let particles = { dust: null, sparks: null };
    let skidMarks = [];

    function initThree() {
        scene = new THREE.Scene();
        scene.background = new THREE.Color(0x87CEEB);
        scene.fog = new THREE.FogExp2(0xC4D8E8, 0.003);

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

        renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
        renderer.shadowMap.enabled = true;
        renderer.shadowMap.type = THREE.PCFSoftShadowMap;
        document.body.appendChild(renderer.domElement);

        clock = new THREE.Clock();

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

        const sunLight = new THREE.DirectionalLight(0xffeedd, 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 = 300;
        sunLight.shadow.camera.left = -80;
        sunLight.shadow.camera.right = 80;
        sunLight.shadow.camera.top = 80;
        sunLight.shadow.camera.bottom = -80;
        scene.add(sunLight);

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

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

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

    // ==================== TRACK GENERATION ====================
    let trackPoints = [];
    let trackNormals = [];
    let trackSurfaces = [];
    let trackLength = 0;

    function generateTrack() {
        // Define control points for a winding rally track
        const ctrlPts = [
            new THREE.Vector3(0, 0, 0),
            new THREE.Vector3(40, 2, -60),
            new THREE.Vector3(80, 5, -100),
            new THREE.Vector3(120, 3, -80),
            new THREE.Vector3(150, 8, -120),
            new THREE.Vector3(160, 4, -170),
            new THREE.Vector3(130, 6, -220),
            new THREE.Vector3(80, 10, -250),
            new THREE.Vector3(30, 7, -230),
            new THREE.Vector3(-20, 4, -260),
            new THREE.Vector3(-60, 9, -220),
            new THREE.Vector3(-80, 5, -170),
            new THREE.Vector3(-100, 3, -120),
            new THREE.Vector3(-120, 7, -60),
            new THREE.Vector3(-100, 4, -10),
            new THREE.Vector3(-80, 2, 40),
            new THREE.Vector3(-50, 5, 80),
            new THREE.Vector3(-20, 3, 100),
            new THREE.Vector3(20, 6, 90),
        ];

        trackCurve = new THREE.CatmullRomCurve3(ctrlPts, true, 'catmullrom', 0.5);

        // Sample points along the curve
        for (let i = 0; i < CFG.trackSegments; i++) {
            const t = i / CFG.trackSegments;
            trackPoints.push(trackCurve.getPointAt(t));
        }

        // Compute normals and tangents
        for (let i = 0; i < trackPoints.length; i++) {
            const t = i / trackPoints.length;
            const nextT = ((i + 1) % trackPoints.length) / trackPoints.length;
            const prevT = ((i - 1 + trackPoints.length) % trackPoints.length) / trackPoints.length;
            const tangent = new THREE.Vector3().subVectors(
                trackCurve.getPointAt(nextT),
                trackCurve.getPointAt(prevT)
            ).normalize();

            const up = new THREE.Vector3(0, 1, 0);
            const normal = new THREE.Vector3().crossVectors(up, tangent).normalize();
            trackNormals.push(normal);

            // Surface type: mostly asphalt, some dirt/gravel sections
            if (t > 0.25 && t < 0.35) trackSurfaces.push('dirt');
            else if (t > 0.6 && t < 0.7) trackSurfaces.push('mud');
            else if (t > 0.85 && t < 0.92) trackSurfaces.push('gravel');
            else trackSurfaces.push('asphalt');
        }

        // Compute total track length
        for (let i = 1; i <= trackPoints.length; i++) {
            const a = trackPoints[(i - 1) % trackPoints.length];
            const b = trackPoints[i % trackPoints.length];
            trackLength += a.distanceTo(b);
        }

        // Checkpoints evenly spaced around the track
        for (let i = 0; i < CFG.checkpointCount; i++) {
            const t = i / CFG.checkpointCount;
            checkpoints.push({
                position: trackCurve.getPointAt(t),
                passed: false,
            });
        }
    }

    function buildTrackMesh() {
        // Road surface
        const roadGeo = new THREE.BufferGeometry();
        const vertices = [];
        const colors = [];
        const uvs = [];
        const indices = [];

        for (let i = 0; i < trackPoints.length; i++) {
            const p = trackPoints[i];
            const n = trackNormals[i];
            const hw = CFG.trackWidth / 2;

            // Left and right edge of road
            const left = new THREE.Vector3().copy(p).addScaledVector(n, -hw);
            const right = new THREE.Vector3().copy(p).addScaledVector(n, hw);

            vertices.push(left.x, left.y, left.z);
            vertices.push(right.x, right.y, right.z);

            // Color based on surface type
            let r, g, b;
            switch (trackSurfaces[i]) {
                case 'dirt': r = 0.65; g = 0.45; b = 0.25; break;
                case 'mud': r = 0.35; g = 0.28; b = 0.18; break;
                case 'gravel': r = 0.55; g = 0.52; b = 0.48; break;
                default: r = 0.3; g = 0.3; b = 0.32; // asphalt
            }
            colors.push(r, g, b);
            colors.push(r, g, b);

            uvs.push(0, i * 0.5);
            uvs.push(1, i * 0.5);

            const base = i * 2;
            if (i < trackPoints.length - 1) {
                indices.push(base, base + 1, base + 2);
                indices.push(base + 1, base + 3, base + 2);
            } else {
                // Close the loop
                const lastBase = (trackPoints.length - 1) * 2;
                indices.push(lastBase, lastBase + 1, 0);
                indices.push(lastBase + 1, 1, 0);
            }
        }

        roadGeo.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
        roadGeo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
        roadGeo.setIndex(indices);
        roadGeo.computeVertexNormals();

        const roadMat = new THREE.MeshLambertMaterial({ vertexColors: true });
        const roadMesh = new THREE.Mesh(roadGeo, roadMat);
        roadMesh.receiveShadow = true;
        scene.add(roadMesh);

        // Road shoulders (slightly wider, grass color)
        const shoulderGeo = new THREE.BufferGeometry();
        const sVerts = [];
        const sColors = [];
        const sIndices = [];

        for (let i = 0; i < trackPoints.length; i++) {
            const p = trackPoints[i];
            const n = trackNormals[i];
            const hw = CFG.trackWidth / 2;
            const sw = hw + 4; // shoulder width

            const innerLeft = new THREE.Vector3().copy(p).addScaledVector(n, -hw);
            const outerLeft = new THREE.Vector3().copy(p).addScaledVector(n, -sw);
            const innerRight = new THREE.Vector3().copy(p).addScaledVector(n, hw);
            const outerRight = new THREE.Vector3().copy(p).addScaledVector(n, sw);

            // Left shoulder
            sVerts.push(innerLeft.x, innerLeft.y + 0.05, innerLeft.z);
            sVerts.push(outerLeft.x, outerLeft.y + 0.05, outerLeft.z);
            sColors.push(0.25, 0.45, 0.15);
            sColors.push(0.25, 0.45, 0.15);

            // Right shoulder
            sVerts.push(innerRight.x, innerRight.y + 0.05, innerRight.z);
            sVerts.push(outerRight.x, outerRight.y + 0.05, outerRight.z);
            sColors.push(0.25, 0.45, 0.15);
            sColors.push(0.25, 0.45, 0.15);

            const base = i * 4;
            if (i < trackPoints.length - 1) {
                // Left shoulder strip
                sIndices.push(base, base + 1, base + 4);
                sIndices.push(base + 1, base + 5, base + 4);
                // Right shoulder strip
                sIndices.push(base + 2, base + 3, base + 6);
                sIndices.push(base + 3, base + 7, base + 6);
            } else {
                const lastBase = (trackPoints.length - 1) * 4;
                sIndices.push(lastBase, lastBase + 1, 0);
                sIndices.push(lastBase + 1, 1, 0);
                sIndices.push(lastBase + 2, lastBase + 3, 2);
                sIndices.push(lastBase + 3, 3, 2);
            }
        }

        shoulderGeo.setAttribute('position', new THREE.Float32BufferAttribute(sVerts, 3));
        shoulderGeo.setAttribute('color', new THREE.Float32BufferAttribute(sColors, 3));
        shoulderGeo.setIndex(sIndices);
        shoulderGeo.computeVertexNormals();

        const shoulderMat = new THREE.MeshLambertMaterial({ vertexColors: true });
        const shoulderMesh = new THREE.Mesh(shoulderGeo, shoulderMat);
        shoulderMesh.receiveShadow = true;
        scene.add(shoulderMesh);

        // Road edge lines (white dashed)
        for (let side = -1; side <= 1; side += 2) {
            const lineVerts = [];
            for (let i = 0; i < trackPoints.length; i++) {
                if (i % 4 < 2) continue; // Dashed effect
                const p = trackPoints[i];
                const n = trackNormals[i];
                const edge = new THREE.Vector3().copy(p).addScaledVector(n, side * CFG.trackWidth / 2);
                lineVerts.push(edge.x, edge.y + 0.1, edge.z);
            }
            const lineGeo = new THREE.BufferGeometry();
            lineGeo.setAttribute('position', new THREE.Float32BufferAttribute(lineVerts, 3));
            const lineMat = new THREE.PointsMaterial({ color: 0xffffff, size: 0.5 });
            scene.add(new THREE.Points(lineGeo, lineMat));
        }

        // Start/finish line
        const startP = trackPoints[0];
        const startN = trackNormals[0];
        const startLineGeo = new THREE.PlaneGeometry(CFG.trackWidth, 1.5);
        const startLineMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
        const startLine = new THREE.Mesh(startLineGeo, startLineMat);
        startLine.position.copy(startP).add(new THREE.Vector3(0, 0.2, 0));
        startLine.lookAt(startP.clone().add(new THREE.Vector3(0, 1, 0)));
        scene.add(startLine);

        // Checkpoint markers (invisible gates)
        for (let i = 0; i < checkpoints.length; i++) {
            const cp = checkpoints[i];
            const idx = Math.floor(i * trackPoints.length / CFG.checkpointCount) % trackPoints.length;
            const n = trackNormals[idx];

            // Visual marker (small arch)
            const archGeo = new THREE.BoxGeometry(CFG.trackWidth + 2, 4, 0.5);
            const archMat = new THREE.MeshBasicMaterial({ color: i === 0 ? 0x00ff00 : 0xffaa00, transparent: true, opacity: 0.3 });
            const arch = new THREE.Mesh(archGeo, archMat);
            arch.position.copy(cp.position).add(new THREE.Vector3(0, 2, 0));
            arch.lookAt(cp.position.clone().add(n));
            scene.add(arch);
        }
    }

    // ==================== GROUND PLANE ====================
    function buildGround() {
        const groundGeo = new THREE.PlaneGeometry(1500, 1500, 20, 20);
        // Add some vertex displacement for hills
        const pos = groundGeo.attributes.position;
        for (let i = 0; i < pos.count; i++) {
            const x = pos.getX(i);
            const y = pos.getY(i);
            const distFromCenter = Math.sqrt(x * x + y * y);
            if (distFromCenter > 200) {
                pos.setZ(i, (Math.sin(x * 0.01) * Math.cos(y * 0.01)) * 8 + Math.random() * 2);
            }
        }
        groundGeo.computeVertexNormals();

        const groundMat = new THREE.MeshLambertMaterial({ color: 0x4a7c3f });
        const ground = new THREE.Mesh(groundGeo, groundMat);
        ground.rotation.x = -Math.PI / 2;
        ground.position.y = -1;
        ground.receiveShadow = true;
        scene.add(ground);
    }

    // ==================== SCENERY ====================
    function buildScenery() {
        const treeGeo = new THREE.ConeGeometry(2, 8, 6);
        const trunkGeo = new THREE.CylinderGeometry(0.4, 0.5, 3, 6);
        const rockGeo = new THREE.DodecahedronGeometry(1.5, 0);

        const treeMat = new THREE.MeshLambertMaterial({ color: 0x2d5a1e });
        const trunkMat = new THREE.MeshLambertMaterial({ color: 0x8B4513 });
        const rockMat = new THREE.MeshLambertMaterial({ color: 0x777777 });

        for (let i = 0; i < CFG.treeCount; i++) {
            const t = Math.random();
            const idx = Math.floor(t * trackPoints.length) % trackPoints.length;
            const p = trackPoints[idx];
            const n = trackNormals[idx];

            // Place trees off the road
            const offset = (Math.random() > 0.5 ? 1 : -1) * (CFG.trackWidth / 2 + 8 + Math.random() * 40);
            const treePos = new THREE.Vector3().copy(p).addScaledVector(n, offset);
            treePos.y += Math.random() * 2;

            // Tree group
            const treeGroup = new THREE.Group();

            const trunk = new THREE.Mesh(trunkGeo, trunkMat);
            trunk.position.y = 1.5;
            trunk.castShadow = true;
            treeGroup.add(trunk);

            const foliage = new THREE.Mesh(treeGeo, treeMat);
            foliage.position.y = 6 + Math.random() * 2;
            foliage.scale.set(0.8 + Math.random() * 0.5, 0.7 + Math.random() * 0.6, 0.8 + Math.random() * 0.5);
            foliage.castShadow = true;
            treeGroup.add(foliage);

            // Sometimes add a second cone for fuller trees
            if (Math.random() > 0.4) {
                const f2 = new THREE.Mesh(treeGeo, treeMat);
                f2.position.y = 8 + Math.random();
                f2.scale.set(0.6, 0.5, 0.6);
                f2.castShadow = true;
                treeGroup.add(f2);
            }

            treeGroup.position.copy(treePos);
            scene.add(treeGroup);
            sceneryObjects.push({ mesh: treeGroup, position: treePos.clone(), radius: 1.5 });
        }

        // Rocks
        for (let i = 0; i < CFG.rockCount; i++) {
            const t = Math.random();
            const idx = Math.floor(t * trackPoints.length) % trackPoints.length;
            const p = trackPoints[idx];
            const n = trackNormals[idx];

            const offset = (Math.random() > 0.5 ? 1 : -1) * (CFG.trackWidth / 2 + 3 + Math.random() * 25);
            const rockPos = new THREE.Vector3().copy(p).addScaledVector(n, offset);
            rockPos.y += 0.5;

            const rock = new THREE.Mesh(rockGeo, rockMat);
            rock.position.copy(rockPos);
            rock.scale.set(0.5 + Math.random(), 0.4 + Math.random() * 0.6, 0.5 + Math.random());
            rock.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0);
            rock.castShadow = true;
            scene.add(rock);
            sceneryObjects.push({ mesh: rock, position: rockPos.clone(), radius: 1 });
        }

        // Guard rails along track edges (every few segments)
        const railGeo = new THREE.CylinderGeometry(0.15, 0.15, 2, 4);
        const railMat = new THREE.MeshLambertMaterial({ color: 0xcccccc });
        for (let i = 0; i < trackPoints.length; i += 8) {
            for (let side = -1; side <= 1; side += 2) {
                if (Math.random() > 0.6) continue; // Not everywhere
                const p = trackPoints[i];
                const n = trackNormals[i];
                const railPos = new THREE.Vector3().copy(p).addScaledVector(n, side * (CFG.trackWidth / 2 + 1));
                railPos.y += 1;

                const post = new THREE.Mesh(railGeo, railMat);
                post.position.copy(railPos);
                scene.add(post);
            }
        }

        // Banners/flags at checkpoints
        for (let i = 0; i < CFG.checkpointCount; i++) {
            const cp = checkpoints[i];
            const idx = Math.floor(i * trackPoints.length / CFG.checkpointCount) % trackPoints.length;
            const n = trackNormals[idx];

            // Banner pole on each side
            for (let side = -1; side <= 1; side += 2) {
                const bannerPos = new THREE.Vector3().copy(cp.position).addScaledVector(n, side * (CFG.trackWidth / 2 + 0.5));
                bannerPos.y += 4;

                const poleGeo = new THREE.CylinderGeometry(0.1, 0.1, 8, 4);
                const poleMat = new THREE.MeshLambertMaterial({ color: i % 2 === 0 ? 0xff3333 : 0x3333ff });
                const pole = new THREE.Mesh(poleGeo, poleMat);
                pole.position.copy(bannerPos);
                scene.add(pole);

                // Flag
                const flagGeo = new THREE.PlaneGeometry(2, 1.5);
                const flagMat = new THREE.MeshBasicMaterial({ color: i % 2 === 0 ? 0xff3333 : 0x3333ff, side: THREE.DoubleSide });
                const flag = new THREE.Mesh(flagGeo, flagMat);
                flag.position.copy(bannerPos).add(new THREE.Vector3(1, -2, 0));
                scene.add(flag);
            }
        }

        // Distant mountains (simple cones)
        for (let i = 0; i < 20; i++) {
            const angle = (i / 20) * Math.PI * 2;
            const dist = 300 + Math.random() * 150;
            const mtnGeo = new THREE.ConeGeometry(30 + Math.random() * 40, 60 + Math.random() * 80, 5);
            const mtnMat = new THREE.MeshLambertMaterial({ color: 0x6B7B8D });
            const mountain = new THREE.Mesh(mtnGeo, mtnMat);
            mountain.position.set(Math.cos(angle) * dist, -10, Math.sin(angle) * dist);
            scene.add(mountain);
        }
    }

    // ==================== CAR MODEL ====================
    function buildCar() {
        carGroup = new THREE.Group();

        // Car body (main chassis)
        const bodyGeo = new THREE.BoxGeometry(2, 0.8, 4.5);
        const bodyMat = new THREE.MeshLambertMaterial({ color: 0xdd2200 });
        carBody = new THREE.Mesh(bodyGeo, bodyMat);
        carBody.position.y = 0.6;
        carBody.castShadow = true;
        carGroup.add(carBody);

        // Cabin/roof
        const cabinGeo = new THREE.BoxGeometry(1.7, 0.7, 2);
        const cabinMat = new THREE.MeshLambertMaterial({ color: 0x333344 });
        const cabin = new THREE.Mesh(cabinGeo, cabinMat);
        cabin.position.set(0, 1.25, -0.3);
        cabin.castShadow = true;
        carGroup.add(cabin);

        // Windshield (slanted)
        const windshieldGeo = new THREE.PlaneGeometry(1.6, 0.7);
        const windshieldMat = new THREE.MeshLambertMaterial({ color: 0x88bbff, transparent: true, opacity: 0.5 });
        const windshield = new THREE.Mesh(windshieldGeo, windshieldMat);
        windshield.position.set(0, 1.2, 0.7);
        windshield.rotation.x = -0.4;
        carGroup.add(windshield);

        // Spoiler
        const spoilerGeo = new THREE.BoxGeometry(2.2, 0.1, 0.5);
        const spoilerMat = new THREE.MeshLambertMaterial({ color: 0x222222 });
        const spoiler = new THREE.Mesh(spoilerGeo, spoilerMat);
        spoiler.position.set(0, 1.4, -2.1);
        carGroup.add(spoiler);

        // Spoiler supports
        for (let x = -0.7; x <= 0.7; x += 1.4) {
            const supportGeo = new THREE.BoxGeometry(0.1, 0.5, 0.1);
            const support = new THREE.Mesh(supportGeo, spoilerMat);
            support.position.set(x, 1.15, -2.1);
            carGroup.add(support);
        }

        // Headlights
        for (let x = -0.7; x <= 0.7; x += 1.4) {
            const hlGeo = new THREE.SphereGeometry(0.15, 6, 6);
            const hlMat = new THREE.MeshBasicMaterial({ color: 0xffffcc });
            const hl = new THREE.Mesh(hlGeo, hlMat);
            hl.position.set(x, 0.6, 2.3);
            carGroup.add(hl);
        }

        // Taillights
        for (let x = -0.7; x <= 0.7; x += 1.4) {
            const tlGeo = new THREE.BoxGeometry(0.3, 0.2, 0.05);
            const tlMat = new THREE.MeshBasicMaterial({ color: 0xff0000 });
            const tl = new THREE.Mesh(tlGeo, tlMat);
            tl.position.set(x, 0.6, -2.3);
            carGroup.add(tl);
        }

        // Wheels
        const wheelGeo = new THREE.CylinderGeometry(0.4, 0.4, 0.3, 12);
        const wheelMat = new THREE.MeshLambertMaterial({ color: 0x111111 });
        const wheelPositions = [
            { x: -1.1, y: 0.4, z: 1.5 },
            { x: 1.1, y: 0.4, z: 1.5 },
            { x: -1.1, y: 0.4, z: -1.5 },
            { x: 1.1, y: 0.4, z: -1.5 },
        ];

        wheelPositions.forEach(pos => {
            const wheel = new THREE.Mesh(wheelGeo, wheelMat);
            wheel.rotation.z = Math.PI / 2;
            wheel.position.set(pos.x, pos.y, pos.z);
            wheel.castShadow = true;
            carGroup.add(wheel);
            wheels.push(wheel);
        });

        // Number decal on side (simple plane)
        const numGeo = new THREE.PlaneGeometry(0.8, 0.6);
        const numCanvas = document.createElement('canvas');
        numCanvas.width = 128;
        numCanvas.height = 96;
        const ctx = numCanvas.getContext('2d');
        ctx.fillStyle = '#ffffff';
        ctx.fillRect(0, 0, 128, 96);
        ctx.fillStyle = '#dd2200';
        ctx.font = 'bold 72px Arial';
        ctx.textAlign = 'center';
        ctx.fillText('7', 64, 75);
        const numTex = new THREE.CanvasTexture(numCanvas);
        const numMat = new THREE.MeshBasicMaterial({ map: numTex });

        for (let side = -1; side <= 1; side += 2) {
            const numMesh = new THREE.Mesh(numGeo, numMat);
            numMesh.position.set(side * 1.01, 0.7, 0);
            numMesh.rotation.y = side > 0 ? Math.PI / 2 : -Math.PI / 2;
            carGroup.add(numMesh);
        }

        scene.add(carGroup);
    }

    // ==================== CAR PHYSICS ====================
    const car = {
        position: new THREE.Vector3(),
        velocity: new THREE.Vector3(),
        heading: 0,         // radians, direction car faces
        speed: 0,           // forward speed (positive = forward)
        lateralSpeed: 0,    // sideways drift component
        gear: 1,
        rpm: 0,
        onTrack: true,
        currentSurface: 'asphalt',
        drifting: false,
        collisionCooldown: 0,
    };

    const gears = [
        { min: 0, max: 25, ratio: 3.5 },
        { min: 20, max: 45, ratio: 2.8 },
        { min: 40, max: 60, ratio: 2.1 },
        { min: 55, max: 75, ratio: 1.6 },
        { min: 70, max: 90, ratio: 1.2 },
    ];

    function resetCar() {
        const startP = trackPoints[0];
        car.position.copy(startP);
        car.position.y += 1;
        car.velocity.set(0, 0, 0);
        car.heading = Math.atan2(
            trackCurve.getTangentAt(0).x,
            trackCurve.getTangentAt(0).z
        );
        car.speed = 0;
        car.lateralSpeed = 0;
        car.gear = 1;
        car.rpm = 0;
    }

    function getClosestTrackPoint(pos) {
        let minDist = Infinity;
        let closestIdx = 0;
        // Optimization: only check nearby points based on last known position
        const searchRange = Math.floor(trackPoints.length * 0.1);
        const startIdx = Math.max(0, state.currentCheckpoint - searchRange);
        const endIdx = Math.min(trackPoints.length, state.currentCheckpoint + searchRange);

        for (let i = startIdx; i < endIdx; i++) {
            const d = pos.distanceToSquared(trackPoints[i]);
            if (d < minDist) {
                minDist = d;
                closestIdx = i;
            }
        }
        // Also check wrap-around
        for (let i = 0; i < Math.min(searchRange, trackPoints.length); i++) {
            const d = pos.distanceToSquared(trackPoints[i]);
            if (d < minDist) {
                minDist = d;
                closestIdx = i;
            }
        }
        return { index: closestIdx, distance: Math.sqrt(minDist) };
    }

    function updateCarPhysics(dt) {
        dt = Math.min(dt, 0.033); // Cap delta time

        const accelInput = input('accel') ? 1 : (input('brake') || input('reverse') ? -1 : 0);
        const steerInput = (input('left') ? 1 : 0) - (input('right') ? 1 : 0);
        const handbrake = input('handbrake');

        // Determine surface and track proximity
        const closest = getClosestTrackPoint(car.position);
        car.onTrack = closest.distance < CFG.trackWidth / 2 + 2;
        car.currentSurface = car.onTrack ? trackSurfaces[closest.index] : 'grass';

        let maxSpeedMult = 1.0;
        let gripFactor = 1.0;

        switch (car.currentSurface) {
            case 'asphalt': maxSpeedMult = 1.0; gripFactor = 1.0; break;
            case 'dirt': maxSpeedMult = 0.75; gripFactor = 0.6; break;
            case 'gravel': maxSpeedMult = 0.65; gripFactor = 0.5; break;
            case 'mud': maxSpeedMult = CFG.mudSlowdown; gripFactor = 0.3; break;
            default: maxSpeedMult = CFG.offRoadSlowdown; gripFactor = 0.4; // grass/off-road
        }

        if (handbrake) {
            gripFactor *= 0.2;
            car.drifting = true;
        } else {
            car.drifting = false;
        }

        const maxSpeed = CFG.carMaxSpeed * maxSpeedMult;

        // Acceleration / braking
        if (accelInput > 0) {
            car.speed += CFG.carAccel * dt;
        } else if (accelInput < 0) {
            if (car.speed > 1) {
                car.speed -= CFG.carBrakeForce * dt;
            } else {
                car.speed -= CFG.carAccel * 0.4 * dt; // Reverse
            }
        } else {
            // Rolling resistance
            car.speed *= (1 - 1.5 * dt);
        }

        car.speed = Math.max(-CFG.carReverseMax, Math.min(maxSpeed, car.speed));

        // Steering
        const speedFactor = Math.abs(car.speed) / maxSpeed;
        const steerAmount = CFG.carSteerSpeed * (1 - speedFactor * 0.5) * dt;
        if (steerInput !== 0 && Math.abs(car.speed) > 0.5) {
            car.heading += steerInput * steerAmount * (car.speed > 0 ? 1 : -1);
        }

        // Lateral drift physics
        const forwardDir = new THREE.Vector3(Math.sin(car.heading), 0, Math.cos(car.heading));
        const rightDir = new THREE.Vector3(-Math.cos(car.heading), 0, Math.sin(car.heading));

        // Decompose velocity into forward and lateral components
        const velForward = car.velocity.dot(forwardDir);
        const velLateral = car.velocity.dot(rightDir);

        // Blend toward desired speed with grip factor
        const targetSpeed = handbrake ? car.speed * 0.95 : car.speed;
        car.speed += (targetSpeed - car.speed) * dt * 10;

        // Lateral velocity decays based on grip
        car.lateralSpeed *= Math.pow(CFG.driftFactor, dt * 60);
        if (!car.onTrack || handbrake) {
            car.lateralSpeed += velLateral * (1 - gripFactor) * dt * 5;
        }

        // Apply velocity
        const totalForward = car.speed + car.lateralSpeed * 0.3;
        car.velocity.copy(forwardDir).multiplyScalar(totalForward);
        car.velocity.add(rightDir.clone().multiplyScalar(car.lateralSpeed));

        // Gravity effect on hills (simple)
        if (car.onTrack && closest.index < trackPoints.length - 1) {
            const nextP = trackPoints[(closest.index + 1) % trackPoints.length];
            const currP = trackPoints[closest.index];
            const slopeY = nextP.y - currP.y;
            car.speed -= slopeY * dt * 5; // Gravity on slopes
        }

        // Update position
        car.position.addScaledVector(car.velocity, dt);

        // Keep car at appropriate height (track elevation + offset)
        if (car.onTrack) {
            const targetY = trackPoints[closest.index].y + 0.8;
            car.position.y += (targetY - car.position.y) * dt * 10;
        } else {
            // Gentle return to ground level when off-track
            car.position.y += (2 - car.position.y) * dt * 3;
        }

        // Gear calculation
        const absSpeed = Math.abs(car.speed);
        for (let i = gears.length - 1; i >= 0; i--) {
            if (absSpeed > gears[i].min || i === 0) {
                car.gear = i + 1;
                break;
            }
        }
        if (Math.abs(car.speed) < 2) car.gear = 0; // Neutral

        // RPM calculation
        const g = gears[car.gear - 1] || gears[0];
        const gearRange = g.max - g.min;
        const gearPos = Math.max(0, Math.min(1, (absSpeed - g.min) / gearRange));
        car.rpm = 2000 + gearPos * 6500;

        // Collision cooldown
        if (car.collisionCooldown > 0) car.collisionCooldown -= dt;

        // Check obstacle collisions
        checkObstacleCollisions();

        // Update checkpoint progress
        updateCheckpoints();

        // Spawn particles
        spawnParticles(dt);
    }

    function checkObstacleCollisions() {
        if (car.collisionCooldown > 0) return;

        for (const obj of sceneryObjects) {
            const dx = car.position.x - obj.position.x;
            const dz = car.position.z - obj.position.z;
            const dist = Math.sqrt(dx * dx + dz * dz);

            if (dist < obj.radius + 1.5) {
                // Collision!
                car.speed *= 0.3;
                car.collisionCooldown = 0.5;

                // Bounce away
                const pushDir = new THREE.Vector2(dx, dz).normalize();
                car.position.x += pushDir.x * 0.5;
                car.position.z += pushDir.y * 0.5;

                spawnSparks(car.position);
                playCrashSound();
                flashWarning();
                break;
            }
        }
    }

    function updateCheckpoints() {
        const cp = checkpoints[state.currentCheckpoint];
        if (!cp) return;

        const distToCP = car.position.distanceTo(cp.position);
        if (distToCP < CFG.trackWidth + 5 && !cp.passed) {
            cp.passed = true;
            state.currentCheckpoint++;

            // Check for lap completion
            if (state.currentCheckpoint >= checkpoints.length) {
                state.currentCheckpoint = 0;
                completeLap();
            }

            // Reset all checkpoint passed flags when starting new lap
            if (state.currentCheckpoint === 0 && state.lap > 1) {
                for (const c of checkpoints) c.passed = false;
            }
        }
    }

    function completeLap() {
        const lapTime = state.raceTime - state.lapStartTime;
        state.lapTimes.push(lapTime);

        if (lapTime < state.bestLap) {
            state.bestLap = lapTime;
        }

        state.lap++;
        state.lapStartTime = state.raceTime;

        // Reset checkpoints for new lap
        for (const c of checkpoints) c.passed = false;

        if (state.lap > CFG.totalLaps) {
            finishRace();
        }
    }

    function updateCarVisuals() {
        carGroup.position.copy(car.position);
        carGroup.rotation.y = -car.heading + Math.PI / 2; // Three.js rotation convention

        // Wheel spin animation
        const wheelSpin = car.speed * 0.15;
        wheels.forEach(w => {
            w.rotation.x += wheelSpin;
        });

        // Front wheel steering visual
        if (wheels.length >= 2) {
            const steerInput = (input('left') ? -1 : 0) + (input('right') ? 1 : 0);
            const steerAngle = steerInput * 0.3;
            wheels[0].rotation.y = steerAngle;
            wheels[1].rotation.y = steerAngle;
        }

        // Body lean during turns
        if (carBody) {
            const leanAmount = ((input('left') ? -1 : 0) + (input('right') ? 1 : 0)) * Math.abs(car.speed) / CFG.carMaxSpeed * 0.08;
            carBody.rotation.z = leanAmount;
        }
    }

    // ==================== CAMERA ====================
    let cameraMode = 'chase'; // chase | cinematic
    const camTargetPos = new THREE.Vector3();
    const camCurrentPos = new THREE.Vector3();

    function toggleCamera() {
        cameraMode = cameraMode === 'chase' ? 'cinematic' : 'chase';
    }

    function updateCamera(dt) {
        let dist, height;
        if (cameraMode === 'chase') {
            dist = CFG.cameraDistance;
            height = CFG.cameraHeight;
        } else {
            dist = 30;
            height = 20;
        }

        const forwardDir = new THREE.Vector3(Math.sin(car.heading), 0, Math.cos(car.heading));
        const desiredPos = car.position.clone()
            .sub(forwardDir.multiplyScalar(dist))
            .add(new THREE.Vector3(0, height, 0));

        // Smooth camera follow with lag
        camCurrentPos.lerp(desiredPos, CFG.cameraLag * dt * 60);
        camera.position.copy(camCurrentPos);

        // Look slightly ahead of car
        const lookTarget = car.position.clone()
            .add(forwardDir.multiplyScalar(10))
            .add(new THREE.Vector3(0, 2, 0));
        camera.lookAt(lookTarget);
    }

    // ==================== PARTICLES ====================
    let dustParticles = [];
    let sparkParticles = [];
    let skidMarkMeshes = [];

    function spawnParticles(dt) {
        const absSpeed = Math.abs(car.speed);

        // Dust when off-road or drifting
        if ((car.drifting || !car.onTrack) && absSpeed > 5) {
            for (let i = 0; i < 2; i++) {
                const dustPos = car.position.clone();
                dustPos.x += (Math.random() - 0.5) * 2;
                dustPos.z += (Math.random() - 0.5) * 2;
                dustPos.y += Math.random() * 0.5;

                const size = 0.3 + Math.random() * 0.5;
                const geo = new THREE.SphereGeometry(size, 4, 4);
                const mat = new THREE.MeshBasicMaterial({
                    color: car.onTrack ? 0xccaa77 : 0x88aa66,
                    transparent: true,
                    opacity: 0.6,
                });
                const dust = new THREE.Mesh(geo, mat);
                dust.position.copy(dustPos);
                scene.add(dust);

                dustParticles.push({
                    mesh: dust,
                    velocity: new THREE.Vector3((Math.random() - 0.5) * 2, Math.random() * 2 + 1, (Math.random() - 0.5) * 2),
                    life: 1.0,
                });

                if (!car.onTrack && absSpeed > 10) {
                    playGravelSound();
                }
            }
        }

        // Skid marks when drifting on asphalt
        if (car.drifting && car.onTrack && trackSurfaces[getClosestTrackPoint(car.position).index] === 'asphalt' && absSpeed > 15) {
            const skidGeo = new THREE.PlaneGeometry(0.3, 0.8);
            const skidMat = new THREE.MeshBasicMaterial({ color: 0x222222, transparent: true, opacity: 0.4 });

            for (let side = -1; side <= 1; side += 2) {
                const rightDir = new THREE.Vector3(-Math.cos(car.heading), 0, Math.sin(car.heading));
                const skidPos = car.position.clone()
                    .add(rightDir.multiplyScalar(side * 1))
                    .sub(new THREE.Vector3(Math.sin(car.heading), 0, Math.cos(car.heading)).multiplyScalar(1.5));
                skidPos.y += 0.05;

                const skid = new THREE.Mesh(skidGeo, skidMat);
                skid.position.copy(skidPos);
                skid.rotation.x = -Math.PI / 2;
                skid.rotation.z = car.heading;
                scene.add(skid);
                skidMarkMeshes.push({ mesh: skid, life: 5 });
            }
        }

        // Update dust particles
        for (let i = dustParticles.length - 1; i >= 0; i--) {
            const p = dustParticles[i];
            p.life -= dt * 1.5;
            p.mesh.position.addScaledVector(p.velocity, dt);
            p.mesh.material.opacity = Math.max(0, p.life * 0.6);
            p.mesh.scale.multiplyScalar(1 + dt * 2);

            if (p.life <= 0) {
                scene.remove(p.mesh);
                p.mesh.geometry.dispose();
                p.mesh.material.dispose();
                dustParticles.splice(i, 1);
            }
        }

        // Update spark particles
        for (let i = sparkParticles.length - 1; i >= 0; i--) {
            const p = sparkParticles[i];
            p.life -= dt * 3;
            p.mesh.position.addScaledVector(p.velocity, dt);
            p.mesh.material.opacity = Math.max(0, p.life);

            if (p.life <= 0) {
                scene.remove(p.mesh);
                p.mesh.geometry.dispose();
                p.mesh.material.dispose();
                sparkParticles.splice(i, 1);
            }
        }

        // Fade skid marks
        for (let i = skidMarkMeshes.length - 1; i >= 0; i--) {
            const s = skidMarkMeshes[i];
            s.life -= dt;
            if (s.life <= 0) {
                scene.remove(s.mesh);
                s.mesh.geometry.dispose();
                s.mesh.material.dispose();
                skidMarkMeshes.splice(i, 1);
            }
        }

        // Limit particle counts for performance
        while (dustParticles.length > 200) {
            const p = dustParticles.shift();
            scene.remove(p.mesh);
            p.mesh.geometry.dispose();
            p.mesh.material.dispose();
        }
    }

    function spawnSparks(pos) {
        for (let i = 0; i < 15; i++) {
            const geo = new THREE.SphereGeometry(0.1, 4, 4);
            const mat = new THREE.MeshBasicMaterial({ color: 0xffaa33, transparent: true, opacity: 1 });
            const spark = new THREE.Mesh(geo, mat);
            spark.position.copy(pos).add(new THREE.Vector3((Math.random() - 0.5) * 2, Math.random(), (Math.random() - 0.5) * 2));
            scene.add(spark);

            sparkParticles.push({
                mesh: spark,
                velocity: new THREE.Vector3(
                    (Math.random() - 0.5) * 10,
                    Math.random() * 8 + 2,
                    (Math.random() - 0.5) * 10
                ),
                life: 1.0,
            });
        }
    }

    // ==================== AUDIO SYSTEM ====================
    let audioCtx = null;
    let engineOsc = null;
    let engineGain = null;
    let gravelNode = null;
    let gravelGain = null;
    let gravelPlaying = false;

    function initAudio() {
        try {
            audioCtx = new (window.AudioContext || window.webkitAudioContext)();

            // Engine sound: oscillator with gain
            engineOsc = audioCtx.createOscillator();
            engineGain = audioCtx.createGain();
            const engineFilter = audioCtx.createBiquadFilter();
            engineFilter.type = 'lowpass';
            engineFilter.frequency.value = 800;

            engineOsc.type = 'sawtooth';
            engineOsc.frequency.value = 60;
            engineGain.gain.value = 0.05;

            engineOsc.connect(engineFilter);
            engineFilter.connect(engineGain);
            engineGain.connect(audioCtx.destination);
            engineOsc.start();

            // Gravel noise buffer
            const bufferSize = audioCtx.sampleRate * 2;
            const gravelBuffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
            const data = gravelBuffer.getChannelData(0);
            for (let i = 0; i < bufferSize; i++) {
                data[i] = (Math.random() * 2 - 1) * 0.3;
            }

            gravelNode = audioCtx.createBufferSource();
            gravelNode.buffer = gravelBuffer;
            gravelNode.loop = true;
            gravelGain = audioCtx.createGain();
            gravelGain.gain.value = 0;

            const gravelFilter = audioCtx.createBiquadFilter();
            gravelFilter.type = 'bandpass';
            gravelFilter.frequency.value = 2000;
            gravelFilter.Q.value = 1;

            gravelNode.connect(gravelFilter);
            gravelFilter.connect(gravelGain);
            gravelGain.connect(audioCtx.destination);
            gravelNode.start();
        } catch (e) {
            console.warn('Audio init failed:', e);
        }
    }

    function updateEngineSound() {
        if (!engineOsc || !audioCtx) return;
        const rpm = car.rpm / 1000;
        engineOsc.frequency.value = 40 + rpm * 8;
        engineGain.gain.value = Math.min(0.12, 0.02 + (Math.abs(car.speed) / CFG.carMaxSpeed) * 0.1);

        // Gravel sound
        if ((car.drifting || !car.onTrack) && Math.abs(car.speed) > 5) {
            gravelGain.gain.value = Math.min(0.08, gravelGain.gain.value + 0.02);
        } else {
            gravelGain.gain.value = Math.max(0, gravelGain.gain.value - 0.03);
        }
    }

    function playBeep(freq, duration) {
        if (!audioCtx) return;
        const osc = audioCtx.createOscillator();
        const gain = audioCtx.createGain();
        osc.type = 'sine';
        osc.frequency.value = freq;
        gain.gain.value = 0.3;
        gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration);
        osc.connect(gain);
        gain.connect(audioCtx.destination);
        osc.start();
        osc.stop(audioCtx.currentTime + duration);
    }

    function playCrashSound() {
        if (!audioCtx) return;
        const bufferSize = audioCtx.sampleRate * 0.3;
        const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
        const data = buffer.getChannelData(0);
        for (let i = 0; i < bufferSize; i++) {
            data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (audioCtx.sampleRate * 0.05));
        }
        const source = audioCtx.createBufferSource();
        source.buffer = buffer;
        const gain = audioCtx.createGain();
        gain.gain.value = 0.3;
        source.connect(gain);
        gain.connect(audioCtx.destination);
        source.start();
    }

    function playGravelSound() {
        // Handled by continuous gravel noise in updateEngineSound
    }

    function playFinishTone() {
        if (!audioCtx) return;
        const notes = [523, 659, 784, 1047];
        notes.forEach((freq, i) => {
            setTimeout(() => playBeep(freq, 0.3), i * 200);
        });
    }

    // ==================== HUD / UI ====================
    function updateHUD() {
        const speedKmh = Math.abs(Math.round(car.speed * 3.6));
        document.getElementById('speed-value').textContent = speedKmh;
        document.getElementById('lap-count').textContent = `LAP ${Math.min(state.lap, CFG.totalLaps)} / ${CFG.totalLaps}`;

        const timeStr = formatTime(state.raceTime);
        document.getElementById('current-time').textContent = timeStr;

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

        // Gear display
        const gearText = car.gear === 0 ? 'N' : car.gear.toString();
        document.getElementById('gear-display').textContent = gearText;

        // RPM bar
        const rpmPercent = Math.min(100, ((car.rpm - 2000) / 6500) * 100);
        document.getElementById('rpm-bar').style.width = rpmPercent + '%';

        // Surface indicator
        const surfaceNames = { asphalt: 'ASPHALT', dirt: 'DIRT', gravel: 'GRAVEL', mud: 'MUD', grass: 'OFF-ROAD' };
        document.getElementById('surface-indicator').textContent = surfaceNames[car.currentSurface] || car.currentSurface.toUpperCase();

        // Minimap
        drawMinimap();
    }

    function formatTime(seconds) {
        const mins = Math.floor(seconds / 60);
        const secs = seconds % 60;
        return `${mins.toString().padStart(2, '0')}:${secs.toFixed(3).padStart(6, '0')}`;
    }

    function drawMinimap() {
        const canvas = document.getElementById('minimap');
        const ctx = canvas.getContext('2d');
        const w = canvas.width;
        const h = canvas.height;

        ctx.clearRect(0, 0, w, h);

        // Find track bounds
        let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
        for (const p of trackPoints) {
            if (p.x < minX) minX = p.x;
            if (p.x > maxX) maxX = p.x;
            if (p.z < minZ) minZ = p.z;
            if (p.z > maxZ) maxZ = p.z;
        }

        const padding = 20;
        const scaleX = (w - padding * 2) / (maxX - minX || 1);
        const scaleZ = (h - padding * 2) / (maxZ - minZ || 1);
        const scale = Math.min(scaleX, scaleZ);

        const offsetX = (w - (maxX - minX) * scale) / 2;
        const offsetZ = (h - (maxZ - minZ) * scale) / 2;

        function mapPoint(p) {
            return {
                x: (p.x - minX) * scale + offsetX,
                y: (p.z - minZ) * scale + offsetZ,
            };
        }

        // Draw track outline
        ctx.strokeStyle = '#555';
        ctx.lineWidth = 4;
        ctx.beginPath();
        for (let i = 0; i < trackPoints.length; i += 3) {
            const mp = mapPoint(trackPoints[i]);
            if (i === 0) ctx.moveTo(mp.x, mp.y);
            else ctx.lineTo(mp.x, mp.y);
        }
        ctx.closePath();
        ctx.stroke();

        // Draw checkpoints
        for (let i = 0; i < checkpoints.length; i++) {
            const cp = checkpoints[i];
            const mp = mapPoint(cp.position);
            ctx.fillStyle = cp.passed ? '#44ff44' : '#ffaa00';
            ctx.beginPath();
            ctx.arc(mp.x, mp.y, 2, 0, Math.PI * 2);
            ctx.fill();
        }

        // Draw car position with heading indicator
        const carMP = mapPoint(car.position);
        ctx.fillStyle = '#ff3300';
        ctx.beginPath();
        ctx.arc(carMP.x, carMP.y, 4, 0, Math.PI * 2);
        ctx.fill();

        // Heading arrow
        const arrowLen = 8;
        ctx.strokeStyle = '#ff3300';
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(carMP.x, carMP.y);
        ctx.lineTo(
            carMP.x + Math.sin(-car.heading) * arrowLen,
            carMP.y + Math.cos(-car.heading) * arrowLen
        );
        ctx.stroke();
    }

    // ==================== GAME FLOW ====================
    function startRace() {
        document.getElementById('start-screen').style.display = 'none';
        state.phase = 'countdown';
        state.lap = 1;
        state.currentCheckpoint = 0;
        state.raceTime = 0;
        state.bestLap = Infinity;
        state.lapTimes = [];

        for (const c of checkpoints) c.passed = false;

        resetCar();
        initAudio();

        // Countdown sequence
        doCountdown(3);
    }

    function doCountdown(n) {
        const el = document.getElementById('countdown-text');
        if (n > 0) {
            el.textContent = n.toString();
            el.style.opacity = '1';
            playBeep(440, 0.2);
            setTimeout(() => { el.style.opacity = '0'; }, 800);
            setTimeout(() => doCountdown(n - 1), 1000);
        } else {
            el.textContent = 'GO!';
            el.style.color = '#44ff44';
            el.style.opacity = '1';
            playBeep(880, 0.3);
            setTimeout(() => {
                el.style.opacity = '0';
                el.style.color = '#fff';
                state.phase = 'racing';
                state.lapStartTime = 0;
            }, 600);
        }
    }

    function pause() {
        if (state.phase !== 'racing') return;
        state.phase = 'paused';
        document.getElementById('pause-menu').classList.add('active');
    }

    function resume() {
        if (state.phase !== 'paused') return;
        state.phase = 'racing';
        document.getElementById('pause-menu').classList.remove('active');
    }

    function restart() {
        // Clean up particles and skid marks
        dustParticles.forEach(p => { scene.remove(p.mesh); p.mesh.geometry.dispose(); p.mesh.material.dispose(); });
        sparkParticles.forEach(p => { scene.remove(p.mesh); p.mesh.geometry.dispose(); p.mesh.material.dispose(); });
        skidMarkMeshes.forEach(s => { scene.remove(s.mesh); s.mesh.geometry.dispose(); s.mesh.material.dispose(); });
        dustParticles = [];
        sparkParticles = [];
        skidMarkMeshes = [];

        document.getElementById('pause-menu').classList.remove('active');
        document.getElementById('finish-screen').classList.remove('active');
        startRace();
    }

    function finishRace() {
        state.phase = 'finished';
        playFinishTone();

        document.getElementById('final-time').textContent = `Total Time: ${formatTime(state.raceTime)}`;
        document.getElementById('final-best-lap').textContent = `Best Lap: ${state.bestLap < Infinity ? formatTime(state.bestLap) : 'N/A'}`;
        document.getElementById('finish-screen').classList.add('active');
    }

    function flashWarning() {
        const el = document.getElementById('warning-flash');
        el.style.opacity = '1';
        setTimeout(() => { el.style.opacity = '0'; }, 200);
    }

    // ==================== MAIN LOOP ====================
    let lastTime = 0;

    function gameLoop() {
        requestAnimationFrame(gameLoop);

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

        if (state.phase === 'racing') {
            state.raceTime += dt;
            updateCarPhysics(dt);
        } else if (state.phase === 'countdown' || state.phase === 'paused' || state.phase === 'finished') {
            // Still render but don't update physics
        }

        updateCarVisuals();
        updateCamera(dt);
        updateEngineSound();
        updateHUD();

        renderer.render(scene, camera);
    }

    // ==================== INITIALIZATION ====================
    function init() {
        setupTouch();
        initThree();
        generateTrack();
        buildTrackMesh();
        buildGround();
        buildScenery();
        buildCar();
        resetCar();

        camCurrentPos.copy(car.position).add(new THREE.Vector3(0, CFG.cameraHeight, CFG.cameraDistance));

        gameLoop();
    }

    // Public API
    return {
        init,
        startRace,
        pause,
        resume,
        restart,
    };

})();

// Start the game when page loads
window.addEventListener('load', () => RallyGame.init());
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"full","sessionId":"1ee324d0-cac9-482f-9d0a-04c1e217340c","tokensIn":591335,"tokensOut":21359,"tokensTotal":612694,"turns":17,"toolCalls":16,"failedToolCalls":0,"timestamp":"2026-07-18T21:43:16.102Z"} -->