← Rally results

Rally

inkling · basic

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,169 bytes · SHA-256 f9e14a025697
<!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 Drive — 3D Rally Race</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
  @import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@700;900&family=Rajdhani:wght@600&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:#1a1a2e;color:#fff;font-family:'Rajdhani',sans-serif;}
  #gameCanvas{position:fixed;top:0;left:0;width:100%;height:100%;display:block;z-index:1;}
  
  /* HUD */
  #hud{position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:10;}
  .hud-box{position:absolute;background:rgba(0,0,0,0.55);backdrop-filter:blur(6px);border:1px solid rgba(255,255,255,0.15);border-radius:10px;padding:10px 14px;}
  #speedo{top:14px;right:14px;width:160px;text-align:center;}
  #speedo .value{font-size:2.8rem;font-family:'Orbitron',sans-serif;color:#ffdd00;text-shadow:0 0 10px rgba(255,221,0,0.6);line-height:1;}
  #speedo .label{font-size:0.85rem;color:#ccc;letter-spacing:1px;}
  #lapinfo{top:14px;left:14px;min-width:200px;}
  #lapinfo h2{font-size:0.85rem;color:#aaa;margin-bottom:4px;}
  #lapinfo .big{font-size:1.6rem;font-family:'Orbitron',sans-serif;color:#fff;}
  #lapinfo .sub{font-size:0.9rem;color:#ddd;}
  
  /* Minimap */
  #minimapWrap{position:absolute;bottom:14px;left:14px;width:160px;height:160px;border-radius:8px;overflow:hidden;box-shadow:0 0 20px rgba(0,0,0,0.6);border:2px solid rgba(255,255,255,0.1);pointer-events:none;}
  #minimap{width:100%;height:100%;background:#111;display:block;}
  
  /* Overlays */
  .overlay{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.65);backdrop-filter:blur(8px);flex-direction:column;transition:opacity 0.3s;}
  .overlay.hidden{opacity:0;pointer-events:none;}
  .overlay h1{font-family:'Orbitron',sans-serif;font-size:5rem;color:#ff3333;text-shadow:0 0 30px rgba(255,50,50,0.6);letter-spacing:6px;}
  .overlay h2{font-family:'Orbitron',sans-serif;font-size:3rem;color:#ffd700;text-shadow:0 0 20px rgba(255,215,0,0.5);margin-bottom:12px;}
  .overlay p{font-size:1.3rem;color:#eee;margin-bottom:8px;}
  .btn{background:linear-gradient(135deg,#ff3333 0%,#aa0000 100%);border:none;color:#fff;padding:14px 32px;border-radius:8px;font-size:1.1rem;font-family:'Orbitron',sans-serif;cursor:pointer;box-shadow:0 4px 15px rgba(255,50,50,0.4);margin:8px;transition:transform .1s,box-shadow .2s;}
  .btn:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(255,50,50,0.6);}
  
  /* Touch controls */
  #touchUI{position:fixed;bottom:0;left:0;width:100%;height:220px;z-index:10;pointer-events:none;display:none;}
  .touch-pad{position:absolute;bottom:20px;display:flex;flex-direction:column;align-items:center;}
  .touch-pad.left{left:20px;}
  .touch-pad.right{right:20px;}
  .touch-btn{width:70px;height:70px;border-radius:50%;background:rgba(255,255,255,0.15);border:2px solid rgba(255,255,255,0.25);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.4);backdrop-filter:blur(4px);margin:4px;transition:background 0.1s;}
  .touch-btn:active{background:rgba(255,255,255,0.35);transform:scale(0.95);}
  .touch-btn.hand{background:rgba(255,221,0,0.2);border-color:rgba(255,221,0,0.5);font-size:22px;}
  
  /* Countdown */
  #countdown{text-align:center;}
  #countdown .num{font-size:8rem;font-family:'Orbitron',sans-serif;color:#fff;animation:pop 0.8s ease-out;}
  @keyframes pop{0%{transform:scale(0.2);opacity:0;}60%{transform:scale(1.1);opacity:1;}100%{transform:scale(1);opacity:1;}}
  
  @media(max-width:768px){
    #touchUI{display:block;}
  }
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>

<div id="hud">
  <div class="hud-box" id="lapinfo">
    <h2>RALLY DRIVE</h2>
    <div class="big" id="lapDisplay">LAP 1 / 3</div>
    <div class="sub" id="timerDisplay">00:00.00</div>
    <div style="margin-top:6px;font-size:0.8rem;color:#ccc;" id="bestLap">BEST LAP: --:--.--</div>
  </div>
  
  <div class="hud-box" id="speedo">
    <div class="label">SPEED</div>
    <div class="value" id="speedVal">0</div>
    <div style="font-size:0.7rem;color:#aaa;margin-top:4px;" id="gearVal">NEUTRAL</div>
  </div>
  
  <div id="minimapWrap">
    <canvas id="minimap"></canvas>
  </div>
</div>

<div id="countdown" class="overlay">
  <h2 id="countText">READY</h2>
  <p>3 Laps • Stay on the road</p>
</div>

<div id="pauseOverlay" class="overlay hidden">
  <h1>PAUSED</h1>
  <button class="btn" onclick="Rally.resume()">Resume</button>
  <button class="btn" onclick="Rally.restart()">Restart</button>
</div>

<div id="finishOverlay" class="overlay hidden">
  <h2>FINISH</h2>
  <h1>RACE COMPLETE</h1>
  <p id="finalTime">Final: 00:00.00</p>
  <p id="finalBest">Best Lap: 00:00.00</p>
  <button class="btn" onclick="Rally.restart()">Restart Race</button>
</div>

<div id="touchUI">
  <div class="touch-pad left">
    <button class="touch-btn" id="btn-up">▲</button>
    <div style="display:flex;">
      <button class="touch-btn" id="btn-left">◀</button>
      <button class="touch-btn" id="btn-right">▶</button>
    </div>
    <button class="touch-btn" id="btn-down">▼</button>
  </div>
  <div class="touch-pad right">
    <button class="touch-btn hand" id="btn-hand">◆<br><span style="font-size:10px;">HAND</span></button>
  </div>
</div>

<script>
const Rally = (() => {
  // ============== STATE ==============
  let scene, camera, renderer, sun, ambient;
  let carGroup, carMesh, wheels = [];
  let trackMeshes = [];
  let scenery = [];
  let particles = [];
  let skidMarks = [];
  let clock = new THREE.Clock();
  let audioCtx, engineOsc, engineGain;
  
  // Game state
  let started = false;
  let finished = false;
  let paused = false;
  let countdownPhase = -1; // -1=ready, 0=3,1=2,2=1,3=GO
  let countdownTimer = 0;
  
  // Physics state
  let carPos = new THREE.Vector3(0, 0.5, 5); // near start/finish
  let carVel = new THREE.Vector3(0,0,0);
  let heading = 0; // radians
  let speed = 0;
  let lapTime = 0;
  let bestLap = Infinity;
  let currentLap = 1;
  let totalLaps = 3;
  let nextCheckpoint = 0; // 0,1,2,3 (finish)
  let lastCheckpoint = -1;
  
  // Input state
  const input = { accel: false, brake: false, left: false, right: false, hand: false };
  
  // Track data
  const trackPoints = [];
  const segments = [];
  const checkpoints = []; // array of {pos, normal}
  
  // Materials
  const matAsphalt = new THREE.MeshPhongMaterial({color: 0x2a2a35, shininess: 40});
  const matDirt = new THREE.MeshPhongMaterial({color: 0x6b4423, shininess: 20});
  const matGrass = new THREE.MeshPhongMaterial({color: 0x4a7c2e, shininess: 10});
  const matMud = new THREE.MeshPhongMaterial({color: 0x3a2a1a, shininess: 5});
  
  // ============== INIT ==============
  function init() {
    // Three.js setup
    const canvas = document.getElementById('gameCanvas');
    renderer = new THREE.WebGLRenderer({canvas, antialias:true, alpha: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(0x87CEEB);
    scene.fog = new THREE.FogExp2(0x87CEEB, 0.008);
    
    camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.5, 400);
    camera.position.set(0, 8, -15);
    camera.lookAt(new THREE.Vector3(0,0,0));
    
    // Lights
    sun = new THREE.DirectionalLight(0xffffff, 1.5);
    sun.position.set(60, 120, 40);
    sun.castShadow = true;
    sun.shadow.mapSize.width = 1024;
    sun.shadow.mapSize.height = 1024;
    sun.shadow.bias = -0.001;
    scene.add(sun);
    ambient = new THREE.AmbientLight(0x8899aa, 0.6);
    scene.add(ambient);
    
    // Ground plane
    const ground = new THREE.Mesh(
      new THREE.PlaneGeometry(300, 300),
      new THREE.MeshPhongMaterial({color: 0x334422, depthWrite: true, shininess: 5})
    );
    ground.rotation.x = -Math.PI/2;
    ground.receiveShadow = true;
    scene.add(ground);
    
    buildTrack();
    buildCar();
    buildScenery();
    buildCheckpoints();
    buildUI();
    initAudio();
    bindInputs();
    startCountdown();
    
    window.addEventListener('resize', onResize);
    animate();
  }
  
  // ============== TRACK ==============
  function buildTrack() {
    // Define looped control points (winding rally course)
    const pts = [
      {x:0,z:10,y:0,w:10},
      {x:25,z:30,y:0.2,w:10},
      {x:55,z:25,y:0.5,w:10},
      {x:75,z:55,y:2,w:10},
      {x:60,z:85,y:1.5,w:10},
      {x:30,z:95,y:0.8,w:10},
      {x:0,z:80,y:0.5,w:10},
      {x:-20,z:60,y:0,w:10},
      {x:-15,z:35,y:0.2,w:10},
      {x:5,z:15,y:0,w:10},
    ];
    // Interpolate additional points to make loop smoother
    const raw = [];
    for(let i=0;i<pts.length;i++){
      raw.push({x:pts[i].x, z:pts[i].z, y:pts[i].y, w:pts[i].w});
    }
    // Duplicate last to close loop cleanly for segment generation? No, we'll connect last to first.
    for(let i=0;i<raw.length;i++){
      trackPoints.push(new THREE.Vector3(raw[i].x, raw[i].y, raw[i].z));
    }
    const count = raw.length;
    
    // Build segments
    const types = ['asphalt','asphalt','dirt','dirt','grass','mud','asphalt','dirt','asphalt','asphalt'];
    for(let i=0;i<count;i++){
      const p1 = raw[i];
      const p2 = raw[(i+1)%count];
      const start = new THREE.Vector3(p1.x, p1.y, p1.z);
      const end = new THREE.Vector3(p2.x, p2.y, p2.z);
      const mid = new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5);
      
      const direction = new THREE.Vector3().subVectors(end, start).normalize();
      const right = new THREE.Vector3(-direction.z, 0, direction.x); // perpendicular on XZ
      const width = p1.w || 12;
      const segType = types[i % types.length];
      const mat = (segType==='asphalt')? matAsphalt : (segType==='dirt')? matDirt : (segType==='grass')? matGrass : matMud;
      
      // Create a flat quad segment oriented in 3D space
      const geo = new THREE.BufferGeometry();
      const posArr = [];
      const w2 = width/2;
      // Four corners: start+right*w2, start-right*w2, end-right*w2, end+right*w2
      const corners = [
        new THREE.Vector3().addVectors(start, right.clone().multiplyScalar(w2)),
        new THREE.Vector3().addVectors(start, right.clone().multiplyScalar(-w2)),
        new THREE.Vector3().addVectors(end, right.clone().multiplyScalar(-w2)),
        new THREE.Vector3().addVectors(end, right.clone().multiplyScalar(w2))
      ];
      // Triangles (0,1,2) and (0,2,3)
      const verts = [
        ...corners[0].toArray(), ...corners[1].toArray(), ...corners[2].toArray(),
        ...corners[0].toArray(), ...corners[2].toArray(), ...corners[3].toArray()
      ];
      posArr.push(...verts);
      
      // Normals
      const normal = new THREE.Vector3().crossVectors(direction, right).normalize();
      const norms = [];
      for(let j=0;j<6;j++) norms.push(normal.x, normal.y, normal.z);
      
      // UVs
      const uvs = [0,0, 1,0, 1,1, 0,0, 1,1, 0,1];
      
      geo.setAttribute('position', new THREE.Float32BufferAttribute(posArr, 3));
      geo.setAttribute('normal', new THREE.Float32BufferAttribute(norms, 3));
      geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
      geo.computeBoundingSphere();
      
      const mesh = new THREE.Mesh(geo, mat);
      mesh.receiveShadow = true;
      mesh.castShadow = false;
      mesh.userData = { type: segType, width: width, direction: direction, right: right, start: start, end: end, center: mid, index: i };
      scene.add(mesh);
      trackMeshes.push(mesh);
      segments.push({mesh, ...mesh.userData});
    }
  }
  
  // ============== CHECKPOINTS ==============
  function buildCheckpoints() {
    // Place 3 gates + finish at start point
    const gatePositions = [
      new THREE.Vector3(30, 0.5, 55),
      new THREE.Vector3(0, 0.5, 80),
      new THREE.Vector3(-12, 0.3, 45)
    ];
    const finishPos = trackPoints[1] ? trackPoints[1].clone() : new THREE.Vector3(5,0,15);
    finishPos.y = 0.5;
    
    checkpoints.length = 0;
    
    // Mid gates
    for(let i=0;i<gatePositions.length;i++){
      const pos = gatePositions[i];
      const gate = new THREE.Mesh(
        new THREE.PlaneGeometry(20, 6),
        new THREE.MeshBasicMaterial({visible:false, transparent:true, opacity:0})
      );
      gate.position.copy(pos);
      gate.rotation.y = Math.PI/4 * i; // approximate orientation; exact not critical for basic detection
      gate.userData = { type: 'gate', index: i, pos: pos.clone() };
      scene.add(gate);
      checkpoints.push({pos: pos.clone(), normal: new THREE.Vector3(0,1,0), mesh: gate});
    }
    
    // Finish gate at start/finish line near point 0
    const finishGate = new THREE.Mesh(
      new THREE.PlaneGeometry(16, 6),
      new THREE.MeshBasicMaterial({visible:false, transparent:true, opacity:0})
    );
    finishGate.position.copy(finishPos);
    finishGate.rotation.y = 0.2;
    finishGate.userData = { type: 'finish', index: 3, pos: finishPos.clone() };
    scene.add(finishGate);
    checkpoints.push({pos: finishPos.clone(), normal: new THREE.Vector3(0.2,0,1), mesh: finishGate});
    
    // Initialize next checkpoint = first mid gate (index 0)
    nextCheckpoint = 0;
    lastCheckpoint = -1;
  }
  
  // ============== CAR MODEL ==============
  function buildCar() {
    carGroup = new THREE.Group();
    carGroup.castShadow = true;
    scene.add(carGroup);
    
    const bodyMat = new THREE.MeshPhongMaterial({color: 0xdd1111, shininess: 80});
    const darkMat = new THREE.MeshPhongMaterial({color: 0x880000});
    const chromeMat = new THREE.MeshPhongMaterial({color: 0xdddddd, shininess: 120});
    const glassMat = new THREE.MeshPhongMaterial({color: 0x222233, shininess: 10, transparent: true, opacity: 0.7});
    const tireMat = new THREE.MeshPhongMaterial({color: 0x111, shininess: 5});
    const lightMat = new THREE.MeshPhongMaterial({color: 0xffffcc, emissive: 0xffffaa, emissiveIntensity: 0.8});
    
    // Main body
    const body = new THREE.Mesh(new THREE.BoxGeometry(3.2, 0.7, 4.8), bodyMat);
    body.position.y = 0.45;
    body.castShadow = true;
    carGroup.add(body);
    carMesh = body;
    
    // Roof / cockpit
    const roof = new THREE.Mesh(new THREE.BoxGeometry(2.6, 0.5, 3.2), darkMat);
    roof.position.set(0, 1.05, -0.2);
    roof.castShadow = true;
    carGroup.add(roof);
    
    // Windshield
    const windshield = new THREE.Mesh(new THREE.BoxGeometry(2.0, 0.55, 0.1), glassMat);
    windshield.position.set(0, 1.0, 2.1);
    carGroup.add(windshield);
    
    // Spoiler
    const spoiler = new THREE.Mesh(new THREE.BoxGeometry(3.4, 0.15, 0.8), darkMat);
    spoiler.position.set(0, 1.1, -2.3);
    carGroup.add(spoiler);
    
    // Wheels
    const wheelGeo = new THREE.CylinderGeometry(0.38, 0.38, 0.9, 10);
    const positions = [[-0.95, -1.6], [0.95, -1.6], [-0.95, 1.6], [0.95, 1.6]];
    positions.forEach(p => {
      const w = new THREE.Mesh(wheelGeo, tireMat);
      w.rotation.x = Math.PI/2;
      w.position.set(p[0], 0.38, p[1]);
      w.castShadow = true;
      carGroup.add(w);
      wheels.push(w);
    });
    
    // Headlights
    const headL = new THREE.Mesh(new THREE.SphereGeometry(0.22, 8, 8), lightMat);
    headL.position.set(1.1, 0.55, 2.45);
    headL.castShadow = true;
    carGroup.add(headL);
    const headR = headL.clone();
    headR.position.set(-1.1, 0.55, 2.45);
    carGroup.add(headR);
    
    // Taillights
    const tailL = new THREE.Mesh(new THREE.SphereGeometry(0.18, 8, 8), new THREE.MeshPhongMaterial({color: 0xff3333, emissive: 0xff0000, emissiveIntensity: 0.5}));
    tailL.position.set(1.0, 0.55, -2.45);
    carGroup.add(tailL);
    const tailR = tailL.clone();
    tailR.position.set(-1.0, 0.55, -2.45);
    carGroup.add(tailR);
    
    // Front bumper trim
    const trim = new THREE.Mesh(new THREE.BoxGeometry(3.0, 0.15, 0.3), chromeMat);
    trim.position.set(0, 0.35, 2.6);
    carGroup.add(trim);
  }
  
  // ============== SCENERY ==============
  function buildScenery() {
    // Trees
    const treeCount = 40;
    for(let i=0;i<treeCount;i++){
      const angle = (i/treeCount)*Math.PI*2 + Math.random()*0.5;
      const dist = 20 + Math.random()*50;
      const x = Math.sin(angle)*dist;
      const z = Math.cos(angle)*dist;
      const y = 0;
      // Simple low-poly tree
      const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.6, 2.5, 6), new THREE.MeshPhongMaterial({color: 0x5a3f1e}));
      trunk.position.set(x, 1.25, z);
      trunk.castShadow = true;
      scene.add(trunk);
      
      const leaves = new THREE.Mesh(new THREE.ConeGeometry(1.4, 3.5, 6), new THREE.MeshPhongMaterial({color: 0x2a5a1e}));
      leaves.position.set(x, 4.2, z);
      leaves.castShadow = true;
      scene.add(leaves);
      scenery.push({trunk, leaves});
    }
    
    // Rocks
    for(let i=0;i<15;i++){
      const x = (Math.random() - 0.5)*120 + (Math.random()*20 - 10);
      const z = (Math.random() - 0.5)*120 + (Math.random()*20 - 10);
      const rock = new THREE.Mesh(
        new THREE.DodecahedronGeometry(1.2 + Math.random()*1.5, 0),
        new THREE.MeshPhongMaterial({color: 0x777777, shininess: 10})
      );
      rock.position.set(x, 1.2, z);
      rock.rotation.y = Math.random()*Math.PI;
      rock.rotation.x = Math.random()*0.5;
      rock.castShadow = true;
      scene.add(rock);
      scenery.push(rock);
    }
    
    // Signs / banners
    for(let i=0;i<12;i++){
      const x = (Math.random()-0.5)*140 + 20;
      const z = (Math.random()-0.5)*140;
      const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.15, 4, 6), new THREE.MeshPhongMaterial({color: 0xaaa}));
      pole.position.set(x, 2, z);
      pole.castShadow = true;
      scene.add(pole);
      
      const banner = new THREE.Mesh(
        new THREE.PlaneGeometry(3, 1.5),
        new THREE.MeshPhongMaterial({color: i%2===0?0xdd3333:0x3333dd, shininess: 20})
      );
      banner.position.set(x, 3.5, z);
      banner.rotation.y = Math.random()*Math.PI*2;
      scene.add(banner);
      scenery.push(pole, banner);
    }
    
    // Guard rails (simple boxes along track)
    for(let i=0;i<20;i++){
      const t = (i/20);
      const px = Math.sin(t*6)*35;
      const pz = Math.cos(t*6)*35 + 20;
      const rail = new THREE.Mesh(
        new THREE.BoxGeometry(2, 0.6, 0.3),
        new THREE.MeshPhongMaterial({color: 0x999999, shininess: 30})
      );
      rail.position.set(px, 0.3, pz);
      rail.rotation.y = t*Math.PI*2 + 0.5;
      rail.castShadow = false;
      rail.receiveShadow = true;
      scene.add(rail);
      scenery.push(rail);
    }
  }
  
  // ============== UI / OVERLAYS ==============
  function buildUI() {
    document.getElementById('minimap').width = 160;
    document.getElementById('minimap').height = 160;
    document.getElementById('countdown').classList.remove('hidden');
    document.getElementById('pauseOverlay').classList.add('hidden');
    document.getElementById('finishOverlay').classList.add('hidden');
  }
  
  function updateHUD() {
    document.getElementById('lapDisplay').textContent = `LAP ${currentLap} / ${totalLaps}`;
    document.getElementById('timerDisplay').textContent = formatTime(lapTime);
    document.getElementById('bestLap').textContent = `BEST LAP: ${formatTime(bestLap)}`;
    document.getElementById('speedVal').textContent = Math.round(speed*20); // arbitrary scale
    document.getElementById('gearVal').textContent = getGearLabel();
    
    // Update minimap
    const canvas = document.getElementById('minimap');
    const ctx = canvas.getContext('2d');
    ctx.fillStyle = '#222';
    ctx.fillRect(0,0,160,160);
    
    // Draw track outline (simplified)
    ctx.strokeStyle = '#555';
    ctx.lineWidth = 3;
    ctx.beginPath();
    for(let i=0;i<trackPoints.length;i++){
      const p = trackPoints[i];
      const sx = 80 + p.x*0.6;
      const sy = 80 + p.z*0.6;
      if(i===0) ctx.moveTo(sx, sy); else ctx.lineTo(sx, sy);
    }
    ctx.stroke();
    
    // Draw checkpoints
    checkpoints.forEach(cp => {
      ctx.fillStyle = cp.mesh.userData.type==='finish'? '#ff3333':'#ffdd00';
      const sx = 80 + cp.pos.x*0.6;
      const sy = 80 + cp.pos.z*0.6;
      ctx.fillRect(sx-3, sy-3, 6, 6);
    });
    
    // Draw car
    ctx.fillStyle = '#00ff00';
    const cx = 80 + carPos.x*0.6;
    const cy = 80 + carPos.z*0.6;
    ctx.beginPath();
    ctx.arc(cx, cy, 4, 0, Math.PI*2);
    ctx.fill();
  }
  
  function formatTime(t) {
    if(t === Infinity) return '--:--.--';
    const m = Math.floor(t/60);
    const s = Math.floor(t%60);
    const ms = Math.floor((t%1)*100);
    return `${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}.${ms.toString().padStart(2,'0')}`;
  }
  
  function getGearLabel() {
    if(Math.abs(speed)<1) return 'NEUTRAL';
    if(speed < 10) return '1ST';
    if(speed < 25) return '2ND';
    if(speed < 45) return '3RD';
    return '4TH';
  }
  
  // ============== INPUTS ==============
  function bindInputs() {
    window.addEventListener('keydown', e => {
      if(e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') input.accel = true;
      if(e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') input.brake = true;
      if(e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') input.left = true;
      if(e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') input.right = true;
      if(e.key === ' ') input.hand = true;
      if(e.key === 'c' || e.key === 'C') toggleCamera();
      if(e.key === 'r' || e.key === 'R') restart();
      if(e.key === 'p' || e.key === 'P') togglePause();
    });
    window.addEventListener('keyup', e => {
      if(['ArrowUp','w','W'].includes(e.key)) input.accel = false;
      if(['ArrowDown','s','S'].includes(e.key)) input.brake = false;
      if(['ArrowLeft','a','A'].includes(e.key)) input.left = false;
      if(['ArrowRight','d','D'].includes(e.key)) input.right = false;
      if(e.key === ' ') input.hand = false;
    });
    
    // Touch
    const btns = {
      'btn-up': () => input.accel = true,
      'btn-down': () => input.brake = true,
      'btn-left': () => input.left = true,
      'btn-right': () => input.right = true,
      'btn-hand': () => input.hand = true
    };
    ['btn-up','btn-down','btn-left','btn-right','btn-hand'].forEach(id => {
      const btn = document.getElementById(id);
      const onDown = btns[id];
      btn.addEventListener('touchstart', (e)=>{e.preventDefault(); onDown();});
      btn.addEventListener('mousedown', (e)=>{e.preventDefault(); onDown();});
      btn.addEventListener('touchend', () => { if(id==='btn-hand') input.hand=false; else { input.accel=false; input.brake=false; input.left=false; input.right=false; } });
      btn.addEventListener('mouseup', () => { if(id==='btn-hand') input.hand=false; else { input.accel=false; input.brake=false; input.left=false; input.right=false; } });
      btn.addEventListener('mouseleave', () => { if(id==='btn-hand') input.hand=false; });
    });
  }
  
  // ============== AUDIO ==============
  function initAudio() {
    try {
      audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      engineOsc = audioCtx.createOscillator();
      engineGain = audioCtx.createGain();
      engineOsc.type = 'triangle';
      engineOsc.frequency.value = 150;
      engineGain.gain.value = 0.05;
      engineOsc.connect(engineGain);
      engineGain.connect(audioCtx.destination);
      engineOsc.start();
    } catch(e) { console.log('Audio init failed'); }
  }
  
  function updateEngineSound() {
    if(!audioCtx) return;
    const freq = 150 + Math.abs(speed)*25;
    engineOsc.frequency.setTargetAtTime(freq, audioCtx.currentTime, 0.05);
    const vol = 0.05 + Math.min(Math.abs(speed)/60, 1)*0.15;
    engineGain.gain.setTargetAtTime(vol, audioCtx.currentTime, 0.05);
  }
  
  function playCountdownSound(step) {
    if(!audioCtx) return;
    const osc = audioCtx.createOscillator();
    const gain = audioCtx.createGain();
    osc.connect(gain);
    gain.connect(audioCtx.destination);
    osc.type = 'sine';
    const notes = [440, 494, 554, 880];
    osc.frequency.value = notes[step] || 880;
    gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.4);
    osc.start();
    osc.stop(audioCtx.currentTime + 0.4);
  }
  
  function playCrashSound() {
    if(!audioCtx) return;
    try {
      const bufferSize = audioCtx.sampleRate * 0.3;
      const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
      const data = buffer.getChannelData(0);
      for(let i=0;i<bufferSize;i++) data[i] = Math.random()*2-1;
      const src = audioCtx.createBufferSource();
      const gain = audioCtx.createGain();
      src.buffer = buffer;
      src.connect(gain);
      gain.connect(audioCtx.destination);
      gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
      gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3);
      src.start();
    } catch(e){}
  }
  
  function playFinishSound() {
    if(!audioCtx) return;
    const osc = audioCtx.createOscillator();
    const gain = audioCtx.createGain();
    osc.connect(gain);
    gain.connect(audioCtx.destination);
    osc.type = 'sine';
    osc.frequency.setValueAtTime(300, audioCtx.currentTime);
    osc.frequency.linearRampToValueAtTime(880, audioCtx.currentTime + 0.5);
    gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.8);
    osc.start();
    osc.stop(audioCtx.currentTime + 0.8);
  }
  
  // ============== PHYSICS ==============
  function getSegmentNear(pos) {
    // Find closest segment by projecting onto line segments
    let bestSeg = segments[0];
    let bestDist = Infinity;
    for(let s of segments){
      const start = s.userData.start ? s.userData.start : s.start;
      const end = s.userData.end ? s.userData.end : s.end;
      const proj = closestPointOnSegment(pos, start, end);
      const d2 = new THREE.Vector3().subVectors(pos, proj).lengthSq();
      if(d2 < bestDist){
        bestDist = d2;
        bestSeg = s;
      }
    }
    return bestSeg;
  }
  
  function closestPointOnSegment(p, a, b) {
    const ab = new THREE.Vector3().subVectors(b, a);
    const t = Math.max(0, Math.min(1, new THREE.Vector3().subVectors(p, a).dot(ab) / ab.lengthSq()));
    return new THREE.Vector3().addVectors(a, ab.multiplyScalar(t));
  }
  
  function updatePhysics(delta) {
    if(paused || finished || countdownPhase >= 0 && countdownPhase < 3) return;
    if(!started) return;
    
    // Update lap timer
    lapTime += delta;
    
    // Handle input
    const steerRate = 2.5 * (1 - Math.min(Math.abs(speed)/60, 0.8)); // tight at low speed, stable at high
    const accel = 12;
    const brake = 8;
    const handBrakeForce = 0.92;
    
    // Steering
    if(input.left) heading += steerRate * delta;
    if(input.right) heading -= steerRate * delta;
    
    // Acceleration / braking along heading
    const headingDir = new THREE.Vector3(Math.sin(heading), 0, Math.cos(heading));
    if(input.accel) {
      carVel.addScaledVector(headingDir, accel * delta);
    }
    if(input.brake) {
      if(carVel.length() < 1) {
        // Reverse
        carVel.addScaledVector(headingDir, -accel*0.6*delta);
      } else {
        carVel.multiplyScalar(0.92);
      }
    }
    if(input.hand) {
      carVel.multiplyScalar(handBrakeForce);
      // Extra slide when turning + handbrake
      if(Math.abs(input.left - input.right) > 0 || (Math.abs(speed)>8)) {
        const slideDir = new THREE.Vector3(Math.sin(heading + 1.5), 0, Math.cos(heading + 1.5));
        carVel.addScaledVector(slideDir, 2*delta);
      }
    }
    
    // Friction / drag
    const drag = 1.0 - (0.3 * delta);
    carVel.multiplyScalar(drag);
    
    // Apply grip / slide based on surface
    const seg = getSegmentNear(carPos);
    const surface = seg ? seg.userData.type : 'asphalt';
    let grip = 1.0;
    let maxSp = 55;
    if(surface === 'dirt') { grip = 0.65; maxSp = 45; }
    if(surface === 'grass') { grip = 0.45; maxSp = 28; }
    if(surface === 'mud') { grip = 0.2; maxSp = 18; }
    
    // Align velocity to heading based on grip (causes slide on low grip)
    const targetVel = headingDir.clone().multiplyScalar(carVel.length());
    carVel.lerp(targetVel, grip * 1.5 * delta); // quick alignment on asphalt
    
    // Cap speed based on surface
    speed = carVel.length();
    if(speed > maxSp) {
      carVel.multiplyScalar(maxSp / speed);
      speed = maxSp;
    }
    
    // Update position
    carPos.add(carVel);
    
    // Update car rotation
    carGroup.rotation.y = heading;
    carGroup.position.copy(carPos);
    carGroup.position.y = 0.8; // keep slightly above ground
    
    // Update wheel rotation
    const rotationSpeed = speed * delta * 2;
    wheels.forEach(w => w.rotation.z -= rotationSpeed);
    
    // Checkpoint detection
    for(let cp of checkpoints){
      const d = carPos.distanceTo(cp.pos);
      if(d < 6) {
        if(cp.mesh.userData.type === 'finish'){
          if(lastCheckpoint >= 2) { // crossed all 3 gates
            completeLap();
            lastCheckpoint = -1;
          }
        } else {
          if(cp.mesh.userData.index === nextCheckpoint) {
            lastCheckpoint = cp.mesh.userData.index;
            nextCheckpoint = (nextCheckpoint + 1) % checkpoints.length;
            // Note: index mapping needs care. We'll use simple sequential logic.
          }
        }
      }
    }
    // Simplified checkpoint progression: just use distance from start and gates as approximate
    // Actually, let's implement a simpler sequential logic based on the checkpoints array order:
    // We defined checkpoints as [gate0, gate1, gate2, finish]. We'll manually check if car crosses them in order.
    // For simplicity, let's just use distance traveled and gates as approximate anti-cheat:
    // We'll count laps when the car passes the finish gate after having a large lap distance.
    // Actually, to keep it robust, I'll use the first approach with nextCheckpoint and only count if sequence followed.
    // Let's reset and use a simpler approach:
  }
  
  // Redo checkpoint logic cleanly
  function updatePhysics(delta) {
    if(paused || finished || (countdownPhase >= 0 && countdownPhase < 3)) return;
    if(!started) return;
    
    lapTime += delta;
    
    // Inputs / steering / accel (same as above)
    const steerRate = 2.5 * (1 - Math.min(Math.abs(speed)/60, 0.8));
    const accel = 12, brake = 8;
    if(input.left) heading += steerRate * delta;
    if(input.right) heading -= steerRate * delta;
    if(input.accel) carVel.addScaledVector(new THREE.Vector3(Math.sin(heading),0,Math.cos(heading)), accel*delta);
    if(input.brake) {
      if(carVel.length() < 1) carVel.addScaledVector(new THREE.Vector3(Math.sin(heading),0,Math.cos(heading)), -accel*0.6*delta);
      else carVel.multiplyScalar(0.92);
    }
    if(input.hand) {
      carVel.multiplyScalar(0.92);
      const slideDir = new THREE.Vector3(Math.sin(heading + 1.5), 0, Math.cos(heading + 1.5));
      carVel.addScaledVector(slideDir, 2*delta);
    }
    
    const drag = 1.0 - (0.3 * delta);
    carVel.multiplyScalar(drag);
    
    const seg = getSegmentNear(carPos);
    const surface = seg ? seg.userData.type : 'asphalt';
    let grip = 1.0, maxSp = 55;
    if(surface === 'dirt') { grip = 0.65; maxSp = 45; }
    if(surface === 'grass') { grip = 0.45; maxSp = 28; }
    if(surface === 'mud') { grip = 0.2; maxSp = 18; }
    const headingDir = new THREE.Vector3(Math.sin(heading), 0, Math.cos(heading));
    const targetVel = headingDir.clone().multiplyScalar(carVel.length());
    carVel.lerp(targetVel, grip * 1.5 * delta);
    speed = carVel.length();
    if(speed > maxSp) {
      carVel.multiplyScalar(maxSp / speed);
      speed = maxSp;
    }
    carPos.add(carVel);
    
    carGroup.rotation.y = heading;
    carGroup.position.copy(carPos);
    carGroup.position.y = 0.8 + Math.sin(speed*5*clock.getElapsedTime())*0.1; // subtle bounce
    
    wheels.forEach(w => { w.rotation.z -= speed*delta*2; });
    
    // Checkpoint sequence: gates [0,1,2], finish [3]
    for(let cp of checkpoints){
      const d = carPos.distanceTo(cp.pos);
      if(d < 7) {
        const idx = cp.mesh.userData.index;
        if(idx === 3) { // finish
          if(lastCheckpoint >= 2) {
            completeLap();
            lastCheckpoint = -1;
            nextCheckpoint = 0;
          }
        } else {
          if(idx === nextCheckpoint) {
            lastCheckpoint = idx;
            nextCheckpoint = (nextCheckpoint + 1) % 3;
          }
        }
      }
    }
    
    // Spawn dust/particles based on surface/off-road
    spawnParticles(delta, surface);
    
    // Collision with scenery (simple sphere approximation for rocks/trees)
    for(let obj of scenery){
      if(obj.userData && obj.userData.type === 'rock') {
        const d = carPos.distanceTo(obj.position);
        if(d < 2.5) {
          carVel.multiplyScalar(-0.3);
          spawnSparks();
          playCrashSound();
        }
      }
    }
    
    updateEngineSound();
  }
  
  function spawnParticles(delta, surface) {
    if(Math.random() < 0.3 && (surface === 'grass' || surface === 'dirt' || surface === 'mud' || Math.abs(speed) > 10)) {
      const spawnPos = carPos.clone();
      spawnPos.add(new THREE.Vector3((Math.random()-0.5)*2, 0, (Math.random()-0.5)*2 - 2));
      spawnPos.y = 0.1;
      particles.push({
        mesh: createDustMesh(),
        life: 1.0,
        vel: new THREE.Vector3((Math.random()-0.5)*2, 2+Math.random()*2, (Math.random()-0.5)*2)
      });
      particles[particles.length-1].mesh.position.copy(spawnPos);
      scene.add(particles[particles.length-1].mesh);
    }
    // Update particles
    for(let i=particles.length-1;i>=0;i--){
      const p = particles[i];
      p.life -= 0.05;
      p.mesh.position.add(p.vel);
      p.vel.multiplyScalar(0.95);
      p.vel.y -= 1.0*0.05; // gravity
      p.mesh.scale.multiplyScalar(0.95);
      if(p.life <= 0) {
        scene.remove(p.mesh);
        particles.splice(i, 1);
      }
    }
  }
  
  function spawnSparks() {
    for(let i=0;i<8;i++){
      const pos = carPos.clone();
      pos.add(new THREE.Vector3((Math.random()-0.5)*1, 0.5, (Math.random()-0.5)*1));
      const mesh = new THREE.Mesh(
        new THREE.SphereGeometry(0.1 + Math.random()*0.2, 4, 4),
        new THREE.MeshPhongMaterial({color: 0xffff00, emissive: 0xff8800})
      );
      mesh.position.copy(pos);
      scene.add(mesh);
      particles.push({
        mesh: mesh,
        life: 0.6,
        vel: new THREE.Vector3((Math.random()-0.5)*4, (Math.random()-0.5)*4, (Math.random()-0.5)*4)
      });
    }
  }
  
  function createDustMesh() {
    return new THREE.Mesh(
      new THREE.SphereGeometry(0.4, 4, 4),
      new THREE.MeshPhongMaterial({color: 0xaaaaaa, transparent: true, opacity: 0.5})
    );
  }
  
  // ============== LAP / GAME STATE ==============
  function startCountdown() {
    countdownPhase = 0;
    countdownTimer = 0;
    document.getElementById('countdown').classList.remove('hidden');
    document.getElementById('countText').textContent = '3';
    playCountdownSound(0);
    started = false;
    finished = false;
  }
  
  function updateCountdown(delta) {
    countdownTimer += delta;
    if(countdownTimer > 1.0) {
      countdownPhase++;
      countdownTimer = 0;
      if(countdownPhase > 3) {
        countdownPhase = -1;
        started = true;
        document.getElementById('countdown').classList.add('hidden');
        document.getElementById('countText').textContent = 'GO!';
        playCountdownSound(3);
      } else {
        document.getElementById('countText').textContent = String(3 - countdownPhase);
        playCountdownSound(countdownPhase);
      }
    }
  }
  
  function completeLap() {
    if(lapTime < bestLap) bestLap = lapTime;
    currentLap++;
    lapTime = 0;
    if(currentLap > totalLaps) {
      finished = true;
      document.getElementById('finishOverlay').classList.remove('hidden');
      document.getElementById('finalTime').textContent = 'Total: ' + formatTime(lapTime + bestLap); // rough total
      document.getElementById('finalBest').textContent = 'Best Lap: ' + formatTime(bestLap);
      playFinishSound();
    }
    lastCheckpoint = -1;
    nextCheckpoint = 0;
  }
  
  function restart() {
    carPos.set(0, 0.5, 5);
    carVel.set(0,0,0);
    heading = 0;
    speed = 0;
    lapTime = 0;
    bestLap = Infinity;
    currentLap = 1;
    lastCheckpoint = -1;
    nextCheckpoint = 0;
    finished = false;
    started = false;
    countdownPhase = 0;
    countdownTimer = 0;
    document.getElementById('finishOverlay').classList.add('hidden');
    document.getElementById('countdown').classList.remove('hidden');
    document.getElementById('countText').textContent = '3';
    particles = [];
    // Clean up particles from scene
    for(let p of particles) scene.remove(p.mesh);
    particles = [];
  }
  
  function toggleCamera() {
    // Toggle between chase (low) and cinematic (high)
    // We'll just adjust target camera offset in render
  }
  
  // ============== CAMERA ==============
  let cinematic = false;
  function updateCamera() {
    // Chase camera with lag
    const offset = cinematic ? new THREE.Vector3(0, 20, -25) : new THREE.Vector3(0, 10, -18);
    const targetPos = carPos.clone().add(offset.applyAxisAngle(new THREE.Vector3(0,1,0), heading));
    // Lag interpolation
    camera.position.lerp(targetPos, 0.08);
    // Look ahead of car direction
    const lookTarget = carPos.clone();
    lookTarget.add(new THREE.Vector3(Math.sin(heading), 0, Math.cos(heading)).multiplyScalar(8));
    camera.lookAt(lookTarget);
  }
  
  // ============== RENDER LOOP ==============
  let lastTime = 0;
  function animate(timestamp) {
    requestAnimationFrame(animate);
    const dt = Math.min((timestamp - lastTime)/1000, 0.05);
    lastTime = timestamp;
    
    if(!paused) {
      if(countdownPhase >= 0 && countdownPhase < 3) {
        updateCountdown(dt);
      } else {
        updatePhysics(dt);
        if(started && !finished) updateCamera();
      }
      updateHUD();
    }
    renderer.render(scene, camera);
  }
  
  function onResize() {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  }
  
  // Public interface
  return {
    init: init,
    resume: () => { paused = false; document.getElementById('pauseOverlay').classList.add('hidden'); },
    restart: restart,
    toggleCamera: () => { cinematic = !cinematic; }
  };
})();
window.Rally = Rally;
// Initialize
window.addEventListener('DOMContentLoaded', () => {
  Rally.init();
  // Bind C key globally
  document.addEventListener('keydown', e => {
    if(e.key === 'c' || e.key === 'C') Rally.toggleCamera();
    if(e.key === 'p' || e.key === 'P') {
      const pausedEl = document.getElementById('pauseOverlay');
      if(pausedEl.classList.contains('hidden')) {
        pausedEl.classList.remove('hidden');
        window.Rally.pauseState = true;
      } else {
        pausedEl.classList.add('hidden');
        window.Rally.pauseState = false;
      }
    }
  });
});
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingmachines/inkling","provider":"openrouter","persona":"basic","sessionId":"7bf50b7d-bc31-46f7-ab04-aaf7079e563a","tokensIn":1131,"tokensOut":23746,"tokensTotal":24877,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-07-18T21:03:58.322Z"} -->