← Rally results

Rally

Qwen 3.6 27b MTP · full

Initial view of Rally

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

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

Generation prompt · exact text unavailable

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

Inspect original source 55,357 bytes · SHA-256 1a3c3b7a68fd
<!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; touch-action: none; user-select: none; -webkit-user-select: none; }
canvas { display: block; }

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

/* Speedometer */
#speedo { position: absolute; bottom: 20px; right: 20px; width: 180px; height: 180px; background: radial-gradient(circle, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.4) 100%); border-radius: 50%; border: 3px solid #ff6600; display: flex; align-items: center; justify-content: center; flex-direction: column; }
#speed-val { color: #fff; font-size: 36px; font-weight: bold; text-shadow: 0 0 10px #ff6600; }
#speed-unit { color: #aaa; font-size: 12px; }
#gear-display { position: absolute; bottom: 5px; right: 5px; color: #ff6600; font-size: 24px; font-weight: bold; background: rgba(0,0,0,0.6); padding: 2px 8px; border-radius: 4px; }
#rpm-bar { position: absolute; bottom: 35px; right: 15px; width: 150px; height: 8px; background: rgba(0,0,0,0.5); border-radius: 4px; overflow: hidden; }
#rpm-fill { height: 100%; width: 0%; background: linear-gradient(to right, #0f0, #ff0, #f00); transition: width 0.1s; border-radius: 4px; }

/* Lap Info */
#lap-info { position: absolute; top: 20px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.7); padding: 8px 24px; border-radius: 8px; text-align: center; color: #fff; }
#lap-info .lap-num { font-size: 18px; font-weight: bold; color: #ff6600; }
#lap-info .time { font-size: 14px; margin-top: 2px; }

/* Best Lap */
#best-lap { position: absolute; top: 75px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.6); padding: 4px 16px; border-radius: 6px; color: #0f0; font-size: 13px; }

/* Minimap */
#minimap { position: absolute; top: 20px; left: 20px; width: 150px; height: 150px; background: rgba(0,0,0,0.6); border-radius: 8px; border: 2px solid #ff6600; }
#minimap canvas { width: 100%; height: 100%; border-radius: 6px; }

/* Countdown */
#countdown { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 120px; font-weight: bold; color: #ff6600; text-shadow: 0 0 30px rgba(255,102,0,0.8), 0 0 60px rgba(255,102,0,0.4); z-index: 100; display: none; animation: countPulse 0.5s ease-out; }
@keyframes countPulse { from { transform: translate(-50%, -50%) scale(1.8); opacity: 0; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; } }

/* Start prompt */
#start-prompt { position: fixed; top: 60%; left: 50%; transform: translateX(-50%); color: #fff; font-size: 20px; text-align: center; z-index: 90; animation: blink 1.5s infinite; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }

/* Lap notification */
#lap-notification { position: fixed; top: 40%; left: 50%; transform: translateX(-50%); color: #ff6600; font-size: 32px; font-weight: bold; text-shadow: 0 0 15px rgba(255,102,0,0.6); z-index: 90; opacity: 0; transition: opacity 0.3s; pointer-events: none; }

