← Rally results

Rally

thinkingcap qwen3.6 27b · composer

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 61,186 bytes · SHA-256 5c751b73ba0f
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rally Racer 3D</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
/* ── css: layout ── */
* { 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; }
#app { position: relative; width: 100vw; height: 100vh; }
#gameCanvas { display: block; width: 100%; height: 100%; }

/* HUD */
#hud { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; z-index: 10; }
#speedometer { position: absolute; bottom: 20px; right: 20px; color: #fff; font-size: 36px; font-weight: bold; text-shadow: 2px 2px 4px rgba(0,0,0,0.8); }
#gearDisplay { position: absolute; bottom: 65px; right: 20px; color: #ffcc00; font-size: 18px; font-weight: bold; text-shadow: 1px 1px 3px rgba(0,0,0,0.8); }
#rpmBar { position: absolute; bottom: 95px; right: 20px; width: 160px; height: 14px; background: rgba(0,0,0,0.6); border-radius: 7px; overflow: hidden; border: 1px solid #555; }
#rpmFill { height: 100%; width: 0%; background: linear-gradient(to right, #0f0, #ff0, #f00); transition: width 0.1s; }
#lapInfo { position: absolute; top: 20px; left: 50%; transform: translateX(-50%); color: #fff; font-size: 24px; font-weight: bold; text-shadow: 2px 2px 4px rgba(0,0,0,0.8); }
#timerDisplay { position: absolute; top: 50px; left: 50%; transform: translateX(-50%); color: #fff; font-size: 20px; text-shadow: 1px 1px 3px rgba(0,0,0,0.8); }
#bestLapDisplay { position: absolute; top: 75px; left: 50%; transform: translateX(-50%); color: #ffcc00; font-size: 16px; text-shadow: 1px 1px 3px rgba(0,0,0,0.8); }
#lapTimeDisplay { position: absolute; top: 95px; left: 50%; transform: translateX(-50%); color: #aaa; font-size: 14px; text-shadow: 1px 1px 3px rgba(0,0,0,0.8); }
#minimap { position: absolute; bottom: 20px; left: 20px; border-radius: 8px; border: 2px solid rgba(255,255,255,0.4); background: rgba(0,0,0,0.5); }

/* Countdown */
#countdownOverlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; z-index: 20; background: rgba(0,0,0,0.5); }
#countdownText { font-size: 120px; color: #fff; font-weight: bold; text-shadow: 4px 4px 8px rgba(0,0,0,0.9); animation: pulse 0.5s ease-in-out infinite alternate; }
@keyframes pulse { from { transform: scale(1); } to { transform: scale(1.1); } }

/* Pause Menu */
#pauseMenu { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 30; background: rgba(0,0,0,0.7); }
#pauseMenu h2 { color: #fff; font-size: 48px; margin-bottom: 30px; text-shadow: 2px 2px 6px rgba(0,0,0,0.9); }
#pauseMenu button, #finishScreen button { pointer-events: auto; padding: 15px 40px; font-size: 20px; margin: 8px; border: none; border-radius: 8px; cursor: pointer; background: linear-gradient(to bottom, #ff6b35, #e84118); color: #fff; font-weight: bold; box-shadow: 0 4px 8px rgba(0,0,0,0.4); }
#pauseMenu button:hover, #finishScreen button:hover { background: linear-gradient(to bottom, #ff8c5a, #f05530); }

/* Finish Screen */
#finishScreen { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 30; background: rgba(0,0,0,0.8); }
#finishScreen h2 { color: #ffcc00; font-size: 48px; margin-bottom: 20px; text-shadow: 2px 2px 6px rgba(0,0,0,0.9); }
#finishScreen p { color: #fff; font-size: 24px; margin: 5px; }

/* Touch Controls */
#touchControls { position: absolute; bottom: 10px; left: 0; right: 0; z-index: 15; display: flex; justify-content: space-between; padding: 0 20px; pointer-events: none; }
#dpad { display: grid; grid-template-columns: 60px 60px 60px; grid-template-rows: 60px 60px; gap: 4px; pointer-events: auto; }
#actionBtns { display: flex; flex-direction: column; gap: 8px; pointer-events: auto; }
#touchControls button { width: 60px; height: 60px; border-radius: 12px; border: 2px solid rgba(255,255,255,0.4); background: rgba(255,255,255,0.15); color: #fff; font-size: 20px; font-weight: bold; touch-action: none; }
#btnUp { grid-column: 2; grid-row: 1; }
#btnLeft { grid-column: 1; grid-row: 2; }
#btnRight { grid-column: 3; grid-row: 2; }
#btnDown { grid-column: 2; grid-row: 2; }
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<div id="hud" style="display:none;">
  <div id="speedometer"><span id="speedVal">0</span> km/h</div>
  <div id="gearDisplay">Gear: <span id="gearVal">N</span></div>
  <div id="rpmBar"><div id="rpmFill"></div></div>
  <div id="lapInfo">Lap <span id="currentLap">1</span>/3</div>
  <div id="timerDisplay">00:00.000</div>
  <div id="bestLapDisplay">Best Lap: --:--.--</div>
  <div id="lapTimeDisplay">Lap Time: 00:00.000</div>
  <canvas id="minimap" width="160" height="160"></canvas>
</div>
<div id="countdownOverlay"><span id="countdownText">3</span></div>
<div id="pauseMenu" style="display:none;">
  <h2>PAUSED</h2>
  <button id="resumeBtn">Resume</button>
  <button id="restartPauseBtn">Restart</button>
</div>
<div id="finishScreen" style="display:none;">
  <h2>RACE COMPLETE!</h2>
  <p>Final Time: <span id="finalTime"></span></p>
  <p>Best Lap: <span id="bestLapFinish"></span></p>
  <button id="restartBtn">Race Again</button>
</div>
<div id="touchControls" style="display:none;">
  <div id="dpad">
    <button id="btnUp">▲</button>
    <button id="btnLeft">◄</button>
    <button id="btnRight">►</button>
    <button id="btnDown">▼</button>
  </div>
  <div id="actionBtns">
    <button id="btnGas">GAS</button>
    <button id="btnBrake">BRAKE</button>
    <button id="btnHandbrake">HB</button>
  </div>
</div>
<script>
'use strict';

// ── const: CONFIG ──
// ============================================================
// Rally Racer 3D - Constants & Configuration
// ============================================================
const CONFIG = {
  TOTAL_LAPS: 3,
  TRACK_POINTS: 200,
  TRACK_WIDTH: 14,
  CAR_ACCEL: 18,
  CAR_BRAKE: 25,
  CAR_MAX_SPEED: 65,
  CAR_STEER_RATE: 2.8,
  DRIFT_FACTOR: 0.92,
  OFF_TRACK_SLOWDOWN: 0.4,
  CHECKPOINT_COUNT: 12,
  CAMERA_DISTANCE: 12,
  CAMERA_HEIGHT: 5,
  FOG_NEAR: 60,
  FOG_FAR: 300,
};

// ── const: TRACK_CONTROL_POINTS ──
// Track control points (hand-crafted winding rally track)
const TRACK_CONTROL_POINTS = [
  { x: 0, z: 0, y: 0 },
  { x: 40, z: -20, y: 1 },
  { x: 80, z: -30, y: 2 },
  { x: 120, z: -10, y: 3 },
  { x: 150, z: 30, y: 4 },
  { x: 160, z: 80, y: 2 },
  { x: 140, z: 130, y: 1 },
  { x: 100, z: 160, y: 5 },
  { x: 50, z: 170, y: 6 },
  { x: 0, z: 150, y: 4 },
  { x: -40, z: 120, y: 3 },
  { x: -70, z: 80, y: 2 },
  { x: -90, z: 30, y: 5 },
  { x: -80, z: -20, y: 4 },
  { x: -60, z: -60, y: 3 },
  { x: -30, z: -80, y: 1 },
];

// ── function: catmullRom ──
// ============================================================
// Track Generation - Catmull-Rom spline interpolation
// ============================================================
function catmullRom(p0, p1, p2, p3, t) {
  const t2 = t * t;
  const 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),
  };
}

