Rally
inkling · basic

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