/* Pause Menu */
#pause-menu { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 200; display: none; align-items: center; justify-content: center; flex-direction: column; }
#pause-menu h1 { color: #ff6600; font-size: 48px; margin-bottom: 30px; text-shadow: 0 0 20px rgba(255,102,0,0.5); }
.menu-btn { background: linear-gradient(to bottom, #ff6600, #cc5200); color: #fff; border: none; padding: 12px 40px; font-size: 20px; border-radius: 8px; cursor: pointer; margin: 8px; min-width: 200px; text-transform: uppercase; letter-spacing: 2px; }
.menu-btn:hover { background: linear-gradient(to bottom, #ff8533, #e66000); transform: scale(1.05); }

/* Finish Screen */
#finish-screen { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); z-index: 200; display: none; align-items: center; justify-content: center; flex-direction: column; }
#finish-screen h1 { color: #ff6600; font-size: 52px; margin-bottom: 10px; text-shadow: 0 0 30px rgba(255,102,0,0.6); }
#finish-stats { color: #fff; font-size: 20px; line-height: 2; text-align: center; margin-bottom: 30px; }
#finish-stats span { color: #ff6600; font-weight: bold; }

/* Touch Controls */
#touch-controls { position: fixed; bottom: 10px; left: 0; width: 100%; height: 140px; z-index: 50; display: none; pointer-events: none; }
.touch-btn { pointer-events: auto; background: rgba(255,255,255,0.15); border: 2px solid rgba(255,255,255,0.3); border-radius: 50%; color: #fff; font-size: 24px; display: flex; align-items: center; justify-content: center; position: absolute; }
.touch-btn:active { background: rgba(255,102,0,0.4); border-color: #ff6600; }
#btn-left { left: 15px; bottom: 30px; width: 70px; height: 70px; }
#btn-right { left: 95px; bottom: 30px; width: 70px; height: 70px; }
#btn-gas { right: 15px; bottom: 60px; width: 80px; height: 80px; background: rgba(0,200,0,0.2); border-color: rgba(0,200,0,0.4); }
#btn-brake { right: 15px; bottom: -10px; width: 70px; height: 70px; background: rgba(200,0,0,0.2); border-color: rgba(200,0,0,0.4); }
#btn-handbrake { right: 110px; bottom: 30px; width: 60px; height: 60px; background: rgba(255,165,0,0.2); border-color: rgba(255,165,0,0.4); font-size: 14px; }

/* Surface indicator */
#surface-indicator { position: absolute; bottom: 210px; right: 30px; color: #fff; font-size: 12px; background: rgba(0,0,0,0.5); padding: 3px 10px; border-radius: 4px; }
</style>
</head>
<body>

<div id="hud">
  <div id="lap-info"><span class="lap-num" id="lap-display">LAP 1 / 3</span><br><span class="time" id="timer-display">00:00.000</span></div>
  <div id="best-lap">BEST LAP: --:--.---</div>
  <div id="minimap"><canvas id="minimap-canvas" width="150" height="150"></canvas></div>
  <div id="speedo"><span id="speed-val">0</span><span id="speed-unit">KM/H</span><div id="rpm-bar"><div id="rpm-fill"></div></div></div>
  <div id="gear-display">N</div>
  <div id="surface-indicator">ASPHALT</div>
</div>

<div id="countdown"></div>
<div id="start-prompt">Click or press any key to start</div>
<div id="lap-notification"></div>

<div id="pause-menu">
  <h1>PAUSED</h1>
  <button class="menu-btn" onclick="RallyGame.resume()">RESUME</button>
  <button class="menu-btn" onclick="RallyGame.restart()">RESTART</button>
</div>

<div id="finish-screen">
  <h1>RACE COMPLETE!</h1>
  <div id="finish-stats"></div>
  <button class="menu-btn" onclick="RallyGame.restart()">RACE AGAIN</button>
</div>

<div id="touch-controls">
  <div class="touch-btn" id="btn-left">&#9664;</div>
  <div class="touch-btn" id="btn-right">&#9654;</div>
  <div class="touch-btn" id="btn-gas">&#9650;</div>
  <div class="touch-btn" id="btn-brake">STOP</div>
  <div class="touch-btn" id="btn-handbrake">HBK</div>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ============================================================
// RALLY RACER 3D - Single-file rally racing game
// ============================================================
const RallyGame = (function() {
'use strict';

// ==================== CONSTANTS ====================
const TOTAL_LAPS = 3;
const TRACK_WIDTH = 12;
const CAR_LENGTH = 4.5;
const CAR_WIDTH = 2.0;
const MAX_SPEED_ON_TRACK = 65; // m/s (~234 km/h)
const ACCELERATION = 28;
const BRAKE_FORCE = 40;
const REVERSE_ACCEL = 12;
const STEER_RATE = 2.8;
const DRIFT_THRESHOLD = 25;

// ==================== GAME STATE ====================
let state = {
  phase: 'countdown', // countdown, racing, paused, finished
  lap: 1,
  currentCheckpoint: 0,
  totalCheckpoints: 0,
  startTime: 0,
  elapsedTime: 0,
  lapTimes: [],
  bestLap: Infinity,
  countdownValue: 3,
  countdownTimer: 0,
};

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

function onKey(e) {
  keys[e.code] = e.type === 'keydown';
  if (e.type === 'keydown') {
    if (e.code === 'KeyP' && state.phase !== 'finished') togglePause();
    if (e.code === 'KeyR' && state.phase !== 'countdown') restart();
    if (e.code === 'KeyC') toggleCamera();
  }
}

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

function input() {
  return {
    accel: keys['ArrowUp'] || keys['KeyW'] || touchInput.gas,
    brake: keys['ArrowDown'] || keys['KeyS'] || touchInput.brake,
    left: keys['ArrowLeft'] || keys['KeyA'] || touchInput.left,
    right: keys['ArrowRight'] || keys['KeyD'] || touchInput.right,
    handbrake: keys['Space'] || touchInput.handbrake,
  };
}

// ==================== AUDIO ENGINE (Web Audio API) ====================
let audioCtx = null;
let engineOsc = null, engineGain = null;
let skidNode = null, skidGain = null;

function initAudio() {
  if (audioCtx) return;
  try {
    audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    
    // Engine sound - oscillator with noise
    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;
    engineOsc.connect(engineFilter);
    engineFilter.connect(engineGain);
    engineGain.connect(audioCtx.destination);
    engineOsc.start();
    
    // Skid/gravel noise
    const bufferSize = audioCtx.sampleRate * 2;
    const skidBuffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
    const data = skidBuffer.getChannelData(0);
    for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
    skidNode = audioCtx.createBufferSource();
    skidNode.buffer = skidBuffer;
    skidNode.loop = true;
    const skidFilter = audioCtx.createBiquadFilter();
    skidFilter.type = 'bandpass';
    skidFilter.frequency.value = 2000;
    skidFilter.Q.value = 0.5;
    skidGain = audioCtx.createGain();
    skidGain.gain.value = 0;
    skidNode.connect(skidFilter);
    skidFilter.connect(skidGain);
    skidGain.connect(audioCtx.destination);
    skidNode.start();
  } catch(e) { console.warn('Audio init failed:', e); }
}

function updateEngineSound(speed, gear) {
  if (!engineOsc) return;
  const rpm = Math.max(60, 60 + Math.abs(speed) * 15 + (gear - 1) * 80);
  engineOsc.frequency.setTargetAtTime(rpm, audioCtx.currentTime, 0.05);
  const vol = state.phase === 'racing' ? Math.min(0.12, 0.02 + Math.abs(speed) / MAX_SPEED_ON_TRACK * 0.1) : 0;
  engineGain.gain.setTargetAtTime(vol, audioCtx.currentTime, 0.05);
}

function updateSkidSound(isDrifting, isOffRoad) {
  if (!skidGain) return;
  const vol = (isDrifting || isOffRoad) ? 0.06 : 0;
  skidGain.gain.setTargetAtTime(vol, audioCtx.currentTime, 0.1);
}

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;
  osc.connect(gain);
  gain.connect(audioCtx.destination);
  osc.start();
  gain.gain.setTargetAtTime(0, audioCtx.currentTime + duration * 0.8, duration * 0.1);
  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 src = audioCtx.createBufferSource();
  src.buffer = buffer;
  const gain = audioCtx.createGain();
  gain.gain.value = 0.4;
  const filter = audioCtx.createBiquadFilter();
  filter.type = 'lowpass';
  filter.frequency.value = 600;
  src.connect(filter);
  filter.connect(gain);
  gain.connect(audioCtx.destination);
  src.start();
}

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

// ==================== TRACK GENERATION ====================
let trackPoints = []; // Interpolated track points (dense array)
let trackControlPoints = []; // Original control points
let checkpoints = []; // Checkpoint indices for lap validation
let sceneryObjects = []; // Trees, rocks, signs etc.

function generateTrack() {
  // Define a winding loop with curves and hills
  const cp = [
    { x: 0, z: 0, y: 0 },
    { x: 80, z: -30, y: 2 },
    { x: 160, z: -10, y: 5 },
    { x: 220, z: 40, y: 3 },
    { x: 250, z: 120, y: 8 },
    { x: 220, z: 200, y: 6 },
    { x: 160, z: 260, y: 4 },
    { x: 80, z: 300, y: 7 },
    { x: -20, z: 310, y: 5 },
    { x: -100, z: 280, y: 9 },
    { x: -160, z: 220, y: 6 },
    { x: -200, z: 140, y: 3 },
    { x: -210, z: 50, y: 2 },
    { x: -180, z: -30, y: 4 },
    { x: -100, z: -70, y: 1 },
    { x: -40, z: -50, y: 0 },
  ];
  
  trackControlPoints = cp;
  
  // Catmull-Rom spline interpolation for smooth track
  const numSegments = 800;
  trackPoints = [];
  
  function catmullRom(p0, p1, p2, p3, t) {
    const t2 = t * t, t3 = t2 * t;
    return {
      x: 0.5 * ((2*p1.x) + (-p0.x+p2.x)*t + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t2 + (-p0.x+3*p1.x-3*p2.x+p3.x)*t3),
      y: 0.5 * ((2*p1.y) + (-p0.y+p2.y)*t + (2*p0.y-5*p1.y+4*p2.y-p3.y)*t2 + (-p0.y+3*p1.y-3*p2.y+p3.y)*t3),
      z: 0.5 * ((2*p1.z) + (-p0.z+p2.z)*t + (2*p0.z-5*p1.z+4*p2.z-p3.z)*t2 + (-p0.z+3*p1.z-3*p2.z+p3.z)*t3),
    };
  }
  
  const n = cp.length;
  for (let i = 0; i < numSegments; i++) {
    const idx = Math.floor(i / (numSegments / n));
    const t = (i % (numSegments / n)) / (numSegments / n);
    const p0 = cp[(idx - 1 + n) % n];
    const p1 = cp[idx % n];
    const p2 = cp[(idx + 1) % n];
    const p3 = cp[(idx + 2) % n];
    trackPoints.push(catmullRom(p0, p1, p2, p3, t));
  }
  
  // Set up checkpoints (8 evenly spaced around the track)
  state.totalCheckpoints = 8;
  const checkpointInterval = Math.floor(trackPoints.length / state.totalCheckpoints);
  for (let i = 0; i < state.totalCheckpoints; i++) {
    checkpoints.push(i * checkpointInterval);
  }
}

// ==================== THREE.JS SCENE SETUP ====================
let scene, camera, renderer, clock;
let carGroup, carBody, wheels = [];
let trackMesh, groundMesh;
let particles = [];
let skidMarks = [];
let camMode = 0; // 0=chase, 1=cinematic

function initScene() {
  scene = new THREE.Scene();
  // Sky gradient using a large sphere
  const skyGeo = new THREE.SphereGeometry(500, 16, 16);
  const skyMat = new THREE.ShaderMaterial({
    uniforms: {
      topColor: { value: new THREE.Color(0x0044aa) },
      bottomColor: { value: new THREE.Color(0xaaddff) },
      offset: { value: 20 },
      exponent: { value: 0.6 }
    },
    vertexShader: `
      varying vec3 vWorldPosition;
      void main() {
        vec4 worldPos = modelMatrix * vec4(position, 1.0);
        vWorldPosition = worldPos.xyz;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
      }
    `,
    fragmentShader: `
      uniform vec3 topColor;
      uniform vec3 bottomColor;
      uniform float offset;
      uniform float exponent;
      varying vec3 vWorldPosition;
      void main() {
        float h = normalize(vWorldPosition + offset).y;
        gl_FragColor = vec4(mix(bottomColor, topColor, max(pow(max(h, 0.0), exponent), 0.0)), 1.0);
      }
    `,
    side: THREE.BackSide
  });
  const skyMesh = new THREE.Mesh(skyGeo, skyMat);
  scene.add(skyMesh);
  
  // Clouds
  for (let i = 0; i < 25; 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(8 + Math.random() * 12, 7, 5);
      const puffMat = new THREE.MeshLambertMaterial({ color: 0xffffff, transparent: true, opacity: 0.8 });
      const puff = new THREE.Mesh(puffGeo, puffMat);
      puff.position.set(j * 10 - numPuffs * 5 + Math.random() * 5, Math.random() * 3, Math.random() * 6);
      puff.scale.y = 0.4;
      cloudGroup.add(puff);
    }
    const angle = (i / 25) * Math.PI * 2;
    const radius = 150 + Math.random() * 200;
    cloudGroup.position.set(Math.cos(angle) * radius, 60 + Math.random() * 40, Math.sin(angle) * radius);
    scene.add(cloudGroup);
  }
  
  scene.fog = new THREE.FogExp2(0xaaddff, 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.insertBefore(renderer.domElement, document.getElementById('hud'));
  
  clock = new THREE.Clock();
  
  // Lighting
  const ambientLight = new THREE.AmbientLight(0x6688aa, 0.5);
  scene.add(ambientLight);
  
  const sunLight = new THREE.DirectionalLight(0xffeedd, 1.2);
  sunLight.position.set(100, 80, 50);
  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 = -100;
  sunLight.shadow.camera.right = 100;
  sunLight.shadow.camera.top = 100;
  sunLight.shadow.camera.bottom = -100;
  scene.add(sunLight);
  
  const hemiLight = new THREE.HemisphereLight(0x87CEEB, 0x445522, 0.3);
  scene.add(hemiLight);
}

// ==================== BUILD TRACK MESH ====================
function buildTrackMesh() {
  // Create track surface as a ribbon mesh
  const vertices = [];
  const colors = [];
  const indices = [];
  
  for (let i = 0; i < trackPoints.length; i++) {
    const p = trackPoints[i];
    const nextP = trackPoints[(i + 1) % trackPoints.length];
    
    // Direction vector
    const dx = nextP.x - p.x;
    const dz = nextP.z - p.z;
    const len = Math.sqrt(dx * dx + dz * dz);
    const nx = -dz / len;
    const nz = dx / len;
    
    const hw = TRACK_WIDTH / 2;
    
    // Left edge (with slight color variation for visual interest)
    vertices.push(p.x + nx * hw, p.y + 0.1, p.z + nz * hw);
    colors.push(0.35, 0.35, 0.38);
    // Right edge
    vertices.push(p.x - nx * hw, p.y + 0.1, p.z - nz * hw);
    colors.push(0.35, 0.35, 0.38);
    
    // Create quads (exactly 2 vertices per segment)
    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);
    }
  }
  
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
  geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
  geometry.setIndex(indices);
  geometry.computeVertexNormals();
  
  const material = new THREE.MeshLambertMaterial({ vertexColors: true });
  trackMesh = new THREE.Mesh(geometry, material);
  trackMesh.receiveShadow = true;
  scene.add(trackMesh);
  
  // Add center line dashes using thin planes along the track
  buildCenterLine();
  
  // Add start/finish checkerboard line
  buildStartFinishLine();
}

function buildCenterLine() {
  const dashLength = 3;
  const gapLength = 2;
  let totalDist = 0;
  
  // Calculate cumulative distances
  const dists = [0];
  for (let i = 0; i < trackPoints.length; i++) {
    const p = trackPoints[i];
    const nextP = trackPoints[(i + 1) % trackPoints.length];
    totalDist += Math.sqrt((nextP.x-p.x)**2 + (nextP.z-p.z)**2);
    dists.push(totalDist);
  }
  
  // Place dashes along the track
  let dashStart = 0;
  while (dashStart < totalDist) {
    const pIdx = findPointAtDistance(dashStart, dists, totalDist);
    if (pIdx >= 0) {
      const p = trackPoints[pIdx];
      const nextP = trackPoints[(pIdx + 1) % trackPoints.length];
      const dx = nextP.x - p.x, dz = nextP.z - p.z;
      const dirLen = Math.sqrt(dx*dx+dz*dz);
      
      // Small white dash plane
      const dashGeo = new THREE.PlaneGeometry(0.15, dashLength);
      const dashMat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide });
      const dash = new THREE.Mesh(dashGeo, dashMat);
      dash.position.set(p.x, p.y + 0.12, p.z);
      dash.rotation.x = -Math.PI / 2;
      dash.rotation.z = -Math.atan2(dx, dz) + Math.PI/2;
      scene.add(dash);
    }
    
    dashStart += dashLength + gapLength;
  }
}

function findPointAtDistance(targetDist, dists, totalDist) {
  // Binary search for the segment containing target distance
  let lo = 0, hi = dists.length - 1;
  while (lo < hi - 1) {
    const mid = (lo + hi) >> 1;
    if (dists[mid] <= targetDist % totalDist) lo = mid;
    else hi = mid;
  }
  return Math.min(lo, trackPoints.length - 1);
}

function buildStartFinishLine() {
  // Checkerboard pattern at start of track
  const p0 = trackPoints[0];
  const p1 = trackPoints[1];
  const dx = p1.x - p0.x, dz = p1.z - p0.z;
  const len = Math.sqrt(dx*dx+dz*dz);
  const nx = -dz/len, nz = dx/len;
  
  // Create checkerboard using small planes
  const tileSize = TRACK_WIDTH / 8;
  for (let row = 0; row < 2; row++) {
    for (let col = 0; col < 8; col++) {
      if ((row + col) % 2 === 0) continue; // Skip every other tile
      
      const geo = new THREE.PlaneGeometry(tileSize, tileSize);
      const mat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide });
      const tile = new THREE.Mesh(geo, mat);
      
      const offsetZ = (row - 0.5) * tileSize;
      const offsetX = (col - 3.5) * tileSize;
      
      tile.position.set(
        p0.x + nx * offsetX + dx/len * offsetZ,
        p0.y + 0.13,
        p0.z + nz * offsetX + dz/len * offsetZ
      );
      tile.rotation.x = -Math.PI / 2;
      tile.rotation.z = Math.atan2(dx, dz);
      scene.add(tile);
    }
  }
}

// ==================== GROUND PLANE ====================
function buildGround() {
  const groundGeo = new THREE.PlaneGeometry(800, 800, 40, 40);
  
  // Add some terrain variation
  const pos = groundGeo.attributes.position;
  for (let i = 0; i < pos.count; i++) {
    const x = pos.getX(i), y = pos.getY(i);
    const dist = Math.sqrt(x*x + y*y);
    if (dist > 150) {
      pos.setZ(i, (Math.sin(x*0.02)*Math.cos(y*0.03)) * 8 + Math.random() * 2);
    }
  }
  groundGeo.computeVertexNormals();
  
  const groundMat = new THREE.MeshLambertMaterial({ color: 0x4a7c3f });
  groundMesh = new THREE.Mesh(groundGeo, groundMat);
  groundMesh.rotation.x = -Math.PI / 2;
  groundMesh.position.y = -0.5;
  groundMesh.receiveShadow = true;
  scene.add(groundMesh);
}

// ==================== SCENERY GENERATION ====================
function buildScenery() {
  // Trees
  for (let i = 0; i < trackPoints.length; i += 8) {
    const p = trackPoints[i];
    const nextP = trackPoints[(i+1)%trackPoints.length];
    const dx = nextP.x - p.x, dz = nextP.z - p.z;
    const len = Math.sqrt(dx*dx+dz*dz);
    const nx = -dz/len, nz = dx/len;
    
    // Place trees on both sides of track
    for (let side = -1; side <= 1; side += 2) {
      if (Math.random() > 0.4) continue;
      const dist = TRACK_WIDTH/2 + 5 + Math.random() * 30;
      const tx = p.x + nx * dist * side + (Math.random()-0.5)*10;
      const tz = p.z + nz * dist * side + (Math.random()-0.5)*10;
      
      // Simple tree: trunk + foliage cone
      const treeGroup = new THREE.Group();
      
      const trunkGeo = new THREE.CylinderGeometry(0.3, 0.4, 3, 6);
      const trunkMat = new THREE.MeshLambertMaterial({ color: 0x8B5E3C });
      const trunk = new THREE.Mesh(trunkGeo, trunkMat);
      trunk.position.y = 1.5;
      trunk.castShadow = true;
      treeGroup.add(trunk);
      
      const foliageGeo = new THREE.ConeGeometry(2 + Math.random(), 4 + Math.random()*3, 6);
      const greenShade = 0x2d6b1e + Math.floor(Math.random() * 0x1a3a0f);
      const foliageMat = new THREE.MeshLambertMaterial({ color: greenShade });
      const foliage = new THREE.Mesh(foliageGeo, foliageMat);
      foliage.position.y = 5;
      foliage.castShadow = true;
      treeGroup.add(foliage);
      
      // Second layer for some trees
      if (Math.random() > 0.5) {
        const f2 = new THREE.Mesh(new THREE.ConeGeometry(1.5, 3, 6), foliageMat);
        f2.position.y = 7;
        f2.castShadow = true;
        treeGroup.add(f2);
      }
      
      treeGroup.position.set(tx, p.y - 0.5, tz);
      scene.add(treeGroup);
    }
  }
  
  // Rocks scattered around
  for (let i = 0; i < trackPoints.length; i += 15) {
    const p = trackPoints[i];
    const nextP = trackPoints[(i+1)%trackPoints.length];
    const dx = nextP.x - p.x, dz = nextP.z - p.z;
    const len = Math.sqrt(dx*dx+dz*dz);
    const nx = -dz/len, nz = dx/len;
    
    if (Math.random() > 0.5) continue;
    const dist = TRACK_WIDTH/2 + 3 + Math.random() * 15;
    const side = Math.random() > 0.5 ? 1 : -1;
    
    const rockGeo = new THREE.DodecahedronGeometry(0.8 + Math.random()*1.5, 1);
    // Deform vertices for natural look
    const rPos = rockGeo.attributes.position;
    for (let j = 0; j < rPos.count; j++) {
      rPos.setX(j, rPos.getX(j) * (0.7 + Math.random()*0.6));
      rPos.setY(j, rPos.getY(j) * (0.5 + Math.random()*0.4));
      rPos.setZ(j, rPos.getZ(j) * (0.7 + Math.random()*0.6));
    }
    rockGeo.computeVertexNormals();
    
    const rockMat = new THREE.MeshLambertMaterial({ color: 0x888888 });
    const rock = new THREE.Mesh(rockGeo, rockMat);
    rock.position.set(p.x + nx*dist*side, p.y - 0.2, p.z + nz*dist*side);
    rock.rotation.set(Math.random(), Math.random(), Math.random());
    rock.castShadow = true;
    scene.add(rock);
    
    // Store as obstacle for collision
    sceneryObjects.push({ mesh: rock, radius: 1.5 });
  }
  
  // Banners/signs at start and checkpoints
  const bannerPositions = [0, Math.floor(trackPoints.length/4), Math.floor(trackPoints.length/2), Math.floor(3*trackPoints.length/4)];
  bannerPositions.forEach(idx => {
    const p = trackPoints[idx];
    const nextP = trackPoints[(idx+1)%trackPoints.length];
    const dx = nextP.x - p.x, dz = nextP.z - p.z;
    const len = Math.sqrt(dx*dx+dz*dz);
    const nx = -dz/len, nz = dx/len;
    
    // Banner pole on left side
    for (let s = -1; s <= 1; s += 2) {
      const poleGeo = new THREE.CylinderGeometry(0.1, 0.1, 6, 4);
      const poleMat = new THREE.MeshLambertMaterial({ color: 0xcccccc });
      const pole = new THREE.Mesh(poleGeo, poleMat);
      pole.position.set(p.x + nx*(TRACK_WIDTH/2+1)*s, p.y+3, p.z + nz*(TRACK_WIDTH/2+1)*s);
      scene.add(pole);
    }
    
    // Banner between poles
    const bannerGeo = new THREE.PlaneGeometry(TRACK_WIDTH+4, 2.5);
    const isStart = idx === 0;
    const bannerMat = new THREE.MeshLambertMaterial({ 
      color: isStart ? 0xff6600 : (idx % 2 === 0 ? 0x3399ff : 0x33cc33),
      side: THREE.DoubleSide
    });
    const banner = new THREE.Mesh(bannerGeo, bannerMat);
    
    // Position banner above track
    banner.position.set(p.x, p.y + 5.5, p.z);
    banner.rotation.y = Math.atan2(dx, dz);
    scene.add(banner);
  });
  
  // Guard rails along some sections
  for (let i = 0; i < trackPoints.length; i += 4) {
    const p = trackPoints[i];
    if (Math.random() > 0.3) continue;
    
    const nextP = trackPoints[(i+1)%trackPoints.length];
    const dx = nextP.x - p.x, dz = nextP.z - p.z;
    const len = Math.sqrt(dx*dx+dz*dz);
    const nx = -dz/len, nz = dx/len;
    
    // Small guardrail post on one side
    const side = (i % 8 < 4) ? 1 : -1;
    const postGeo = new THREE.BoxGeometry(0.15, 0.8, 0.15);
    const postMat = new THREE.MeshLambertMaterial({ color: 0xdddddd });
    const post = new THREE.Mesh(postGeo, postMat);
    post.position.set(p.x + nx*(TRACK_WIDTH/2+0.3)*side, p.y+0.4, p.z + nz*(TRACK_WIDTH/2+0.3)*side);
    scene.add(post);
    
    // Rail between posts (every other)
    if (i % 8 === 0) {
      const railGeo = new THREE.BoxGeometry(0.1, 0.15, 4);
      const railMat = new THREE.MeshLambertMaterial({ color: 0xcccccc });
      const rail = new THREE.Mesh(railGeo, railMat);
      rail.position.set(p.x + nx*(TRACK_WIDTH/2+0.3)*side, p.y+0.7, p.z + nz*(TRACK_WIDTH/2+0.3)*side);
      rail.rotation.y = Math.atan2(dx, dz);
      scene.add(rail);
    }
  }
}

// ==================== BUILD RALLY CAR ====================
function buildCar() {
  carGroup = new THREE.Group();
  
  // Main body
  const bodyGeo = new THREE.BoxGeometry(CAR_WIDTH, 0.8, CAR_LENGTH * 0.7);
  const bodyMat = new THREE.MeshLambertMaterial({ color: 0xcc2200 });
  carBody = new THREE.Mesh(bodyGeo, bodyMat);
  carBody.position.y = 0.6;
  carBody.castShadow = true;
  carGroup.add(carBody);
  
  // Cabin/roof
  const cabinGeo = new THREE.BoxGeometry(CAR_WIDTH * 0.85, 0.7, CAR_LENGTH * 0.4);
  const cabinMat = new THREE.MeshLambertMaterial({ color: 0x222233 });
  const cabin = new THREE.Mesh(cabinGeo, cabinMat);
  cabin.position.set(0, 1.25, -0.2);
  cabin.castShadow = true;
  carGroup.add(cabin);
  
  // Windshield (front)
  const windshieldGeo = new THREE.PlaneGeometry(CAR_WIDTH * 0.8, 0.6);
  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.05);
  windshield.rotation.x = -Math.PI * 0.3;
  carGroup.add(windshield);
  
  // Spoiler
  const spoilerGeo = new THREE.BoxGeometry(CAR_WIDTH + 0.4, 0.08, 0.6);
  const spoilerMat = new THREE.MeshLambertMaterial({ color: 0x111111 });
  const spoiler = new THREE.Mesh(spoilerGeo, spoilerMat);
  spoiler.position.set(0, 1.4, -CAR_LENGTH * 0.35);
  carGroup.add(spoiler);
  
  // Spoiler supports
  for (let s = -1; s <= 1; s += 2) {
    const supportGeo = new THREE.BoxGeometry(0.08, 0.4, 0.08);
    const support = new THREE.Mesh(supportGeo, spoilerMat);
    support.position.set(s * CAR_WIDTH * 0.35, 1.2, -CAR_LENGTH * 0.35);
    carGroup.add(support);
  }
  
  // Headlights (front)
  for (let s = -1; s <= 1; s += 2) {
    const hlGeo = new THREE.SphereGeometry(0.15, 6, 4);
    const hlMat = new THREE.MeshLambertMaterial({ color: 0xffffcc, emissive: 0xffffaa });
    const hl = new THREE.Mesh(hlGeo, hlMat);
    hl.position.set(s * CAR_WIDTH * 0.35, 0.6, CAR_LENGTH * 0.35);
    carGroup.add(hl);
  }
  
  // Taillights (rear)
  for (let s = -1; s <= 1; s += 2) {
    const tlGeo = new THREE.BoxGeometry(0.3, 0.15, 0.05);
    const tlMat = new THREE.MeshLambertMaterial({ color: 0xff0000, emissive: 0xff0000 });
    const tl = new THREE.Mesh(tlGeo, tlMat);
    tl.position.set(s * CAR_WIDTH * 0.35, 0.6, -CAR_LENGTH * 0.37);
    carGroup.add(tl);
  }
  
  // Wheels (4)
  const wheelGeo = new THREE.CylinderGeometry(0.35, 0.35, 0.25, 8);
  const wheelMat = new THREE.MeshLambertMaterial({ color: 0x1a1a1a });
  
  const wheelPositions = [
    { x: -CAR_WIDTH/2 - 0.1, y: 0.35, z: CAR_LENGTH * 0.25 },
    { x: CAR_WIDTH/2 + 0.1, y: 0.35, z: CAR_LENGTH * 0.25 },
    { x: -CAR_WIDTH/2 - 0.1, y: 0.35, z: -CAR_LENGTH * 0.25 },
    { x: CAR_WIDTH/2 + 0.1, y: 0.35, z: -CAR_LENGTH * 0.25 },
  ];
  
  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);
    
    // 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(pos.x, pos.y, pos.z);
    carGroup.add(hub);
  });
  
  // Rally number on side
  const numCanvas = document.createElement('canvas');
  numCanvas.width = 64; numCanvas.height = 64;
  const ctx2d = numCanvas.getContext('2d');
  ctx2d.fillStyle = '#ffffff';
  ctx2d.fillRect(0, 0, 64, 64);
  ctx2d.fillStyle = '#cc2200';
  ctx2d.font = 'bold 36px Arial';
  ctx2d.textAlign = 'center';
  ctx2d.fillText('7', 32, 45);
  
  const numTexture = new THREE.CanvasTexture(numCanvas);
  const numGeo = new THREE.PlaneGeometry(0.8, 0.8);
  const numMat = new THREE.MeshLambertMaterial({ map: numTexture });
  
  for (let s = -1; s <= 1; s += 2) {
    const numMesh = new THREE.Mesh(numGeo, numMat);
    numMesh.position.set(s * (CAR_WIDTH/2 + 0.06), 0.7, 0.3);
    numMesh.rotation.y = s > 0 ? Math.PI / 2 : -Math.PI / 2;
    carGroup.add(numMesh);
  }
  
  scene.add(carGroup);
}

