← Rally results

Rally

thinkingcap qwen3.6 27b · middle

Initial view of Rally

Browser check: Artifact runtime error. 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 64,061 bytes · SHA-256 75509c30562b
<!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 { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: 'Segoe UI', Arial, sans-serif; }
canvas { display: block; width: 100%; height: 100%; }

/* 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-val { font-size: 48px; font-weight: bold; text-shadow: 0 0 10px rgba(0,0,0,0.8); }
#speed-unit { font-size: 16px; opacity: 0.7; }
#gear-display { font-size: 24px; color: #ffcc00; margin-top: 4px; }
#rpm-bar-container { width: 150px; height: 8px; background: rgba(0,0,0,0.5); border-radius: 4px; margin-top: 4px; overflow: hidden; }
#rpm-bar { height: 100%; width: 0%; background: linear-gradient(to right, #0f0, #ff0, #f00); transition: width 0.05s; }

/* Lap info */
#lap-info { position: absolute; top: 20px; left: 20px; color: #fff; text-shadow: 0 0 8px rgba(0,0,0,0.9); }
#lap-info div { margin-bottom: 4px; font-size: 16px; }
#lap-count { font-size: 28px; font-weight: bold; color: #ffcc00; }

/* Timer */
#timer-display { position: absolute; top: 20px; right: 20px; color: #fff; text-align: right; text-shadow: 0 0 8px rgba(0,0,0,0.9); font-size: 16px; }
#current-time { font-size: 32px; font-weight: bold; }

/* Minimap */
#minimap-container { position: absolute; bottom: 20px; left: 20px; width: 180px; height: 180px; background: rgba(0,0,0,0.6); border-radius: 50%; overflow: hidden; 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: 100; background: rgba(0,0,0,0.5); }
#countdown-text { font-size: 120px; font-weight: bold; color: #fff; text-shadow: 0 0 30px rgba(255,200,0,0.8); animation: pulse 0.5s ease-in-out infinite alternate; }
@keyframes pulse { from { transform: scale(1); } to { transform: scale(1.1); } }

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