// ── function: generateTrack ──
function generateTrack() {
  const pts = TRACK_CONTROL_POINTS;
  const n = pts.length;
  const trackPoints = [];
  
  for (let i = 0; i < n; i++) {
    const p0 = pts[(i - 1 + n) % n];
    const p1 = pts[i];
    const p2 = pts[(i + 1) % n];
    const p3 = pts[(i + 2) % n];
    
    for (let t = 0; t < 1; t += 1 / CONFIG.TRACK_POINTS * n) {
      trackPoints.push(catmullRom(p0, p1, p2, p3, t));
    }
  }
  
  return trackPoints.slice(0, CONFIG.TRACK_POINTS);
}

// ── function: getTrackPoint ──
function getTrackPoint(track, index) {
  const i = Math.floor(index) % track.length;
  const frac = index - Math.floor(index);
  const p1 = track[(i + track.length) % track.length];
  const p2 = track[(i + 1) % track.length];
  return {
    x: p1.x + (p2.x - p1.x) * frac,
    y: p1.y + (p2.y - p1.y) * frac,
    z: p1.z + (p2.z - p1.z) * frac,
  };
}

// ── function: getTrackDirection ──
function getTrackDirection(track, index) {
  const p1 = getTrackPoint(track, index);
  const p2 = getTrackPoint(track, index + 5);
  return { x: p2.x - p1.x, z: p2.z - p1.z };
}

// ── function: nearestTrackIndex ──
function nearestTrackIndex(track, px, pz) {
  let minDist = Infinity;
  let minIdx = 0;
  for (let i = 0; i < track.length; i += 3) {
    const dx = px - track[i].x;
    const dz = pz - track[i].z;
    const d = dx*dx + dz*dz;
    if (d < minDist) { minDist = d; minIdx = i; }
  }
  // Refine search around best index
  for (let i = Math.max(0, minIdx-15); i <= Math.min(track.length-1, minIdx+15); i++) {
    const dx = px - track[i].x;
    const dz = pz - track[i].z;
    const d = dx*dx + dz*dz;
    if (d < minDist) { minDist = d; minIdx = i; }
  }
  return minIdx;
}

// ── function: getDistanceFromTrack ──
function getDistanceFromTrack(track, px, pz) {
  let minDist = Infinity;
  for (let i = 0; i < track.length; i += 2) {
    const dx = px - track[i].x;
    const dz = pz - track[i].z;
    const d = Math.sqrt(dx*dx + dz*dz);
    if (d < minDist) minDist = d;
  }
  return minDist;
}

// ── function: createCarState ──
// ============================================================
// Car Physics State & Update
// ============================================================
function createCarState() {
  return {
    x: 0, y: 0, z: 0,
    heading: 0,       // radians, facing +z direction initially
    speed: 0,
    steerAngle: 0,
    gear: 1,
    rpm: 800,
    driftFactor: 0,
    onTrack: true,
    surfaceType: 'asphalt',
    skidding: false,
  };
}

// ── function: updateCarPhysics ──
function updateCarPhysics(car, input, track, dt) {
  // Clamp dt to avoid physics explosions
  dt = Math.min(dt, 0.05);
  
  const distFromTrack = getDistanceFromTrack(track, car.x, car.z);
  car.onTrack = distFromTrack < CONFIG.TRACK_WIDTH * 0.6;
  
  if (distFromTrack > CONFIG.TRACK_WIDTH * 1.2) {
    car.surfaceType = 'mud';
  } else if (!car.onTrack) {
    car.surfaceType = 'grass';
  } else {
    car.surfaceType = 'asphalt';
  }
  
  // Surface modifiers
  let speedMult = 1;
  let gripMult = 1;
  switch (car.surfaceType) {
    case 'grass': speedMult = 0.65; gripMult = 0.7; break;
    case 'mud': speedMult = 0.35; gripMult = 0.4; break;
    default: speedMult = 1; gripMult = 1;
  }
  
  const maxSpeed = CONFIG.CAR_MAX_SPEED * speedMult;
  
  // Acceleration / braking
  if (input.gas) {
    car.speed += CONFIG.CAR_ACCEL * dt;
  } else if (input.brake || input.reverse) {
    if (car.speed > 0.5) {
      car.speed -= CONFIG.CAR_BRAKE * dt;
    } else {
      car.speed -= CONFIG.CAR_ACCEL * 0.4 * dt; // reverse slowly
    }
  } else {
    // Rolling resistance
    car.speed *= (1 - 1.5 * dt);
  }
  
  // Handbrake
  if (input.handbrake) {
    car.speed *= (1 - 3 * dt);
    gripMult *= 0.3;
    car.skidding = true;
  } else {
    car.skidding = false;
  }
  
  car.speed = Math.max(-maxSpeed * 0.25, Math.min(maxSpeed, car.speed));
  
  // Steering (speed-dependent)
  const steerFactor = Math.max(0.3, 1 - Math.abs(car.speed) / maxSpeed * 0.6);
  if (input.left) {
    car.steerAngle += CONFIG.CAR_STEER_RATE * steerFactor * dt;
  } else if (input.right) {
    car.steerAngle -= CONFIG.CAR_STEER_RATE * steerFactor * dt;
  } else {
    car.steerAngle *= (1 - 5 * dt); // auto-center
  }
  car.steerAngle = Math.max(-0.8, Math.min(0.8, car.steerAngle));
  
  // Turning with drift physics
  const turnRate = car.steerAngle * (Math.abs(car.speed) / maxSpeed) * gripMult;
  car.heading += turnRate * dt * Math.sign(car.speed);
  
  // Drift factor for visual effects
  if (car.skidding || !car.onTrack) {
    car.driftFactor = Math.min(1, car.driftFactor + dt * 3);
  } else {
    car.driftFactor = Math.max(0, car.driftFactor - dt * 4);
  }
  
  // Move car forward
  const moveSpeed = car.speed * dt;
  car.x += Math.sin(car.heading) * moveSpeed;
  car.z -= Math.cos(car.heading) * moveSpeed;
  
  // Elevation from track
  const nearestIdx = nearestTrackIndex(track, car.x, car.z);
  const trackPt = getTrackPoint(track, nearestIdx);
  const distToCenter = Math.sqrt((car.x - trackPt.x)**2 + (car.z - trackPt.z)**2);
  
  // Smooth elevation transition
  if (distToCenter < CONFIG.TRACK_WIDTH * 1.5) {
    car.y += (trackPt.y - car.y) * dt * 8;
  } else {
    car.y += (0 - car.y) * dt * 3;
  }
  
  // Gear simulation
  const absSpeed = Math.abs(car.speed);
  if (absSpeed < 1) { car.gear = car.speed >= 0 ? 1 : 'R'; car.rpm = 800 + absSpeed * 50; }
  else if (absSpeed < maxSpeed * 0.2) { car.gear = 1; car.rpm = 800 + absSpeed * 60; }
  else if (absSpeed < maxSpeed * 0.4) { car.gear = 2; car.rpm = 800 + (absSpeed - maxSpeed*0.2) * 50; }
  else if (absSpeed < maxSpeed * 0.6) { car.gear = 3; car.rpm = 800 + (absSpeed - maxSpeed*0.4) * 50; }
  else if (absSpeed < maxSpeed * 0.8) { car.gear = 4; car.rpm = 800 + (absSpeed - maxSpeed*0.6) * 50; }
  else { car.gear = 5; car.rpm = 800 + (absSpeed - maxSpeed*0.8) * 50; }
  
  if (car.speed < 0 && car.gear !== 'R') { car.gear = 'R'; car.rpm = 800 + absSpeed * 60; }
  car.rpm = Math.min(7500, Math.max(800, car.rpm));
}

