Rally
inkling · full

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"} -->