// ==================== CAR PHYSICS ====================
let carState = {
  x: 0, y: 0, z: 0,
  heading: 0, // radians, facing +X direction initially
  speed: 0,
  lateralSpeed: 0,
  angularVel: 0,
  gear: 1,
  rpm: 0,
  isDrifting: false,
  surfaceType: 'asphalt',
};

function getTrackInfo(x, z) {
  // Find closest track point and distance from center
  let minDist = Infinity;
  let closestIdx = 0;
  
  // Search nearby checkpoints first for efficiency
  const searchRange = 50;
  const startSearch = Math.max(0, state.currentCheckpoint - searchRange);
  const endSearch = Math.min(trackPoints.length, state.currentCheckpoint + searchRange);
  
  for (let i = startSearch; i < endSearch; i++) {
    const p = trackPoints[i];
    const dx = x - p.x, dz = z - p.z;
    const d = dx*dx + dz*dz;
    if (d < minDist) {
      minDist = d;
      closestIdx = i;
    }
  }
  
  // Also check wrap-around region
  for (let i = 0; i < searchRange && i < trackPoints.length - searchRange; i++) {
    const p = trackPoints[i];
    const dx = x - p.x, dz = z - p.z;
    const d = dx*dx + dz*dz;
    if (d < minDist) {
      minDist = d;
      closestIdx = i;
    }
  }
  
  const dist = Math.sqrt(minDist);
  const p = trackPoints[closestIdx];
  const nextP = trackPoints[(closestIdx + 1) % trackPoints.length];
  
  // Track direction
  const dx = nextP.x - p.x;
  const dz = nextP.z - p.z;
  const dirLen = Math.sqrt(dx*dx + dz*dz);
  const trackDirX = dx / dirLen;
  const trackDirZ = dz / dirLen;
  
  // Normal to track (left side)
  const normalX = -trackDirZ;
  const normalZ = trackDirX;
  
  // Lateral offset from center
  const lateralOffset = ((x - p.x) * normalX + (z - p.z) * normalZ);
  
  let surfaceType = 'asphalt';
  if (dist > TRACK_WIDTH / 2 + 3) {
    surfaceType = 'grass';
  } else if (dist > TRACK_WIDTH / 2) {
    surfaceType = 'gravel';
  }
  
  return {
    closestIdx,
    distance: dist,
    lateralOffset,
    trackY: p.y,
    trackDirX,
    trackDirZ,
    normalX,
    normalZ,
    surfaceType,
    onTrack: dist <= TRACK_WIDTH / 2,
  };
}