// ── function: buildCarMesh ──
// ============================================================
// Build Rally Car Mesh (low-poly)
// ============================================================
function buildCarMesh() {
  const group = new THREE.Group();
  
  // Main body
  const bodyGeo = new THREE.BoxGeometry(2.2, 1, 4.5);
  const bodyMat = new THREE.MeshLambertMaterial({ color: 0xe83020 });
  const body = new THREE.Mesh(bodyGeo, bodyMat);
  body.position.y = 0.7;
  group.add(body);
  
  // Cabin / roof
  const cabinGeo = new THREE.BoxGeometry(1.9, 0.7, 2);
  const cabinMat = new THREE.MeshLambertMaterial({ color: 0xcccccc });
  const cabin = new THREE.Mesh(cabinGeo, cabinMat);
  cabin.position.set(0, 1.45, -0.3);
  group.add(cabin);
  
  // Windshield (front)
  const windGeo = new THREE.BoxGeometry(1.8, 0.6, 0.1);
  const windMat = new THREE.MeshLambertMaterial({ color: 0x4488cc });
  const windshield = new THREE.Mesh(windGeo, windMat);
  windshield.position.set(0, 1.35, -1.25);
  group.add(windshield);
  
  // Rear window
  const rearWind = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.6, 0.1), windMat);
  rearWind.position.set(0, 1.35, 0.65);
  group.add(rearWind);
  
  // Spoiler
  const spoilerGeo = new THREE.BoxGeometry(2.4, 0.1, 0.5);
  const spoilerMat = new THREE.MeshLambertMaterial({ color: 0x333333 });
  const spoiler = new THREE.Mesh(spoilerGeo, spoilerMat);
  spoiler.position.set(0, 1.6, 2);
  group.add(spoiler);
  
  // Spoiler supports
  for (let side of [-0.8, 0.8]) {
    const supGeo = new THREE.BoxGeometry(0.1, 0.4, 0.1);
    const sup = new THREE.Mesh(supGeo, spoilerMat);
    sup.position.set(side, 1.4, 2);
    group.add(sup);
  }
  
  // Headlights (front)
  for (let side of [-0.7, 0.7]) {
    const hlGeo = new THREE.BoxGeometry(0.4, 0.25, 0.1);
    const hlMat = new THREE.MeshLambertMaterial({ color: 0xffffcc, emissive: 0xffffaa });
    const hl = new THREE.Mesh(hlGeo, hlMat);
    hl.position.set(side, 0.7, -2.3);
    group.add(hl);
  }
  
  // Taillights (rear)
  for (let side of [-0.8, 0.8]) {
    const tlGeo = new THREE.BoxGeometry(0.4, 0.25, 0.1);
    const tlMat = new THREE.MeshLambertMaterial({ color: 0xff0000, emissive: 0xff0000 });
    const tl = new THREE.Mesh(tlGeo, tlMat);
    tl.position.set(side, 0.7, 2.3);
    group.add(tl);
  }
  
  // Wheels
  const wheelGeo = new THREE.CylinderGeometry(0.4, 0.4, 0.3, 8);
  const wheelMat = new THREE.MeshLambertMaterial({ color: 0x222222 });
  const wheelPositions = [
    [-1.1, 0.4, -1.5], [1.1, 0.4, -1.5],
    [-1.1, 0.4, 1.5], [1.1, 0.4, 1.5]
  ];
  
  const wheels = [];
  for (const pos of wheelPositions) {
    const wheel = new THREE.Mesh(wheelGeo, wheelMat);
    wheel.rotation.z = Math.PI / 2;
    wheel.position.set(...pos);
    group.add(wheel);
    wheels.push(wheel);
  }
  
  // Number plate on hood
  const numGeo = new THREE.BoxGeometry(0.8, 0.5, 0.05);
  const numMat = new THREE.MeshLambertMaterial({ color: 0xffffff });
  const numPlate = new THREE.Mesh(numGeo, numMat);
  numPlate.position.set(0, 1.2, -2.3);
  group.add(numPlate);
  
  return { mesh: group, wheels };
}

// ── function: buildTrackMesh ──
// ============================================================
// Scene Building - Track Mesh, Ground, Scenery
// ============================================================
function buildTrackMesh(track) {
  const shape = new THREE.Shape();
  
  // Build track as a series of connected segments
  const vertices = [];
  const indices = [];
  const colors = [];
  
  for (let i = 0; i < track.length; i++) {
    const p1 = track[i];
    const p2 = track[(i + 1) % track.length];
    
    // Direction vector
    let dx = p2.x - p1.x;
    let dz = p2.z - p1.z;
    const len = Math.sqrt(dx*dx + dz*dz);
    if (len === 0) { dx = 0; dz = 1; } else { dx /= len; dz /= len; }
    
    // Perpendicular vector for track width
    const px = -dz * CONFIG.TRACK_WIDTH / 2;
    const pz = dx * CONFIG.TRACK_WIDTH / 2;
    
    // Left and right vertices
    const leftIdx = vertices.length / 3;
    const rightIdx = leftIdx + 1;
    
    vertices.push(p1.x - px, p1.y, p1.z - pz);
    vertices.push(p1.x + px, p1.y, p1.z + pz);
    colors.push(0.25, 0.25, 0.28);
    colors.push(0.25, 0.25, 0.28);
    
    if (i < track.length - 1) {
      indices.push(leftIdx, rightIdx, leftIdx + 2);
      indices.push(rightIdx, rightIdx + 1, leftIdx + 2);
    } else {
      // Close the loop
      const firstLeft = 0;
      const firstRight = 1;
      indices.push(leftIdx, rightIdx, firstLeft);
      indices.push(rightIdx, firstRight, firstLeft);
    }
  }
  
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
  geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
  geo.setIndex(indices);
  geo.computeVertexNormals();
  
  const mat = new THREE.MeshLambertMaterial({ vertexColors: true });
  return new THREE.Mesh(geo, mat);
}

// ── function: buildTrackShoulders ──
function buildTrackShoulders(track) {
  const group = new THREE.Group();
  
  for (let i = 0; i < track.length; i += 4) {
    const p1 = track[i];
    const p2 = track[(i + 1) % track.length];
    
    let dx = p2.x - p1.x;
    let dz = p2.z - p1.z;
    const len = Math.sqrt(dx*dx + dz*dz);
    if (len === 0) { dx = 0; dz = 1; } else { dx /= len; dz /= len; }
    
    const px = -dz * CONFIG.TRACK_WIDTH / 2;
    const pz = dx * CONFIG.TRACK_WIDTH / 2;
    
    // Gravel shoulders on both sides
    for (let side of [-1, 1]) {
      const shoulderGeo = new THREE.BoxGeometry(3, 0.15, len + 0.5);
      const shoulderMat = new THREE.MeshLambertMaterial({ color: 0x8B7D6B });
      const shoulder = new THREE.Mesh(shoulderGeo, shoulderMat);
      shoulder.position.set(
        p1.x + px * side * 1.3,
        p1.y - 0.05,
        p1.z + pz * side * 1.3
      );
      const angle = Math.atan2(dx, dz);
      shoulder.rotation.y = angle;
      group.add(shoulder);
    }
  }
  
  return group;
}

// ── function: buildGround ──
function buildGround() {
  // Large ground plane with grass color
  const geo = new THREE.PlaneGeometry(800, 800, 20, 20);
  const mat = new THREE.MeshLambertMaterial({ color: 0x3a7d32 });
  
  // Add some vertex displacement for terrain variation
  const pos = geo.attributes.position;
  for (let i = 0; i < pos.count; i++) {
    const x = pos.getX(i);
    const z = pos.getY(i);
    pos.setZ(i, Math.sin(x * 0.02) * Math.cos(z * 0.02) * 1.5 + Math.random() * 0.3);
  }
  
  const ground = new THREE.Mesh(geo, mat);
  ground.rotation.x = -Math.PI / 2;
  ground.position.y = -0.5;
  return ground;
}

