Rally
Qwen 3.6 35b a3b · composer

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 42,303 bytes · SHA-256 f0f0defcca89
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rally Rush 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; }
#app { width: 100%; height: 100%; position: relative; }
#gameCanvas { width: 100%; height: 100%; display: block; }
/* ── css: hud ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
#app { width: 100%; height: 100%; position: relative; }
#gameCanvas { width: 100%; height: 100%; display: block; }
#hudCanvas {
position: absolute; bottom: 10px; right: 10px;
border-radius: 8px; box-shadow: 0 0 20px rgba(0,0,0,0.5);
image-rendering: auto; pointer-events: none;
}
#touchControls { display: none; position: absolute; bottom: 0; left: 0; width: 100%; height: 200px; pointer-events: none; z-index: 10; }
#touchControls > div { pointer-events: auto; }
#dpad { position: absolute; left: 20px; bottom: 20px; width: 150px; height: 150px; }
#dpad > div { position: absolute; width: 50px; height: 50px; background: rgba(255,255,255,0.2); border-radius: 8px; }
#dpadUp { top: 0; left: 50px; }
#dpadDown { bottom: 0; left: 50px; }
#dpadLeft { top: 50px; left: 0; }
#dpadRight { top: 50px; right: 0; }
#pedals { position: absolute; right: 20px; bottom: 20px; }
#gasBtn { position: absolute; bottom: 0; right: 0; width: 70px; height: 70px; background: rgba(0,255,0,0.25); border-radius: 50%; }
#brakeBtn { position: absolute; bottom: 80px; right: 10px; width: 55px; height: 55px; background: rgba(255,0,0,0.25); border-radius: 50%; }
#hbBtn { position: absolute; bottom: 0; right: 85px; width: 55px; height: 55px; background: rgba(255,255,0,0.25); border-radius: 50%; }
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<canvas id="hudCanvas" width="512" height="384"></canvas>
<div id="touchControls" style="display:none;position:absolute;bottom:0;left:0;width:100%;height:200px;pointer-events:none;">
<div id="dpad" style="position:absolute;left:20px;bottom:20px;width:150px;height:150px;pointer-events:auto;">
<div id="dpadUp" style="position:absolute;top:0;left:50px;width:50px;height:50px;background:rgba(255,255,255,0.2);border-radius:8px;"></div>
<div id="dpadDown" style="position:absolute;bottom:0;left:50px;width:50px;height:50px;background:rgba(255,255,255,0.2);border-radius:8px;"></div>
<div id="dpadLeft" style="position:absolute;top:50px;left:0;width:50px;height:50px;background:rgba(255,255,255,0.2);border-radius:8px;"></div>
<div id="dpadRight" style="position:absolute;top:50px;right:0;width:50px;height:50px;background:rgba(255,255,255,0.2);border-radius:8px;"></div>
</div>
<div id="pedals" style="position:absolute;right:20px;bottom:20px;pointer-events:auto;">
<div id="gasBtn" style="position:absolute;bottom:0;right:0;width:70px;height:70px;background:rgba(0,255,0,0.25);border-radius:50%;"></div>
<div id="brakeBtn" style="position:absolute;bottom:80px;right:10px;width:55px;height:55px;background:rgba(255,0,0,0.25);border-radius:50%;"></div>
<div id="hbBtn" style="position:absolute;bottom:0;right:85px;width:55px;height:55px;background:rgba(255,255,255,0.25);border-radius:50%;"></div>
</div>
</div>
<script>
'use strict';
// ── const: TRACK_POINTS ──
// ===== CONSTANTS =====
const TRACK_POINTS = [
{x:0, z:0, w:18, h:0},
{x:40, z:-10, w:18, h:2},
{x:80, z:5, w:20, h:5},
{x:110, z:30, w:18, h:8},
{x:120, z:70, w:16, h:10},
{x:100, z:110, w:18, h:8},
{x:60, z:130, w:20, h:5},
{x:20, z:120, w:18, h:3},
{x:-10, z:90, w:16, h:0},
{x:-20, z:50, w:18, h:-2},
{x:-10, z:10, w:20, h:-5},
{x:10, z:-20, w:18, h:-3},
{x:30, z:-35, w:16, h:0},
{x:55, z:-30, w:18, h:2},
{x:70, z:-10, w:20, h:4},
{x:65, z:15, w:18, h:6},
{x:45, z:35, w:16, h:7},
{x:20, z:40, w:18, h:5},
{x:0, z:30, w:20, h:3},
{x:-15, z:10, w:18, h:1},
];
// ── const: TRACK_SEGMENTS ──
const TRACK_SEGMENTS = 12;
// ── const: TRACK_LENGTH_EST ──
const TRACK_LENGTH_EST = 500;
// ── const: TOTAL_LAPS ──
const TOTAL_LAPS = 3;
// ── const: GRAVITY ──
const GRAVITY = -20;
// ── const: MAX_SPEED_ASPHALT ──
const MAX_SPEED_ASPHALT = 65;
// ── const: MAX_SPEED_DIRT ──
const MAX_SPEED_DIRT = 45;
// ── const: MAX_SPEED_GRASS ──
const MAX_SPEED_GRASS = 30;
// ── const: MAX_SPEED_MUD ──
const MAX_SPEED_MUD = 20;
// ── const: ACCEL ──
const ACCEL = 28;
// ── const: BRAKE ──
const BRAKE = 35;
// ── const: STEER_BASE ──
const STEER_BASE = 2.2;
// ── const: STEER_SPEED_MULT ──
const STEER_SPEED_MULT = 0.008;
// ── const: DRIFT_FACTOR ──
const DRIFT_FACTOR = 0.92;
// ── const: GRIP_ASPHALT ──
const GRIP_ASPHALT = 0.85;
// ── const: GRIP_DIRT ──
const GRIP_DIRT = 0.55;
// ── const: GRIP_GRASS ──
const GRIP_GRASS = 0.35;
// ── const: GRIP_MUD ──
const GRIP_MUD = 0.2;
// ── const: CHECKPOINT_COUNT ──
const CHECKPOINT_COUNT = 12;
// ── const: DUST_PARTICLE_COUNT ──
const DUST_PARTICLE_COUNT = 80;
// ── const: SPARK_COUNT ──
const SPARK_COUNT = 30;
// ── function: catmullRom ──
// ===== TRACK MATH =====
function catmullRom(p0, p1, p2, p3, t) {
const t2 = t * t, t3 = t2 * t;
return {
x: 0.5 * ((2*p1.x) + (-p0.x+p2.x)*t + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t2 + (-p0.x+3*p1.x-3*p2.x+p3.x)*t3),
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),
h: 0.5 * ((2*p1.h) + (-p0.h+p2.h)*t + (2*p0.h-5*p1.h+4*p2.h-p3.h)*t2 + (-p0.h+3*p1.h-3*p2.h+p3.h)*t3),
};
}
// ── function: getTrackPoint ──
function getTrackPoint(t) {
// t in [0, 1) along the track
const n = TRACK_POINTS.length;
const scaledT = ((t % 1) + 1) % 1;
const segLen = 1 / n;
const seg = Math.floor(scaledT / segLen) % n;
const localT = (scaledT - seg * segLen) / segLen;
const p0 = TRACK_POINTS[(seg - 1 + n) % n];
const p1 = TRACK_POINTS[seg];
const p2 = TRACK_POINTS[(seg + 1) % n];
const p3 = TRACK_POINTS[(seg + 2) % n];
return catmullRom(p0, p1, p2, p3, localT);
}
// ── function: getTrackDirection ──
function getTrackDirection(t) {
const eps = 0.001;
const a = getTrackPoint(t);
const b = getTrackPoint(t + eps);
const dx = b.x - a.x, dz = b.z - a.z;
const len = Math.sqrt(dx*dx + dz*dz) || 1;
return { x: dx/len, z: dz/len };
}
// ── function: getClosestTrack ──
function getClosestTrack(t) {
// Find closest point on track to a given position
let bestT = 0, bestDist = Infinity;
for (let i = 0; i <= 360; i++) {
const tt = i / 360;
const p = getTrackPoint(tt);
const dx = p.x - t.x, dz = p.z - t.z;
const d = dx*dx + dz*dz;
if (d < bestDist) { bestDist = d; bestT = tt; }
}
// Refine
for (let i = 0; i < 20; i++) {
const delta = 0.001 / (i + 1);
const a = getTrackPoint(bestT - delta);
const b = getTrackPoint(bestT + delta);
const da = (a.x-t.x)**2 + (a.z-t.z)**2;
const db = (b.x-t.x)**2 + (b.z-t.z)**2;
if (da < bestDist) { bestDist = da; bestT = bestT - delta; }
else if (db < bestDist) { bestDist = db; bestT = bestT + delta; }
}
return { t: ((bestT % 1) + 1) % 1, dist: Math.sqrt(bestDist) };
}
// ── function: getSurfaceAt ──
function getSurfaceAt(closestT, lateralOffset) {
const halfW = getTrackPoint(closestT).w / 2;
const absOff = Math.abs(lateralOffset);
if (absOff < halfW * 0.4) return { type: 'asphalt', grip: GRIP_ASPHALT, maxSpeed: MAX_SPEED_ASPHALT, color: '#555' };
if (absOff < halfW * 0.75) return { type: 'dirt', grip: GRIP_DIRT, maxSpeed: MAX_SPEED_DIRT, color: '#8B7355' };
if (absOff < halfW * 1.5) return { type: 'grass', grip: GRIP_GRASS, maxSpeed: MAX_SPEED_GRASS, color: '#4a7c3f' };
return { type: 'mud', grip: GRIP_MUD, maxSpeed: MAX_SPEED_MUD, color: '#3d2b1f' };
}
// ── function: getTrackWidthAt ──
function getTrackWidthAt(t) {
return getTrackPoint(t).w;
}
// ── function: createCar ──
// ===== CAR PHYSICS =====
function createCar() {
return {
x: 0, z: 0, y: 0,
heading: 0,
speed: 0,
lateralVel: 0,
driftAngle: 0,
onTrack: true,
surfaceType: 'asphalt',
gear: 1,
rpm: 800,
lap: 0,
lastCheckpoint: -1,
checkpointsPassed: [],
lapStartTime: 0,
lapTimes: [],
bestLap: Infinity,
finished: false,
totalElapsed: 0,
bounceTimer: 0,
skidMarks: [],
};
}
// ── function: updateCar ──
function updateCar(car, dt, input) {
const closest = getClosestTrack({x: car.x, z: car.z});
const trackPt = getTrackPoint(closest.t);
const dir = getTrackDirection(closest.t);
// Lateral offset from track center
const rightX = -dir.z, rightZ = dir.x;
const toCarX = car.x - trackPt.x, toCarZ = car.z - trackPt.z;
let latOff = toCarX * rightX + toCarZ * rightZ;
const surface = getSurfaceAt(closest.t, latOff);
car.surfaceType = surface.type;
car.onTrack = Math.abs(latOff) < trackPt.w / 2 * 0.75;
// Max speed based on surface
let maxSpd = surface.maxSpeed;
let grip = surface.grip;
// Acceleration
if (input.accel) {
car.speed += ACCEL * dt * (car.onTrack ? 1 : 0.5);
}
if (input.brake) {
car.speed -= BRAKE * dt;
}
if (input.reverse) {
car.speed -= ACCEL * 0.4 * dt;
}
// Handbrake
if (input.handbrake) {
grip *= 0.3;
car.driftAngle *= 1.02;
}
// Clamp speed
car.speed = Math.max(-15, Math.min(maxSpd, car.speed));
// Steering
const speedSteer = 1 - Math.abs(car.speed) / (maxSpd + 10);
const steerAmt = input.steer * STEER_BASE * (0.3 + 0.7 * speedSteer);
const speedFactor = Math.min(Math.abs(car.speed) / 10, 1) * STEER_SPEED_MULT * 100;
car.heading += steerAmt * speedFactor * dt * (car.speed >= 0 ? 1 : -1);
// Drift physics
const forwardX = Math.cos(car.heading), forwardZ = Math.sin(car.heading);
const targetVelX = forwardX * car.speed;
const targetVelZ = forwardZ * car.speed;
const lateralMix = grip * dt * 8;
car.lateralVel = car.lateralVel * (1 - lateralMix) + (targetVelX - car.x * 0) * lateralMix;
// Compute actual velocity
const vx = forwardX * car.speed + (-forwardZ) * car.lateralVel * 0.3;
const vz = forwardZ * car.speed + (forwardX) * car.lateralVel * 0.3;
// Apply handbrake drift
if (input.handbrake && Math.abs(car.speed) > 10) {
car.driftAngle += input.steer * 2.5 * dt;
car.lateralVel += input.steer * 15 * dt;
}
// Update position
car.x += vx * dt;
car.z += vz * dt;
// Bounce off track boundaries
if (Math.abs(latOff) > trackPt.w / 2 * 1.5) {
const pushDir = latOff > 0 ? -1 : 1;
car.x += pushDir * 3 * dt;
car.z += pushDir * (-dir.z) * 3 * dt;
car.speed *= 0.5;
car.bounceTimer = 0.2;
}
// Elevation
car.y = trackPt.h + 0.5;
// Gear simulation
const absSpd = Math.abs(car.speed);
if (absSpd < 8) car.gear = 1;
else if (absSpd < 18) car.gear = 2;
else if (absSpd < 30) car.gear = 3;
else if (absSpd < 45) car.gear = 4;
else car.gear = 5;
car.rpm = 800 + (absSpd / maxSpd) * 6000 + (car.gear - 1) * 200;
// Drift angle decay
car.driftAngle *= 0.95;
// Bounce timer
if (car.bounceTimer > 0) car.bounceTimer -= dt;
// Checkpoints
const cpDist = Math.abs(closest.t - car.lastCheckpoint);
if (cpDist > 0.02 || car.lastCheckpoint === -1) {
// Find nearest checkpoint
for (let i = 0; i < CHECKPOINT_COUNT; i++) {
const cpT = i / CHECKPOINT_COUNT;
const d = Math.abs(closest.t - cpT);
if (d < 0.03 || d > 0.97) {
const cpIdx = (i + 1) % CHECKPOINT_COUNT;
if (!car.checkpointsPassed.includes(cpIdx)) {
car.checkpointsPassed.push(cpIdx);
if (car.checkpointsPassed.length === CHECKPOINT_COUNT) {
// Lap completed
const now = performance.now();
const lapTime = (now - car.lapStartTime) / 1000;
car.lapTimes.push(lapTime);
if (lapTime < car.bestLap) car.bestLap = lapTime;
car.checkpointsPassed = [];
car.lap++;
car.lapStartTime = now;
if (car.lap >= TOTAL_LAPS) {
car.finished = true;
car.totalElapsed = lapTime + (car.lapTimes.length > 0 ? car.lapTimes.reduce((a,b)=>a+b,0) - car.lapTimes[car.lapTimes.length-1] : 0);
}
}
}
}
}
car.lastCheckpoint = closest.t;
}
return surface;
}
// ── function: createCameraState ──
// ===== CAMERA =====
function createCameraState() {
return {
x: 0, y: 5, z: 10,
targetX: 0, targetY: 2, targetZ: 0,
cinematic: false,
cinematicAngle: 0,
};
}
// ── function: updateCamera ──
function updateCamera(cam, car) {
const behindX = -Math.sin(car.heading);
const behindZ = -Math.cos(car.heading);
const dist = cam.cinematic ? 25 : 10;
const height = cam.cinematic ? 15 : 5;
cam.targetX = car.x + behindX * dist;
cam.targetY = car.y + height;
cam.targetZ = car.z + behindZ * dist;
const lerp = 0.06;
cam.x += (cam.targetX - cam.x) * lerp;
cam.y += (cam.targetY - cam.y) * lerp;
cam.z += (cam.targetZ - cam.z) * lerp;
if (cam.cinematic) {
cam.cinematicAngle += 0.002;
}
}
// ── function: createParticleSystem ──
// ===== PARTICLE SYSTEM =====
function createParticleSystem() {
const dust = [];
const sparks = [];
for (let i = 0; i < DUST_PARTICLE_COUNT; i++) dust.push({active: false, life: 0, x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, size: 0});
for (let i = 0; i < SPARK_COUNT; i++) sparks.push({active: false, life: 0, x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, size: 0});
return { dust, sparks };
}
// ── function: spawnDust ──
function spawnDust(particles, x, y, z, intensity) {
for (let i = 0; i < particles.dust.length; i++) {
if (!particles.dust[i].active) {
const p = particles.dust[i];
p.active = true;
p.life = 0.5 + Math.random() * 0.5;
p.x = x + (Math.random() - 0.5) * 2;
p.y = y;
p.z = z + (Math.random() - 0.5) * 2;
p.vx = (Math.random() - 0.5) * 3;
p.vy = Math.random() * 2 + 1;
p.vz = (Math.random() - 0.5) * 3;
p.size = 0.3 + Math.random() * 0.5;
break;
}
}
}
// ── function: spawnSparks ──
function spawnSparks(particles, x, y, z) {
for (let i = 0; i < particles.sparks.length; i++) {
if (!particles.sparks[i].active) {
const p = particles.sparks[i];
p.active = true;
p.life = 0.3 + Math.random() * 0.3;
p.x = x; p.y = y; p.z = z;
p.vx = (Math.random() - 0.5) * 10;
p.vy = Math.random() * 8 + 3;
p.vz = (Math.random() - 0.5) * 10;
p.size = 0.15;
break;
}
}
}
// ── function: updateParticles ──
function updateParticles(particles, dt) {
for (const p of particles.dust) {
if (!p.active) continue;
p.life -= dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
p.vy -= 3 * dt;
p.size += dt * 0.5;
if (p.life <= 0) p.active = false;
}
for (const p of particles.sparks) {
if (!p.active) continue;
p.life -= dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
p.vy -= 15 * dt;
if (p.life <= 0) p.active = false;
}
}
// ── function: createAudioManager ──
// ===== AUDIO MANAGER =====
function createAudioManager() {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
let engineOsc = null, engineGain = null, engineFilter = null;
let noiseNode = null, noiseGain = null;
function initEngine() {
engineOsc = ctx.createOscillator();
engineGain = ctx.createGain();
engineFilter = ctx.createBiquadFilter();
engineOsc.type = 'sawtooth';
engineOsc.frequency.value = 80;
engineFilter.type = 'lowpass';
engineFilter.frequency.value = 400;
engineGain.gain.value = 0;
engineOsc.connect(engineFilter);
engineFilter.connect(engineGain);
engineGain.connect(ctx.destination);
engineOsc.start();
// Noise for skid/gravel
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;
noiseNode = ctx.createBufferSource();
noiseNode.buffer = buffer;
noiseNode.loop = true;
noiseGain = ctx.createGain();
noiseGain.gain.value = 0;
const noiseFilter = ctx.createBiquadFilter();
noiseFilter.type = 'bandpass';
noiseFilter.frequency.value = 800;
noiseFilter.Q.value = 0.5;
noiseNode.connect(noiseFilter);
noiseFilter.connect(noiseGain);
noiseGain.connect(ctx.destination);
noiseNode.start();
}
function updateEngine(speed, gear) {
if (!engineOsc) return;
const rpm = 800 + Math.abs(speed) * 120;
engineOsc.frequency.value = Math.min(rpm / 6, 600);
const vol = Math.min(0.08, Math.abs(speed) / 100);
engineGain.gain.value = vol;
engineFilter.frequency.value = 300 + Math.abs(speed) * 10;
}
function updateSkid(onDirt, onGrass, isDrifting) {
if (!noiseGain) return;
const vol = (onDirt ? 0.02 : 0) + (onGrass ? 0.03 : 0) + (isDrifting ? 0.04 : 0);
noiseGain.gain.value = Math.min(vol, 0.06);
}
function playCountdownBeep(high) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = high ? 880 : 440;
gain.gain.value = 0.15;
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.3);
}
function playGo() {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.value = 660;
gain.gain.value = 0.1;
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.5);
}
function playCrash() {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.value = 60;
gain.gain.value = 0.2;
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.3);
}
function playFinish() {
[523, 659, 784].forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.value = 0.12;
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4 + i * 0.15);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime + i * 0.15);
osc.stop(ctx.currentTime + 0.4 + i * 0.15);
});
}
return { initEngine, updateEngine, updateSkid, playCountdownBeep, playGo, playCrash, playFinish, ctx };
}
// ── function: createHUD ──
// ===== HUD RENDERER =====
function createHUD() {
const canvas = document.createElement('canvas');
canvas.id = 'hudCanvas';
canvas.width = 512;
canvas.height = 384;
canvas.style.position = 'absolute';
canvas.style.bottom = '10px';
canvas.style.right = '10px';
canvas.style.borderRadius = '8px';
canvas.style.boxShadow = '0 0 20px rgba(0,0,0,0.5)';
canvas.style.imageRendering = 'auto';
return canvas;
}
// ── function: formatTime ──
function formatTime(seconds) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 100);
return `${m}:${s.toString().padStart(2,'0')}.${ms.toString().padStart(2,'0')}`;
}
// ── function: renderHUD ──
function renderHUD(hudCanvas, car, gameState) {
const ctx = hudCanvas.getContext('2d');
const W = hudCanvas.width, H = hudCanvas.height;
ctx.clearRect(0, 0, W, H);
// Background
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(0, 0, W, H);
// Speed
const speed = Math.abs(car.speed);
ctx.fillStyle = '#fff';
ctx.font = 'bold 48px monospace';
ctx.textAlign = 'center';
ctx.fillText(Math.floor(speed), W/2, 70);
ctx.font = '16px monospace';
ctx.fillStyle = '#aaa';
ctx.fillText('km/h', W/2, 95);
// Speed bar
const maxSpd = MAX_SPEED_ASPHALT;
const barW = 200;
const barH = 8;
ctx.fillStyle = '#333';
ctx.fillRect(W/2 - barW/2, 105, barW, barH);
const ratio = Math.min(speed / maxSpd, 1);
const color = ratio > 0.8 ? '#ff4444' : ratio > 0.5 ? '#ffaa00' : '#44ff44';
ctx.fillStyle = color;
ctx.fillRect(W/2 - barW/2, 105, barW * ratio, barH);
// Gear / RPM
ctx.font = 'bold 20px monospace';
ctx.fillStyle = '#ffcc00';
ctx.fillText(`G${car.gear}`, W/2, 145);
ctx.fillStyle = '#888';
ctx.font = '14px monospace';
ctx.fillText(`${Math.floor(car.rpm)} RPM`, W/2, 165);
// Lap info
ctx.textAlign = 'left';
ctx.font = 'bold 18px monospace';
ctx.fillStyle = '#fff';
ctx.fillText(`Lap ${Math.min(car.lap + 1, TOTAL_LAPS)}/${TOTAL_LAPS}`, 15, 30);
ctx.font = '14px monospace';
ctx.fillStyle = '#aaa';
ctx.fillText(`Time: ${formatTime(gameState.elapsed)}`, 15, 55);
if (car.lapTimes.length > 0) {
ctx.fillStyle = '#4f4';
ctx.fillText(`Best: ${formatTime(car.bestLap)}`, 15, 75);
}
// Surface indicator
const surfColors = {asphalt: '#666', dirt: '#8B7355', grass: '#4a7c3f', mud: '#3d2b1f'};
ctx.fillStyle = surfColors[car.surfaceType] || '#666';
ctx.fillRect(15, 85, 12, 12);
ctx.fillStyle = '#ccc';
ctx.font = '12px monospace';
ctx.fillText(car.surfaceType.toUpperCase(), 32, 96);
// Minimap
const mmX = W - 160, mmY = H - 150, mmW = 145, mmH = 135;
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(mmX, mmY, mmW, mmH);
ctx.strokeStyle = '#555';
ctx.strokeRect(mmX, mmY, mmW, mmH);
// Track on minimap
ctx.beginPath();
ctx.strokeStyle = '#888';
ctx.lineWidth = 3;
for (let i = 0; i <= 360; i++) {
const tt = i / 360;
const p = getTrackPoint(tt);
const mx = mmX + (p.x + 80) / 200 * mmW;
const my = mmY + (p.z + 80) / 200 * mmH;
if (i === 0) ctx.moveTo(mx, my);
else ctx.lineTo(mx, my);
}
ctx.closePath();
ctx.stroke();
// Player on minimap
const pmx = mmX + (car.x + 80) / 200 * mmW;
const pmy = mmY + (car.z + 80) / 200 * mmH;
ctx.beginPath();
ctx.arc(pmx, pmy, 4, 0, Math.PI * 2);
ctx.fillStyle = '#ff4444';
ctx.fill();
// Countdown overlay
if (gameState.countdown > 0) {
ctx.textAlign = 'center';
ctx.font = 'bold 80px monospace';
const num = gameState.countdown > 0 ? Math.ceil(gameState.countdown) : 'GO!';
ctx.fillStyle = gameState.countdown > 0 ? '#ffcc00' : '#44ff44';
ctx.globalAlpha = 0.9;
ctx.fillText(num, W/2, H/2 + 20);
ctx.globalAlpha = 1;
}
// Pause overlay
if (gameState.paused) {
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(0, 0, W, H);
ctx.textAlign = 'center';
ctx.font = 'bold 40px monospace';
ctx.fillStyle = '#fff';
ctx.fillText('PAUSED', W/2, H/2 - 20);
ctx.font = '18px monospace';
ctx.fillStyle = '#aaa';
ctx.fillText('Press P to resume', W/2, H/2 + 20);
}
// Finish overlay
if (gameState.finished) {
ctx.fillStyle = 'rgba(0,0,0,0.8)';
ctx.fillRect(0, 0, W, H);
ctx.textAlign = 'center';
ctx.font = 'bold 36px monospace';
ctx.fillStyle = '#ffcc00';
ctx.fillText('FINISHED!', W/2, H/2 - 50);
ctx.font = '20px monospace';
ctx.fillStyle = '#fff';
ctx.fillText(`Time: ${formatTime(gameState.elapsed)}`, W/2, H/2);
ctx.fillText(`Best Lap: ${formatTime(car.bestLap)}`, W/2, H/2 + 30);
ctx.font = '16px monospace';
ctx.fillStyle = '#aaa';
ctx.fillText('Press R to restart', W/2, H/2 + 65);
}
}
// ── function: createInput ──
// ===== INPUT =====
function createInput() {
return {
accel: false, brake: false, steer: 0, reverse: false, handbrake: false,
restart: false, pause: false, camToggle: false,
};
}
// ── function: updateInput ──
function updateInput(input, keys, touch) {
input.accel = keys['ArrowUp'] || keys['KeyW'] || touch.accel;
input.brake = keys['ArrowDown'] || keys['KeyS'] || touch.brake;
input.steer = (keys['ArrowRight'] || keys['KeyD'] ? 1 : 0) - (keys['ArrowLeft'] || keys['KeyA'] ? 1 : 0) + touch.steer;
input.reverse = keys['ArrowDown'] && !input.accel;
input.handbrake = keys['Space'] || touch.handbrake;
input.restart = keys['KeyR'];
input.pause = keys['KeyP'];
input.camToggle = keys['KeyC'];
}
// ── main ──
// ===== MAIN =====
(function() {
// --- Three.js Setup ---
const canvas = document.getElementById('gameCanvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setClearColor(0x87CEEB);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x87CEEB, 0.004);
const camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.5, 500);
// --- Lighting ---
const ambientLight = new THREE.AmbientLight(0x6688aa, 0.5);
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xffeedd, 1.0);
sunLight.position.set(50, 80, 30);
sunLight.castShadow = true;
sunLight.shadow.mapSize.width = 2048;
sunLight.shadow.mapSize.height = 2048;
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);
scene.add(sunLight.target);
const hemiLight = new THREE.HemisphereLight(0x87CEEB, 0x444422, 0.4);
scene.add(hemiLight);
// --- Ground Plane ---
const groundGeo = new THREE.BufferGeometry();
const gSize = 300;
const gVerts = new Float32Array([-gSize,-0.5,-gSize, gSize,-0.5,-gSize, gSize,-0.5,gSize, -gSize,-0.5,gSize]);
groundGeo.setAttribute('position', new THREE.Float32BufferAttribute(gVerts, 3));
groundGeo.setIndex([0,1,2, 0,2,3]);
groundGeo.computeVertexNormals();
const groundMat = new THREE.MeshLambertMaterial({ color: 0x3a6b2a });
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.receiveShadow = true;
scene.add(ground);
// --- Track Geometry ---
const trackCurve = new THREE.CatmullRomCurve3(
TRACK_POINTS.map(p => new THREE.Vector3(p.x, p.h + 0.3, p.z)),
true, 'catmullrom', 0.5
);
const trackSegments = 400;
const trackVerts = [], trackUVs = [], trackIndices = [];
const trackColors = [];
for (let i = 0; i <= trackSegments; i++) {
const t = i / trackSegments;
const pos = trackCurve.getPointAt(t);
const tangent = trackCurve.getTangentAt(t);
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
const w = getTrackWidthAt(t);
if (isNaN(normal.x) || isNaN(normal.y) || isNaN(normal.z)) continue;
if (isNaN(pos.x) || isNaN(pos.z)) continue;
const left = pos.clone().add(normal.clone().multiplyScalar(w / 2));
const right = pos.clone().add(normal.clone().multiplyScalar(-w / 2));
trackVerts.push(left.x, left.y, left.z);
trackVerts.push(right.x, right.y, right.z);
trackUVs.push(0, t * 20);
trackUVs.push(1, t * 20);
const isEdge = i % 30 < 3;
if (isEdge) {
trackColors.push(0.55, 0.45, 0.33, 0.55, 0.45, 0.33);
} else {
trackColors.push(0.33, 0.33, 0.33, 0.33, 0.33, 0.33);
}
if (i < trackSegments) {
const base = i * 2;
trackIndices.push(base, base + 1, base + 2);
trackIndices.push(base + 1, base + 3, base + 2);
}
}
const trackGeo = new THREE.BufferGeometry();
trackGeo.setAttribute('position', new THREE.Float32BufferAttribute(trackVerts, 3));
trackGeo.setAttribute('uv', new THREE.Float32BufferAttribute(trackUVs, 2));
trackGeo.setAttribute('color', new THREE.Float32BufferAttribute(trackColors, 3));
trackGeo.setIndex(trackIndices);
try { trackGeo.computeVertexNormals(); } catch(e) {}
const trackMat = new THREE.MeshLambertMaterial({ vertexColors: true });
const trackMesh = new THREE.Mesh(trackGeo, trackMat);
trackMesh.receiveShadow = true;
scene.add(trackMesh);
// --- Track edge markers (posts) ---
const postMat = new THREE.MeshLambertMaterial({ color: 0xff6600 });
for (let side = -1; side <= 1; side += 2) {
for (let i = 0; i < trackSegments; i += 20) {
const t = i / trackSegments;
const pos = trackCurve.getPointAt(t);
const tangent = trackCurve.getTangentAt(t);
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
const w = getTrackWidthAt(t);
if (isNaN(normal.x)) continue;
const offset = side * (w / 2 + 0.5);
const p = pos.clone().add(normal.clone().multiplyScalar(offset));
if (isNaN(p.x) || isNaN(p.z)) continue;
const postGeo = new THREE.CylinderGeometry(0.08, 0.08, 1.5, 4);
const post = new THREE.Mesh(postGeo, postMat);
post.position.set(p.x, p.y + 0.75, p.z);
post.castShadow = true;
scene.add(post);
const stripeGeo = new THREE.CylinderGeometry(0.1, 0.1, 0.3, 4);
const stripeMat = new THREE.MeshLambertMaterial({ color: i % 40 < 20 ? 0xffffff : 0xff0000 });
const stripe = new THREE.Mesh(stripeGeo, stripeMat);
stripe.position.set(p.x, p.y + 0.75, p.z);
scene.add(stripe);
}
}
// --- Scenery ---
const sceneryGroup = new THREE.Group();
scene.add(sceneryGroup);
const treeMat = new THREE.MeshLambertMaterial({ color: 0x2d5a1e });
const trunkMat = new THREE.MeshLambertMaterial({ color: 0x5a3a1a });
const rockMat = new THREE.MeshLambertMaterial({ color: 0x666666 });
const signMat = new THREE.MeshLambertMaterial({ color: 0xff4444 });
const n = TRACK_POINTS.length;
for (let i = 0; i < n; i++) {
const pt = TRACK_POINTS[i];
const dir = getTrackDirection(i / n);
const rightX = -dir.z, rightZ = dir.x;
const halfW = pt.w / 2 + 3;
for (let side = -1; side <= 1; side += 2) {
const offset = side * (halfW + 3 + Math.random() * 8);
const tx = pt.x + rightX * offset;
const tz = pt.z + rightZ * offset;
const sc = 0.7 + Math.random() * 0.6;
const trunkGeo = new THREE.CylinderGeometry(0.2 * sc, 0.3 * sc, 2 * sc, 6);
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
trunk.position.set(tx, pt.h + sc, tz);
trunk.castShadow = true;
sceneryGroup.add(trunk);
const leavesGeo = new THREE.ConeGeometry(1.5 * sc, 3 * sc, 6);
const leaves = new THREE.Mesh(leavesGeo, treeMat);
leaves.position.set(tx, pt.h + 2 * sc + 1.5 * sc, tz);
leaves.castShadow = true;
sceneryGroup.add(leaves);
}
if (Math.random() < 0.4) {
const side = Math.random() < 0.5 ? -1 : 1;
const offset = side * (halfW + 1 + Math.random() * 4);
const sc = 0.5 + Math.random() * 1.2;
const geo = new THREE.IcosahedronGeometry(sc, 0);
const mesh = new THREE.Mesh(geo, rockMat);
mesh.position.set(pt.x + rightX * offset, pt.h + sc * 0.5, pt.z + rightZ * offset);
mesh.rotation.set(Math.random(), Math.random(), Math.random());
mesh.castShadow = true;
sceneryGroup.add(mesh);
}
if (Math.random() < 0.15) {
const side = Math.random() < 0.5 ? -1 : 1;
const offset = side * (halfW + 1);
const poleGeo = new THREE.CylinderGeometry(0.05, 0.05, 2, 4);
const pole = new THREE.Mesh(poleGeo, new THREE.MeshLambertMaterial({ color: 0x888888 }));
pole.position.set(pt.x + rightX * offset, pt.h + 1, pt.z + rightZ * offset);
sceneryGroup.add(pole);
const boardGeo = new THREE.BoxGeometry(1.2, 0.6, 0.05);
const board = new THREE.Mesh(boardGeo, signMat);
board.position.set(pt.x + rightX * offset, pt.h + 2.2, pt.z + rightZ * offset);
sceneryGroup.add(board);
}
}
// --- Car Model ---
const carGroup = new THREE.Group();
scene.add(carGroup);
const bodyGeo = new THREE.BoxGeometry(2, 0.6, 4);
const bodyMat = new THREE.MeshLambertMaterial({ color: 0xcc2222 });
const body = new THREE.Mesh(bodyGeo, bodyMat);
body.position.y = 0.5;
body.castShadow = true;
carGroup.add(body);
const cabinGeo = new THREE.BoxGeometry(1.6, 0.5, 1.8);
const cabinMat = new THREE.MeshLambertMaterial({ color: 0x333333 });
const cabin = new THREE.Mesh(cabinGeo, cabinMat);
cabin.position.set(0, 1.05, -0.3);
carGroup.add(cabin);
const spoilerGeo = new THREE.BoxGeometry(2.2, 0.08, 0.4);
const spoilerMat = new THREE.MeshLambertMaterial({ color: 0x111111 });
const spoiler = new THREE.Mesh(spoilerGeo, spoilerMat);
spoiler.position.set(0, 1.3, 1.8);
carGroup.add(spoiler);
[-0.8, 0.8].forEach(x => {
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.3, 0.1), spoilerMat);
leg.position.set(x, 1.15, 1.8);
carGroup.add(leg);
});
const wheelGeo = new THREE.CylinderGeometry(0.35, 0.35, 0.25, 8);
const wheelMat = new THREE.MeshLambertMaterial({ color: 0x222222 });
const wheelPositions = [[-1.0, 0.35, 1.3], [1.0, 0.35, 1.3], [-1.0, 0.35, -1.3], [1.0, 0.35, -1.3]];
const wheels = [];
wheelPositions.forEach(([x, y, z]) => {
const wheel = new THREE.Mesh(wheelGeo, wheelMat);
wheel.rotation.z = Math.PI / 2;
wheel.position.set(x, y, z);
wheel.castShadow = true;
carGroup.add(wheel);
wheels.push(wheel);
});
const hlMat = new THREE.MeshLambertMaterial({ color: 0xffffcc, emissive: 0xffffcc, emissiveIntensity: 0.5 });
[-0.7, 0.7].forEach(x => {
const hl = new THREE.Mesh(new THREE.SphereGeometry(0.15, 6, 6), hlMat);
hl.position.set(x, 0.5, -2.0);
carGroup.add(hl);
});
const tlMat = new THREE.MeshLambertMaterial({ color: 0xff0000, emissive: 0xff0000, emissiveIntensity: 0.3 });
[-0.7, 0.7].forEach(x => {
const tl = new THREE.Mesh(new THREE.SphereGeometry(0.15, 6, 6), tlMat);
tl.position.set(x, 0.5, 2.0);
carGroup.add(tl);
});
// --- Particle Meshes ---
const dustGroup = new THREE.Group();
scene.add(dustGroup);
const dustMat = new THREE.MeshBasicMaterial({ color: 0x8B7355, transparent: true, opacity: 0.4, depthWrite: false });
const dustMeshes = [];
for (let i = 0; i < 30; i++) {
const m = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), dustMat.clone());
m.visible = false;
dustGroup.add(m);
dustMeshes.push(m);
}
const sparkGroup = new THREE.Group();
scene.add(sparkGroup);
const sparkMat = new THREE.MeshBasicMaterial({ color: 0xffaa00 });
const sparkMeshes = [];
for (let i = 0; i < 15; i++) {
const m = new THREE.Mesh(new THREE.SphereGeometry(0.05, 6, 6), sparkMat.clone());
m.visible = false;
sparkGroup.add(m);
sparkMeshes.push(m);
}
// --- Skid marks ---
const skidGroup = new THREE.Group();
scene.add(skidGroup);
const skidMat = new THREE.MeshBasicMaterial({ color: 0x111111, transparent: true, opacity: 0.5, depthWrite: false });
const skidMeshes = [];
for (let i = 0; i < 40; i++) {
const m = new THREE.Mesh(new THREE.PlaneGeometry(0.15, 2), skidMat.clone());
m.visible = false;
skidGroup.add(m);
skidMeshes.push(m);
}
// --- Game State ---
const car = createCar();
const cam = createCameraState();
const particles = createParticleSystem();
const audio = createAudioManager();
const input = createInput();
const keys = {};
const touch = { accel: false, brake: false, steer: 0, handbrake: false };
let gameState = {
countdown: 3.5,
paused: false,
finished: false,
elapsed: 0,
started: false,
};
let dustIdx = 0, sparkIdx = 0, skidIdx = 0;
let lastTime = performance.now();
let audioInitialized = false;
// --- Input ---
document.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'KeyR' && (gameState.finished || gameState.countdown <= 0)) resetGame();
if (e.code === 'KeyP' && gameState.countdown <= 0) gameState.paused = !gameState.paused;
if (e.code === 'KeyC') cam.cinematic = !cam.cinematic;
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
function setupTouch(el, prop, val) {
el.addEventListener('touchstart', e => { e.preventDefault(); touch[prop] = val; });
el.addEventListener('touchend', e => { e.preventDefault(); touch[prop] = 0; });
el.addEventListener('touchcancel', e => { touch[prop] = 0; });
}
const tc = document.getElementById('touchControls');
if (tc) {
setupTouch(document.getElementById('dpadUp'), 'accel', true);
setupTouch(document.getElementById('dpadDown'), 'brake', true);
setupTouch(document.getElementById('dpadLeft'), 'steer', -1);
setupTouch(document.getElementById('dpadRight'), 'steer', 1);
setupTouch(document.getElementById('gasBtn'), 'accel', true);
setupTouch(document.getElementById('brakeBtn'), 'brake', true);
setupTouch(document.getElementById('hbBtn'), 'handbrake', true);
}
function resetGame() {
const startPt = getTrackPoint(0);
const dir = getTrackDirection(0);
car.x = startPt.x; car.z = startPt.z; car.y = startPt.h;
car.heading = Math.atan2(dir.x, dir.z);
car.speed = 0; car.lateralVel = 0; car.driftAngle = 0;
car.lap = 0; car.lastCheckpoint = -1; car.checkpointsPassed = [];
car.lapTimes = []; car.bestLap = Infinity; car.finished = false;
car.totalElapsed = 0; car.bounceTimer = 0;
gameState = { countdown: 3.5, paused: false, finished: false, elapsed: 0, started: false };
cam.cinematic = false;
dustMeshes.forEach(m => m.visible = false);
sparkMeshes.forEach(m => m.visible = false);
skidMeshes.forEach(m => m.visible = false);
}
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// --- Game Loop ---
function gameLoop() {
requestAnimationFrame(gameLoop);
const now = performance.now();
let dt = (now - lastTime) / 1000;
lastTime = now;
dt = Math.min(dt, 0.05);
updateInput(input, keys, touch);
if (!audioInitialized && (input.accel || input.brake)) {
audio.initEngine();
audioInitialized = true;
}
if (gameState.countdown > 0) {
gameState.countdown -= dt;
if (gameState.countdown <= 0) {
gameState.started = true;
gameState.elapsed = 0;
}
if (Math.ceil(gameState.countdown + dt) !== Math.ceil(gameState.countdown) && gameState.countdown > 0) {
audio.playCountdownBeep(gameState.countdown < 1.5);
}
}
if (gameState.started && !gameState.paused && !gameState.finished) {
gameState.elapsed += dt;
const surface = updateCar(car, dt, input);
const absSpd = Math.abs(car.speed);
if (absSpd > 5 && (surface.type === 'dirt' || surface.type === 'grass' || input.handbrake)) {
spawnDust(particles, car.x, car.y - 0.3, car.z, absSpd);
}
if (car.bounceTimer > 0) spawnSparks(particles, car.x, car.y, car.z);
if (input.handbrake && absSpd > 10) {
const skid = skidMeshes[skidIdx % skidMeshes.length];
skid.visible = true;
skid.position.set(car.x - 0.4, car.y - 0.05, car.z + 0.5);
skid.rotation.y = car.heading;
skidIdx++;
const skid2 = skidMeshes[skidIdx % skidMeshes.length];
skid2.visible = true;
skid2.position.set(car.x + 0.4, car.y - 0.05, car.z + 0.5);
skid2.rotation.y = car.heading;
skidIdx++;
}
carGroup.position.set(car.x, car.y, car.z);
carGroup.rotation.y = car.heading + car.driftAngle;
wheels.forEach((w, i) => {
w.rotation.x += car.speed * dt * 0.5;
if (i >= 2) w.rotation.y = input.steer * 0.3;
});
updateCamera(cam, car);
camera.position.set(cam.x, cam.y, cam.z);
if (cam.cinematic) {
const lookX = car.x + Math.sin(cam.cinematicAngle) * 5;
const lookZ = car.z + Math.cos(cam.cinematicAngle) * 5;
camera.lookAt(lookX, car.y, lookZ);
} else {
camera.lookAt(car.x, car.y + 1, car.z);
}
sunLight.position.set(car.x + 50, 80, car.z + 30);
sunLight.target.position.set(car.x, car.y, car.z);
updateParticles(particles, dt);
particles.dust.forEach((p, i) => {
if (p.active && dustMeshes[i]) {
dustMeshes[i].visible = true;
dustMeshes[i].position.set(p.x, p.y, p.z);
dustMeshes[i].scale.setScalar(p.size);
dustMeshes[i].material.opacity = p.life * 0.4;
dustMeshes[i].lookAt(camera.position);
} else if (dustMeshes[i]) {
dustMeshes[i].visible = false;
}
});
particles.sparks.forEach((p, i) => {
if (p.active && sparkMeshes[i]) {
sparkMeshes[i].visible = true;
sparkMeshes[i].position.set(p.x, p.y, p.z);
sparkMeshes[i].scale.setScalar(p.size);
} else if (sparkMeshes[i]) {
sparkMeshes[i].visible = false;
}
});
skidMeshes.forEach((m, i) => {
if (m.visible && Math.random() < 0.005) m.visible = false;
});
if (audioInitialized) {
audio.updateEngine(car.speed, car.gear);
audio.updateSkid(surface.type === 'dirt', surface.type === 'grass', input.handbrake);
}
}
const hudCanvas = document.getElementById('hudCanvas');
renderHUD(hudCanvas, car, gameState);
renderer.render(scene, camera);
}
resetGame();
gameLoop();
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen/qwen3.6-35b-a3b","provider":"lmstudio","persona":"composer","sessionId":"a9ebff1e-6cc8-4a27-a612-11cb20e74f25","tokensIn":1552026,"tokensOut":70483,"tokensTotal":1622509,"turns":37,"toolCalls":36,"failedToolCalls":1,"timestamp":"2026-07-20T23:31:59.530Z"} -->