function updateCarPhysics(dt) {
  const inp = input();
  
  // Get track info
  const trackInfo = getTrackInfo(carState.x, carState.z);
  carState.surfaceType = trackInfo.surfaceType;
  
  // Surface properties
  let maxSpeed = MAX_SPEED_ON_TRACK;
  let accelMult = 1.0;
  let gripFactor = 1.0;
  let friction = 0.98;
  
  switch (trackInfo.surfaceType) {
    case 'asphalt': break;
    case 'gravel': maxSpeed *= 0.75; accelMult = 0.7; gripFactor = 0.6; friction = 0.96; break;
    case 'grass': maxSpeed *= 0.5; accelMult = 0.4; gripFactor = 0.35; friction = 0.92; break;
  }
  
  // Acceleration
  if (inp.accel) {
    carState.speed += ACCELERATION * accelMult * dt;
  } else if (inp.brake) {
    if (carState.speed > 1) {
      carState.speed -= BRAKE_FORCE * dt;
    } else {
      carState.speed -= REVERSE_ACCEL * dt;
    }
  } else {
    // Natural deceleration
    carState.speed *= Math.pow(friction, dt * 60);
  }
  
  // Clamp speed
  carState.speed = Math.max(-15, Math.min(maxSpeed, carState.speed));
  
  // Handbrake - reduces grip dramatically
  if (inp.handbrake) {
    gripFactor *= 0.2;
    friction = 0.94;
  }
  
  // Steering
  const speedRatio = Math.abs(carState.speed) / maxSpeed;
  let steerAmount = 0;
  if (Math.abs(carState.speed) > 0.5) {
    const steerFactor = STEER_RATE * (1 - speedRatio * 0.4); // Less steering at high speed
    if (inp.left) steerAmount = steerFactor;
    if (inp.right) steerAmount = -steerFactor;
    
    // Reverse steering direction when going backwards
    if (carState.speed < 0) steerAmount *= -1;
  }
  
  carState.angularVel += steerAmount * dt * Math.min(1, Math.abs(carState.speed) / 5);
  carState.angularVel *= 0.85; // Angular damping
  
  // Apply rotation
  carState.heading += carState.angularVel * dt;
  
  // Drift detection
  const lateralGrip = gripFactor;
  const driftAngle = Math.abs(carState.angularVel) * Math.abs(carState.speed);
  carState.isDrifting = driftAngle > DRIFT_THRESHOLD && !inp.handbrake || (driftAngle > 15 && inp.handbrake);
  
  if (!carState.isDrifting) {
    // Align velocity with heading gradually based on grip
    const alignRate = 3 * lateralGrip;
    carState.lateralSpeed *= Math.pow(0.9, dt * 60 * alignRate);
  } else {
    // Build up lateral speed when drifting
    carState.lateralSpeed += carState.angularVel * carState.speed * dt * 2;
    carState.lateralSpeed = Math.max(-15, Math.min(15, carState.lateralSpeed));
  }
  
  // Calculate velocity components in world space
  const forwardX = Math.sin(carState.heading);
  const forwardZ = Math.cos(carState.heading);
  const rightX = Math.cos(carState.heading);
  const rightZ = -Math.sin(carState.heading);
  
  // Move car
  const moveSpeed = carState.speed * dt;
  const lateralMove = carState.lateralSpeed * dt * (1 - gripFactor) * 0.5;
  
  carState.x += forwardX * moveSpeed + rightX * lateralMove;
  carState.z += forwardZ * moveSpeed + rightZ * lateralMove;
  
  // Elevation: follow track height with some smoothing
  const targetY = trackInfo.trackY;
  carState.y += (targetY - carState.y) * Math.min(1, dt * 8);
  
  // Collision with obstacles
  for (const obj of sceneryObjects) {
    const dx = carState.x - obj.mesh.position.x;
    const dz = carState.z - obj.mesh.position.z;
    const dist = Math.sqrt(dx*dx + dz*dz);
    if (dist < obj.radius + 1.2) {
      // Bounce off
      const nx = dx / dist, nz = dz / dist;
      carState.x += nx * (obj.radius + 1.5 - dist);
      carState.z += nz * (obj.radius + 1.5 - dist);
      carState.speed *= 0.3;
      carState.angularVel += (Math.random() - 0.5) * 2;
      playCrashSound();
      
      // Spawn sparks
      spawnSparks(carState.x, carState.y + 1, carState.z);
    }
  }
  
  // Track boundary collision (soft bounce when way off track)
  if (!trackInfo.onTrack && Math.abs(trackInfo.lateralOffset) > TRACK_WIDTH/2 + 8) {
    const pushBack = (Math.abs(trackInfo.lateralOffset) - TRACK_WIDTH/2 - 8) * dt * 3;
    carState.x -= trackInfo.normalX * pushBack * Math.sign(trackInfo.lateralOffset);
    carState.z -= trackInfo.normalZ * pushBack * Math.sign(trackInfo.lateralOffset);
    carState.speed *= 0.95;
  }
  
  // Gear simulation
  const absSpeed = Math.abs(carState.speed);
  if (absSpeed < 1) {
    carState.gear = carState.speed >= 0 ? 1 : -1;
  } else {
    carState.gear = Math.min(6, Math.floor(absSpeed / 12) + 1);
    if (carState.speed < 0) carState.gear = -carState.gear;
  }
  
  // RPM simulation
  const gearRange = 12;
  const speedInGear = absSpeed % gearRange;
  carState.rpm = Math.min(8000, 1500 + (speedInGear / gearRange) * 6500);
  
  // Update audio
  updateEngineSound(carState.speed, carState.gear);
  updateSkidSound(carState.isDrifting, !trackInfo.onTrack);
  
  // Spawn particles when drifting or off-road
  if (carState.isDrifting || (!trackInfo.onTrack && absSpeed > 5)) {
    spawnDust(carState.x - forwardX * CAR_LENGTH * 0.3, carState.y + 0.2, carState.z - forwardZ * CAR_LENGTH * 0.3);
  }
  
  // Checkpoint validation
  checkCheckpoints(trackInfo.closestIdx);
}