// ── function: buildScenery ──
function buildScenery(track) {
  const group = new THREE.Group();
  
  // Tree geometry templates (reuse for performance)
  const trunkGeo = new THREE.CylinderGeometry(0.3, 0.4, 2, 6);
  const trunkMat = new THREE.MeshLambertMaterial({ color: 0x8B5A2B });
  const leavesGeo = new THREE.ConeGeometry(1.8, 4, 6);
  const leavesMat = new THREE.MeshLambertMaterial({ color: 0x2d7a2d });
  const rockGeo = new THREE.DodecahedronGeometry(0.8, 0);
  const rockMat = new THREE.MeshLambertMaterial({ color: 0x777777 });
  
  // Place trees and rocks along the track
  for (let i = 0; i < track.length; i += 3) {
    const p = track[i];
    const nextP = track[(i + 1) % track.length];
    
    let dx = nextP.x - p.x;
    let dz = nextP.z - p.z;
    const len = Math.sqrt(dx*dx + dz*dz);
    if (len === 0) { dx = 0; dz = 1; } else { dx /= len; dz /= len; }
    
    // Trees on both sides at varying distances
    for (let side of [-1, 1]) {
      const dist = CONFIG.TRACK_WIDTH * 0.8 + Math.random() * 25;
      const treeX = p.x + (-dz) * dist * side;
      const treeZ = p.z + dx * dist * side;
      
      // Trunk
      const trunk = new THREE.Mesh(trunkGeo, trunkMat);
      trunk.position.set(treeX, 1, treeZ);
      group.add(trunk);
      
      // Leaves (varying height)
      const leavesH = 3 + Math.random() * 2;
      const leaves = new THREE.Mesh(
        new THREE.ConeGeometry(1.5 + Math.random(), leavesH, 6),
        leavesMat
      );
      leaves.position.set(treeX, 1 + leavesH/2, treeZ);
      group.add(leaves);
    }
    
    // Occasional rocks
    if (Math.random() < 0.3) {
      for (let side of [-1, 1]) {
        const dist = CONFIG.TRACK_WIDTH * 0.7 + Math.random() * 5;
        const rock = new THREE.Mesh(rockGeo, rockMat);
        rock.position.set(
          p.x + (-dz) * dist * side,
          0.3,
          p.z + dx * dist * side
        );
        rock.scale.setScalar(0.5 + Math.random() * 1);
        rock.rotation.y = Math.random() * Math.PI;
        group.add(rock);
      }
    }
    
    // Occasional rally banners/signs
    if (i % 30 === 0) {
      const bannerSide = (i / 30) % 2 === 0 ? -1 : 1;
      const dist = CONFIG.TRACK_WIDTH * 0.75;
      
      // Pole
      const poleGeo = new THREE.CylinderGeometry(0.08, 0.08, 4, 4);
      const poleMat = new THREE.MeshLambertMaterial({ color: 0xaaaaaa });
      const pole = new THREE.Mesh(poleGeo, poleMat);
      pole.position.set(
        p.x + (-dz) * dist * bannerSide,
        2,
        p.z + dx * dist * bannerSide
      );
      group.add(pole);
      
      // Banner cloth
      const bannerGeo = new THREE.BoxGeometry(3, 1.5, 0.05);
      const bannerColors = [0xff4444, 0x4444ff, 0xffff44, 0x44ff44];
      const bannerMat = new THREE.MeshLambertMaterial({ 
        color: bannerColors[Math.floor(Math.random() * bannerColors.length)] 
      });
      const banner = new THREE.Mesh(bannerGeo, bannerMat);
      banner.position.set(
        p.x + (-dz) * dist * bannerSide,
        3.5,
        p.z + dx * dist * bannerSide
      );
      group.add(banner);
    }
    
    // Guard rails on sharp curves (every ~10 points)
    if (i % 20 === 0) {
      for (let side of [-1, 1]) {
        const dist = CONFIG.TRACK_WIDTH * 0.55;
        const railGeo = new THREE.BoxGeometry(0.15, 0.6, len + 1);
        const railMat = new THREE.MeshLambertMaterial({ color: 0xcccccc });
        const rail = new THREE.Mesh(railGeo, railMat);
        rail.position.set(
          p.x + (-dz) * dist * side,
          0.3,
          p.z + dx * dist * side
        );
        const angle = Math.atan2(dx, dz);
        rail.rotation.y = angle;
        group.add(rail);
        
        // Rail post
        for (let j = -1; j <= 1; j += 2) {
          const postGeo = new THREE.CylinderGeometry(0.06, 0.06, 0.8, 4);
          const post = new THREE.Mesh(postGeo, railMat);
          post.position.set(
            p.x + (-dz) * dist * side + dx * j * len/2,
            0.4,
            p.z + dx * dist * side - dz * j * len/2
          );
          group.add(post);
        }
      }
    }
  }
  
  return group;
}

// ── function: buildCheckpoints ──
// ============================================================
// Checkpoint Gates (invisible)
// ============================================================
function buildCheckpoints(track) {
  const checkpoints = [];
  const count = CONFIG.CHECKPOINT_COUNT;
  
  for (let i = 0; i < count; i++) {
    const idx = Math.floor(i * track.length / count);
    const p = track[idx];
    const nextP = track[(idx + 1) % track.length];
    
    let dx = nextP.x - p.x;
    let dz = nextP.z - p.z;
    const len = Math.sqrt(dx*dx + dz*dz);
    if (len === 0) { dx = 0; dz = 1; } else { dx /= len; dz /= len; }
    
    checkpoints.push({
      index: idx,
      x: p.x, z: p.z, y: p.y,
      nx: -dz, nz: dx, // normal direction for gate plane
      passed: false,
    });
  }
  
  return checkpoints;
}

// ── function: checkCheckpointPass ──
function checkCheckpointPass(car, checkpoints) {
  if (checkpoints.length === 0) return;
  
  const next = checkpoints[car.nextCheckpoint % checkpoints.length];
  const dx = car.x - next.x;
  const dz = car.z - next.z;
  const dist = Math.sqrt(dx*dx + dz*dz);
  
  if (dist < CONFIG.TRACK_WIDTH * 1.5) {
    // Check if we crossed the gate plane in the right direction
    const dot = dx * next.nx + dz * next.nz;
    if (dot > 0 || !next.passed) {
      next.passed = true;
      car.nextCheckpoint++;
      
      // Lap completion check
      if (car.nextCheckpoint >= checkpoints.length) {
        car.lapCompleted = true;
        car.nextCheckpoint = 0;
        checkpoints.forEach(cp => cp.passed = false);
      }
    }
  }
}

// ── function: createCamera ──
// ============================================================
// Camera System - Chase Camera with Lag
// ============================================================
function createCamera() {
  return {
    position: new THREE.Vector3(0, CONFIG.CAMERA_HEIGHT, CONFIG.CAMERA_DISTANCE),
    target: new THREE.Vector3(0, 1, 0),
    mode: 'chase', // or 'cinematic'
    smoothPos: new THREE.Vector3(),
    smoothTarget: new THREE.Vector3(),
  };
}

// ── function: updateCamera ──
function updateCamera(cam, carMesh, dt) {
  const carPos = carMesh.position;
  const carHeading = carMesh.rotation.y;
  
  let desiredDist, desiredHeight;
  
  if (cam.mode === 'chase') {
    desiredDist = CONFIG.CAMERA_DISTANCE;
    desiredHeight = CONFIG.CAMERA_HEIGHT;
  } else {
    desiredDist = 25;
    desiredHeight = 18;
  }
  
  // Desired camera position behind car
  const dx = Math.sin(carHeading) * desiredDist;
  const dz = -Math.cos(carHeading) * desiredDist;
  
  const targetPos = new THREE.Vector3(
    carPos.x + dx,
    carPos.y + desiredHeight,
    carPos.z + dz
  );
  
  // Smooth interpolation (lag effect)
  const lerpFactor = 1 - Math.pow(0.05, dt);
  cam.smoothPos.lerp(targetPos, lerpFactor);
  
  // Look slightly ahead of the car
  const lookAhead = new THREE.Vector3(
    carPos.x + Math.sin(carHeading) * 5,
    carPos.y + 1.5,
    carPos.z - Math.cos(carHeading) * 5
  );
  cam.smoothTarget.lerp(lookAhead, lerpFactor);
  
  // Apply to camera
  cam.position.copy(cam.smoothPos);
  cam.target.copy(cam.smoothTarget);
}

// ── function: createParticleSystem ──
// ============================================================
// Particle Effects - Dust, Sparks, Skid Marks
// ============================================================
function createParticleSystem(maxParticles) {
  const positions = new Float32Array(maxParticles * 3);
  const colors = new Float32Array(maxParticles * 3);
  const sizes = new Float32Array(maxParticles);
  
  for (let i = 0; i < maxParticles; i++) {
    positions[i*3] = 0;
    positions[i*3+1] = -100; // hide initially
    positions[i*3+2] = 0;
    sizes[i] = 0;
  }
  
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
  geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
  geo.setAttribute('size', new THREE.Float32BufferAttribute(sizes, 1));
  
  const mat = new THREE.PointsMaterial({
    size: 0.5,
    vertexColors: true,
    transparent: true,
    opacity: 0.8,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
  });
  
  const points = new THREE.Points(geo, mat);
  
  return {
    mesh: points,
    particles: [],
    maxParticles,
    positions,
    colors,
    sizes,
  };
}

