← Rally results

Rally

inkling · 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 40,007 bytes · SHA-256 cc8228c1901c
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Rally Dash — 3D Racing</title>
<style>
  @import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@700;900&family=Share+Tech+Mono&display=swap');
  * { margin: 0; padding: 0; box-sizing: border-box; touch-action: manipulation; user-select: none; -webkit-user-select: none; }
  html, body { width: 100%; height: 100%; overflow: hidden; background: #0b0c15; color: #fff; font-family: 'Share Tech Mono', monospace; }
  #gameCanvas { display: block; width: 100vw; height: 100vh; }
  .hud { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
  .hud .top { position: absolute; top: 16px; left: 16px; right: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
  .hud .bottom { position: absolute; bottom: 80px; left: 16px; right: 16px; display: flex; justify-content: space-between; align-items: flex-end; }
  .hud .center { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; pointer-events: auto; }
  .panel { background: rgba(0,0,0,0.55); border: 1px solid rgba(255,255,255,0.12); border-radius: 12px; padding: 10px 16px; backdrop-filter: blur(6px); box-shadow: 0 8px 32px rgba(0,0,0,0.6); }
  .panel h2 { font-family: 'Orbitron', sans-serif; font-size: 14px; letter-spacing: 2px; color: #ffd166; text-shadow: 0 0 10px rgba(255,209,102,0.4); margin-bottom: 6px; }
  .stat { font-size: 13px; color: #fff; letter-spacing: 1px; line-height: 1.6; }
  .stat span { color: #ffd166; font-weight: bold; }
  .speed { font-family: 'Orbitron', sans-serif; font-size: 42px; color: #fff; text-shadow: 0 0 20px rgba(255,255,255,0.3); letter-spacing: 4px; }
  .speed .unit { font-size: 18px; color: #aaa; letter-spacing: 1px; }
  #minimap { position: absolute; top: 16px; right: 16px; width: 160px; height: 160px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.15); overflow: hidden; background: rgba(0,0,0,0.45); box-shadow: 0 0 20px rgba(0,0,0,0.6); pointer-events: none; }
  #minimap canvas { width: 100%; height: 100%; display: block; }
  .countdown { font-family: 'Orbitron', sans-serif; font-size: 120px; color: #ffd166; text-shadow: 0 0 40px rgba(255,209,102,0.8), 0 0 80px rgba(255,209,102,0.4); letter-spacing: 8px; animation: pulse 1s ease-in-out infinite alternate; }
  .countdown small { display: block; font-size: 28px; letter-spacing: 4px; color: #fff; margin-top: 8px; text-shadow: none; }
  @keyframes pulse { from { transform: translate(-50%, -50%) scale(1); opacity: 0.9; } to { transform: translate(-50%, -50%) scale(1.1); opacity: 1; } }
  .overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.65); backdrop-filter: blur(12px); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 50; pointer-events: auto; transition: opacity 0.3s ease; }
  .overlay.hidden { opacity: 0; pointer-events: none; }
  .overlay h1 { font-family: 'Orbitron', sans-serif; font-size: 48px; color: #ffd166; text-shadow: 0 0 30px rgba(255,209,102,0.6); letter-spacing: 6px; margin-bottom: 10px; }
  .overlay p { font-size: 20px; color: #ccc; margin: 8px 0; }
  .btn { background: linear-gradient(135deg, #ffd166 0%, #e6a100 100%); color: #1a1a2e; border: none; border-radius: 8px; padding: 14px 32px; font-family: 'Orbitron', sans-serif; font-size: 18px; letter-spacing: 2px; cursor: pointer; box-shadow: 0 0 20px rgba(255,209,102,0.3); transition: transform 0.15s ease, box-shadow 0.15s ease; pointer-events: auto; }
  .btn:hover { transform: translateY(-2px); box-shadow: 0 0 30px rgba(255,209,102,0.6); }
  .touch-controls { position: fixed; bottom: 16px; left: 0; width: 100%; display: flex; justify-content: space-between; align-items: flex-end; padding: 0 20px; pointer-events: none; z-index: 20; }
  .touch-btn { width: 70px; height: 70px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.08); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; font-size: 28px; color: #fff; pointer-events: auto; box-shadow: 0 4px 15px rgba(0,0,0,0.3); transition: background 0.1s; }
  .touch-btn:active { background: rgba(255,255,255,0.25); transform: scale(0.96); }
  .touch-group { display: flex; gap: 12px; }
  .mini-label { position: absolute; bottom: 6px; left: 50%; transform: translateX(-50%); font-size: 9px; color: rgba(255,255,255,0.7); text-transform: uppercase; letter-spacing: 1px; }
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
</head>
<body>
<canvas id="gameCanvas"></canvas>

<div class="hud">
  <div class="top">
    <div class="panel">
      <h2>RALLY DASH</h2>
      <div class="stat">LAP <span id="lapDisplay">1</span> / 3</div>
      <div class="stat">TIME <span id="timeDisplay">00:00.00</span></div>
      <div class="stat">BEST <span id="bestLapDisplay">--:--.--</span></div>
      <div class="stat">GEAR <span id="gearDisplay">1</span></div>
      <div class="stat">POS <span id="posDisplay">P1</span></div>
    </div>
    <div id="minimap"><canvas id="minimapCanvas"></canvas></div>
  </div>
  <div class="bottom">
    <div class="panel" style="text-align:center; min-width: 140px;">
      <div class="speed"><span id="speedDisplay">0</span> <span class="unit">KM/H</span></div>
      <div class="stat" style="margin-top:4px; font-size:10px; color:#aaa;">RPM <span id="rpmDisplay">0</span></div>
    </div>
  </div>
  <div id="countdownOverlay" class="center">
    <div class="countdown" id="countdownText">3<small>GET READY</small></div>
  </div>
</div>

<div id="pauseOverlay" class="overlay hidden">
  <h1>PAUSED</h1>
  <p>Press <strong>P</strong> or click Resume to continue</p>
  <button class="btn" onclick="window.RallyGame.resumeGame()">Resume</button>
  <button class="btn" onclick="window.RallyGame.restartGame()" style="margin-top:12px; background:linear-gradient(135deg,#e74c3c,#c0392b);">Restart</button>
</div>

<div id="finishOverlay" class="overlay hidden">
  <h1>FINISH!</h1>
  <p>Race Complete — 3 Laps</p>
  <p style="font-family:'Orbitron'; font-size:32px; color:#ffd166; margin-top:12px;">Total Time: <span id="finalTimeDisplay">00:00.00</span></p>
  <p style="font-size:16px; color:#aaa;">Best Lap: <span id="finalBestDisplay">--:--.--</span></p>
  <button class="btn" onclick="window.RallyGame.restartGame()" style="margin-top:24px;">Race Again</button>
</div>

<div class="touch-controls" id="touchControls">
  <div class="touch-group">
    <button class="touch-btn" id="btnLeft" aria-label="Left">◀</button>
    <button class="touch-btn" id="btnRight" aria-label="Right">▶</button>
  </div>
  <div class="touch-group">
    <button class="touch-btn" id="btnBrake" aria-label="Brake" style="font-size:20px;">⏹</button>
    <button class="touch-btn" id="btnGas" aria-label="Gas" style="font-size:20px;">▲</button>
    <button class="touch-btn" id="btnHandbrake" aria-label="Handbrake" style="font-size:20px;">◉</button>
  </div>
</div>

<script>
// ============================================================
// RALLY DASH — Single-file 3D rally racing game
// ============================================================
window.RallyGame = (function() {
  'use strict';

  // --- Constants ---
  const TRACK_SEGMENTS = 20;
  const LAP_COUNT = 3;
  const COUNTDOWN_DURATION = 3000; // ms

  // --- Game State ---
  let canvas, renderer, scene, camera, clock, audioCtx;
  let carMesh, groundMesh, sunLight, ambientLight;
  let carBody, wheels = [], exhaustParticles = [], skidMarks = [];
  let trackNodes = [], trackSegments = [], trackMeshes = [];
  let obstacles = [], obstacleMeshes = [], obstacleColliders = [];
  let checkpoints = [];
  let minimapCtx, minimapCanvas;

  let state = 'INIT'; // INIT, COUNTDOWN, RACING, PAUSED, FINISHED
  let startTime = 0, currentLap = 1, bestLapMs = Infinity;
  let lapTimes = [Infinity, Infinity, Infinity];
  let totalTimeMs = 0;
  let countdownEnd = 0;
  let checkpointIndex = 0;

  // Car physics
  let carProgress = 0; // distance along track loop
  let carOffset = 0; // lateral offset from centerline (meters)
  let carSpeed = 0; // forward speed (units/sec)
  let carYaw = 0; // rotation relative to tangent (radians)
  let carRotY = 0; // absolute rotation
  let carPos = new THREE.Vector3(0, 0, 0);
  let carTangent = new THREE.Vector3(0, 0, 1);

  // Input
  let input = { accel: 0, steer: 0, brake: 0, handbrake: 0, toggleCam: false };
  let keys = {};
  let isMobile = false;

  // Camera
  let cameraMode = 'chase'; // chase, cinematic
  let camTarget = new THREE.Vector3();
  let camPos = new THREE.Vector3();

  // Audio nodes
  let engineOsc, engineGain, engineFilter;
  let skidOsc, skidGain, skidFilter;
  let audioEnabled = false;

  // --- Utilities ---
  function rand(min, max) { return Math.random() * (max - min) + min; }
  function dist(a, b) { return Math.sqrt((a.x-b.x)*(a.x-b.x)+(a.y-b.y)*(a.y-b.y)+(a.z-b.z)*(a.z-b.z)); }
  function lerp(a, b, t) { return a + (b - a) * t; }
  function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }

  // --- Audio ---
  function initAudio() {
    try {
      audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      audioEnabled = true;
    } catch (e) { audioEnabled = false; }

    if (!audioEnabled) return;

    // Engine tone
    engineOsc = audioCtx.createOscillator();
    engineOsc.type = 'sawtooth';
    engineOsc.frequency.value = 120;
    engineGain = audioCtx.createGain();
    engineGain.gain.value = 0;
    engineFilter = audioCtx.createBiquadFilter();
    engineFilter.type = 'lowpass';
    engineFilter.frequency.value = 800;
    engineOsc.connect(engineFilter);
    engineFilter.connect(engineGain);
    engineGain.connect(audioCtx.destination);
    engineOsc.start();

    // Skid tone
    skidOsc = audioCtx.createOscillator();
    skidOsc.type = 'square';
    skidOsc.frequency.value = 300;
    skidGain = audioCtx.createGain();
    skidGain.gain.value = 0;
    skidFilter = audioCtx.createBiquadFilter();
    skidFilter.type = 'bandpass';
    skidFilter.frequency.value = 1200;
    skidOsc.connect(skidFilter);
    skidFilter.connect(skidGain);
    skidGain.connect(audioCtx.destination);
    skidOsc.start();
  }

  function resumeAudio() {
    if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
  }

  function playTone(freq, duration, gainVal = 0.15, type = 'triangle') {
    if (!audioEnabled || !audioCtx) return;
    const osc = audioCtx.createOscillator();
    const g = audioCtx.createGain();
    osc.type = type;
    osc.frequency.value = freq;
    g.gain.setValueAtTime(gainVal, audioCtx.currentTime);
    g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
    osc.connect(g);
    g.connect(audioCtx.destination);
    osc.start();
    osc.stop(audioCtx.currentTime + duration);
  }

  function playCrash() {
    playTone(60, 0.4, 0.2, 'sine');
  }

  // --- Procedural Track ---
  function buildTrack() {
    // Nodes: position, width (meters), surface type
    trackNodes = [
      { pos: new THREE.Vector3(0, 1.5, 0), w: 16, type: 'asphalt' },
      { pos: new THREE.Vector3(45, 2.0, 35), w: 18, type: 'asphalt' },
      { pos: new THREE.Vector3(85, 3.5, 75), w: 16, type: 'dirt' },
      { pos: new THREE.Vector3(75, 5.5, 120), w: 14, type: 'mud' },
      { pos: new THREE.Vector3(30, 4.0, 160), w: 16, type: 'asphalt' },
      { pos: new THREE.Vector3(-20, 2.5, 150), w: 16, type: 'grass' },
      { pos: new THREE.Vector3(-60, 1.8, 100), w: 18, type: 'asphalt' },
      { pos: new THREE.Vector3(-75, 2.2, 50), w: 16, type: 'dirt' },
      { pos: new THREE.Vector3(-40, 1.2, 10), w: 14, type: 'asphalt' },
      { pos: new THREE.Vector3(10, 1.0, -20), w: 16, type: 'grass' },
      { pos: new THREE.Vector3(30, 2.0, -40), w: 16, type: 'asphalt' },
      { pos: new THREE.Vector3(0, 2.5, -70), w: 16, type: 'mud' },
    ];

    // Create segments
    trackSegments = [];
    let totalLength = 0;
    for (let i = 0; i < trackNodes.length; i++) {
      const a = trackNodes[i];
      const b = trackNodes[(i + 1) % trackNodes.length];
      const len = dist(a.pos, b.pos);
      const seg = {
        start: a.pos.clone(),
        end: b.pos.clone(),
        startDist: totalLength,
        length: len,
        type: a.type,
        widthA: a.w,
        widthB: b.w,
        centerMid: new THREE.Vector3().addVectors(a.pos, b.pos).multiplyScalar(0.5),
      };
      // Average elevation
      seg.centerMid.y = (a.pos.y + b.pos.y) * 0.5;
      trackSegments.push(seg);
      totalLength += len;
    }

    // Build meshes for track surface (boxes)
    const asphaltMat = new THREE.MeshStandardMaterial({ color: 0x2a2a36, roughness: 0.8, metalness: 0.2 });
    const dirtMat = new THREE.MeshStandardMaterial({ color: 0x5c4a30, roughness: 1.0, metalness: 0.05 });
    const grassMat = new THREE.MeshStandardMaterial({ color: 0x3a5a2a, roughness: 0.9, metalness: 0.05 });
    const mudMat = new THREE.MeshStandardMaterial({ color: 0x3a2a20, roughness: 0.9, metalness: 0.1 });

    for (const seg of trackSegments) {
      const mat = seg.type === 'dirt' ? dirtMat : seg.type === 'grass' ? grassMat : seg.type === 'mud' ? mudMat : asphaltMat;
      const len = seg.length;
      const w = (seg.widthA + seg.widthB) * 0.5;
      const geo = new THREE.BoxGeometry(len, 0.4, w);
      const mesh = new THREE.Mesh(geo, mat);
      // Position and rotate to align with segment
      const center = seg.centerMid.clone();
      const dir = new THREE.Vector3().subVectors(seg.end, seg.start).normalize();
      const angle = Math.atan2(dir.x, dir.z);
      mesh.rotation.y = angle;
      mesh.position.copy(center);
      mesh.position.y -= 0.2; // half thickness
      mesh.receiveShadow = true;
      mesh.castShadow = true;
      scene.add(mesh);
      trackMeshes.push(mesh);
    }

    // Shoulders (grass) alongside
    for (let i = 0; i < trackSegments.length; i++) {
      const seg = trackSegments[i];
      const mat = new THREE.MeshStandardMaterial({ color: 0x3a5a2a, roughness: 1.0 });
      const len = seg.length;
      const w = 10; // shoulder width
      const geo = new THREE.BoxGeometry(len, 0.35, w);
      const mesh = new THREE.Mesh(geo, mat);
      const center = new THREE.Vector3().addVectors(seg.start, seg.end).multiplyScalar(0.5);
      const dir = new THREE.Vector3().subVectors(seg.end, seg.start).normalize();
      const angle = Math.atan2(dir.x, dir.z);
      mesh.rotation.y = angle;
      mesh.position.copy(center);
      mesh.position.y = -0.175;
      mesh.receiveShadow = true;
      scene.add(mesh);
      // Offset shoulder slightly outward on left and right using cross product
      const normal = new THREE.Vector3(-dir.z, 0, dir.x).normalize();
      // We don't actually separate shoulders; this is fine for visual
      // Instead, let's add a wider grass strip under everything
    }

    // Ground plane
    groundMesh = new THREE.Mesh(
      new THREE.CircleGeometry(300, 64),
      new THREE.MeshStandardMaterial({ color: 0x2a3a1a, roughness: 0.9, metalness: 0.1 })
    );
    groundMesh.rotation.x = -Math.PI / 2;
    groundMesh.position.y = -0.4;
    groundMesh.receiveShadow = true;
    scene.add(groundMesh);

    // Place obstacles and scenery
    spawnScenery();

    // Checkpoints every segment midpoint
    checkpoints = [];
    for (let i = 0; i < trackSegments.length; i++) {
      const seg = trackSegments[i];
      checkpoints.push({ dist: seg.startDist + seg.length * 0.5 });
    }
  }

  function spawnScenery() {
    // Trees: low-poly cone + cylinder
    const treeMat = new THREE.MeshStandardMaterial({ color: 0x2d5a27, roughness: 0.9 });
    const trunkMat = new THREE.MeshStandardMaterial({ color: 0x4a2e18, roughness: 0.95 });
    const rockMat = new THREE.MeshStandardMaterial({ color: 0x6a5a50, roughness: 0.8 });
    const barrierMat = new THREE.MeshStandardMaterial({ color: 0x8a5a2a, roughness: 0.9 });

    obstacleColliders = []; // spheres for simple collision

    for (let i = 0; i < 60; i++) {
      const seg = trackSegments[i % trackSegments.length];
      const t = rand(0.15, 0.85);
      const pos = new THREE.Vector3().lerpVectors(seg.start, seg.end, t);
      const dir = new THREE.Vector3().subVectors(seg.end, seg.start).normalize();
      const normal = new THREE.Vector3(-dir.z, 0, dir.x).normalize();
      const offset = rand(10, 22) * (Math.random() > 0.5 ? 1 : -1);
      pos.addScaledVector(normal, offset);
      pos.y = rand(0, 1);

      if (Math.random() < 0.5) {
        // Tree
        const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.6, 2), trunkMat);
        trunk.position.copy(pos);
        trunk.position.y += 1;
        trunk.castShadow = true;
        scene.add(trunk);

        const leaves = new THREE.Mesh(new THREE.ConeGeometry(1.2, 3, 8), treeMat);
        leaves.position.copy(pos);
        leaves.position.y += 3.5;
        leaves.castShadow = true;
        scene.add(leaves);

        // Add collider
        obstacleColliders.push({ pos: pos.clone(), posY: 1, r: 2.5 });
      } else {
        // Rock
        const rock = new THREE.Mesh(new THREE.IcosahedronGeometry(rand(0.8, 1.4), 0), rockMat);
        rock.position.copy(pos);
        rock.position.y += 0.8;
        rock.rotation.y = rand(0, Math.PI * 2);
        rock.castShadow = true;
        scene.add(rock);
        obstacleColliders.push({ pos: pos.clone(), posY: 0.8, r: 1.5 });
      }
    }

    // Barriers (guard rails) along some segments
    const railMat = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.3, metalness: 0.8 });
    for (let i = 0; i < trackSegments.length; i++) {
      if (i % 2 === 0) continue;
      const seg = trackSegments[i];
      const len = seg.length;
      const dir = new THREE.Vector3().subVectors(seg.end, seg.start).normalize();
      const normal = new THREE.Vector3(-dir.z, 0, dir.x).normalize();
      const side = rand(0.5, 0.8);
      for (const s of [side, -side]) {
        const rail = new THREE.Mesh(new THREE.BoxGeometry(len, 0.8, 0.15), railMat);
        const center = new THREE.Vector3().addVectors(seg.start, seg.end).multiplyScalar(0.5);
        center.addScaledVector(normal, s * ((seg.widthA + seg.widthB) * 0.5));
        rail.rotation.y = Math.atan2(dir.x, dir.z);
        rail.position.copy(center);
        rail.position.y = 0.4;
        rail.castShadow = true;
        scene.add(rail);
      }
    }

    // Simple billboard banners (vertical planes)
    const bannerMat = new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0x440000, roughness: 0.9 });
    for (let i = 0; i < Math.min(8, Math.floor(trackSegments.length / 2)); i++) {
      const seg = trackSegments[i * 2 % trackSegments.length];
      const pos = new THREE.Vector3().lerpVectors(seg.start, seg.end, 0.3);
      const dir = new THREE.Vector3().subVectors(seg.end, seg.start).normalize();
      const banner = new THREE.Mesh(new THREE.BoxGeometry(3, 4, 0.1), bannerMat);
      banner.rotation.y = Math.atan2(dir.x, dir.z) + Math.PI / 2;
      banner.position.copy(pos);
      banner.position.y = 2;
      scene.add(banner);
    }

    // Obstacle colliders stored in local variable
  }

  // --- Track Queries ---
  function getTrackPoint(dist) {
    let d = dist % getTotalLength();
    if (d < 0) d += getTotalLength();
    for (const seg of trackSegments) {
      if (d <= seg.startDist + seg.length) {
        const t = (d - seg.startDist) / seg.length;
        const pos = new THREE.Vector3().lerpVectors(seg.start, seg.end, t);
        const width = lerp(seg.widthA, seg.widthB, t);
        const tangent = new THREE.Vector3().subVectors(seg.end, seg.start).normalize();
        return { pos, tangent, width, type: seg.type };
      }
    }
    return getTrackPoint(0);
  }

  function getTotalLength() {
    return trackSegments.reduce((s, seg) => s + seg.length, 0);
  }

  // --- Car Model ---
  function buildCar() {
    carBody = new THREE.Group();
    // Main body
    const bodyGeo = new THREE.BoxGeometry(2.8, 0.9, 4.2);
    const bodyMat = new THREE.MeshStandardMaterial({ color: 0xe63946, roughness: 0.3, metalness: 0.6 });
    const body = new THREE.Mesh(bodyGeo, bodyMat);
    body.position.y = 0.5;
    body.castShadow = true;
    carBody.add(body);

    // Cabin
    const cabinGeo = new THREE.BoxGeometry(1.4, 0.6, 2.4);
    const cabinMat = new THREE.MeshStandardMaterial({ color: 0x1d1d2e, roughness: 0.6, metalness: 0.4 });
    const cabin = new THREE.Mesh(cabinGeo, cabinMat);
    cabin.position.set(0, 1.05, -0.2);
    cabin.castShadow = true;
    carBody.add(cabin);

    // Spoiler
    const spoilerGeo = new THREE.BoxGeometry(1.8, 0.2, 0.4);
    const spoilerMat = new THREE.MeshStandardMaterial({ color: 0x111, roughness: 0.9 });
    const spoiler = new THREE.Mesh(spoilerGeo, spoilerMat);
    spoiler.position.set(0, 0.7, -2.1);
    carBody.add(spoiler);

    // Headlights
    const headMat = new THREE.MeshStandardMaterial({ color: 0xfff3a0, emissive: 0xffaa66, roughness: 0.4 });
    for (const x of [-0.7, 0.7]) {
      const head = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.15, 0.1, 8), headMat);
      head.rotation.z = Math.PI / 2;
      head.position.set(x, 0.6, 2.0);
      carBody.add(head);
    }

    // Taillights
    const tailMat = new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xcc1111, roughness: 0.5 });
    for (const x of [-0.7, 0.7]) {
      const tail = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.15, 0.1), tailMat);
      tail.position.set(x, 0.55, -2.05);
      carBody.add(tail);
    }

    // Wheels
    const wheelGeo = new THREE.CylinderGeometry(0.38, 0.38, 0.35, 12);
    const wheelMat = new THREE.MeshStandardMaterial({ color: 0x222, roughness: 0.8 });
    for (const x of [-1.0, 1.0]) {
      for (const z of [-1.2, 1.2]) {
        const w = new THREE.Mesh(wheelGeo, wheelMat);
        w.rotation.x = Math.PI / 2;
        w.position.set(x, 0.38, z);
        carBody.add(w);
        wheels.push(w);
      }
    }

    carBody.castShadow = true;
    scene.add(carBody);
  }

  // --- Particles ---
  function spawnParticles(type, pos, count, color, size) {
    for (let i = 0; i < count; i++) {
      const geo = new THREE.BoxGeometry(0.08, 0.08, 0.08);
      const mat = new THREE.MeshStandardMaterial({ color: color, emissive: color, roughness: 1.0 });
      const mesh = new THREE.Mesh(geo, mat);
      mesh.position.copy(pos);
      mesh.position.y += rand(0, 0.3);
      mesh.rotation.set(rand(0, Math.PI), rand(0, Math.PI), rand(0, Math.PI));
      scene.add(mesh);
      // Remove after short time
      setTimeout(() => {
        scene.remove(mesh);
      }, rand(300, 800));
    }
  }

  function spawnDust(pos) {
    spawnParticles('dust', pos, 5, 0xaaaa88, 0.08);
  }

  function spawnSparks(pos) {
    spawnParticles('spark', pos, 8, 0xffaa00, 0.06);
  }

  function spawnSkidMark(pos) {
    const geo = new THREE.PlaneGeometry(0.2, 0.6);
    const mat = new THREE.MeshStandardMaterial({ color: 0x111, roughness: 1.0, transparent: true, opacity: 0.7 });
    const mesh = new THREE.Mesh(geo, mat);
    mesh.rotation.x = -Math.PI / 2;
    mesh.position.copy(pos);
    mesh.position.y = 0.05;
    scene.add(mesh);
    skidMarks.push({ mesh, life: 1.0 });
  }

  // --- Scene Setup ---
  function init() {
    canvas = document.getElementById('gameCanvas');
    minimapCanvas = document.getElementById('minimapCanvas');
    minimapCtx = minimapCanvas.getContext('2d');

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

    scene = new THREE.Scene();
    scene.background = new THREE.Color(0x1a1a2e);
    scene.fog = new THREE.FogExp2(0x8899aa, 0.008);

    // Sun
    sunLight = new THREE.DirectionalLight(0xffffff, 1.2);
    sunLight.position.set(50, 80, 30);
    sunLight.castShadow = true;
    sunLight.shadow.mapSize.width = 1024;
    sunLight.shadow.mapSize.height = 1024;
    scene.add(sunLight);

    ambientLight = new THREE.AmbientLight(0x5577aa, 0.6);
    scene.add(ambientLight);

    // Sky dome
    const skyGeo = new THREE.SphereGeometry(400, 32, 16);
    const skyMat = new THREE.MeshStandardMaterial({ color: 0x223344, roughness: 1, metalness: 0, side: THREE.BackSide, fog: false });
    const sky = new THREE.Mesh(skyGeo, skyMat);
    scene.add(sky);

    // Camera
    camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 1, 600);
    camera.position.set(0, 6, -16);

    clock = new THREE.Clock();

    buildTrack();
    buildCar();
    initAudio();

    state = 'COUNTDOWN';
    countdownEnd = performance.now() + COUNTDOWN_DURATION;

    window.addEventListener('resize', onResize);
    document.addEventListener('keydown', onKey);
    document.addEventListener('keyup', onKey);

    // Touch inputs
    document.getElementById('btnGas').addEventListener('touchstart', () => input.accel = 1);
    document.getElementById('btnGas').addEventListener('touchend', () => input.accel = 0);
    document.getElementById('btnBrake').addEventListener('touchstart', () => { input.accel = -0.8; input.handbrake = 1; });
    document.getElementById('btnBrake').addEventListener('touchend', () => { input.accel = 0; input.handbrake = 0; });
    document.getElementById('btnHandbrake').addEventListener('touchstart', () => input.handbrake = 1);
    document.getElementById('btnHandbrake').addEventListener('touchend', () => input.handbrake = 0);
    document.getElementById('btnLeft').addEventListener('touchstart', () => input.steer = -1);
    document.getElementById('btnLeft').addEventListener('touchend', () => input.steer = 0);
    document.getElementById('btnRight').addEventListener('touchstart', () => input.steer = 1);
    document.getElementById('btnRight').addEventListener('touchend', () => input.steer = 0);

    // Mouse is not needed for gameplay but helps; we'll ignore for simplicity

    isMobile = window.innerWidth < 900;
    loop();
  }

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

  function onKey(e) {
    const down = e.type === 'keydown';
    keys[e.key.toLowerCase()] = down;
    keys[e.code] = down;
    if (down) {
      if (e.key === 'p' || e.key === 'P') { togglePause(); }
      if (e.key === 'r' || e.key === 'R') { restartGame(); }
      if (e.key === 'c' || e.key === 'C') { cameraMode = cameraMode === 'chase' ? 'cinematic' : 'chase'; }
      if (e.key === ' ') { input.handbrake = down ? 1 : 0; }
    }
  }

  document.addEventListener('keydown', (e) => {
    if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') input.accel = 1;
    if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') input.accel = -0.6;
    if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') input.steer = -1;
    if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') input.steer = 1;
  });
  document.addEventListener('keyup', (e) => {
    if (['ArrowUp','ArrowDown','w','W','s','S'].includes(e.key)) input.accel = 0;
    if (['ArrowLeft','ArrowRight','a','A','d','D'].includes(e.key)) input.steer = 0;
  });

  // --- Game Loop ---
  function loop() {
    const dt = Math.min(clock.getDelta(), 0.05);
    requestAnimationFrame(loop);

    if (state === 'RACING') {
      updatePhysics(dt);
      updateParticles();
      updateCamera();
      updateAudio();
    } else if (state === 'COUNTDOWN') {
      const remaining = Math.ceil((countdownEnd - performance.now()) / 1000);
      if (remaining <= 0) {
        startRace();
      } else {
        document.getElementById('countdownText').innerHTML = remaining + '<small>GET READY</small>';
      }
      updateCamera();
    } else if (state === 'PAUSED') {
      // Nothing updates
    }

    // Always render for visuals
    renderer.render(scene, camera);
    updateMinimap();
    updateHUD();
  }

  function startRace() {
    state = 'RACING';
    startTime = performance.now();
    document.getElementById('countdownOverlay').style.display = 'none';
    playTone(440, 0.15, 0.1, 'triangle');
    playTone(880, 0.3, 0.1, 'triangle');
  }

  function togglePause() {
    if (state === 'FINISHED') return;
    if (state === 'PAUSED') {
      state = 'RACING';
      document.getElementById('pauseOverlay').classList.add('hidden');
      resumeAudio();
      clock.getDelta(); // reset delta
    } else if (state === 'RACING') {
      state = 'PAUSED';
      document.getElementById('pauseOverlay').classList.remove('hidden');
    }
  }

  function restartGame() {
    carProgress = 0;
    carOffset = 0;
    carSpeed = 0;
    carYaw = 0;
    checkpointIndex = 0;
    currentLap = 1;
    bestLapMs = Infinity;
    lapTimes = [Infinity, Infinity, Infinity];
    totalTimeMs = 0;
    state = 'COUNTDOWN';
    countdownEnd = performance.now() + COUNTDOWN_DURATION;
    document.getElementById('countdownOverlay').style.display = 'flex';
    document.getElementById('pauseOverlay').classList.add('hidden');
    document.getElementById('finishOverlay').classList.add('hidden');
    clock.getDelta();
  }

  window.RallyGame = {
    resumeGame: () => togglePause(),
    restartGame,
    init
  };

  // --- Physics ---
  function updatePhysics(dt) {
    // Read input
    let accelInput = input.accel;
    let steerInput = input.steer;
    let braking = input.handbrake;

    // Get current track segment info
    const info = getTrackPoint(carProgress);
    carPos.copy(info.pos);
    carTangent.copy(info.tangent);
    const surfaceType = info.type;

    // Surface drag and grip
    let drag = 6;
    let maxSpd = 95;
    let grip = 0.5;
    let offRoadPenalty = 0;
    if (surfaceType === 'grass') { drag = 18; maxSpd = 60; grip = 0.35; offRoadPenalty = 0.3; }
    if (surfaceType === 'mud') { drag = 35; maxSpd = 35; grip = 0.15; offRoadPenalty = 0.7; }
    if (surfaceType === 'dirt') { drag = 12; maxSpd = 75; grip = 0.45; offRoadPenalty = 0.15; }

    // Off-track check: if offset exceeds width / 2, apply stronger penalty
    const offTrack = Math.abs(carOffset) > info.width / 2;
    if (offTrack) {
      drag += 25;
      maxSpd *= 0.6;
      grip = 0.2;
      offRoadPenalty = 0.6;
    }

    // Handbrake: big drag, reduces steering
    if (braking > 0.1) {
      drag += 60;
      accelInput *= 0.2;
      maxSpd *= 0.4;
    }

    // Engine acceleration / braking
    const accelForce = 50;
    const brakeForce = 70;
    if (accelInput > 0) carSpeed += accelForce * accelInput * dt;
    else if (accelInput < 0) carSpeed += brakeForce * accelInput * dt; // reverse/braking

    // Drag
    carSpeed -= drag * dt * (carSpeed / 20);
    carSpeed = clamp(carSpeed, -20, maxSpd);

    // Steering / offset physics
    const steerRateBase = 2.5 * dt;
    const steerRate = steerRateBase * grip * (1 - carSpeed / maxSpd * 0.3);
    const offsetVel = input.steer * steerRate;
    carOffset += offsetVel;

    // Drift / slide: at higher speeds, offset tends to increase (slide out)
    const slideFactor = Math.abs(input.steer) * (carSpeed / maxSpd) * 0.8;
    carOffset += slideFactor * Math.sign(input.steer) * dt;

    // Limit offset to stay somewhat on path for playability (bounce at edges is fun)
    if (Math.abs(carOffset) > info.width / 2 + 4) {
      // Bounce
      carSpeed *= 0.4;
      carOffset = Math.sign(carOffset) * (info.width / 2 + 3);
      spawnSparks({ x: carPos.x, y: 0.3, z: carPos.z });
      playCrash();
    } else {
      carOffset = clamp(carOffset, -info.width / 2 - 6, info.width / 2 + 6);
    }

    // Apply forward movement
    carProgress += (carSpeed * dt) % getTotalLength();
    if (carProgress < 0) carProgress += getTotalLength();

    // Recompute position after progress
    const newInfo = getTrackPoint(carProgress);
    carPos.copy(newInfo.pos);
    carTangent.copy(newInfo.tangent);

    // Apply offset perpendicular to tangent
    const normal = new THREE.Vector3(-carTangent.z, 0, carTangent.x).normalize();
    carPos.addScaledVector(normal, carOffset);

    // Y elevation from segment interpolation (re-interpolate for height)
    const segDist = newInfo.pos ? 0 : 0; // We already have it in info
    // For simplicity, keep y from info.pos; offset doesn't change y

    // Update car rotation to face tangent + yaw
    const baseAngle = Math.atan2(carTangent.x, carTangent.z);
    const speedRatio = clamp(carSpeed / 20, 0, 1);
    carYaw = input.steer * 0.5 + (Math.abs(input.steer) > 0.8 && carSpeed > 20 ? Math.sign(input.steer) * 0.35 : 0);
    carRotY = baseAngle + carYaw;

    // Place car mesh
    carBody.position.copy(carPos);
    carBody.position.y = 0.45; // wheel height
    carBody.rotation.y = carRotY;

    // Update wheels (spin slightly)
    const spin = carSpeed * dt * 0.2;
    for (const w of wheels) { w.rotation.x += spin; }

    // Checkpoints
    for (let i = 0; i < checkpoints.length; i++) {
      if (i >= checkpointIndex) {
        const cpDist = checkpoints[i].dist;
        // Check if we've passed this checkpoint
        const prevDist = checkpointIndex > 0 ? checkpoints[checkpointIndex - 1].dist : 0;
        // Use progress wrapping
        let passed = false;
        if (prevDist <= cpDist) {
          passed = carProgress >= cpDist;
        } else {
          // Wrapped around loop
          passed = carProgress >= cpDist || carProgress < prevDist;
        }
        if (passed) {
          checkpointIndex++;
          if (checkpointIndex % checkpoints.length === 0) {
            // Completed lap
            completeLap();
          }
        }
        break; // Only check next needed
      }
    }

    // Obstacle collisions
    for (const c of obstacleColliders || []) {
      const dx = carPos.x - c.pos.x;
      const dz = carPos.z - c.pos.z;
      const d = Math.sqrt(dx*dx + dz*dz);
      if (d < c.r) {
        carSpeed *= 0.35;
        carOffset += (Math.random() - 0.5) * 1.5;
        spawnSparks(carPos);
        playCrash();
      }
    }

    // Particles: skid marks when off-track or sliding, dust off-track
    if (offTrack || (Math.abs(input.steer) > 0.5 && carSpeed > 30)) {
      spawnSkidMark(carPos);
    }
    if (carSpeed > 0 && (surfaceType === 'dirt' || surfaceType === 'grass' || surfaceType === 'mud')) {
      if (Math.random() < 0.3) spawnDust({ x: carPos.x + rand(-0.5, 0.5), y: 0.1, z: carPos.z + rand(-0.5, 0.5) });
    }
  }

  function completeLap() {
    const now = performance.now();
    const lapMs = now - startTime - totalTimeMs;
    lapTimes[currentLap - 1] = lapMs;
    totalTimeMs += lapMs;
    if (lapMs < bestLapMs) bestLapMs = lapMs;
    currentLap++;
    checkpointIndex = 0;
    if (currentLap > LAP_COUNT) {
      finishRace();
    }
  }

  function finishRace() {
    state = 'FINISHED';
    document.getElementById('finishOverlay').classList.remove('hidden');
    document.getElementById('finalTimeDisplay').textContent = formatTime(totalTimeMs);
    document.getElementById('finalBestDisplay').textContent = formatTime(bestLapMs);
  }

  // --- Camera ---
  function updateCamera() {
    const lookAhead = new THREE.Vector3().copy(carTangent).multiplyScalar(6);
    const target = carPos.clone().add(lookAhead).add(new THREE.Vector3(0, 2, 0));
    camTarget.copy(target);

    // Camera position based on mode
    let desiredPos = new THREE.Vector3();
    const back = new THREE.Vector3().copy(carTangent).multiplyScalar(-1);
    if (cameraMode === 'chase') {
      desiredPos.copy(carPos).addScaledVector(back, 16).add(new THREE.Vector3(0, 7, 0));
    } else {
      // Cinematic: higher, further back, slightly offset
      desiredPos.copy(carPos).addScaledVector(back, 28).add(new THREE.Vector3(0, 14, 0)).add(new THREE.Vector3(4, 0, 0));
    }

    // Lag
    camPos.lerp(desiredPos, 0.08);
    camera.position.copy(camPos);
    camera.lookAt(camTarget);
  }

  // --- Particles Cleanup ---
  function updateParticles() {
    // Skid marks fade (remove oldest after some count)
    for (let i = skidMarks.length - 1; i >= 0; i--) {
      skidMarks[i].life -= 0.005;
      skidMarks[i].mesh.material.opacity = skidMarks[i].life;
      skidMarks[i].mesh.material.needsUpdate = true;
      if (skidMarks[i].life <= 0) {
        scene.remove(skidMarks[i].mesh);
        skidMarks.splice(i, 1);
      }
    }
    // Limit total skid marks for performance
    while (skidMarks.length > 200) {
      const m = skidMarks.shift();
      scene.remove(m.mesh);
    }
  }

  // --- Audio Update ---
  function updateAudio() {
    if (!audioEnabled) return;
    const rpm = Math.abs(carSpeed) / 100 * 600 + 800;
    engineOsc.frequency.value = 120 + (carSpeed / 100) * 300;
    engineGain.gain.value = 0.15 + (Math.abs(carSpeed) / 100) * 0.25;

    // Skid sound when turning at speed
    if (Math.abs(input.steer) > 0.6 && Math.abs(carSpeed) > 20) {
      skidOsc.frequency.value = 300 + Math.random() * 400;
      skidGain.gain.value = 0.15;
    } else {
      skidGain.gain.value = 0;
    }
  }

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

  function updateHUD() {
    document.getElementById('speedDisplay').textContent = Math.round(Math.abs(carSpeed) * 3.6);
    document.getElementById('lapDisplay').textContent = Math.min(currentLap, LAP_COUNT);
    document.getElementById('timeDisplay').textContent = formatTime(performance.now() - (startTime - totalTimeMs));
    document.getElementById('bestLapDisplay').textContent = bestLapMs === Infinity ? '--:--.--' : formatTime(bestLapMs);
    document.getElementById('gearDisplay').textContent = Math.max(1, Math.floor(Math.abs(carSpeed) / 25) + 1);
    document.getElementById('rpmDisplay').textContent = Math.round(Math.abs(carSpeed) * 10 + 1200);
  }

  // --- Minimap ---
  function updateMinimap() {
    const w = 160, h = 160;
    minimapCtx.clearRect(0, 0, w, h);
    // Draw track
    minimapCtx.strokeStyle = '#555';
    minimapCtx.lineWidth = 4;
    minimapCtx.beginPath();
    for (let i = 0; i < trackSegments.length; i++) {
      const seg = trackSegments[i];
      const x = 80 + seg.start.x / 3;
      const y = 80 - seg.start.z / 3;
      if (i === 0) minimapCtx.moveTo(x, y);
      else minimapCtx.lineTo(x, y);
    }
    // Close loop approximate
    const first = trackSegments[0];
    minimapCtx.lineTo(80 + first.start.x / 3, 80 - first.start.z / 3);
    minimapCtx.stroke();

    // Nodes
    for (const seg of trackSegments) {
      const mx = 80 + seg.centerMid.x / 3;
      const my = 80 - seg.centerMid.z / 3;
      minimapCtx.fillStyle = seg.type === 'mud' ? '#442222' : seg.type === 'grass' ? '#335522' : seg.type === 'dirt' ? '#665533' : '#777777';
      minimapCtx.beginPath();
      minimapCtx.arc(mx, my, 3, 0, Math.PI * 2);
      minimapCtx.fill();
    }

    // Car dot
    const cx = 80 + carPos.x / 3;
    const cy = 80 - carPos.z / 3;
    minimapCtx.fillStyle = '#ffd166';
    minimapCtx.beginPath();
    minimapCtx.arc(cx, cy, 4, 0, Math.PI * 2);
    minimapCtx.fill();
    minimapCtx.strokeStyle = '#ff3333';
    minimapCtx.beginPath();
    minimapCtx.arc(cx, cy, 6, 0, Math.PI * 2);
    minimapCtx.stroke();
  }

  // Initialize immediately
  window.RallyGame.init();
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingmachines/inkling","provider":"openrouter","persona":"full","sessionId":"2b65797d-878e-4f89-8348-3c7d13dd4a08","tokensIn":1181884,"tokensOut":23780,"tokensTotal":1205664,"turns":20,"toolCalls":19,"failedToolCalls":0,"timestamp":"2026-07-18T22:21:12.687Z"} -->