// ==================== CHECKPOINT & LAP SYSTEM ====================
function checkCheckpoints(idx) {
  if (state.phase !== 'racing') return;
  
  const cp = checkpoints[state.currentCheckpoint];
  const distToCP = Math.abs(idx - cp);
  const wrapDist = Math.min(distToCP, trackPoints.length - distToCP);
  
  if (wrapDist < 30) {
    state.currentCheckpoint++;
    
    // Check for lap completion
    if (state.currentCheckpoint >= state.totalCheckpoints) {
      completeLap();
    }
  }
}

function completeLap() {
  const lapTime = performance.now() - state.startTime;
  state.lapTimes.push(lapTime);
  
  if (lapTime < state.bestLap) {
    state.bestLap = lapTime;
  }
  
  state.currentCheckpoint = 0;
  state.startTime = performance.now();
  
  if (state.lapTimes.length >= TOTAL_LAPS) {
    finishRace();
  } else {
    state.lap++;
  }
}

function finishRace() {
  state.phase = 'finished';
  playFinishTone();
  
  const totalTime = state.lapTimes.reduce((a, b) => a + b, 0);
  document.getElementById('finish-stats').innerHTML = 
    `Total Time: <span>${formatTime(totalTime)}</span><br>` +
    `Best Lap: <span>${formatTime(state.bestLap)}</span><br>` +
    state.lapTimes.map((t, i) => `<small>Lap ${i+1}: ${formatTime(t)}</small>`).join('<br>');
  
  document.getElementById('finish-screen').style.display = 'flex';
}