// ── function: emitParticle ──
function emitParticle(system, x, y, z, r, g, b, life, size) {
  if (system.particles.length >= system.maxParticles) return;
  
  system.particles.push({
    x, y, z,
    vx: (Math.random() - 0.5) * 3,
    vy: Math.random() * 2 + 1,
    vz: (Math.random() - 0.5) * 3,
    r, g, b,
    life,
    maxLife: life,
    size,
  });
}

// ── function: updateParticles ──
function updateParticles(system, dt) {
  const pos = system.mesh.geometry.attributes.position;
  const col = system.mesh.geometry.attributes.color;
  
  // Update existing particles
  for (let i = system.particles.length - 1; i >= 0; i--) {
    const p = system.particles[i];
    p.life -= dt;
    
    if (p.life <= 0) {
      system.particles.splice(i, 1);
      continue;
    }
    
    p.x += p.vx * dt;
    p.y += p.vy * dt;
    p.z += p.vz * dt;
    p.vy -= 2 * dt; // gravity
    
    const alpha = p.life / p.maxLife;
    pos.setXYZ(i, p.x, p.y, p.z);
    col.setRGB(i, p.r * alpha, p.g * alpha, p.b * alpha);
  }
  
  // Hide unused particles
  for (let i = system.particles.length; i < system.maxParticles; i++) {
    pos.setY(i, -100);
  }
  
  pos.needsUpdate = true;
  col.needsUpdate = true;
}

// ── function: emitDust ──
function emitDust(system, x, y, z) {
  const count = 2 + Math.floor(Math.random() * 3);
  for (let i = 0; i < count; i++) {
    emitParticle(system, 
      x + (Math.random()-0.5)*1.5, y + Math.random()*0.3, z + (Math.random()-0.5)*1.5,
      0.7, 0.6, 0.4, // brownish dust color
      0.8 + Math.random() * 0.5,
      0.5 + Math.random() * 0.5
    );
  }
}

// ── function: emitSparks ──
function emitSparks(system, x, y, z) {
  const count = 3 + Math.floor(Math.random() * 4);
  for (let i = 0; i < count; i++) {
    emitParticle(system,
      x + (Math.random()-0.5)*1, y + Math.random()*0.5, z + (Math.random()-0.5)*1,
      1, 0.8, 0.2, // orange sparks
      0.3 + Math.random() * 0.3,
      0.2 + Math.random() * 0.3
    );
  }
}

// ── function: createSkidMarkSystem ──
// ============================================================
// Skid Mark System (persistent on ground)
// ============================================================
function createSkidMarkSystem(maxMarks) {
  const positions = [];
  const geo = new THREE.PlaneGeometry(1, 2);
  
  for (let i = 0; i < maxMarks; i++) {
    const mat = new THREE.MeshBasicMaterial({ 
      color: 0x222222, transparent: true, opacity: 0.5, depthWrite: false 
    });
    const mark = new THREE.Mesh(geo, mat);
    mark.rotation.x = -Math.PI / 2;
    mark.position.y = 0.01;
    mark.visible = false;
    positions.push({ mesh: mark, life: 0 });
  }
  
  return { marks: positions, nextIndex: 0 };
}

// ── function: addSkidMark ──
function addSkidMark(system, x, z, heading) {
  const idx = system.nextIndex % system.marks.length;
  const mark = system.marks[idx];
  mark.position.set(x, 0.01, z);
  mark.rotation.z = -heading;
  mark.visible = true;
  mark.material.opacity = 0.5;
  mark.life = 8; // seconds before fading
  system.nextIndex++;
}

// ── function: updateSkidMarks ──
function updateSkidMarks(system, dt) {
  for (const mark of system.marks) {
    if (mark.life > 0) {
      mark.life -= dt;
      mark.material.opacity = Math.max(0, mark.life / 8 * 0.5);
      if (mark.life <= 0) mark.visible = false;
    }
  }
}

// ── function: createAudioSystem ──
// ============================================================
// Audio System - Web Audio API Synthesis (fixed init)
// ============================================================
function createAudioSystem() {
  let ctx = null;
  let engineOsc = null;
  let engineGain = null;
  let skidNode = null;
  let skidGain = null;
  let initialized = false;
  
  function tryInit() {
    if (initialized) return true;
    try {
      ctx = new (window.AudioContext || window.webkitAudioContext)();
      
      // Engine sound: oscillator + gain
      engineOsc = ctx.createOscillator();
      engineGain = ctx.createGain();
      const filter = ctx.createBiquadFilter();
      filter.type = 'lowpass';
      filter.frequency.value = 800;
      
      engineOsc.type = 'sawtooth';
      engineOsc.frequency.value = 60;
      engineGain.gain.value = 0.05;
      
      engineOsc.connect(filter);
      filter.connect(engineGain);
      engineGain.connect(ctx.destination);
      engineOsc.start();
      
      initialized = true;
      return true;
    } catch(e) {
      console.warn('Audio init failed:', e);
      return false;
    }
  }
  
  function updateEngine(car) {
    if (!engineOsc || !ctx) return;
    
    const absSpeed = Math.abs(car.speed);
    const maxSpeed = CONFIG.CAR_MAX_SPEED;
    const speedRatio = absSpeed / maxSpeed;
    
    // Engine pitch based on RPM
    engineOsc.frequency.value = 50 + car.rpm * 0.8;
    
    // Volume based on speed and gear changes
    engineGain.gain.value = 0.02 + speedRatio * 0.06;
  }
  
  function playBeep(freq, duration) {
    if (!ctx) return;
    const osc = ctx.createOscillator();
    const gain = ctx.createGain();
    osc.type = 'sine';
    osc.frequency.value = freq;
    gain.gain.setValueAtTime(0.15, ctx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
    osc.connect(gain);
    gain.connect(ctx.destination);
    osc.start();
    osc.stop(ctx.currentTime + duration);
  }
  
  function playCrash() {
    if (!ctx) return;
    const bufferSize = ctx.sampleRate * 0.2;
    const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
    const data = buffer.getChannelData(0);
    for (let i = 0; i < bufferSize; i++) {
      data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.1));
    }
    const source = ctx.createBufferSource();
    source.buffer = buffer;
    const gain = ctx.createGain();
    gain.gain.value = 0.15;
    source.connect(gain);
    gain.connect(ctx.destination);
    source.start();
  }
  
  function playFinishTone() {
    if (!ctx) return;
    const notes = [523, 659, 784, 1047]; // C E G C
    notes.forEach((freq, i) => {
      setTimeout(() => playBeep(freq, 0.3), i * 150);
    });
  }
  
  function startSkidSound() {
    if (!ctx || skidNode) return;
    
    const bufferSize = ctx.sampleRate * 2;
    const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
    const data = buffer.getChannelData(0);
    for (let i = 0; i < bufferSize; i++) {
      data[i] = (Math.random() * 2 - 1) * 0.3;
    }
    
    skidNode = ctx.createBufferSource();
    skidNode.buffer = buffer;
    skidNode.loop = true;
    skidGain = ctx.createGain();
    skidGain.gain.value = 0.04;
    
    const filter = ctx.createBiquadFilter();
    filter.type = 'bandpass';
    filter.frequency.value = 2000;
    
    skidNode.connect(filter);
    filter.connect(skidGain);
    skidGain.connect(ctx.destination);
    skidNode.start();
  }
  
  function stopSkidSound() {
    if (skidNode) {
      try { skidNode.stop(); } catch(e) {}
      skidNode = null;
      skidGain = null;
    }
  }
  
  return { 
    init: tryInit, 
    updateEngine, 
    playBeep, 
    playCrash, 
    playFinishTone, 
    startSkidSound, 
    stopSkidSound,
    get isReady() { return initialized; }
  };
}