/* Finish screen */
#finish-screen { position: fixed; top: 0; left: 0; width: 100%; height: 100%; display: none; align-items: center; justify-content: center; z-index: 200; background: rgba(0,0,0,0.8); }
#finish-screen.active { display: flex; }
.finish-box { text-align: center; color: #fff; padding: 40px; }
.finish-box h1 { font-size: 56px; color: #ffcc00; margin-bottom: 20px; }
.finish-stat { font-size: 24px; margin: 8px 0; }

/* Touch controls */
#touch-controls { position: fixed; bottom: 10px; left: 0; width: 100%; display: none; z-index: 50; pointer-events: all; }
.touch-row { display: flex; justify-content: space-between; padding: 0 20px; margin-bottom: 8px; }
.touch-btn { width: 70px; height: 70px; border-radius: 50%; background: rgba(255,255,255,0.2); border: 2px solid rgba(255,255,255,0.4); color: #fff; font-size: 28px; display: flex; align-items: center; justify-content: center; user-select: none; -webkit-user-select: none; touch-action: manipulation; }
.touch-btn:active { background: rgba(255,255,255,0.5); }
.touch-btn.gas { width: 90px; height: 90px; background: rgba(0,200,0,0.3); border-color: rgba(0,200,0,0.6); }
.touch-btn.brake { width: 70px; height: 70px; background: rgba(200,0,0,0.3); border-color: rgba(200,0,0,0.6); }
.touch-btn.handbrake { width: 60px; height: 60px; background: rgba(255,165,0,0.3); border-color: rgba(255,165,0,0.6); font-size: 14px; }

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

/* Controls hint */
#controls-hint { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #fff; text-align: center; font-size: 14px; opacity: 0.6; pointer-events: none; }
</style>
</head>
<body>

<div id="hud">
  <div id="lap-info">
    <div id="lap-count">LAP 1 / 3</div>
    <div id="best-lap">Best Lap: --:--.---</div>
  </div>
  <div id="timer-display">
    <div id="current-time">00:00.000</div>
  </div>
  <div id="speedometer">
    <div id="speed-val">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>
  <canvas id="minimap" width="180" height="180"></canvas>
  <div id="controls-hint">Arrow Keys / WASD to drive · Space handbrake · P pause · C camera</div>
</div>

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

<div id="pause-menu">
  <div class="pause-box">
    <h1>PAUSED</h1>
    <button class="pause-btn" onclick="RallyGame.resume()">Resume</button><br>
    <button class="pause-btn" onclick="RallyGame.restart()">Restart</button>
  </div>
</div>

<div id="finish-screen">
  <div class="finish-box">
    <h1>RACE COMPLETE!</h1>
    <div class="finish-stat" id="final-time"></div>
    <div class="finish-stat" id="final-best-lap"></div>
    <br><button class="pause-btn" onclick="RallyGame.restart()">Race Again</button>
  </div>
</div>

<div id="touch-controls">
  <div class="touch-row">
    <div style="display:flex;gap:8px;">
      <div class="touch-btn" data-key="left">◀</div>
      <div class="touch-btn" data-key="right">▶</div>
    </div>
    <div style="display:flex;gap:8px;">
      <div class="touch-btn handbrake" data-key="handbrake">HB</div>
      <div class="touch-btn brake" data-key="down">▼</div>
      <div class="touch-btn gas" data-key="up">▲</div>
    </div>
  </div>
</div>

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

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

    // ==================== CONFIGURATION ====================
    const CFG = {
        totalLaps: 3,
        trackWidth: 14,
        trackSegments: 200,
        carLength: 4.5,
        carWidth: 2.0,
        maxSpeed: 85,          // m/s (~306 km/h) on asphalt
        acceleration: 35,      // m/s²
        brakingForce: 50,
        reverseMaxSpeed: -15,
        steeringSpeed: 2.8,    // rad/s at low speed
        highSpeedSteering: 1.2,
        driftFactor: 0.92,     // lower = more drift
        offRoadSlowdown: 0.4,
        mudSlowdown: 0.25,
        gravity: 20,
        jumpForce: 15,
        cameraLag: 0.06,       // higher = more lag
        fogNear: 80,
        fogFar: 350,
        treeCount: 400,
        rockCount: 80,
        signCount: 20,
    };

    // ==================== GAME STATE ====================
    let state = {
        phase: 'countdown',   // countdown | racing | paused | finished
        speed: 0,
        heading: 0,           // car direction in radians
        lateralVel: 0,
        position: new THREE.Vector3(),
        velocity: new THREE.Vector3(),
        angularVel: 0,
        gear: 1,
        rpm: 0,
        lap: 1,
        currentLapTime: 0,
        totalTime: 0,
        bestLap: Infinity,
        checkpointsPassed: [],
        lastCheckpointIdx: -1,
        surfaceType: 'asphalt',
        isDrifting: false,
        handbrake: false,
        countdownValue: 3,
        countdownTimer: 0,
    };

    // ==================== THREE.JS GLOBALS ====================
    let scene, camera, renderer, clock;
    let carGroup, carBody, wheels = [];
    let trackCurve, trackPoints = [], trackNormals = [];
    let obstacles = [], sceneryObjects = [];
    let particles = { dust: null, sparks: null, skidMarks: [] };
    let audioCtx, engineOsc, engineGain;

    // ==================== INPUT ====================
    const keys = {};
    const touchState = {};

    // ==================== TRACK CONTROL POINTS ====================
    function generateTrackPoints() {
        const pts = [];
        const numPts = 24;
        for (let i = 0; i < numPts; i++) {
            const angle = (i / numPts) * Math.PI * 2;
            // Varying radius for interesting track shape
            const r = 180 + Math.sin(angle * 3) * 60 + Math.cos(angle * 2) * 40 + Math.sin(angle * 5) * 20;
            const x = Math.cos(angle) * r;
            const z = Math.sin(angle) * r;
            // Elevation changes
            const y = Math.sin(angle * 2) * 12 + Math.cos(angle * 3) * 8 + Math.sin(angle * 5) * 4;
            pts.push(new THREE.Vector3(x, y, z));
        }
        return pts;
    }

    // ==================== INITIALIZATION ====================
    function init() {
        // Scene setup
        scene = new THREE.Scene();
        scene.background = new THREE.Color(0x87CEEB);
        scene.fog = new THREE.Fog(0x9DC4E0, CFG.fogNear, CFG.fogFar);

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

        // Renderer
        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.insertBefore(renderer.domElement, document.body.firstChild);

        clock = new THREE.Clock();

        // Lighting
        setupLighting();

        // Ground plane
        createGround();

        // Track
        const controlPts = generateTrackPoints();
        trackCurve = new THREE.CatmullRomCurve3(controlPts, true, 'catmullrom', 0.5);
        buildTrack(trackCurve);

        // Car
        createCar();

        // Scenery
        createScenery();

        // Particles
        setupParticles();

        // Audio
        initAudio();

        // Input
        setupInput();

        // Position car at start
        resetCarPosition();

        // Start countdown
        startCountdown();

        // Resize handler
        window.addEventListener('resize', onResize);

        // Game loop
        animate();
    }

    function setupLighting() {
        const ambient = new THREE.AmbientLight(0x6688aa, 0.6);
        scene.add(ambient);

        const hemi = new THREE.HemisphereLight(0x87CEEB, 0x445522, 0.4);
        scene.add(hemi);

        const sun = new THREE.DirectionalLight(0xffeedd, 1.0);
        sun.position.set(100, 150, 80);
        sun.castShadow = true;
        sun.shadow.mapSize.width = 2048;
        sun.shadow.mapSize.height = 2048;
        sun.shadow.camera.near = 1;
        sun.shadow.camera.far = 400;
        sun.shadow.camera.left = -150;
        sun.shadow.camera.right = 150;
        sun.shadow.camera.top = 150;
        sun.shadow.camera.bottom = -150;
        scene.add(sun);
    }

    function createGround() {
        const groundGeo = new THREE.PlaneGeometry(800, 800, 20, 20);
        // Add some vertex displacement for terrain feel
        const posAttr = groundGeo.attributes.position;
        for (let i = 0; i < posAttr.count; i++) {
            const x = posAttr.getX(i);
            const y = posAttr.getY(i);
            const dist = Math.sqrt(x*x + y*y);
            if (dist > 250) {
                posAttr.setZ(i, (Math.random() - 0.5) * 3 + Math.sin(x*0.02)*2 + Math.cos(y*0.02)*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 = -0.5;
        ground.receiveShadow = true;
        scene.add(ground);

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

        // Sun sphere
        const sunGeo = new THREE.SphereGeometry(15, 8, 8);
        const sunMat = new THREE.MeshBasicMaterial({ color: 0xffffcc });
        const sunMesh = new THREE.Mesh(sunGeo, sunMat);
        sunMesh.position.set(200, 200, -100);
        scene.add(sunMesh);

        // Clouds
        for (let i = 0; i < 30; i++) {
            const cloudGroup = new THREE.Group();
            const numPuffs = 3 + Math.floor(Math.random() * 4);
            for (let j = 0; j < numPuffs; j++) {
                const puffGeo = new THREE.SphereGeometry(5 + Math.random()*8, 6, 4);
                const puffMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.7 });
                const puff = new THREE.Mesh(puffGeo, puffMat);
                puff.position.set(j*6 - numPuffs*3, Math.random()*2, Math.random()*4);
                cloudGroup.add(puff);
            }
            cloudGroup.position.set(
                (Math.random()-0.5)*700,
                80 + Math.random()*40,
                (Math.random()-0.5)*700
            );
            scene.add(cloudGroup);
        }
    }

    function buildTrack(curve) {
        const segs = CFG.trackSegments;
        trackPoints = [];
        for (let i = 0; i <= segs; i++) {
            const t = i / segs;
            trackPoints.push(curve.getPointAt(t));
        }

        // Build road mesh using extruded shape along curve
        const roadGeo = new THREE.BufferGeometry();
        const vertices = [];
        const uvs = [];
        const indices = [];
        const colors = [];

        for (let i = 0; i <= segs; i++) {
            const t = i / segs;
            const point = curve.getPointAt(t);
            const tangent = curve.getTangentAt(t).normalize();
            const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();

            const left = point.clone().add(normal.clone().multiplyScalar(CFG.trackWidth * 0.5));
            const right = point.clone().add(normal.clone().multiplyScalar(-CFG.trackWidth * 0.5));

            // Road surface
            vertices.push(left.x, point.y + 0.1, left.z);
            vertices.push(right.x, point.y + 0.1, right.z);

            // Shoulders (wider)
            const shoulderW = CFG.trackWidth * 0.7;
            const lShoulder = point.clone().add(normal.clone().multiplyScalar(shoulderW));
            const rShoulder = point.clone().add(normal.clone().multiplyScalar(-shoulderW));
            vertices.push(lShoulder.x, point.y + 0.05, lShoulder.z);
            vertices.push(rShoulder.x, point.y + 0.05, rShoulder.z);

            uvs.push(0, t * 20);
            uvs.push(1, t * 20);
            uvs.push(0, t * 20);
            uvs.push(1, t * 20);

            if (i < segs) {
                const base = i * 4;
                // Road surface quad
                indices.push(base, base+1, base+2);
                indices.push(base+1, base+3, base+2);
                // Left shoulder
                indices.push(base+2, base+1, base+5);
                indices.push(base+2, base+5, base+6);
                // Right shoulder
                indices.push(base+3, base+4, base+7);
                indices.push(base+3, base+7, base+1);
            }

            // Color: road is gray, shoulders are brownish dirt
            for (let c = 0; c < 2; c++) colors.push(0.35, 0.35, 0.38);
            for (let c = 0; c < 2; c++) colors.push(0.45, 0.35, 0.25);
        }

        roadGeo.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
        roadGeo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
        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);

        // Track edge markers (cones/posts)
        for (let i = 0; i < segs; i += 4) {
            const t = i / segs;
            const point = curve.getPointAt(t);
            const tangent = curve.getTangentAt(t).normalize();
            const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();

            // Left post
            createPost(point.clone().add(normal.clone().multiplyScalar(CFG.trackWidth * 0.52)), i % 8 === 0 ? 0xff0000 : 0xffffff);
            // Right post
            createPost(point.clone().add(normal.clone().multiplyScalar(-CFG.trackWidth * 0.52)), i % 8 === 0 ? 0xff0000 : 0xffffff);
        }

        // Start/finish line
        const startPt = curve.getPointAt(0);
        const startTan = curve.getTangentAt(0).normalize();
        const startNorm = new THREE.Vector3(-startTan.z, 0, startTan.x).normalize();
        const lineGeo = new THREE.PlaneGeometry(CFG.trackWidth * 1.2, 2);
        // Checkerboard pattern via canvas texture
        const checkerCanvas = document.createElement('canvas');
        checkerCanvas.width = 64; checkerCanvas.height = 8;
        const ctx = checkerCanvas.getContext('2d');
        for (let x = 0; x < 16; x++) {
            for (let y = 0; y < 2; y++) {
                ctx.fillStyle = (x + y) % 2 === 0 ? '#fff' : '#000';
                ctx.fillRect(x * 4, y * 4, 4, 4);
            }
        }
        const checkerTex = new THREE.CanvasTexture(checkerCanvas);
        const lineMat = new THREE.MeshBasicMaterial({ map: checkerTex });
        const startLine = new THREE.Mesh(lineGeo, lineMat);
        startLine.position.copy(startPt).add(new THREE.Vector3(0, 0.2, 0));
        startLine.lookAt(startPt.clone().add(startTan)).rotateX(-Math.PI/2);
        scene.add(startLine);

        // Start arch
        createStartArch(startPt, startNorm, startTan);
    }

    function createPost(position, color) {
        const geo = new THREE.CylinderGeometry(0.15, 0.15, 1.2, 4);
        const mat = new THREE.MeshLambertMaterial({ color: color });
        const post = new THREE.Mesh(geo, mat);
        post.position.copy(position);
        post.position.y += 0.6;
        scene.add(post);

        // Top sphere
        const topGeo = new THREE.SphereGeometry(0.2, 4, 4);
        const topMat = new THREE.MeshLambertMaterial({ color: color });
        const top = new THREE.Mesh(topGeo, topMat);
        top.position.copy(position);
        top.position.y += 1.3;
        scene.add(top);
    }

    function createStartArch(pos, norm, tan) {
        const archGroup = new THREE.Group();
        // Two pillars
        for (let side of [-1, 1]) {
            const pillarGeo = new THREE.BoxGeometry(0.5, 6, 0.5);
            const pillarMat = new THREE.MeshLambertMaterial({ color: 0xffffff });
            const pillar = new THREE.Mesh(pillarGeo, pillarMat);
            pillar.position.copy(pos).add(norm.clone().multiplyScalar(side * CFG.trackWidth * 0.5));
            pillar.position.y += 3;
            archGroup.add(pillar);
        }
        // Banner
        const bannerGeo = new THREE.BoxGeometry(CFG.trackWidth, 1.5, 0.2);
        const bannerCanvas = document.createElement('canvas');
        bannerCanvas.width = 256; bannerCanvas.height = 48;
        const bctx = bannerCanvas.getContext('2d');
        bctx.fillStyle = '#cc0000';
        bctx.fillRect(0, 0, 256, 48);
        bctx.fillStyle = '#fff';
        bctx.font = 'bold 32px Arial';
        bctx.textAlign = 'center';
        bctx.fillText('START / FINISH', 128, 36);
        const bannerTex = new THREE.CanvasTexture(bannerCanvas);
        const bannerMat = new THREE.MeshBasicMaterial({ map: bannerTex });
        const banner = new THREE.Mesh(bannerGeo, bannerMat);
        banner.position.copy(pos).add(new THREE.Vector3(0, 5.5, 0));
        archGroup.add(banner);

        scene.add(archGroup);
    }

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

        // Main body
        const bodyGeo = new THREE.BoxGeometry(CFG.carWidth, 0.8, CFG.carLength);
        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 (windshield area)
        const cabinGeo = new THREE.BoxGeometry(CFG.carWidth * 0.85, 0.6, CFG.carLength * 0.45);
        const cabinMat = new THREE.MeshLambertMaterial({ color: 0x333344 });
        const cabin = new THREE.Mesh(cabinGeo, cabinMat);
        cabin.position.set(0, 1.2, -0.2);
        carGroup.add(cabin);

        // Hood scoop
        const hoodGeo = new THREE.BoxGeometry(CFG.carWidth * 0.4, 0.3, 0.8);
        const hoodMat = new THREE.MeshLambertMaterial({ color: 0xbb1100 });
        const hood = new THREE.Mesh(hoodGeo, hoodMat);
        hood.position.set(0, 1.15, 1.2);
        carGroup.add(hood);

        // Spoiler
        const spoilerArmGeo = new THREE.BoxGeometry(0.1, 0.4, 0.1);
        const spoilerMat = new THREE.MeshLambertMaterial({ color: 0x222222 });
        for (let side of [-1, 1]) {
            const arm = new THREE.Mesh(spoilerArmGeo, spoilerMat);
            arm.position.set(side * CFG.carWidth * 0.35, 1.4, -CFG.carLength * 0.4);
            carGroup.add(arm);
        }
        const spoilerWingGeo = new THREE.BoxGeometry(CFG.carWidth * 1.1, 0.08, 0.5);
        const spoilerWing = new THREE.Mesh(spoilerWingGeo, spoilerMat);
        spoilerWing.position.set(0, 1.6, -CFG.carLength * 0.4);
        carGroup.add(spoilerWing);

        // Headlights
        for (let side of [-1, 1]) {
            const hlGeo = new THREE.BoxGeometry(0.35, 0.2, 0.1);
            const hlMat = new THREE.MeshBasicMaterial({ color: 0xffffcc });
            const hl = new THREE.Mesh(hlGeo, hlMat);
            hl.position.set(side * CFG.carWidth * 0.3, 0.65, CFG.carLength * 0.5);
            carGroup.add(hl);

            // Headlight beam (spot light)
            const spotLight = new THREE.SpotLight(0xffffcc, 0.5, 40, Math.PI/6, 0.5);
            spotLight.position.set(side * CFG.carWidth * 0.3, 0.65, CFG.carLength * 0.52);
            spotLight.target.position.set(side * CFG.carWidth * 0.3, 0, CFG.carLength * 2);
            carGroup.add(spotLight);
            carGroup.add(spotLight.target);
        }

        // Taillights
        for (let side of [-1, 1]) {
            const tlGeo = new THREE.BoxGeometry(0.3, 0.15, 0.08);
            const tlMat = new THREE.MeshBasicMaterial({ color: 0xff0000 });
            const tl = new THREE.Mesh(tlGeo, tlMat);
            tl.position.set(side * CFG.carWidth * 0.35, 0.65, -CFG.carLength * 0.5);
            carGroup.add(tl);
        }

        // Wheels
        const wheelGeo = new THREE.CylinderGeometry(0.35, 0.35, 0.25, 8);
        const wheelMat = new THREE.MeshLambertMaterial({ color: 0x111111 });
        const wheelPositions = [
            { x: -CFG.carWidth * 0.55, y: 0.35, z: CFG.carLength * 0.3 },
            { x: CFG.carWidth * 0.55, y: 0.35, z: CFG.carLength * 0.3 },
            { x: -CFG.carWidth * 0.55, y: 0.35, z: -CFG.carLength * 0.3 },
            { x: CFG.carWidth * 0.55, y: 0.35, z: -CFG.carLength * 0.3 },
        ];
        wheelPositions.forEach((wp) => {
            const wheel = new THREE.Mesh(wheelGeo, wheelMat);
            wheel.rotation.z = Math.PI / 2;
            wheel.position.set(wp.x, wp.y, wp.z);
            wheel.castShadow = true;
            carGroup.add(wheel);
            wheels.push(wheel);

            // Hubcap
            const hubGeo = new THREE.CylinderGeometry(0.15, 0.15, 0.26, 6);
            const hubMat = new THREE.MeshLambertMaterial({ color: 0xcccccc });
            const hub = new THREE.Mesh(hubGeo, hubMat);
            hub.rotation.z = Math.PI / 2;
            hub.position.set(wp.x, wp.y, wp.z);
            carGroup.add(hub);
        });

        // Number on side
        const numCanvas = document.createElement('canvas');
        numCanvas.width = 64; numCanvas.height = 64;
        const nctx = numCanvas.getContext('2d');
        nctx.fillStyle = '#ffffff';
        nctx.fillRect(0, 0, 64, 64);
        nctx.fillStyle = '#dd2200';
        nctx.font = 'bold 48px Arial';
        nctx.textAlign = 'center';
        nctx.fillText('7', 32, 50);
        const numTex = new THREE.CanvasTexture(numCanvas);

        for (let side of [-1, 1]) {
            const numGeo = new THREE.PlaneGeometry(0.8, 0.8);
            const numMat = new THREE.MeshBasicMaterial({ map: numTex });
            const numMesh = new THREE.Mesh(numGeo, numMat);
            numMesh.position.set(side * (CFG.carWidth/2 + 0.01), 0.7, -0.3);
            numMesh.rotation.y = side > 0 ? Math.PI/2 : -Math.PI/2;
            carGroup.add(numMesh);
        }

        scene.add(carGroup);
    }

    // ==================== SCENERY ====================
    function createScenery() {
        const segs = CFG.trackSegments;

        for (let i = 0; i < CFG.treeCount; i++) {
            const t = Math.random();
            const point = trackCurve.getPointAt(t);
            const tangent = trackCurve.getTangentAt(t).normalize();
            const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();

            const side = (Math.random() > 0.5 ? 1 : -1) * (CFG.trackWidth * 0.6 + Math.random() * 80);
            const dist = CFG.trackWidth * 0.7 + Math.random() * 60;
            const pos = point.clone().add(normal.clone().multiplyScalar(side));

            createTree(pos.x, point.y, pos.z);
        }

        for (let i = 0; i < CFG.rockCount; i++) {
            const t = Math.random();
            const point = trackCurve.getPointAt(t);
            const tangent = trackCurve.getTangentAt(t).normalize();
            const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();

            const side = (Math.random() > 0.5 ? 1 : -1) * (CFG.trackWidth * 0.6 + Math.random() * 40);
            const pos = point.clone().add(normal.clone().multiplyScalar(side));

            createRock(pos.x, point.y, pos.z);
        }

        for (let i = 0; i < CFG.signCount; i++) {
            const t = i / CFG.signCount;
            const point = trackCurve.getPointAt(t);
            const tangent = curveTangentSafe(trackCurve, t).normalize();
            const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();

            const side = (Math.random() > 0.5 ? 1 : -1) * CFG.trackWidth * 0.8;
            const pos = point.clone().add(normal.clone().multiplyScalar(side));

            createSign(pos.x, point.y, pos.z);
        }

        // Guard rails on sharp curves
        for (let i = 0; i < segs; i++) {
            const t1 = Math.max(0, (i - 2) / segs);
            const t2 = (i + 2) / segs;
            const p1 = trackCurve.getPointAt(t1);
            const p3 = trackCurve.getPointAt(t2);
            const curvature = p1.distanceTo(p3);

            if (curvature < CFG.trackWidth * 0.8 && i % 5 === 0) {
                const point = trackCurve.getPointAt(i / segs);
                const tangent = curveTangentSafe(trackCurve, i / segs).normalize();
                const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();

                for (let side of [-1, 1]) {
                    createGuardRail(point.clone().add(normal.clone().multiplyScalar(side * CFG.trackWidth * 0.55)), tangent);
                }
            }
        }
    }

    function curveTangentSafe(curve, t) {
        return curve.getTangentAt(Math.max(0, Math.min(1, t)));
    }

    function createTree(x, y, z) {
        const group = new THREE.Group();

        // Trunk
        const trunkGeo = new THREE.CylinderGeometry(0.2, 0.35, 3 + Math.random() * 2, 5);
        const trunkMat = new THREE.MeshLambertMaterial({ color: 0x8B4513 });
        const trunk = new THREE.Mesh(trunkGeo, trunkMat);
        trunk.position.y = 1.5;
        trunk.castShadow = true;
        group.add(trunk);

        // Foliage layers
        const foliageColors = [0x2d6b1e, 0x3a8c2a, 0x4a9e35];
        for (let i = 0; i < 3; i++) {
            const size = 2.5 - i * 0.6 + Math.random() * 0.5;
            const fGeo = new THREE.ConeGeometry(size, 2.5, 6);
            const fMat = new THREE.MeshLambertMaterial({ color: foliageColors[i % 3] });
            const foliage = new THREE.Mesh(fGeo, fMat);
            foliage.position.y = 3 + i * 1.5;
            foliage.castShadow = true;
            group.add(foliage);
        }

        group.position.set(x, y, z);
        scene.add(group);
        sceneryObjects.push({ mesh: group, type: 'tree', radius: 0.8 });
    }

    function createRock(x, y, z) {
        const size = 0.5 + Math.random() * 1.5;
        const geo = new THREE.DodecahedronGeometry(size, 0);
        // Randomize vertices for organic look
        const posAttr = geo.attributes.position;
        for (let i = 0; i < posAttr.count; i++) {
            posAttr.setX(i, posAttr.getX(i) + (Math.random() - 0.5) * size * 0.3);
            posAttr.setY(i, posAttr.getY(i) + (Math.random() - 0.5) * size * 0.3);
            posAttr.setZ(i, posAttr.getZ(i) + (Math.random() - 0.5) * size * 0.3);
        }
        geo.computeVertexNormals();

        const mat = new THREE.MeshLambertMaterial({ color: 0x777777 });
        const rock = new THREE.Mesh(geo, mat);
        rock.position.set(x, y + size * 0.4, z);
        rock.rotation.set(Math.random(), Math.random(), Math.random());
        rock.castShadow = true;
        scene.add(rock);
        sceneryObjects.push({ mesh: rock, type: 'rock', radius: size });
    }

    function createSign(x, y, z) {
        const group = new THREE.Group();

        // Pole
        const poleGeo = new THREE.CylinderGeometry(0.08, 0.08, 3, 4);
        const poleMat = new THREE.MeshLambertMaterial({ color: 0x666666 });
        const pole = new THREE.Mesh(poleGeo, poleMat);
        pole.position.y = 1.5;
        group.add(pole);

        // Sign board
        const signs = ['SLOW', 'CURVE', 'RALLY', 'DANGER', 'TRACK'];
        const signText = signs[Math.floor(Math.random() * signs.length)];
        const sCanvas = document.createElement('canvas');
        sCanvas.width = 128; sCanvas.height = 64;
        const sctx = sCanvas.getContext('2d');
        sctx.fillStyle = '#ffcc00';
        sctx.fillRect(0, 0, 128, 64);
        sctx.strokeStyle = '#333';
        sctx.lineWidth = 4;
        sctx.strokeRect(2, 2, 124, 60);
        sctx.fillStyle = '#333';
        sctx.font = 'bold 28px Arial';
        sctx.textAlign = 'center';
        sctx.fillText(signText, 64, 42);

        const signTex = new THREE.CanvasTexture(sCanvas);
        const boardGeo = new THREE.PlaneGeometry(1.5, 0.75);
        const boardMat = new THREE.MeshBasicMaterial({ map: signTex });
        const board = new THREE.Mesh(boardGeo, boardMat);
        board.position.y = 3;
        group.add(board);

        group.position.set(x, y, z);
        scene.add(group);
    }

    function createGuardRail(pos, tangent) {
        const geo = new THREE.BoxGeometry(0.15, 0.4, 2);
        const mat = new THREE.MeshLambertMaterial({ color: 0x888888 });
        const rail = new THREE.Mesh(geo, mat);
        rail.position.copy(pos);
        rail.position.y += 0.3;
        rail.lookAt(pos.clone().add(tangent));
        scene.add(rail);

        // Support post
        const postGeo = new THREE.CylinderGeometry(0.05, 0.05, 0.6, 4);
        const post = new THREE.Mesh(postGeo, mat);
        post.position.copy(pos);
        post.position.y += 0;
        scene.add(post);

        sceneryObjects.push({ mesh: rail, type: 'barrier', radius: 0.3 });
    }

    // ==================== PARTICLES ====================
    function setupParticles() {
        // Dust particles
        const dustGeo = new THREE.BufferGeometry();
        const dustCount = 500;
        const dustPositions = new Float32Array(dustCount * 3);
        const dustSizes = new Float32Array(dustCount);
        for (let i = 0; i < dustCount; i++) {
            dustPositions[i*3] = 0; dustPositions[i*3+1] = -100; dustPositions[i*3+2] = 0;
            dustSizes[i] = Math.random() * 3 + 1;
        }
        dustGeo.setAttribute('position', new THREE.Float32BufferAttribute(dustPositions, 3));
        dustGeo.setAttribute('size', new THREE.Float32BufferAttribute(dustSizes, 1));

        // Create dust texture procedurally
        const dCanvas = document.createElement('canvas');
        dCanvas.width = 64; dCanvas.height = 64;
        const dctx = dCanvas.getContext('2d');
        const grad = dctx.createRadialGradient(32, 32, 0, 32, 32, 32);
        grad.addColorStop(0, 'rgba(180,160,140,1)');
        grad.addColorStop(0.5, 'rgba(160,140,120,0.5)');
        grad.addColorStop(1, 'rgba(140,120,100,0)');
        dctx.fillStyle = grad;
        dctx.fillRect(0, 0, 64, 64);

        const dustMat = new THREE.PointsMaterial({
            map: new THREE.CanvasTexture(dCanvas),
            size: 3,
            transparent: true,
            opacity: 0.6,
            depthWrite: false,
            blending: THREE.NormalBlending,
        });
        particles.dust = new THREE.Points(dustGeo, dustMat);
        scene.add(particles.dust);

        // Spark particles
        const sparkGeo = new THREE.BufferGeometry();
        const sparkCount = 200;
        const sparkPositions = new Float32Array(sparkCount * 3);
        for (let i = 0; i < sparkCount; i++) {
            sparkPositions[i*3] = 0; sparkPositions[i*3+1] = -100; sparkPositions[i*3+2] = 0;
        }
        sparkGeo.setAttribute('position', new THREE.Float32BufferAttribute(sparkPositions, 3));

        const sparkMat = new THREE.PointsMaterial({
            color: 0xffaa00,
            size: 0.5,
            transparent: true,
            opacity: 0.9,
            depthWrite: false,
        });
        particles.sparks = new THREE.Points(sparkGeo, sparkMat);
        scene.add(particles.sparks);

        // Particle data arrays
        particles.dustData = [];
        particles.sparkData = [];
    }

    function emitDust(pos, count) {
        const geo = particles.dust.geometry;
        for (let i = 0; i < count && particles.dustData.length < 500; i++) {
            particles.dustData.push({
                x: pos.x + (Math.random()-0.5)*2,
                y: pos.y + Math.random()*0.5,
                z: pos.z + (Math.random()-0.5)*2,
                vx: (Math.random()-0.5)*3,
                vy: Math.random()*2 + 1,
                vz: (Math.random()-0.5)*3,
                life: 1.0,
            });
        }
    }

    function emitSparks(pos, count) {
        for (let i = 0; i < count && particles.sparkData.length < 200; i++) {
            particles.sparkData.push({
                x: pos.x + (Math.random()-0.5)*1,
                y: pos.y + Math.random()*0.5,
                z: pos.z + (Math.random()-0.5)*1,
                vx: (Math.random()-0.5)*8,
                vy: Math.random()*6 + 2,
                vz: (Math.random()-0.5)*8,
                life: 1.0,
            });
        }
    }

    function updateParticles(dt) {
        // Dust
        const dPos = particles.dust.geometry.attributes.position;
        for (let i = particles.dustData.length - 1; i >= 0; i--) {
            const p = particles.dustData[i];
            p.x += p.vx * dt;
            p.y += p.vy * dt;
            p.z += p.vz * dt;
            p.vy -= 2 * dt; // gravity on dust
            p.life -= dt * 1.5;
            if (p.life <= 0) {
                particles.dustData.splice(i, 1);
            } else {
                dPos.setXYZ(i, p.x, p.y, p.z);
            }
        }
        // Hide unused particles
        for (let i = particles.dustData.length; i < dPos.count; i++) {
            dPos.setY(i, -100);
        }
        dPos.needsUpdate = true;

        // Sparks
        const sPos = particles.sparks.geometry.attributes.position;
        for (let i = particles.sparkData.length - 1; i >= 0; i--) {
            const p = particles.sparkData[i];
            p.x += p.vx * dt;
            p.y += p.vy * dt;
            p.z += p.vz * dt;
            p.vy -= 10 * dt;
            p.life -= dt * 3;
            if (p.life <= 0) {
                particles.sparkData.splice(i, 1);
            } else {
                sPos.setXYZ(i, p.x, p.y, p.z);
            }
        }
        for (let i = particles.sparkData.length; i < sPos.count; i++) {
            sPos.setY(i, -100);
        }
        sPos.needsUpdate = true;

        // Skid marks fade
        for (let i = particles.skidMarks.length - 1; i >= 0; i--) {
            particles.skidMarks[i].life -= dt * 0.3;
            if (particles.skidMarks[i].life <= 0) {
                scene.remove(particles.skidMarks[i].mesh);
                particles.skidMarks.splice(i, 1);
            } else {
                particles.skidMarks[i].mesh.material.opacity = particles.skidMarks[i].life * 0.5;
            }
        }
    }

    function addSkidMark(pos) {
        const geo = new THREE.PlaneGeometry(0.3, 1.2);
        const mat = new THREE.MeshBasicMaterial({ color: 0x222222, transparent: true, opacity: 0.5 });
        const mark = new THREE.Mesh(geo, mat);
        mark.position.copy(pos);
        mark.position.y += 0.15;
        mark.rotation.x = -Math.PI / 2;
        scene.add(mark);
        particles.skidMarks.push({ mesh: mark, life: 1.0 });
    }

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

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

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

            engineOsc.connect(filter);
            filter.connect(engineGain);
            engineGain.connect(audioCtx.destination);
            engineOsc.start();
        } catch(e) {
            console.warn('Audio not available:', e);
        }
    }

    function updateEngineSound() {
        if (!audioCtx || !engineOsc) return;
        const speedRatio = Math.abs(state.speed) / CFG.maxSpeed;
        const rpmNorm = state.rpm / 8000;
        engineOsc.frequency.value = 55 + rpmNorm * 200;
        engineGain.gain.value = state.phase === 'racing' ? (0.03 + speedRatio * 0.06) : 0;
    }

    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.15;
        osc.connect(gain);
        gain.connect(audioCtx.destination);
        osc.start();
        gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
        osc.stop(audioCtx.currentTime + duration);
    }

    function playCrashSound() {
        if (!audioCtx) return;
        const bufferSize = audioCtx.sampleRate * 0.2;
        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 / (bufferSize * 0.3));
        }
        const source = audioCtx.createBufferSource();
        source.buffer = buffer;
        const gain = audioCtx.createGain();
        gain.gain.value = 0.15;
        source.connect(gain);
        gain.connect(audioCtx.destination);
        source.start();
    }

    function playSkidSound() {
        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 / (bufferSize * 0.5)) * 0.3;
        }
        const source = audioCtx.createBufferSource();
        source.buffer = buffer;
        const gain = audioCtx.createGain();
        gain.gain.value = 0.08;
        source.connect(gain);
        gain.connect(audioCtx.destination);
        source.start();
    }

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

    // ==================== INPUT HANDLING ====================
    function setupInput() {
        document.addEventListener('keydown', (e) => {
            keys[e.code] = true;
            if (e.code === 'KeyP' && state.phase === 'racing') pauseGame();
            else if (e.code === 'KeyP' && state.phase === 'paused') resume();
            if (e.code === 'KeyR') restart();
            if (e.code === 'KeyC') toggleCamera();
        });
        document.addEventListener('keyup', (e) => { keys[e.code] = false; });

        // Touch controls
        const isMobile = /Android|iPhone|iPad|iPod|webOS/i.test(navigator.userAgent);
        if (isMobile || window.innerWidth < 800) {
            document.getElementById('touch-controls').style.display = 'block';
        }

        const touchBtns = document.querySelectorAll('.touch-btn');
        touchBtns.forEach(btn => {
            btn.addEventListener('touchstart', (e) => {
                e.preventDefault();
                const key = btn.dataset.key;
                keys[key] = true;
                // Resume audio context on first touch
                if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
            });
            btn.addEventListener('touchend', (e) => {
                e.preventDefault();
                keys[btn.dataset.key] = false;
            });
        });

        // Resume audio on first keypress
        document.addEventListener('keydown', () => {
            if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
        }, { once: true });
    }

    function isKey(action) {
        switch(action) {
            case 'up': return keys['ArrowUp'] || keys['KeyW'] || keys['up'];
            case 'down': return keys['ArrowDown'] || keys['KeyS'] || keys['down'];
            case 'left': return keys['ArrowLeft'] || keys['KeyA'] || keys['left'];
            case 'right': return keys['ArrowRight'] || keys['KeyD'] || keys['right'];
            case 'handbrake': return keys['Space'] || keys['handbrake'];
        }
    }

    // ==================== PHYSICS ====================
    function resetCarPosition() {
        const startPt = trackCurve.getPointAt(0);
        const startTan = curveTangentSafe(trackCurve, 0).normalize();
        state.position.copy(startPt);
        state.heading = Math.atan2(startTan.x, startTan.z);
        state.speed = 0;
        state.lateralVel = 0;
        state.angularVel = 0;
    }

    function getTrackInfoAtPos(pos) {
        let minDist = Infinity;
        let closestT = 0;
        const segs = CFG.trackSegments;

        // Search nearby checkpoints for efficiency
        if (state.lastCheckpointIdx >= 0) {
            const searchRange = 20;
            const startI = Math.max(0, state.lastCheckpointIdx - searchRange);
            const endI = Math.min(segs, state.lastCheckpointIdx + searchRange);

            for (let i = startI; i <= endI; i++) {
                const t = i / segs;
                const tp = trackCurve.getPointAt(t);
                const dx = pos.x - tp.x;
                const dz = pos.z - tp.z;
                const dist = Math.sqrt(dx*dx + dz*dz);
                if (dist < minDist) {
                    minDist = dist;
                    closestT = t;
                }
            }
        } else {
            // Full search on first frame
            for (let i = 0; i <= segs; i += 2) {
                const t = i / segs;
                const tp = trackCurve.getPointAt(t);
                const dx = pos.x - tp.x;
                const dz = pos.z - tp.z;
                const dist = Math.sqrt(dx*dx + dz*dz);
                if (dist < minDist) {
                    minDist = dist;
                    closestT = t;
                }
            }
        }

        // Store checkpoint index for next frame optimization
        state.lastCheckpointIdx = Math.round(closestT * segs);

        const trackPoint = trackCurve.getPointAt(closestT);
        const tangent = curveTangentSafe(trackCurve, closestT).normalize();
        const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();

        // Lateral offset from center
        const toCar = pos.clone().sub(trackPoint);
        const lateralOffset = Math.abs(toCar.dot(normal));

        let surfaceType = 'asphalt';
        if (lateralOffset > CFG.trackWidth * 0.5 && lateralOffset <= CFG.trackWidth * 0.7) {
            surfaceType = 'gravel';
        } else if (lateralOffset > CFG.trackWidth * 0.7) {
            surfaceType = Math.random() < 0.3 ? 'mud' : 'grass';
        }

        return { closestT, lateralOffset, trackPoint, tangent, normal, surfaceType };
    }

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

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

        const trackInfo = getTrackInfoAtPos(state.position);
        state.surfaceType = trackInfo.surfaceType;

        // Surface modifiers
        let speedMult = 1.0;
        let gripMult = 1.0;
        switch(trackInfo.surfaceType) {
            case 'asphalt': speedMult = 1.0; gripMult = 1.0; break;
            case 'gravel': speedMult = 0.75; gripMult = 0.6; break;
            case 'grass': speedMult = CFG.offRoadSlowdown; gripMult = 0.35; break;
            case 'mud': speedMult = CFG.mudSlowdown; gripMult = 0.2; break;
        }

        const maxSpd = CFG.maxSpeed * speedMult;
        const accelForce = CFG.acceleration * speedMult;

        // Acceleration / braking
        if (isKey('up')) {
            state.speed += accelForce * dt;
        } else if (isKey('down')) {
            if (state.speed > 0.5) {
                state.speed -= CFG.brakingForce * dt;
            } else {
                state.speed -= accelForce * 0.4 * dt; // reverse
            }
        } else {
            // Rolling resistance
            state.speed *= (1 - 1.5 * dt);
        }

        // Handbrake
        state.handbrake = isKey('handbrake');
        if (state.handbrake) {
            state.speed *= (1 - 3 * dt);
            gripMult *= 0.3;
        }

        // Clamp speed
        state.speed = Math.max(CFG.reverseMaxSpeed, Math.min(maxSpd, state.speed));

        // Steering
        const steerFactor = state.speed > 5 ?
            CFG.highSpeedSteering + (CFG.steeringSpeed - CFG.highSpeedSteering) * Math.exp(-state.speed / 30) :
            CFG.steeringSpeed;

        let turnAmount = 0;
        if (isKey('left')) turnAmount += steerFactor;
        if (isKey('right')) turnAmount -= steerFactor;

        // Speed-dependent steering reduction at very high speeds
        const speedSteerMod = Math.max(0.3, 1 - state.speed / (CFG.maxSpeed * 2));
        turnAmount *= speedSteerMod;

        // Apply turning with drift physics
        const effectiveGrip = gripMult * CFG.driftFactor;
        if (!state.handbrake) {
            state.heading += turnAmount * dt * Math.sign(state.speed);
        } else {
            // Handbrake: more rotation, less forward tracking
            state.heading += turnAmount * dt * 1.5 * Math.sign(state.speed || 0.1);
        }

        // Drift detection
        const driftAngle = Math.abs(turnAmount) * (state.speed / CFG.maxSpeed);
        state.isDrifting = driftAngle > 0.3 && state.speed > 20;

        // Calculate forward direction vector
        const fwdX = Math.sin(state.heading);
        const fwdZ = Math.cos(state.heading);

        // Lateral velocity (drift component)
        if (state.isDrifting || trackInfo.surfaceType !== 'asphalt') {
            state.lateralVel += turnAmount * state.speed * dt * 0.5;
            state.lateralVel *= effectiveGrip;
        } else {
            state.lateralVel *= 0.85; // Grip pulls lateral vel to zero
        }

        // Move car
        const moveDist = state.speed * dt;
        state.position.x += fwdX * moveDist + Math.cos(state.heading) * state.lateralVel * dt;
        state.position.z += fwdZ * moveDist - Math.sin(state.heading) * state.lateralVel * dt;

        // Elevation: snap to track height with some smoothing
        const targetY = trackInfo.trackPoint.y + 0.5;
        state.position.y += (targetY - state.position.y) * 0.15;

        // Obstacle collision
        checkObstacleCollisions();

        // Track boundary bounce
        if (trackInfo.lateralOffset > CFG.trackWidth * 1.2) {
            const pushBack = trackInfo.normal.clone().multiplyScalar(
                (trackInfo.lateralOffset - CFG.trackWidth * 1.2) * 0.5
            );
            state.position.add(pushBack);
            state.speed *= 0.7;
        }

        // Lap checking
        checkLapProgress(trackInfo.closestT);

        // Particles
        if (state.isDrifting && Math.abs(state.speed) > 10) {
            emitDust(state.position.clone().add(new THREE.Vector3(0, -0.2, 0)), 2);
            addSkidMark(state.position.clone());
            if (Math.random() < 0.1) playSkidSound();
        } else if (trackInfo.surfaceType !== 'asphalt' && Math.abs(state.speed) > 5) {
            emitDust(state.position.clone().add(new THREE.Vector3(0, -0.2, 0)), 1);
        }

        // Gear simulation
        const absSpeed = Math.abs(state.speed);
        if (absSpeed < 1) state.gear = 0; // N
        else if (absSpeed < 15) state.gear = 1;
        else if (absSpeed < 30) state.gear = 2;
        else if (absSpeed < 48) state.gear = 3;
        else if (absSpeed < 65) state.gear = 4;
        else state.gear = 5;

        // RPM simulation
        const gearRanges = [0, 15, 30, 48, 65, CFG.maxSpeed];
        const gearMin = gearRanges[Math.max(0, state.gear - 1)] || 0;
        const gearMax = gearRanges[state.gear] || CFG.maxSpeed;
        state.rpm = 1000 + (absSpeed - gearMin) / (gearMax - gearMin) * 7000;

        // Timers
        state.currentLapTime += dt;
        state.totalTime += dt;
    }

    function checkObstacleCollisions() {
        for (const obj of sceneryObjects) {
            const dx = state.position.x - obj.mesh.position.x;
            const dz = state.position.z - obj.mesh.position.z;
            const dist = Math.sqrt(dx*dx + dz*dz);
            if (dist < obj.radius + 1.2) {
                // Collision!
                state.speed *= 0.3;
                // Push car away
                const pushDir = new THREE.Vector3(dx, 0, dz).normalize();
                state.position.add(pushDir.clone().multiplyScalar(0.5));
                emitSparks(state.position.clone(), 10);
                playCrashSound();
            }
        }
    }

    function checkLapProgress(closestT) {
        const checkpointCount = 8;
        const currentCheckpoint = Math.floor(closestT * checkpointCount) % checkpointCount;

        // Check if we passed a new checkpoint in sequence
        if (currentCheckpoint !== state.lastCheckpointIdx % checkpointCount ||
            (state.checkpointsPassed.length > 0 &&
             currentCheckpoint === (state.checkpointsPassed[state.checkpointsPassed.length - 1] + 1) % checkpointCount)) {

            // Validate sequential progression
            const expectedNext = (state.checkpointsPassed.length > 0 ?
                state.checkpointsPassed[state.checkpointsPassed.length - 1] : -1) + 1;

            if (currentCheckpoint === expectedNext % checkpointCount && !state.checkpointsPassed.includes(currentCheckpoint)) {
                state.checkpointsPassed.push(currentCheckpoint);
            }

            // Lap completion: all checkpoints passed and crossing start line area
            if (state.checkpointsPassed.length >= checkpointCount && closestT < 0.1) {
                completeLap();
            }
        }
    }

    function completeLap() {
        if (state.currentLapTime < state.bestLap) {
            state.bestLap = state.currentLapTime;
        }

        if (state.lap >= CFG.totalLaps) {
            finishRace();
        } else {
            state.lap++;
            state.checkpointsPassed = [];
            state.currentLapTime = 0;
            updateHUD();
        }
    }

    // ==================== CAMERA ====================
    let cameraMode = 'chase'; // chase | cinematic

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

    function updateCamera(dt) {
        const carPos = state.position;
        const fwdX = Math.sin(state.heading);
        const fwdZ = Math.cos(state.heading);

        let targetCamPos, lookAtPos;

        if (cameraMode === 'chase') {
            // Chase camera behind and above car
            const camDist = 10 + Math.abs(state.speed) * 0.05;
            const camHeight = 4 + Math.abs(state.speed) * 0.02;
            targetCamPos = new THREE.Vector3(
                carPos.x - fwdX * camDist,
                carPos.y + camHeight,
                carPos.z - fwdZ * camDist
            );
            lookAtPos = new THREE.Vector3(
                carPos.x + fwdX * 5,
                carPos.y + 1.5,
                carPos.z + fwdZ * 5
            );
        } else {
            // Cinematic - higher and wider
            targetCamPos = new THREE.Vector3(
                carPos.x - fwdX * 20,
                carPos.y + 18,
                carPos.z - fwdZ * 20
            );
            lookAtPos = carPos.clone().add(new THREE.Vector3(0, 1, 0));
        }

        // Smooth camera follow with lag
        const lerpFactor = 1 - Math.pow(1 - CFG.cameraLag, dt * 60);
        camera.position.lerp(targetCamPos, lerpFactor * 3 + 0.5);
        const currentLookAt = new THREE.Vector3();
        camera.getWorldDirection(currentLookAt);
        camera.lookAt(lookAtPos);

        // Update shadow camera to follow car
        scene.traverse((child) => {
            if (child.isDirectionalLight && child.castShadow) {
                child.position.set(carPos.x + 50, carPos.y + 100, carPos.z + 30);
                child.target.position.copy(carPos);
                child.target.updateMatrixWorld();
            }
        });
    }

    // ==================== CAR VISUAL UPDATE ====================
    function updateCarVisual() {
        if (!carGroup) return;

        carGroup.position.copy(state.position);
        carGroup.rotation.y = -state.heading;

        // Wheel rotation based on speed
        const wheelRot = state.speed * 0.15;
        wheels.forEach(w => w.rotation.x += wheelRot);

        // Front wheel steering visual
        if (wheels.length >= 2) {
            let steerAngle = 0;
            if (isKey('left')) steerAngle = 0.3;
            else if (isKey('right')) steerAngle = -0.3;
            wheels[0].rotation.y = steerAngle;
            wheels[1].rotation.y = steerAngle;
        }

        // Body tilt during turns
        const turnTilt = state.lateralVel * 0.02;
        carBody.rotation.z = Math.max(-0.15, Math.min(0.15, -turnTilt));
    }

    // ==================== HUD ====================
    function updateHUD() {
        document.getElementById('lap-count').textContent = `LAP ${state.lap} / ${CFG.totalLaps}`;
        document.getElementById('best-lap').textContent = state.bestLap < Infinity ?
            `Best Lap: ${formatTime(state.bestLap)}` : 'Best Lap: --:--.---';
        document.getElementById('current-time').textContent = formatTime(state.totalTime);

        const displaySpeed = Math.abs(Math.round(state.speed * 3.6)); // m/s to km/h
        document.getElementById('speed-val').textContent = displaySpeed;

        const gearNames = ['N', '1', '2', '3', '4', '5'];
        document.getElementById('gear-display').textContent = `Gear ${gearNames[state.gear]}`;

        document.getElementById('rpm-bar').style.width = `${(state.rpm / 8000) * 100}%`;

        const surfaceColors = { asphalt: '#aaa', gravel: '#c9a54e', grass: '#4a7c3f', mud: '#6b4226' };
        const si = document.getElementById('surface-indicator');
        si.textContent = state.surfaceType.toUpperCase();
        si.style.color = surfaceColors[state.surfaceType] || '#fff';

        // Minimap
        drawMinimap();
    }

    function formatTime(seconds) {
        if (!isFinite(seconds)) return '--:--.---';
        const mins = Math.floor(seconds / 60);
        const secs = seconds % 60;
        return `${String(mins).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, h = canvas.height;
        const cx = w / 2, cy = h / 2;

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

        // Background circle
        ctx.fillStyle = 'rgba(30,50,30,0.8)';
        ctx.beginPath();
        ctx.arc(cx, cy, cx - 4, 0, Math.PI * 2);
        ctx.fill();

        // Find track bounds for scaling
        let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
        const segs = CFG.trackSegments;
        for (let i = 0; i <= segs; i += 5) {
            const p = trackCurve.getPointAt(i / segs);
            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 rangeX = maxX - minX || 1;
        const rangeZ = maxZ - minZ || 1;
        const scale = (Math.min(w, h) * 0.42) / Math.max(rangeX, rangeZ);
        const offX = cx - ((minX + maxX) / 2) * scale;
        const offY = cy - ((minZ + maxZ) / 2) * scale;

        // Draw track
        ctx.strokeStyle = '#888';
        ctx.lineWidth = 3;
        ctx.beginPath();
        for (let i = 0; i <= segs; i += 2) {
            const p = trackCurve.getPointAt(i / segs);
            const sx = p.x * scale + offX;
            const sy = p.z * scale + offY;
            if (i === 0) ctx.moveTo(sx, sy);
            else ctx.lineTo(sx, sy);
        }
        ctx.closePath();
        ctx.stroke();

        // Draw checkpoints passed
        for (const cp of state.checkpointsPassed) {
            const t = (cp + 0.5) / 8;
            const p = trackCurve.getPointAt(t);
            ctx.fillStyle = '#0f0';
            ctx.beginPath();
            ctx.arc(p.x * scale + offX, p.z * scale + offY, 3, 0, Math.PI * 2);
            ctx.fill();
        }

        // Draw car position
        const carSx = state.position.x * scale + offX;
        const carSy = state.position.z * scale + offY;
        ctx.fillStyle = '#ff3300';
        ctx.beginPath();
        ctx.arc(carSx, carSy, 5, 0, Math.PI * 2);
        ctx.fill();

        // Car direction indicator
        const dirX = Math.sin(state.heading) * 8;
        const dirY = Math.cos(state.heading) * 8;
        ctx.strokeStyle = '#ff3300';
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(carSx, carSy);
        ctx.lineTo(carSx + dirX, carSy + dirY);
        ctx.stroke();

        // Border
        ctx.strokeStyle = 'rgba(255,255,255,0.3)';
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.arc(cx, cy, cx - 4, 0, Math.PI * 2);
        ctx.stroke();
    }

    // ==================== GAME FLOW ====================
    function startCountdown() {
        state.phase = 'countdown';
        state.countdownValue = 3;
        const overlay = document.getElementById('countdown-overlay');
        const text = document.getElementById('countdown-text');
        overlay.style.display = 'flex';

        let count = 3;
        function tick() {
            if (count > 0) {
                text.textContent = count;
                playBeep(440, 0.2);
                count--;
                setTimeout(tick, 1000);
            } else {
                text.textContent = 'GO!';
                text.style.color = '#00ff00';
                playBeep(880, 0.3);
                setTimeout(() => {
                    overlay.style.display = 'none';
                    text.style.color = '#fff';
                    state.phase = 'racing';
                    document.getElementById('controls-hint').style.opacity = '0';
                }, 600);
            }
        }
        tick();
    }

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

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

    function finishRace() {
        state.phase = 'finished';
        playFinishTone();
        document.getElementById('final-time').textContent = `Total Time: ${formatTime(state.totalTime)}`;
        document.getElementById('final-best-lap').textContent = `Best Lap: ${formatTime(state.bestLap)}`;
        document.getElementById('finish-screen').classList.add('active');
    }

    function restart() {
        // Reset state
        state.speed = 0;
        state.heading = 0;
        state.lateralVel = 0;
        state.angularVel = 0;
        state.gear = 1;
        state.rpm = 0;
        state.lap = 1;
        state.currentLapTime = 0;
        state.totalTime = 0;
        state.bestLap = Infinity;
        state.checkpointsPassed = [];
        state.lastCheckpointIdx = -1;
        state.surfaceType = 'asphalt';
        state.isDrifting = false;

        // Clear particles and skid marks
        particles.dustData = [];
        particles.sparkData = [];
        for (const sm of particles.skidMarks) {
            scene.remove(sm.mesh);
        }
        particles.skidMarks = [];

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

        resetCarPosition();
        startCountdown();
    }

    // ==================== MAIN LOOP ====================
    function animate() {
        requestAnimationFrame(animate);

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

        updatePhysics(dt);
        updateCamera(dt);
        updateCarVisual();
        updateParticles(dt);
        updateEngineSound();

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

        renderer.render(scene, camera);
    }

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

    // ==================== PUBLIC API ====================
    return {
        init,
        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":"middle","sessionId":"ca5369d6-8188-462e-a75b-77f4ed17b2a3","tokensIn":44806,"tokensOut":19689,"tokensTotal":64495,"turns":3,"toolCalls":2,"failedToolCalls":0,"timestamp":"2026-07-18T23:44:11.428Z"} -->