// ==================== PARTICLE SYSTEM ====================
function spawnDust(x, y, z) {
  if (particles.length > 200) return; // Limit particles
  
  const geo = new THREE.SphereGeometry(0.15 + Math.random() * 0.2, 4, 3);
  const mat = new THREE.MeshBasicMaterial({ 
    color: carState.surfaceType === 'grass' ? 0x8B7D5B : 0xccbbaa,
    transparent: true, opacity: 0.6
  });
  const mesh = new THREE.Mesh(geo, mat);
  mesh.position.set(x + (Math.random()-0.5)*1, y, z + (Math.random()-0.5)*1);
  
  scene.add(mesh);
  particles.push({
    mesh, life: 1.0, decay: 0.8 + Math.random() * 0.5,
    vx: (Math.random()-0.5) * 2, vy: 0.5 + Math.random(), vz: (Math.random()-0.5) * 2,
  });
}

function spawnSparks(x, y, z) {
  for (let i = 0; i < 8; i++) {
    const geo = new THREE.SphereGeometry(0.05, 3, 2);
    const mat = new THREE.MeshBasicMaterial({ color: 0xffaa00 });
    const mesh = new THREE.Mesh(geo, mat);
    mesh.position.set(x, y, z);
    scene.add(mesh);
    particles.push({
      mesh, life: 1.0, decay: 2 + Math.random() * 2,
      vx: (Math.random()-0.5) * 8, vy: 2 + Math.random()*4, vz: (Math.random()-0.5) * 8,
    });
  }
}