// ── function: createInputHandler ──
// ============================================================
// Input Handling - Keyboard & Touch
// ============================================================
function createInputHandler() {
  const keys = {};
  const input = { gas: false, brake: false, left: false, right: false, handbrake: false, reverse: false };
  
  function onKeyDown(e) {
    keys[e.code] = true;
    updateInput();
    
    // Prevent scrolling on space/arrows
    if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) {
      e.preventDefault();
    }
  }
  
  function onKeyUp(e) {
    keys[e.code] = false;
    updateInput();
  }
  
  function updateInput() {
    input.gas = !!(keys['KeyW'] || keys['ArrowUp']);
    input.brake = !!(keys['KeyS'] || keys['ArrowDown']);
    input.left = !!(keys['KeyA'] || keys['ArrowLeft']);
    input.right = !!(keys['KeyD'] || keys['ArrowRight']);
    input.handbrake = !!keys['Space'];
    input.reverse = input.brake && !input.gas;
  }
  
  function setTouchInput(type, active) {
    switch(type) {
      case 'gas': input.gas = active; break;
      case 'brake': input.brake = active; break;
      case 'left': input.left = active; break;
      case 'right': input.right = active; break;
      case 'handbrake': input.handbrake = active; break;
    }
  }
  
  return { onKeyDown, onKeyUp, setTouchInput, get input() { return input; } };
}

// ── function: formatTime ──
// ============================================================
// HUD Update Functions
// ============================================================
function formatTime(ms) {
  const totalSec = ms / 1000;
  const min = Math.floor(totalSec / 60);
  const sec = Math.floor(totalSec % 60);
  const frac = Math.floor((totalSec * 1000) % 1000);
  return `${String(min).padStart(2,'0')}:${String(sec).padStart(2,'0')}.${String(frac).padStart(3,'0')}`;
}

// ── function: updateHUD ──
function updateHUD(car, gameState) {
  const speedKmh = Math.abs(Math.round(car.speed * 3.6)); // m/s to km/h
  
  document.getElementById('speedVal').textContent = speedKmh;
  document.getElementById('gearVal').textContent = car.gear;
  document.getElementById('currentLap').textContent = gameState.currentLap;
  
  const rpmPercent = ((car.rpm - 800) / (7500 - 800)) * 100;
  document.getElementById('rpmFill').style.width = Math.min(100, rpmPercent) + '%';
  
  // Timer display
  if (gameState.racing) {
    const elapsed = performance.now() - gameState.startTime;
    document.getElementById('timerDisplay').textContent = formatTime(elapsed);
    
    const lapElapsed = performance.now() - gameState.lapStartTime;
    document.getElementById('lapTimeDisplay').textContent = 'Lap Time: ' + formatTime(lapElapsed);
  }
  
  if (gameState.bestLap > 0) {
    document.getElementById('bestLapDisplay').textContent = 'Best Lap: ' + formatTime(gameState.bestLap);
  }
}

// ── function: drawMinimap ──
// ============================================================
// Minimap Drawing
// ============================================================
function drawMinimap(track, carPos) {
  const canvas = document.getElementById('minimap');
  if (!canvas) return;
  
  const ctx = canvas.getContext('2d');
  const w = canvas.width;
  const h = canvas.height;
  
  // Clear
  ctx.fillStyle = 'rgba(0,0,0,0.6)';
  ctx.fillRect(0, 0, w, h);
  
  // Find track bounds
  let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
  for (const p of track) {
    if (p.x < minX) minX = p.x;
    if (p.x > maxX) maxX = p.x;
    if (p.z < minZ) minZ = p.z;
    if (p.z > maxZ) maxZ = p.z;
  }
  
  const padding = 20;
  const scaleX = (w - padding*2) / (maxX - minX || 1);
  const scaleZ = (h - padding*2) / (maxZ - minZ || 1);
  const scale = Math.min(scaleX, scaleZ);
  
  const offsetX = (w - (maxX - minX) * scale) / 2;
  const offsetZ = (h - (maxZ - minZ) * scale) / 2;
  
  function mapX(x) { return (x - minX) * scale + offsetX; }
  function mapZ(z) { return (z - minZ) * scale + offsetZ; }
  
  // Draw track outline
  ctx.strokeStyle = '#666';
  ctx.lineWidth = 4;
  ctx.beginPath();
  for (let i = 0; i < track.length; i += 2) {
    const p = track[i];
    if (i === 0) ctx.moveTo(mapX(p.x), mapZ(p.z));
    else ctx.lineTo(mapX(p.x), mapZ(p.z));
  }
  ctx.closePath();
  ctx.stroke();
  
  // Draw track center line
  ctx.strokeStyle = '#999';
  ctx.lineWidth = 2;
  ctx.beginPath();
  for (let i = 0; i < track.length; i += 3) {
    const p = track[i];
    if (i === 0) ctx.moveTo(mapX(p.x), mapZ(p.z));
    else ctx.lineTo(mapX(p.x), mapZ(p.z));
  }
  ctx.closePath();
  ctx.stroke();
  
  // Draw car position with heading indicator
  const cx = mapX(carPos.x);
  const cy = mapZ(carPos.z);
  
  ctx.fillStyle = '#ff3300';
  ctx.beginPath();
  ctx.arc(cx, cy, 4, 0, Math.PI * 2);
  ctx.fill();
  
  // Heading arrow
  ctx.strokeStyle = '#ffcc00';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(cx, cy);
  ctx.lineTo(
    cx + Math.sin(carPos.heading) * 8,
    cy - Math.cos(carPos.heading) * 8
  );
  ctx.stroke();
}

// ── function: checkObstacleCollision ──
// ============================================================
// Obstacle Collision Detection
// ============================================================
function checkObstacleCollision(car, sceneryGroup) {
  const carRadius = 1.5;
  let collided = false;
  
  // Check against guard rails and rocks (simplified - check bounding boxes of children)
  for (const child of sceneryGroup.children) {
    if (!child.geometry || !child.position) continue;
    
    const dx = car.x - child.position.x;
    const dz = car.z - child.position.z;
    const dist = Math.sqrt(dx*dx + dz*dz);
    
    // Approximate collision radius based on geometry type
    let objRadius = 0.5;
    if (child.geometry.type === 'BoxGeometry') {
      objRadius = Math.max(child.geometry.parameters.width, child.geometry.parameters.depth) * 0.5 || 1;
    } else if (child.geometry.type === 'DodecahedronGeometry') {
      objRadius = child.geometry.parameters.radius * (child.scale.x || 1);
    }
    
    // Skip trees (trunks are thin, leaves are high)
    if (child.geometry.type === 'ConeGeometry' && child.position.y > 2) continue;
    if (child.geometry.type === 'CylinderGeometry' && child.position.y < 1.5 && objRadius < 0.3) continue; // tree trunks
    
    if (dist < carRadius + objRadius) {
      collided = true;
      
      // Bounce: push car away from obstacle
      const nx = dx / dist;
      const nz = dz / dist;
      car.x += nx * 0.5;
      car.z += nz * 0.5;
      
      // Kill momentum
      car.speed *= 0.3;
    }
  }
  
  return collided;
}

// ── function: buildTrackMarkings ──
// ============================================================
// Track Center Line & Road Markings
// ============================================================
function buildTrackMarkings(track) {
  const group = new THREE.Group();
  
  // Dashed center line
  for (let i = 0; i < track.length; i += 4) {
    if (i % 8 === 0) {
      const p1 = track[i];
      const p2 = track[(i + 1) % track.length];
      
      let dx = p2.x - p1.x;
      let dz = p2.z - p1.z;
      const len = Math.sqrt(dx*dx + dz*dz);
      
      // White center line dashes
      const dashGeo = new THREE.BoxGeometry(0.15, 0.02, len * 0.6);
      const dashMat = new THREE.MeshLambertMaterial({ color: 0xffffff });
      const dash = new THREE.Mesh(dashGeo, dashMat);
      dash.position.set((p1.x + p2.x)/2, p1.y + 0.02, (p1.z + p2.z)/2);
      
      if (len > 0) {
        const angle = Math.atan2(dx, dz);
        dash.rotation.y = angle;
      }
      group.add(dash);
    }
    
    // Edge lines (solid white)
    for (let side of [-1, 1]) {
      if (i % 4 === 0) {
        const p1 = track[i];
        const p2 = track[(i + 1) % track.length];
        
        let dx = p2.x - p1.x;
        let dz = p2.z - p1.z;
        const len = Math.sqrt(dx*dx + dz*dz);
        if (len === 0) continue;
        
        const px = (-dz / len) * CONFIG.TRACK_WIDTH * 0.45;
        const pz = (dx / len) * CONFIG.TRACK_WIDTH * 0.45;
        
        const edgeGeo = new THREE.BoxGeometry(0.1, 0.02, len);
        const edgeMat = new THREE.MeshLambertMaterial({ color: 0xffffff });
        const edge = new THREE.Mesh(edgeGeo, edgeMat);
        edge.position.set(p1.x + px * side, p1.y + 0.02, p1.z + pz * side);
        edge.rotation.y = Math.atan2(dx, dz);
        group.add(edge);
      }
    }
  }
  
  // Start/finish line
  const startP = track[0];
  const nextP = track[1];
  let sdx = nextP.x - startP.x;
  let sdz = nextP.z - startP.z;
  const slen = Math.sqrt(sdx*sdx + sdz*sdz);
  
  // Checkered pattern for start line
  const checkSize = CONFIG.TRACK_WIDTH / 8;
  for (let row = 0; row < 2; row++) {
    for (let col = 0; col < 8; col++) {
      if ((row + col) % 2 === 0) {
        const checkGeo = new THREE.BoxGeometry(checkSize, 0.03, checkSize);
        const checkMat = new THREE.MeshLambertMaterial({ color: 0xffffff });
        const check = new THREE.Mesh(checkGeo, checkMat);
        
        const px = (-sdz / (slen || 1)) * (col - 3.5) * checkSize;
        const pz = (sdx / (slen || 1)) * (col - 3.5) * checkSize;
        
        check.position.set(
          startP.x + px,
          startP.y + 0.03,
          startP.z + pz + row * checkSize * 0.5
        );
        group.add(check);
      }
    }
  }
  
  return group;
}

// ── function: buildSkyDome ──
// ============================================================
// Sky Dome with Gradient
// ============================================================
function buildSkyDome() {
  const skyGeo = new THREE.SphereGeometry(350, 16, 12);
  const skyMat = new THREE.ShaderMaterial({
    uniforms: {
      topColor: { value: new THREE.Color(0x0077ff) },
      bottomColor: { value: new THREE.Color(0xaaddff) },
      offset: { value: 20 },
      exponent: { value: 0.4 }
    },
    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,
    depthWrite: false,
  });
  
  return new THREE.Mesh(skyGeo, skyMat);
}

// ── function: buildClouds ──
// ============================================================
// Simple Cloud Meshes
// ============================================================
function buildClouds() {
  const group = new THREE.Group();
  const cloudMat = new THREE.MeshLambertMaterial({ 
    color: 0xffffff, transparent: true, opacity: 0.7 
  });
  
  for (let i = 0; i < 25; i++) {
    const cloudGroup = new THREE.Group();
    
    // Each cloud is several overlapping spheres
    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, 5);
      const puff = new THREE.Mesh(puffGeo, cloudMat);
      puff.position.set(
        (Math.random() - 0.5) * 15,
        (Math.random() - 0.5) * 3,
        (Math.random() - 0.5) * 8
      );
      puff.scale.y = 0.4;
      cloudGroup.add(puff);
    }
    
    cloudGroup.position.set(
      (Math.random() - 0.5) * 600,
      60 + Math.random() * 40,
      (Math.random() - 0.5) * 600
    );
    
    group.add(cloudGroup);
  }
  
  return group;
}