function updateParticles(dt) {
  for (let i = particles.length - 1; i >= 0; i--) {
    const p = particles[i];
    p.life -= p.decay * dt;
    
    if (p.life <= 0) {
      scene.remove(p.mesh);
      p.mesh.geometry.dispose();
      p.mesh.material.dispose();
      particles.splice(i, 1);
      continue;
    }
    
    p.mesh.position.x += p.vx * dt;
    p.mesh.position.y += p.vy * dt;
    p.mesh.position.z += p.vz * dt;
    p.vy -= 2 * dt; // Gravity
    
    if (p.mesh.material.transparent) {
      p.mesh.material.opacity = Math.max(0, p.life * 0.6);
    }
    
    const scale = 1 + (1 - p.life) * 2;
    p.mesh.scale.set(scale, scale, scale);
  }
}

// ==================== CAMERA SYSTEM ====================
let camPos = new THREE.Vector3();
let camTarget = new THREE.Vector3();

function updateCamera(dt) {
  const forwardX = Math.sin(carState.heading);
  const forwardZ = Math.cos(carState.heading);
  
  if (camMode === 0) {
    // Chase camera - behind and above car
    const idealOffset = new THREE.Vector3(
      -forwardX * 12,
      5,
      -forwardZ * 12
    );
    
    const idealPos = new THREE.Vector3(
      carState.x + idealOffset.x,
      carState.y + idealOffset.y,
      carState.z + idealOffset.z
    );
    
    // Smooth camera follow with lag
    camPos.lerp(idealPos, Math.min(1, dt * 4));
    
    // Look slightly ahead of the car
    const lookAhead = new THREE.Vector3(
      carState.x + forwardX * 8,
      carState.y + 2,
      carState.z + forwardZ * 8
    );
    camTarget.lerp(lookAhead, Math.min(1, dt * 6));
    
    camera.position.copy(camPos);
    camera.lookAt(camTarget);
    
  } else {
    // Cinematic camera - higher and wider angle
    const idealOffset = new THREE.Vector3(
      -forwardX * 25,
      18,
      -forwardZ * 25
    );
    
    const idealPos = new THREE.Vector3(
      carState.x + idealOffset.x,
      carState.y + idealOffset.y,
      carState.z + idealOffset.z
    );
    
    camPos.lerp(idealPos, Math.min(1, dt * 2));
    
    const lookAhead = new THREE.Vector3(
      carState.x + forwardX * 5,
      carState.y + 1,
      carState.z + forwardZ * 5
    );
    camTarget.lerp(lookAhead, Math.min(1, dt * 4));
    
    camera.position.copy(camPos);
    camera.lookAt(camTarget);
  }
  
  // Update shadow camera to follow car
  const sunLight = scene.children.find(c => c.isDirectionalLight);
  if (sunLight) {
    sunLight.target.position.set(carState.x, carState.y, carState.z);
    sunLight.position.set(carState.x + 50, 80, carState.z + 30);
  }
}

function toggleCamera() {
  camMode = (camMode + 1) % 2;
}

// ==================== HUD UPDATE ====================
function updateHUD() {
  const speedKmh = Math.abs(Math.round(carState.speed * 3.6));
  document.getElementById('speed-val').textContent = speedKmh;
  
  // Gear display
  const gearText = carState.gear === -1 ? 'R' : (carState.gear === 0 ? 'N' : String(carState.gear));
  document.getElementById('gear-display').textContent = gearText;
  
  // RPM bar
  document.getElementById('rpm-fill').style.width = (carState.rpm / 8000 * 100) + '%';
  
  // Lap display
  document.getElementById('lap-display').textContent = `LAP ${Math.min(state.lap, TOTAL_LAPS)} / ${TOTAL_LAPS}`;
  
  // Timer
  if (state.phase === 'racing') {
    const elapsed = performance.now() - state.startTime;
    let totalTime = state.lapTimes.reduce((a,b) => a+b, 0) + elapsed;
    document.getElementById('timer-display').textContent = formatTime(totalTime);
  } else if (state.phase === 'finished') {
    const total = state.lapTimes.reduce((a,b) => a+b, 0);
    document.getElementById('timer-display').textContent = formatTime(total);
  }
  
  // Best lap
  if (state.bestLap < Infinity) {
    document.getElementById('best-lap').textContent = `BEST LAP: ${formatTime(state.bestLap)}`;
  }
  
  // Surface indicator
  const surfEl = document.getElementById('surface-indicator');
  surfEl.textContent = carState.surfaceType.toUpperCase();
  surfEl.style.color = carState.surfaceType === 'asphalt' ? '#aaaaff' : 
                        carState.surfaceType === 'gravel' ? '#ffaa44' : '#88cc44';
}

function formatTime(ms) {
  const totalSec = ms / 1000;
  const min = Math.floor(totalSec / 60);
  const sec = (totalSec % 60).toFixed(3);
  return `${String(min).padStart(2,'0')}:${sec.padStart(6,'0')}`;
}