// ── main ──
// ============================================================
// Rally Racer 3D - Main Game Loop & Initialization
// ============================================================
(function() {
  // --- Scene Setup ---
  const scene = new THREE.Scene();
  scene.fog = new THREE.Fog(0x9dc4e0, CONFIG.FOG_NEAR, CONFIG.FOG_FAR);
  
  const camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 500);
  const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('gameCanvas'), antialias: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  
  // --- Sky Dome & Clouds ---
  scene.add(buildSkyDome());
  const clouds = buildClouds();
  scene.add(clouds);
  
  // --- Lighting ---
  const ambientLight = new THREE.AmbientLight(0x6688aa, 0.5);
  scene.add(ambientLight);
  
  const sunLight = new THREE.DirectionalLight(0xffeedd, 1.2);
  sunLight.position.set(50, 80, 30);
  sunLight.castShadow = true;
  sunLight.shadow.mapSize.width = 1024;
  sunLight.shadow.mapSize.height = 1024;
  sunLight.shadow.camera.near = 1;
  sunLight.shadow.camera.far = 200;
  sunLight.shadow.camera.left = -60;
  sunLight.shadow.camera.right = 60;
  sunLight.shadow.camera.top = 60;
  sunLight.shadow.camera.bottom = -60;
  scene.add(sunLight);
  
  const hemiLight = new THREE.HemisphereLight(0x87CEEB, 0x3a7d32, 0.4);
  scene.add(hemiLight);
  
  // --- Generate Track ---
  const track = generateTrack();
  
  // --- Build Scene Objects ---
  const trackMesh = buildTrackMesh(track);
  trackMesh.receiveShadow = true;
  scene.add(trackMesh);
  
  const shoulders = buildTrackShoulders(track);
  scene.add(shoulders);
  
  const markings = buildTrackMarkings(track);
  scene.add(markings);
  
  const ground = buildGround();
  ground.receiveShadow = true;
  scene.add(ground);
  
  const sceneryGroup = buildScenery(track);
  scene.add(sceneryGroup);
  
  // --- Build Car ---
  const carData = buildCarMesh();
  const carMesh = carData.mesh;
  carMesh.castShadow = true;
  scene.add(carMesh);
  
  // Position car at start of track
  const startPos = track[0];
  const dir1 = getTrackDirection(track, 0);
  const startHeading = Math.atan2(dir1.x, dir1.z);
  
  carMesh.position.set(startPos.x, startPos.y + 0.7, startPos.z);
  carMesh.rotation.y = startHeading;
  
  // --- Game State ---
  const carState = createCarState();
  carState.heading = startHeading;
  carState.nextCheckpoint = 1;
  carState.lapCompleted = false;
  
  const checkpoints = buildCheckpoints(track);
  
  const gameState = {
    phase: 'countdown', // countdown, racing, paused, finished
    currentLap: 1,
    startTime: 0,
    lapStartTime: 0,
    bestLap: 0,
    lapTimes: [],
    countdownValue: 3,
    countdownTimer: 0,
  };
  
  // --- Camera ---
  const cam = createCamera();
  cam.smoothPos.copy(camera.position);
  cam.smoothTarget.copy(new THREE.Vector3(0, 1, 0));
  
  // --- Particles ---
  const particleSystem = createParticleSystem(500);
  scene.add(particleSystem.mesh);
  
  const skidMarkSystem = createSkidMarkSystem(200);
  for (const mark of skidMarkSystem.marks) {
    scene.add(mark.mesh);
  }
  
  // --- Audio ---
  const audio = createAudioSystem();
  let audioInitialized = false;
  
  function initAudioIfNeeded() {
    if (!audioInitialized && !audio.isReady) {
      audio.init();
      audioInitialized = true;
    }
  }
  
  // --- Input ---
  const inputHandler = createInputHandler();
  window.addEventListener('keydown', function mainKeydown(e) {
    initAudioIfNeeded();
    inputHandler.onKeyDown(e);
  });
  window.addEventListener('keyup', inputHandler.onKeyUp);
  
  // Touch controls setup
  if ('ontouchstart' in window) {
    document.getElementById('touchControls').style.display = 'flex';
    
    function addTouchBtn(id, type) {
      const btn = document.getElementById(id);
      if (!btn) return;
      btn.addEventListener('touchstart', (e) => { 
        e.preventDefault(); 
        initAudioIfNeeded();
        inputHandler.setTouchInput(type, true); 
      });
      btn.addEventListener('touchend', (e) => { e.preventDefault(); inputHandler.setTouchInput(type, false); });
    }
    
    addTouchBtn('btnUp', 'gas');
    addTouchBtn('btnDown', 'brake');
    addTouchBtn('btnLeft', 'left');
    addTouchBtn('btnRight', 'right');
    addTouchBtn('btnGas', 'gas');
    addTouchBtn('btnBrake', 'brake');
    addTouchBtn('btnHandbrake', 'handbrake');
  }
  
  // --- UI Buttons ---
  document.getElementById('resumeBtn').addEventListener('click', () => {
    initAudioIfNeeded();
    gameState.phase = 'racing';
    document.getElementById('pauseMenu').style.display = 'none';
  });
  
  function restartGame() {
    carState.x = startPos.x;
    carState.y = startPos.y + 0.7;
    carState.z = startPos.z;
    carState.heading = startHeading;
    carState.speed = 0;
    carState.steerAngle = 0;
    carState.gear = 1;
    carState.rpm = 800;
    
    carMesh.position.set(startPos.x, startPos.y + 0.7, startPos.z);
    carMesh.rotation.y = startHeading;
    
    gameState.currentLap = 1;
    gameState.bestLap = 0;
    gameState.lapTimes = [];
    gameState.phase = 'countdown';
    gameState.countdownValue = 3;
    gameState.countdownTimer = 0;
    
    checkpoints.forEach(cp => cp.passed = false);
    carState.nextCheckpoint = 1;
    carState.lapCompleted = false;
    
    document.getElementById('finishScreen').style.display = 'none';
    document.getElementById('pauseMenu').style.display = 'none';
    document.getElementById('hud').style.display = 'block';
    document.getElementById('countdownOverlay').style.display = 'flex';
  }
  
  document.getElementById('restartBtn').addEventListener('click', restartGame);
  document.getElementById('restartPauseBtn').addEventListener('click', restartGame);
  
  // --- Resize Handler ---
  window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });
  
  // --- Pause/Camera/Restart key handlers (separate from input) ---
  window.addEventListener('keydown', function controlHandler(e) {
    if (e.code === 'KeyP' && gameState.phase === 'racing') {
      gameState.phase = 'paused';
      document.getElementById('pauseMenu').style.display = 'flex';
      audio.stopSkidSound();
    } else if (e.code === 'KeyP' && gameState.phase === 'paused') {
      gameState.phase = 'racing';
      document.getElementById('pauseMenu').style.display = 'none';
    }
    
    if (e.code === 'KeyC') {
      cam.mode = cam.mode === 'chase' ? 'cinematic' : 'chase';
    }
    
    if (e.code === 'KeyR' && gameState.phase !== 'countdown') {
      restartGame();
    }
  });
  
  // --- Show HUD & Start Countdown ---
  document.getElementById('hud').style.display = 'block';
  document.getElementById('countdownOverlay').style.display = 'flex';
  
  // --- Game Loop Variables ---
  let lastTime = performance.now();
  let dustTimer = 0;
  let minimapTimer = 0;
  
  // ============================================================
  // MAIN GAME LOOP
  // ============================================================
  function gameLoop(now) {
    requestAnimationFrame(gameLoop);
    
    const dt = Math.min((now - lastTime) / 1000, 0.1);
    lastTime = now;
    
    // --- Countdown Phase ---
    if (gameState.phase === 'countdown') {
      gameState.countdownTimer += dt;
      
      if (gameState.countdownTimer >= 1) {
        gameState.countdownTimer -= 1;
        gameState.countdownValue--;
        
        const countdownText = document.getElementById('countdownText');
        
        if (gameState.countdownValue > 0) {
          countdownText.textContent = gameState.countdownValue;
          if (audio.isReady) audio.playBeep(440, 0.2);
        } else {
          countdownText.textContent = 'GO!';
          countdownText.style.color = '#00ff00';
          if (audio.isReady) audio.playBeep(880, 0.3);
          
          setTimeout(() => {
            document.getElementById('countdownOverlay').style.display = 'none';
          }, 500);
          
          gameState.phase = 'racing';
          gameState.startTime = now;
          gameState.lapStartTime = now;
        }
      }
      
      updateCamera(cam, carMesh, dt);
      camera.position.copy(cam.position);
      camera.lookAt(cam.target);
      renderer.render(scene, camera);
      return;
    }
    
    // --- Paused Phase ---
    if (gameState.phase === 'paused') {
      updateCamera(cam, carMesh, dt);
      camera.position.copy(cam.position);
      camera.lookAt(cam.target);
      renderer.render(scene, camera);
      return;
    }
    
    // --- Finished Phase ---
    if (gameState.phase === 'finished') {
      renderer.render(scene, camera);
      return;
    }
    
    // --- Racing Phase: Update Physics ---
    updateCarPhysics(carState, inputHandler.input, track, dt);
    
    // Sync car mesh with physics state
    carMesh.position.set(carState.x, carState.y + 0.7, carState.z);
    carMesh.rotation.y = carState.heading;
    
    // Wheel rotation animation
    const wheelRotSpeed = carState.speed * dt * 2;
    for (const wheel of carData.wheels) {
      wheel.rotation.x += wheelRotSpeed;
    }
    
    // Front wheels steer angle
    if (carData.wheels.length >= 2) {
      carData.wheels[0].rotation.z = Math.PI/2 + carState.steerAngle * 0.5;
      carData.wheels[1].rotation.z = Math.PI/2 + carState.steerAngle * 0.5;
    }
    
    // --- Obstacle Collision ---
    const collided = checkObstacleCollision(carState, sceneryGroup);
    if (collided) {
      audio.playCrash();
      emitSparks(particleSystem, carState.x, carState.y + 1, carState.z);
    }
    
    // --- Checkpoint Progression ---
    checkCheckpointPass(carState, checkpoints);
    
    if (carState.lapCompleted) {
      const lapTime = now - gameState.lapStartTime;
      
      if (gameState.bestLap === 0 || lapTime < gameState.bestLap) {
        gameState.bestLap = lapTime;
      }
      gameState.lapTimes.push(lapTime);
      
      carState.lapCompleted = false;
      gameState.currentLap++;
      gameState.lapStartTime = now;
      
      if (gameState.currentLap > CONFIG.TOTAL_LAPS) {
        // Race complete!
        gameState.phase = 'finished';
        const totalTime = now - gameState.startTime;
        
        document.getElementById('finalTime').textContent = formatTime(totalTime);
        document.getElementById('bestLapFinish').textContent = formatTime(gameState.bestLap);
        document.getElementById('finishScreen').style.display = 'flex';
        
        audio.playFinishTone();
      }
    }
    
    // --- Particle Effects ---
    dustTimer += dt;
    
    if (dustTimer > 0.05) {
      dustTimer = 0;
      
      const absSpeed = Math.abs(carState.speed);
      
      // Dust when off-road or drifting
      if (!carState.onTrack && absSpeed > 3) {
        emitDust(particleSystem, carState.x, carState.y + 0.2, carState.z);
      } else if (carState.skidding && absSpeed > 5) {
        emitDust(particleSystem, carState.x, carState.y + 0.2, carState.z);
      }
      
      // Skid marks when drifting
      if ((carState.skidding || !carState.onTrack) && absSpeed > 8) {
        addSkidMark(skidMarkSystem, 
          carState.x - Math.sin(carState.heading) * 1.5,
          carState.z + Math.cos(carState.heading) * 1.5,
          carState.heading
        );
      }
    }
    
    updateParticles(particleSystem, dt);
    updateSkidMarks(skidMarkSystem, dt);
    
    // --- Audio Updates ---
    if (audio.isReady) {
      audio.updateEngine(carState);
      
      if (carState.skidding || !carState.onTrack) {
        if (!audio._skidPlaying) {
          audio.startSkidSound();
          audio._skidPlaying = true;
        }
      } else {
        if (audio._skidPlaying) {
          audio.stopSkidSound();
          audio._skidPlaying = false;
        }
      }
    }
    
    // --- Camera Update ---
    updateCamera(cam, carMesh, dt);
    camera.position.copy(cam.position);
    camera.lookAt(cam.target);
    
    // Move sun shadow to follow car
    sunLight.position.set(carState.x + 50, 80, carState.z + 30);
    sunLight.target.position.set(carState.x, carState.y, carState.z);
    sunLight.target.updateMatrixWorld();
    
    // --- HUD Update ---
    updateHUD(carState, gameState);
    
    minimapTimer += dt;
    if (minimapTimer > 0.1) {
      minimapTimer = 0;
      drawMinimap(track, carState);
    }
    
    // --- Render ---
    renderer.render(scene, camera);
  }
  
  requestAnimationFrame(gameLoop);
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"composer","sessionId":"ee567ad6-654d-4b20-857e-703df70a1e51","tokensIn":787671,"tokensOut":32600,"tokensTotal":820271,"turns":31,"toolCalls":30,"failedToolCalls":1,"timestamp":"2026-07-18T15:48:20.854Z"} -->