// ==================== MINIMAP ====================
function updateMinimap() {
  const canvas = document.getElementById('minimap-canvas');
  const ctx = canvas.getContext('2d');
  ctx.clearRect(0, 0, 150, 150);
  
  // 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 = (150 - padding*2) / (maxX - minX);
  const scaleZ = (150 - padding*2) / (maxZ - minZ);
  const scale = Math.min(scaleX, scaleZ);
  
  const offsetX = (150 - (maxX - minX) * scale) / 2;
  const offsetZ = (150 - (maxZ - minZ) * scale) / 2;
  
  // Draw track
  ctx.strokeStyle = '#666';
  ctx.lineWidth = 4;
  ctx.beginPath();
  for (let i = 0; i < trackPoints.length; i += 5) {
    const p = trackPoints[i];
    const mx = (p.x - minX) * scale + offsetX;
    const mz = (p.z - minZ) * scale + offsetZ;
    if (i === 0) ctx.moveTo(mx, mz);
    else ctx.lineTo(mx, mz);
  }
  ctx.closePath();
  ctx.stroke();
  
  // Draw checkpoints
  for (let i = 0; i < checkpoints.length; i++) {
    const p = trackPoints[checkpoints[i]];
    const mx = (p.x - minX) * scale + offsetX;
    const mz = (p.z - minZ) * scale + offsetZ;
    ctx.fillStyle = i <= state.currentCheckpoint ? '#0f0' : '#555';
    ctx.beginPath();
    ctx.arc(mx, mz, 3, 0, Math.PI*2);
    ctx.fill();
  }
  
  // Draw car position
  const cx = (carState.x - minX) * scale + offsetX;
  const cz = (carState.z - minZ) * scale + offsetZ;
  ctx.fillStyle = '#ff6600';
  ctx.beginPath();
  ctx.arc(cx, cz, 4, 0, Math.PI*2);
  ctx.fill();
  
  // Draw heading indicator
  const forwardX = Math.sin(carState.heading) * 8;
  const forwardZ = Math.cos(carState.heading) * 8;
  ctx.strokeStyle = '#ff6600';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(cx, cz);
  ctx.lineTo(cx + forwardX, cz + forwardZ);
  ctx.stroke();
}

// ==================== GAME FLOW ====================
function startCountdown() {
  state.phase = 'countdown';
  state.countdownValue = 3;
  state.currentCheckpoint = 0;
  
  const cdEl = document.getElementById('countdown');
  cdEl.style.display = 'block';
  cdEl.textContent = '3';
  playBeep(440, 0.2);
  
  let count = 3;
  const interval = setInterval(() => {
    count--;
    if (count > 0) {
      cdEl.textContent = String(count);
      playBeep(440 + (3-count)*100, 0.2);
    } else if (count === 0) {
      cdEl.textContent = 'GO!';
      cdEl.style.color = '#00ff00';
      playBeep(880, 0.4);
      
      setTimeout(() => {
        cdEl.style.display = 'none';
        cdEl.style.color = '#ff6600';
        state.phase = 'racing';
        state.startTime = performance.now();
      }, 500);
    } else {
      clearInterval(interval);
    }
  }, 1000);
}

function togglePause() {
  if (state.phase === 'racing') {
    state.phase = 'paused';
    document.getElementById('pause-menu').style.display = 'flex';
  } else if (state.phase === 'paused') {
    resume();
  }
}

function resume() {
  state.phase = 'racing';
  state.startTime = performance.now() - (state.lapTimes.reduce((a,b) => a+b, 0));
  document.getElementById('pause-menu').style.display = 'none';
}

function restart() {
  // Reset car position to start of track
  const startPos = trackPoints[0];
  const nextPos = trackPoints[1];
  carState.x = startPos.x;
  carState.y = startPos.y;
  carState.z = startPos.z;
  carState.heading = Math.atan2(nextPos.x - startPos.x, nextPos.z - startPos.z);
  carState.speed = 0;
  carState.lateralSpeed = 0;
  carState.angularVel = 0;
  
  // Reset state
  state.lap = 1;
  state.currentCheckpoint = 0;
  state.lapTimes = [];
  state.bestLap = Infinity;
  
  document.getElementById('pause-menu').style.display = 'none';
  document.getElementById('finish-screen').style.display = 'none';
  document.getElementById('best-lap').textContent = 'BEST LAP: --:--.---';
  
  // Clear particles
  for (const p of particles) {
    scene.remove(p.mesh);
    p.mesh.geometry.dispose();
    p.mesh.material.dispose();
  }
  particles = [];
  
  startCountdown();
}

// ==================== MAIN GAME LOOP ====================
function gameLoop() {
  requestAnimationFrame(gameLoop);
  
  const dt = Math.min(clock.getDelta(), 0.05); // Cap delta time
  
  if (state.phase === 'racing') {
    updateCarPhysics(dt);
    
    // Update car visual
    carGroup.position.set(carState.x, carState.y, carState.z);
    carGroup.rotation.y = -carState.heading;
    
    // Wheel rotation animation
    const wheelRotSpeed = carState.speed * dt * 3;
    wheels.forEach(w => { w.rotation.x += wheelRotSpeed; });
    
    // Front wheel steering visual
    if (wheels.length >= 2) {
      const steerAngle = input().left ? 0.3 : (input().right ? -0.3 : 0);
      wheels[0].rotation.y = steerAngle;
      wheels[1].rotation.y = steerAngle;
    }
    
    // Car body tilt during turns
    if (carBody) {
      carBody.rotation.z = -carState.angularVel * 0.15;
    }
  } else if (state.phase === 'countdown') {
    // Position car at start
    const startPos = trackPoints[0];
    const nextPos = trackPoints[1];
    carState.x = startPos.x;
    carState.y = startPos.y;
    carState.z = startPos.z;
    carState.heading = Math.atan2(nextPos.x - startPos.x, nextPos.z - startPos.z);
    
    carGroup.position.set(carState.x, carState.y, carState.z);
    carGroup.rotation.y = -carState.heading;
  }
  
  updateCamera(dt);
  updateParticles(dt);
  updateHUD();
  
  // Update minimap every few frames
  if (Math.floor(performance.now() / 100) % 2 === 0) {
    updateMinimap();
  }
  
  renderer.render(scene, camera);
}

// ==================== INITIALIZATION ====================
function init() {
  // Setup input listeners
  window.addEventListener('keydown', onKey);
  window.addEventListener('keyup', onKey);
  setupTouch();
  
  // Generate track
  generateTrack();
  
  // Initialize Three.js scene
  initScene();
  
  // Build world
  buildGround();
  buildTrackMesh();
  buildScenery();
  buildCar();
  
  // Position car at start
  const startPos = trackPoints[0];
  const nextPos = trackPoints[1];
  carState.x = startPos.x;
  carState.y = startPos.y;
  carState.z = startPos.z - 5; // Start slightly behind the line
  carState.heading = Math.atan2(nextPos.x - startPos.x, nextPos.z - startPos.z);
  
  camPos.set(carState.x - Math.sin(carState.heading)*12, carState.y + 5, carState.z - Math.cos(carState.heading)*12);
  camTarget.set(carState.x, carState.y + 2, carState.z);
  
  // Handle resize
  window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });
  
  // Start countdown on first interaction (for audio context)
  const startHandler = () => {
    initAudio();
    if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
    document.removeEventListener('click', startHandler);
    document.removeEventListener('keydown', startHandler);
    startCountdown();
  };
  document.addEventListener('click', startHandler);
  document.addEventListener('keydown', startHandler);
  
  // Start game loop
  gameLoop();
}

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

})();

// Start the game when page loads
window.addEventListener('DOMContentLoaded', RallyGame.init);
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"full","sessionId":"0d15a114-605d-4f22-a806-6d88e2329502","tokensIn":953970,"tokensOut":26090,"tokensTotal":980060,"turns":25,"toolCalls":24,"failedToolCalls":0,"timestamp":"2026-07-29T15:41:47.012Z"} -->