Rally
ox alpha · full
◈Preview unavailable — try playing the result
Browser check: Preview unavailable or timed out. This is a technical check, not a quality review.
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 43,287 bytes · SHA-256 6cb02af3ce89
<!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>Dust Rally 3D</title>
<style>
/* ---------- Global layout ---------- */
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000;
font-family:"Segoe UI",Arial,sans-serif;color:#fff;-webkit-user-select:none;user-select:none;}
#game{position:fixed;inset:0;}
canvas#three{display:block;width:100%;height:100%;}
/* ---------- HUD ---------- */
#hud{position:fixed;inset:0;pointer-events:none;z-index:5;}
.panel{background:rgba(10,14,20,.55);border:1px solid rgba(255,255,255,.15);
border-radius:10px;padding:8px 12px;backdrop-filter:blur(3px);}
#topLeft{position:absolute;top:12px;left:12px;font-size:14px;line-height:1.5;text-shadow:0 1px 2px #000;}
#topLeft b{color:#ffd24a;}
#timerBox{position:absolute;top:12px;left:50%;transform:translateX(-50%);text-align:center;font-size:20px;
font-weight:700;letter-spacing:1px;text-shadow:0 2px 4px #000;}
#timerBox .sub{font-size:12px;color:#9fd0ff;font-weight:400;}
/* Speedometer / gear */
#speedo{position:absolute;right:16px;bottom:16px;text-align:center;}
#speedVal{font-size:44px;font-weight:800;line-height:1;text-shadow:0 2px 6px #000;}
#speedUnit{font-size:11px;color:#bbb;letter-spacing:2px;}
#gearVal{font-size:22px;color:#ffd24a;font-weight:700;margin-top:2px;}
#rpmBar{width:150px;height:8px;background:#222;border-radius:4px;margin:6px auto 0;overflow:hidden;border:1px solid #444;}
#rpmFill{height:100%;width:0%;background:linear-gradient(90deg,#6f6,#ff4,#f43);transition:width .05s linear;}
/* Minimap */
#minimapWrap{position:absolute;left:12px;bottom:12px;padding:6px;}
#minimap{display:block;}
/* Countdown */
#countdown{position:absolute;top:38%;left:50%;transform:translate(-50%,-50%);
font-size:120px;font-weight:900;color:#ffd24a;text-shadow:0 0 30px rgba(255,180,40,.8),0 4px 10px #000;
display:none;}
#msgFlash{position:absolute;top:52%;left:50%;transform:translateX(-50%);font-size:26px;font-weight:700;
color:#ff5b5b;text-shadow:0 2px 6px #000;display:none;}
/* Overlays (pause / finish) */
.overlay{position:fixed;inset:0;background:rgba(4,8,14,.78);display:none;z-index:20;
align-items:center;justify-content:center;flex-direction:column;gap:18px;}
.overlay h1{margin:0;font-size:42px;letter-spacing:3px;color:#ffd24a;text-shadow:0 3px 8px #000;}
.overlay .stats{font-size:19px;line-height:1.9;text-align:center;}
.overlay .stats b{color:#9fd0ff;}
.btn{pointer-events:auto;cursor:pointer;background:linear-gradient(180deg,#ffcf4a,#f0a020);color:#20160a;
border:none;border-radius:8px;padding:12px 34px;font-size:18px;font-weight:800;letter-spacing:1px;
box-shadow:0 4px 0 #9a6510;}
.btn:active{transform:translateY(3px);box-shadow:0 1px 0 #9a6510;}
.btn.secondary{background:linear-gradient(180deg,#7fa8c9,#4d7699);color:#eef6ff;box-shadow:0 4px 0 #2c4a63;}
/* Touch controls */
#touchUI{position:fixed;inset:0;display:none;z-index:10;pointer-events:none;}
.tbtn{position:absolute;width:74px;height:74px;border-radius:50%;background:rgba(255,255,255,.13);
border:2px solid rgba(255,255,255,.35);color:#fff;font-size:26px;font-weight:800;
display:flex;align-items:center;justify-content:center;pointer-events:auto;touch-action:none;}
.tbtn.active{background:rgba(255,210,74,.45);}
#tLeft{left:18px;bottom:110px;} #tRight{left:110px;bottom:110px;}
#tGas{right:18px;bottom:170px;} #tBrake{right:18px;bottom:80px;} #tHand{right:106px;bottom:125px;}
#helpTip{position:absolute;bottom:14px;left:50%;transform:translateX(-50%);font-size:12px;color:#cfd8e3;
text-shadow:0 1px 2px #000;opacity:.85;}
</style>
</head>
<body>
<div id="game"><canvas id="three"></canvas></div>
<!-- HUD -->
<div id="hud">
<div id="topLeft" class="panel">
LAP <b id="lapNow">1</b>/3<br>
BEST <b id="bestLap">--:--.--</b><br>
LAST <b id="lastLap">--:--.--</b>
</div>
<div id="timerBox" class="panel">
<span id="raceTime">00:00.00</span><br><span class="sub" id="camMode">CHASE CAM · C to toggle</span>
</div>
<div id="speedo">
<div id="rpmBar"><div id="rpmFill"></div></div>
<div id="speedVal">0</div><div id="speedUnit">KM/H</div>
<div id="gearVal">N</div>
</div>
<div id="minimapWrap" class="panel"><canvas id="minimap" width="150" height="150"></canvas></div>
<div id="countdown">3</div>
<div id="msgFlash"></div>
<div id="helpTip">WASD / Arrows drive · SPACE handbrake · R restart · P pause · C camera</div>
</div>
<!-- Pause -->
<div class="overlay" id="pauseMenu">
<h1>PAUSED</h1>
<button class="btn" id="btnResume">RESUME</button>
<button class="btn secondary" id="btnRestartP">RESTART</button>
</div>
<!-- Finish -->
<div class="overlay" id="finishScreen">
<h1>FINISH!</h1>
<div class="stats" id="finishStats"></div>
<button class="btn" id="btnRestartF">RACE AGAIN</button>
</div>
<!-- Touch controls -->
<div id="touchUI">
<div class="tbtn" id="tLeft">◀</div><div class="tbtn" id="tRight">▶</div>
<div class="tbtn" id="tGas">▲</div><div class="tbtn" id="tBrake">▼</div><div class="tbtn" id="tHand">✋</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
/* =====================================================================
DUST RALLY 3D — single-file rally racer
Modules: Input, AudioSynth, TrackBuilder, Scenery, CarPhysics,
Particles, CameraRig, HUD, Minimap, Game (main loop)
===================================================================== */
(function () {
"use strict";
/* ---------------- Utilities ---------------- */
const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
const lerp = (a,b,t)=>a+(b-a)*t;
const rand = (a,b)=>a+Math.random()*(b-a);
function fmtTime(t){ if(t==null) return "--:--.--";
const m=Math.floor(t/60), s=Math.floor(t%60), c=Math.floor((t*100)%100);
return String(m).padStart(2,"0")+":"+String(s).padStart(2,"0")+"."+String(c).padStart(2,"0"); }
/* ---------------- Input (keyboard + touch) ---------------- */
const Input = {
keys:{}, touch:{left:false,right:false,gas:false,brake:false,hand:false},
init(game){
addEventListener("keydown",e=>{
this.keys[e.code]=true;
if(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Space"].includes(e.code)) e.preventDefault();
if(e.code==="KeyP") game.togglePause();
if(e.code==="KeyR") game.restart();
if(e.code==="KeyC") game.toggleCam();
});
addEventListener("keyup",e=>this.keys[e.code]=false);
// touch
const isTouch = ("ontouchstart" in window);
if(isTouch) document.getElementById("touchUI").style.display="block";
const bind=(id,prop)=>{
const el=document.getElementById(id);
const on=e=>{e.preventDefault();this.touch[prop]=true;el.classList.add("active");};
const off=e=>{e.preventDefault();this.touch[prop]=false;el.classList.remove("active");};
el.addEventListener("touchstart",on,{passive:false});
el.addEventListener("touchend",off);el.addEventListener("touchcancel",off);
el.addEventListener("mousedown",on);el.addEventListener("mouseup",off);el.addEventListener("mouseleave",off);
};
bind("tLeft","left");bind("tRight","right");bind("tGas","gas");bind("tBrake","brake");bind("tHand","hand");
},
get throttle(){ return (this.keys.ArrowUp||this.keys.KeyW||this.touch.gas)?1:0; },
get brake(){ return (this.keys.ArrowDown||this.keys.KeyS||this.touch.brake)?1:0; },
get steer(){ let s=0;
if(this.keys.ArrowLeft||this.keys.KeyA||this.touch.left)s-=1;
if(this.keys.ArrowRight||this.keys.KeyD||this.touch.right)s+=1; return s; },
get handbrake(){ return !!(this.keys.Space||this.touch.hand); }
};
/* ---------------- Synthesized audio (Web Audio API) ---------------- */
const AudioSynth = {
ctx:null, started:false,
init(){
try{ this.ctx=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){ return; }
const c=this.ctx;
this.master=c.createGain(); this.master.gain.value=.5; this.master.connect(c.destination);
// Engine: two detuned saw oscillators through lowpass -> gain
this.engGain=c.createGain(); this.engGain.gain.value=0;
this.engFilter=c.createBiquadFilter(); this.engFilter.type="lowpass"; this.engFilter.frequency.value=600;
this.oscA=c.createOscillator(); this.oscA.type="sawtooth";
this.oscB=c.createOscillator(); this.oscB.type="square";
const bGain=c.createGain(); bGain.gain.value=.35;
this.oscA.connect(this.engFilter); this.oscB.connect(bGain); bGain.connect(this.engFilter);
this.engFilter.connect(this.engGain); this.engGain.connect(this.master);
this.oscA.start(); this.oscB.start();
// Noise buffer for skid/gravel
const len=c.sampleRate*1.5, buf=c.createBuffer(1,len,c.sampleRate), d=buf.getChannelData(0);
for(let i=0;i<len;i++) d[i]=Math.random()*2-1;
this.noiseBuf=buf;
this.skidSrc=c.createBufferSource(); this.skidSrc.buffer=buf; this.skidSrc.loop=true;
this.skidFilter=c.createBiquadFilter(); this.skidFilter.type="bandpass"; this.skidFilter.frequency.value=2200; this.skidFilter.Q.value=.7;
this.skidGain=c.createGain(); this.skidGain.gain.value=0;
this.skidSrc.connect(this.skidFilter); this.skidFilter.connect(this.skidGain); this.skidGain.connect(this.master);
this.skidSrc.start();
this.started=true;
},
resume(){ if(this.ctx&&this.ctx.state==="suspended") this.ctx.resume(); },
/** rpm 0..1, load 0..1 */
engine(rpm,load){
if(!this.started)return;
const f=55+rpm*160;
this.oscA.frequency.setTargetAtTime(f,this.ctx.currentTime,.03);
this.oscB.frequency.setTargetAtTime(f*.5,this.ctx.currentTime,.03);
this.engFilter.frequency.setTargetAtTime(300+rpm*1800,this.ctx.currentTime,.05);
this.engGain.gain.setTargetAtTime(.06+load*.12+rpm*.08,this.ctx.currentTime,.08);
},
skid(amount,gravel){
if(!this.started)return;
this.skidGain.gain.setTargetAtTime(clamp(amount,0,1)*.22,this.ctx.currentTime,.05);
this.skidFilter.frequency.setTargetAtTime(gravel?900:2400,this.ctx.currentTime,.1);
},
beep(freq,dur,vol){
if(!this.started)return; const c=this.ctx,t=c.currentTime;
const o=c.createOscillator(),g=c.createGain();
o.type="square"; o.frequency.value=freq;
g.gain.setValueAtTime(vol||.25,t); g.gain.exponentialRampToValueAtTime(.001,t+dur);
o.connect(g);g.connect(this.master); o.start(t); o.stop(t+dur+.02);
},
crash(intensity){
if(!this.started)return; const c=this.ctx,t=c.currentTime;
const src=c.createBufferSource(); src.buffer=this.noiseBuf;
const f=c.createBiquadFilter(); f.type="lowpass"; f.frequency.value=400;
const g=c.createGain();
g.gain.setValueAtTime(clamp(intensity,.1,.6),t); g.gain.exponentialRampToValueAtTime(.001,t+.25);
src.connect(f);f.connect(g);g.connect(this.master); src.start(t); src.stop(t+.3);
},
finishTone(){
if(!this.started)return;
[523,659,784,1047].forEach((f,i)=>setTimeout(()=>this.beep(f,.28,.28),i*160));
}
};
/* ---------------- Track definition & builder ---------------- */
const TRACK_W = 11; // half width of drivable road
const SAMPLES = 500; // resolution of the centerline
const TrackBuilder = {
curve:null, samples:[], surfaces:[], length:0,
build(scene){
// Control points: [x, z, elevation] — winding loop with hills & dips
const CP=[
[0,0,0],[60,-8,2],[120,10,6],[170,60,10],[180,130,14],[150,190,10],
[90,215,6],[30,195,3],[-30,215,0],[-95,205,-2],[-150,165,-4],[-175,105,-6],
[-160,45,-4],[-110,15,-2],[-70,-40,0],[-90,-100,-3],[-60,-155,-5],[0,-175,-3],
[70,-160,0],[110,-110,2],[80,-60,1],[30,-45,0]
].map(p=>new THREE.Vector3(p[0],p[2],p[1]));
this.curve=new THREE.CatmullRomCurve3(CP,true,"catmullrom",.5);
this.length=this.curve.getLength();
// Sample centerline: position, tangent, side vector, surface type
const S=this.samples=[];
for(let i=0;i<SAMPLES;i++){
const t=i/SAMPLES;
const p=this.curve.getPointAt(t);
const tan=this.curve.getTangentAt(t).normalize();
const side=new THREE.Vector3(-tan.z,0,tan.x).normalize(); // left normal in XZ
S.push({p,tan,side,t});
}
// Surface map around the loop: 0=asphalt(fast) 1=dirt(fast) 2=mud(water/very slow)
const surf=this.surfaces=[];
for(let i=0;i<SAMPLES;i++){
const f=i/SAMPLES;
let s = (f<.35)?0 : (f<.62)?1 : (f<.72)?2 : (f<.86)?1 : 0;
surf.push(s);
}
/* --- Road ribbon mesh --- */
const pos=[],col=[],idx=[];
const surfColors=[new THREE.Color(0x3a3d42),new THREE.Color(0x8a6b42),new THREE.Color(0x5e4a33)];
for(let i=0;i<=SAMPLES;i++){
const j=i%SAMPLES, s=S[j];
const c=surfColors[surf[j]];
const l=s.p.clone().addScaledVector(s.side,TRACK_W);
const r=s.p.clone().addScaledVector(s.side,-TRACK_W);
pos.push(l.x,l.y+.05,l.z, r.x,r.y+.05,r.z);
col.push(c.r,c.g,c.b, c.r,c.g,c.b);
if(i<SAMPLES){ const a=i*2;
idx.push(a,a+1,a+2, a+1,a+3,a+2); }
}
const geo=new THREE.BufferGeometry();
geo.setAttribute("position",new THREE.Float32BufferAttribute(pos,3));
geo.setAttribute("color",new THREE.Float32BufferAttribute(col,3));
geo.setIndex(idx); geo.computeVertexNormals();
const road=new THREE.Mesh(geo,new THREE.MeshLambertMaterial({vertexColors:true}));
road.receiveShadow=true; scene.add(road);
/* --- Gravel shoulders (slightly wider ribbons, both sides) --- */
const mkShoulder=(sign)=>{
const p=[],ix=[];
for(let i=0;i<=SAMPLES;i++){
const j=i%SAMPLES,s=S[j];
const inner=s.p.clone().addScaledVector(s.side,sign*(TRACK_W-.2));
const outer=s.p.clone().addScaledVector(s.side,sign*(TRACK_W+3.2));
p.push(inner.x,inner.y+.04,inner.z, outer.x,outer.y-.15,outer.z);
if(i<SAMPLES){const a=i*2; ix.push(a,a+1,a+2, a+1,a+3,a+2);}
}
const g=new THREE.BufferGeometry();
g.setAttribute("position",new THREE.Float32BufferAttribute(p,3));
g.setIndex(ix); g.computeVertexNormals();
scene.add(new THREE.Mesh(g,new THREE.MeshLambertMaterial({color:0x9a8752,side:THREE.DoubleSide})));
};
mkShoulder(1); mkShoulder(-1);
/* --- Center dashes on asphalt stretches --- */
const dashGeo=[],dashIdx=[];let vi=0;
for(let i=0;i<SAMPLES;i+=8){
if(surf[i]!==0)continue;
const s=S[i],s2=S[(i+3)%SAMPLES];
const a=s.p.clone().addScaledVector(s.side,.35),b=s.p.clone().addScaledVector(s.side,-.35);
const cc=s2.p.clone().addScaledVector(s2.side,.35),d=s2.p.clone().addScaledVector(s2.side,-.35);
dashGeo.push(a.x,a.y+.09,a.z,b.x,b.y+.09,b.z,cc.x,cc.y+.09,cc.z,d.x,d.y+.09,d.z);
dashIdx.push(vi,vi+1,vi+2, vi+1,vi+3,vi+2); vi+=4;
}
const dg=new THREE.BufferGeometry();
dg.setAttribute("position",new THREE.Float32BufferAttribute(dashGeo,3));
dg.setIndex(dashIdx);
scene.add(new THREE.Mesh(dg,new THREE.MeshBasicMaterial({color:0xf5f0d8})));
/* --- Start/finish line checker strip --- */
const s0=S[0], sl=s0.p.clone().addScaledVector(s0.side,TRACK_W), sr=s0.p.clone().addScaledVector(s0.side,-TRACK_W);
const n0=S[4], nl=n0.p.clone().addScaledVector(n0.side,TRACK_W), nr=n0.p.clone().addScaledVector(n0.side,-TRACK_W);
const fg=new THREE.BufferGeometry();
fg.setAttribute("position",new THREE.Float32BufferAttribute(
[sl.x,sl.y+.1,sl.z, sr.x,sr.y+.1,sr.z, nl.x,nl.y+.1,nl.z, nr.x,nr.y+.1,nr.z],3));
fg.setIndex([0,1,2,1,3,2]); fg.computeVertexNormals();
scene.add(new THREE.Mesh(fg,new THREE.MeshBasicMaterial({color:0xeeeeee})));
},
/** Nearest sample index to a position, searching near hint for speed */
nearestIndex(p,hint){
let best=-1,bd=1e9;
const range=25;
for(let k=-range;k<=range;k++){
const i=((hint+k)%SAMPLES+SAMPLES)%SAMPLES, s=this.samples[i];
const dx=p.x-s.p.x, dz=p.z-s.p.z, d=dx*dx+dz*dz;
if(d<bd){bd=d;best=i;}
}
return best;
},
/** Full nearest search (used at init) */
nearestIndexFull(p){
let best=0,bd=1e9;
for(let i=0;i<SAMPLES;i++){const s=this.samples[i];
const dx=p.x-s.p.x,dz=p.z-s.p.z,d=dx*dx+dz*dz;if(d<bd){bd=d;best=i;}}
return best;
}
};
/* ---------------- Procedural textures (canvas-generated) ---------------- */
const Tex = {
dust(){
const c=document.createElement("canvas");c.width=c.height=64;
const g=c.getContext("2d");
const gr=g.createRadialGradient(32,32,2,32,32,30);
gr.addColorStop(0,"rgba(210,190,160,.9)");gr.addColorStop(1,"rgba(210,190,160,0)");
g.fillStyle=gr;g.fillRect(0,0,64,64);
return new THREE.CanvasTexture(c);
},
spark(){
const c=document.createElement("canvas");c.width=c.height=32;
const g=c.getContext("2d");
const gr=g.createRadialGradient(16,16,1,16,16,14);
gr.addColorStop(0,"rgba(255,240,180,1)");gr.addColorStop(.4,"rgba(255,160,40,.8)");gr.addColorStop(1,"rgba(255,120,0,0)");
g.fillStyle=gr;g.fillRect(0,0,32,32);
return new THREE.CanvasTexture(c);
}
};
/* ---------------- Scenery: trees, rocks, signs, barriers, crowd ---------------- */
const Scenery = {
obstacles:[], // {x,z,r} collision circles
build(scene,track){
const S=track.samples;
const addObstacle=(x,z,r)=>this.obstacles.push({x,z,r});
/* Trees — low-poly cone + trunk, scattered off-road */
const treeGeoT=new THREE.CylinderGeometry(.25,.35,1.6,5);
const treeGeoC=new THREE.ConeGeometry(1.6,4.2,6);
const trunkMat=new THREE.MeshLambertMaterial({color:0x6b4a2a});
const leafMats=[new THREE.MeshLambertMaterial({color:0x2e6b2f}),
new THREE.MeshLambertMaterial({color:0x3a7d33}),
new THREE.MeshLambertMaterial({color:0x27592a})];
const treeGroup=new THREE.Group();
for(let i=0;i<260;i++){
const s=S[Math.floor(Math.random()*SAMPLES)];
const off=(TRACK_W+6+Math.random()*45)*(Math.random()<.5?1:-1);
const p=s.p.clone().addScaledVector(s.side,off);
p.y+=rand(-1.5,2.5);
const g=new THREE.Group();
const trunk=new THREE.Mesh(treeGeoT,trunkMat);trunk.position.y=.8;
const leaf=new THREE.Mesh(treeGeoC,leafMats[i%3]);leaf.position.y=3.2;
const sc=rand(.7,1.6);g.scale.setScalar(sc);
g.add(trunk,leaf);g.position.copy(p);
g.rotation.y=Math.random()*Math.PI*2;
treeGroup.add(g);
if(off<TRACK_W+9) addObstacle(p.x,p.z,.9*sc);
}
scene.add(treeGroup);
/* Rocks */
const rockGeo=new THREE.DodecahedronGeometry(1,0);
const rockMat=new THREE.MeshLambertMaterial({color:0x8b8b90});
for(let i=0;i<70;i++){
const s=S[Math.floor(Math.random()*SAMPLES)];
const off=(TRACK_W+4+Math.random()*30)*(Math.random()<.5?1:-1);
const p=s.p.clone().addScaledVector(s.side,off); p.y+=rand(-1,1);
const m=new THREE.Mesh(rockGeo,rockMat);
const sc=rand(.5,2);m.scale.set(sc,sc*.7,sc);
m.position.copy(p);m.rotation.set(Math.random(),Math.random(),Math.random());
m.castShadow=true;scene.add(m);
if(off<TRACK_W+8) addObstacle(p.x,p.z,sc);
}
/* Distance markers / arrow signs on curves */
const signMat=new THREE.MeshLambertMaterial({color:0xd8322a});
const postMat=new THREE.MeshLambertMaterial({color:0xcccccc});
for(let i=0;i<SAMPLES;i+=40){
const a=S[i],b=S[(i+12)%SAMPLES];
// curve direction hint: cross of tangents
const cross=a.tan.x*b.tan.z-a.tan.z*b.tan.x;
if(Math.abs(cross)<.05)continue;
const dir=cross>0?-1:1; // place sign on outside of the corner
const p=a.p.clone().addScaledVector(a.side,dir*(TRACK_W+3.5));
const g=new THREE.Group();
const post=new THREE.Mesh(new THREE.BoxGeometry(.15,1.6,.15),postMat);post.position.y=.8;
const board=new THREE.Mesh(new THREE.BoxGeometry(1.8,.9,.1),signMat);board.position.y=1.9;
g.add(post,board);g.position.copy(p);g.lookAt(p.clone().sub(a.side));
scene.add(g);addObstacle(p.x,p.z,.7);
}
/* Guard-rail barriers along outer edges every so often */
const railMat=new THREE.MeshLambertMaterial({color:0xd0d0d4});
const railPost=new THREE.MeshLambertMaterial({color:0x555a60});
for(let i=0;i<SAMPLES;i+=6){
const s=S[i];
const cross=s.tan.x*S[(i+10)%SAMPLES].tan.z-s.tan.z*S[(i+10)%SAMPLES].tan.x;
const side=cross>0?-1:1;
const p=s.p.clone().addScaledVector(s.side,side*(TRACK_W+3.8));
p.y+=.5;
const rail=new THREE.Mesh(new THREE.BoxGeometry(3.4,.28,.1),railMat);
rail.position.copy(p);
rail.rotation.y=Math.atan2(s.tan.x,s.tan.z)+Math.PI/2;
scene.add(rail);
if(i%18===0){
const post=new THREE.Mesh(new THREE.BoxGeometry(.14,.9,.14),railPost);
post.position.set(p.x,p.y-.45,p.z);scene.add(post);
}
addObstacle(p.x,p.z,.6);
}
/* Start-line banners + crowd */
const s0=S[0];
const bannerMat=new THREE.MeshLambertMaterial({color:0xffd24a});
for(const dir of [1,-1]){
const p=s0.p.clone().addScaledVector(s0.side,dir*(TRACK_W+2.5));
const post=new THREE.Mesh(new THREE.CylinderGeometry(.15,.15,6,6),postMat);
post.position.set(p.x,p.y+3,p.z);scene.add(post);
addObstacle(p.x,p.z,.5);
}
const top=new THREE.Mesh(new THREE.BoxGeometry((TRACK_W+2.5)*2,.8,.5),bannerMat);
top.position.set(s0.p.x,s0.p.y+6.2,s0.p.z);
top.rotation.y=Math.atan2(s0.tan.x,s0.tan.z)+Math.PI/2;
scene.add(top);
// crowd blocks
const crowdMat=new THREE.MeshLambertMaterial({color:0x4466aa});
const crowdMat2=new THREE.MeshLambertMaterial({color:0xaa4466});
for(let i=0;i<40;i++){
const s=S[(i*3)%SAMPLES];
const dir=i%2?1:-1;
const p=s.p.clone().addScaledVector(s.side,dir*(TRACK_W+5.5));
const m=new THREE.Mesh(new THREE.BoxGeometry(rand(.4,.7),rand(.9,1.4),rand(.4,.7)),
i%2?crowdMat:crowdMat2);
m.position.set(p.x,p.y+.5,p.z);m.rotation.y=Math.random()*Math.PI;
scene.add(m);
}
}
};
/* ---------------- Rally car (procedural low-poly) ---------------- */
function buildCar(){
const car=new THREE.Group();
const bodyMat=new THREE.MeshLambertMaterial({color:0xe03a2a});
const darkMat=new THREE.MeshLambertMaterial({color:0x22252a});
const glassMat=new THREE.MeshLambertMaterial({color:0x9fd4ff});
// main body
const body=new THREE.Mesh(new THREE.BoxGeometry(2,.7,4.2),bodyMat);
body.position.y=.65;body.castShadow=true;car.add(body);
// cabin
const cabin=new THREE.Mesh(new THREE.BoxGeometry(1.7,.6,2),bodyMat);
cabin.position.set(0,1.25,.1);cabin.castShadow=true;car.add(cabin);
const glass=new THREE.Mesh(new THREE.BoxGeometry(1.72,.42,1.7),glassMat);
glass.position.set(0,1.28,.1);car.add(glass);
// hood scoop + bumper
const scoop=new THREE.Mesh(new THREE.BoxGeometry(.7,.2,.8),darkMat);
scoop.position.set(0,1.05,-1.5);car.add(scoop);
const bumper=new THREE.Mesh(new THREE.BoxGeometry(2.1,.3,.3),darkMat);
bumper.position.set(0,.45,-2.15);car.add(bumper);
// spoiler
const wing=new THREE.Mesh(new THREE.BoxGeometry(2.1,.1,.55),darkMat);
wing.position.set(0,1.35,1.95);car.add(wing);
const wL=new THREE.Mesh(new THREE.BoxGeometry(.1,.4,.3),darkMat);
wL.position.set(-.85,1.12,1.95);car.add(wL);
const wR=wL.clone();wR.position.x=.85;car.add(wR);
// headlights / taillights
const hlMat=new THREE.MeshBasicMaterial({color:0xfff4c0});
const tlMat=new THREE.MeshBasicMaterial({color:0xff2a1a});
for(const sx of [-.65,.65]){
const h=new THREE.Mesh(new THREE.BoxGeometry(.45,.18,.08),hlMat);
h.position.set(sx,.75,-2.12);car.add(h);
const t=new THREE.Mesh(new THREE.BoxGeometry(.45,.18,.08),tlMat);
t.position.set(sx,.75,2.12);car.add(t);
}
// wheels (stored for spin/steer)
const wheelGeo=new THREE.CylinderGeometry(.42,.42,.34,10);
wheelGeo.rotateZ(Math.PI/2);
const wheelMat=new THREE.MeshLambertMaterial({color:0x1a1a1c});
const hubMat=new THREE.MeshLambertMaterial({color:0xb8b8b8});
const wheels=[];
for(const [x,z] of [[-1.05,-1.35],[1.05,-1.35],[-1.05,1.4],[1.05,1.4]]){
const w=new THREE.Group();
const tire=new THREE.Mesh(wheelGeo,wheelMat);tire.castShadow=true;
const hub=new THREE.Mesh(new THREE.CylinderGeometry(.18,.18,.36,8),hubMat);
hub.rotation.z=Math.PI/2;
w.add(tire,hub);w.position.set(x,.42,z);car.add(w);wheels.push(w);
}
return {group:car,wheels};
}
/* ---------------- Particles: dust, sparks, skid marks ---------------- */
const Particles = {
init(scene){
this.scene=scene;
this.dustTex=Tex.dust(); this.sparkTex=Tex.spark();
// dust sprite pool
this.dust=[];
for(let i=0;i<90;i++){
const sp=new THREE.Sprite(new THREE.SpriteMaterial({map:this.dustTex,transparent:true,opacity:0,depthWrite:false}));
sp.visible=false;sp.userData={life:0};
scene.add(sp);this.dust.push(sp);
}
this.dustIdx=0;
// spark pool
this.sparks=[];
for(let i=0;i<40;i++){
const sp=new THREE.Sprite(new THREE.SpriteMaterial({map:this.sparkTex,transparent:true,opacity:0,depthWrite:false}));
sp.scale.setScalar(.5);sp.visible=false;sp.userData={life:0,vel:new THREE.Vector3()};
scene.add(sp);this.sparks.push(sp);
}
this.sparkIdx=0;
// skid marks: small dark quads laid on the road
this.skids=[];
const skGeo=new THREE.PlaneGeometry(.34,1.1);skGeo.rotateX(-Math.PI/2);
const skMat=new THREE.MeshBasicMaterial({color:0x18140f,transparent:true,opacity:.55,depthWrite:false});
for(let i=0;i<160;i++){
const m=new THREE.Mesh(skGeo,skMat.clone());
m.visible=false;m.userData={life:1};scene.add(m);this.skids.push(m);
}
this.skidIdx=0;
},
spawnDust(p,scale){
const sp=this.dust[this.dustIdx=(this.dustIdx+1)%this.dust.length];
sp.position.copy(p);sp.position.y+=.3;
sp.scale.setScalar(scale||rand(.8,1.6));
sp.material.opacity=.55;sp.visible=true;sp.userData.life=1;
},
spawnSpark(p){
const sp=this.sparks[this.sparkIdx=(this.sparkIdx+1)%this.sparks.length];
sp.position.copy(p);sp.material.opacity=1;sp.visible=true;
sp.userData.life=1;
sp.userData.vel.set(rand(-4,4),rand(2,6),rand(-4,4));
},
spawnSkid(p,angle){
const m=this.skids[this.skidIdx=(this.skidIdx+1)%this.skids.length];
m.position.copy(p);m.position.y+=.06;
m.rotation.y=angle;m.visible=true;m.userData.life=1;m.material.opacity=.55;
},
update(dt){
for(const sp of this.dust){ if(!sp.visible)continue;
sp.userData.life-=dt*1.4;
sp.material.opacity=Math.max(0,sp.userData.life*.55);
sp.position.y+=dt*1.2; sp.scale.multiplyScalar(1+dt*1.5);
if(sp.userData.life<=0)sp.visible=false;
}
for(const sp of this.sparks){ if(!sp.visible)continue;
sp.userData.life-=dt*2.5;
sp.userData.vel.y-=20*dt;
sp.position.addScaledVector(sp.userData.vel,dt);
sp.material.opacity=Math.max(0,sp.userData.life);
if(sp.userData.life<=0)sp.visible=false;
}
for(const m of this.skids){ if(!m.visible)continue;
m.userData.life-=dt*.12;
m.material.opacity=Math.max(0,m.userData.life*.55);
if(m.userData.life<=0)m.visible=false;
}
}
};
/* ---------------- Car physics (arcade rally model) ---------------- */
const Car = {
pos:new THREE.Vector3(), heading:0,
vel:new THREE.Vector3(), // world-space velocity (XZ)
yawRate:0, steerVis:0, wheelSpin:0,
surface:0, onTrack:true, nearest:0, progress:0,
MAXSPEED:46, // m/s ~165 km/h
reset(track){
const s=track.samples[2];
this.pos.copy(s.p);this.pos.y+=.6;
this.heading=Math.atan2(s.tan.x,s.tan.z); // face along tangent
this.vel.set(0,0,0);this.yawRate=0;this.nearest=2;this.progress=0;
},
/** returns telemetry for audio/fx */
update(dt,track){
const th=Input.throttle, br=Input.brake, st=Input.steer, hb=Input.handbrake;
// --- surface lookup ---
this.nearest=track.nearestIndex(this.pos,this.nearest);
const ns=track.samples[this.nearest];
const dxz=this.pos.clone().sub(ns.p); dxz.y=0;
const latDist=Math.abs(dxz.dot(ns.side));
this.onTrack = latDist < TRACK_W+2.5;
this.surface = this.onTrack ? track.surfaces[this.nearest] : 3; // 3=grass
// grip & top speed per surface: asphalt, dirt, mud, grass
const GRIP=[9.5,6.0,3.2,4.0][this.surface];
const TOP =[this.MAXSPEED,this.MAXSPEED*.92,10,14][this.surface];
// --- decompose velocity ---
const fwd=new THREE.Vector3(Math.sin(this.heading),0,Math.cos(this.heading));
const right=new THREE.Vector3(fwd.z,0,-fwd.x);
let vF=this.vel.dot(fwd), vR=this.vel.dot(right);
// --- engine / brakes ---
const power=26;
if(th) vF += power*dt*(1-Math.max(0,vF)/TOP);
if(br){ if(vF>1) vF-=34*dt; else vF-=10*dt; } // brake then reverse
vF=clamp(vF,-10,TOP*(this.onTrack?1:.55));
// drag & rolling resistance
vF -= vF*(this.onTrack?0.25:1.1)*dt;
if(this.surface===2) vF-=vF*1.5*dt; // mud is glue
// --- steering: speed-sensitive ---
const speed=Math.abs(vF);
const steerMax=lerp(.9,.32,clamp(speed/40,0,1));
const steer=st*steerMax;
this.steerVis=lerp(this.steerVis,steer,dt*8);
this.yawRate=lerp(this.yawRate,steer*clamp(speed/8,0,1)*2.2,dt*6);
this.heading+=this.yawRate*dt;
// --- lateral grip / drift ---
let grip=GRIP*(hb?.25:1);
// centrifugal push in corners
vR += this.yawRate*vF*dt;
vR -= vR*Math.min(1,grip*dt);
// handbrake kick
if(hb&&speed>8) vR+=this.yawRate*vF*dt*.6;
// recompose
this.vel.copy(fwd).multiplyScalar(vF).addScaledVector(right,vR);
this.pos.addScaledVector(this.vel,dt);
this.pos.y=ns.p.y+.55+lerp(-.3,.3,clamp(latDist/TRACK_W,0,1)); // follow terrain
// keep car inside world bounds
const lim=230;
this.pos.x=clamp(this.pos.x,-lim,lim);this.pos.z=clamp(this.pos.z,-lim,lim);
this.wheelSpin+=vF*dt/.42;this.lastVF=vF;
return {vF,vR,speed,drift:Math.abs(vR),surface:this.surface,onTrack:this.onTrack};
}
};
/* ---------------- Lap / checkpoint logic ---------------- */
const Race = {
state:"countdown", // countdown | racing | paused | finished
lap:1, totalLaps:3, raceTime:0, lapStart:0,
lapTimes:[], best:null,
nextCP:0, cpCount:12, lastProg:0,
init(track){
// evenly spaced checkpoints around the loop (fractions of the track)
this.cps=[];for(let i=0;i<this.cpCount;i++)this.cps.push(i/this.cpCount);
this.resetLapState();
},
resetLapState(){ this.nextCP=1; this.lastProg=0; },
fullRestart(){
this.state="countdown";this.lap=1;this.raceTime=0;this.lapStart=0;
this.lapTimes=[];// keep best across restarts? no — fresh race
this.resetLapState();
},
update(car,game){
if(this.state!=="racing")return;
this.raceTime+=game.dt;
const prog=car.nearest/SAMPLES;
const target=this.cps[this.nextCP];
// detect passing the next checkpoint (within tolerance, forward direction)
const d=Math.abs(prog-target);
if(d<.04 && prog>this.lastProg-.5){
this.nextCP++;
if(this.nextCP>=this.cpCount){
// all checkpoints cleared — next crossing of start line completes the lap
this.nextCP=this.cpCount; // wait for wrap
}
}
// lap complete when we wrap from high progress to low with all CPs hit
if(this.nextCP>=this.cpCount && prog<this.lastProg-0.5){
const lt=this.raceTime-this.lapStart;this.lapStart=this.raceTime;
this.lapTimes.push(lt);
if(this.best==null||lt<this.best){this.best=lt;game.hud.flash("NEW BEST LAP!");}
else game.hud.flash("LAP "+fmtTime(lt));
if(this.lap>=this.totalLaps){ game.finish(); return; }
this.lap++;this.resetLapState();
AudioSynth.beep(880,.15,.2);
}
// going backwards past start without CPs → guard against cheating
this.lastProg=prog;
}
};
/* ---------------- Camera rig ---------------- */
const CameraRig = {
mode:0, pos:new THREE.Vector3(0,10,-20), look:new THREE.Vector3(),
toggle(){ this.mode=(this.mode+1)%2; },
update(dt,cam,car,speed){
const fwd=new THREE.Vector3(Math.sin(car.heading),0,Math.cos(car.heading));
let want,look;
if(this.mode===0){ // chase cam
want=car.pos.clone().addScaledVector(fwd,-9.5).add(new THREE.Vector3(0,4.4,0));
look=car.pos.clone().addScaledVector(fwd,7).add(new THREE.Vector3(0,1.4,0));
}else{ // cinematic high cam
want=car.pos.clone().addScaledVector(fwd,-22).add(new THREE.Vector3(0,16,0));
look=car.pos.clone().addScaledVector(fwd,10);
}
// lag: slower follow at speed for dynamic feel
const k=1-Math.pow(.0018,dt)*(this.mode===0?1:.6);
this.pos.lerp(want,k);
cam.position.copy(this.pos);
this.look.lerp(look,1-Math.pow(.0005,dt));
cam.lookAt(this.look);
// subtle FOV stretch with speed
cam.fov=lerp(cam.fov,62+clamp(speed/46,0,1)*14,dt*3);
cam.updateProjectionMatrix();
}
};
/* ---------------- HUD + Minimap ---------------- */
const HUD = {
els:{}, flashT:0,
init(game){
const id=s=>document.getElementById(s);
this.els={lap:id("lapNow"),best:id("bestLap"),last:id("lastLap"),
time:id("raceTime"),speed:id("speedVal"),gear:id("gearVal"),rpm:id("rpmFill"),
cd:id("countdown"),flash:id("msgFlash"),camMode:id("camMode")};
id("btnResume").onclick=()=>game.togglePause();
id("btnRestartP").onclick=()=>{game.togglePause(true);game.restart();};
id("btnRestartF").onclick=()=>game.restart();
// minimap path precomputed
const c=id("minimap"),g=c.getContext("2d");
this.mmC=c;this.mmG=g;
this.mmPath=TrackBuilder.samples.map(s=>({x:s.p.x,z:s.p.z}));
},
flash(msg){ this.els.flash.textContent=msg;this.els.flash.style.display="block";this.flashT=2; },
showCountdown(txt){ const e=this.els.cd;e.style.display="block";e.textContent=txt; },
hideCountdown(){ this.els.cd.style.display="none"; },
setCam(mode){ this.els.camMode.textContent=mode===0?"CHASE CAM · C to toggle":"CINEMATIC CAM · C to toggle"; },
update(dt,car,race,audioRpm){
const e=this.els;
e.lap.textContent=race.lap;e.best.textContent=fmtTime(race.best);
e.last.textContent=fmtTime(race.lapTimes[race.lapTimes.length-1]||null);
e.time.textContent=fmtTime(race.raceTime);
const kmh=Math.round(Car.vel.length()*3.6);
e.speed.textContent=kmh;
// simulated gear from speed
const gears=[0,20,45,75,110,150,999];let gi=0;
while(kmh>gears[gi+1])gi++;
const lo=gears[gi],hi=gears[gi+1];
const rpm=.25+.75*clamp((kmh-lo)/(hi-lo),0,1);
e.gear.textContent=Car.vel.length()<.5?"N":(Car.lastVF<-0.5?"R":Math.max(1,gi));
e.rpm.style.width=(rpm*100)+"%";
if(this.flashT>0){this.flashT-=dt;if(this.flashT<=0)this.els.flash.style.display="none";}
// minimap
const g=this.mmG,W=this.mmC.width;
g.clearRect(0,0,W,W);
g.fillStyle="rgba(0,0,0,.35)";g.fillRect(0,0,W,W);
const map=(v)=>W/2+v/240*(W/2-8);
g.strokeStyle="#666";g.lineWidth=4;g.lineJoin="round";g.beginPath();
this.mmPath.forEach((p,i)=>{const x=map(p.x),y=map(p.z);i?g.lineTo(x,y):g.moveTo(x,y);});
g.closePath();g.stroke();
g.strokeStyle="#ddd";g.lineWidth=1.5;g.stroke();
// player dot
g.fillStyle="#ffd24a";g.beginPath();
g.arc(map(car.pos.x),map(car.pos.z),4,0,7);g.fill();
}
};
/* ---------------- Main game object ---------------- */
const Game = {
dt:0,lastT:0,countdownT:-1,
init(){
// renderer
this.renderer=new THREE.WebGLRenderer({canvas:document.getElementById("three"),antialias:true});
this.renderer.setSize(innerWidth,innerHeight);
this.renderer.setPixelRatio(Math.min(devicePixelRatio,2));
this.renderer.shadowMap.enabled=true;
this.renderer.shadowMap.type=THREE.PCFSoftShadowMap;
this.scene=new THREE.Scene();
this.scene.background=new THREE.Color(0x87b8e0);
this.scene.fog=new THREE.Fog(0x9cc4e4,120,420);
this.camera=new THREE.PerspectiveCamera(64,innerWidth/innerHeight,.1,600);
// lights
const sun=new THREE.DirectionalLight(0xfff2d8,1.05);
sun.position.set(80,140,40);sun.castShadow=true;
sun.shadow.mapSize.set(2048,2048);
const sc=sun.shadow.camera;sc.left=-160;sc.right=160;sc.top=160;sc.bottom=-160;sc.far=400;
this.scene.add(sun);
this.scene.add(new THREE.AmbientLight(0x8899bb,.55));
this.scene.add(new THREE.HemisphereLight(0xbfdcff,0x556b44,.35));
// ground plane
const ground=new THREE.Mesh(
new THREE.PlaneGeometry(900,900,64,64),
new THREE.MeshLambertMaterial({color:0x5d7a42}));
ground.rotation.x=-Math.PI/2;ground.position.y=-.35;
ground.receiveShadow=true;this.scene.add(ground);
// gentle hills via vertex noise on the ground
const gp=ground.geometry.attributes.position;
for(let i=0;i<gp.count;i++){
const x=gp.getX(i),y=gp.getY(i);
gp.setZ(i,Math.sin(x*.02)*Math.cos(y*.03)*1.2+Math.sin(x*.008+y*.01)*2);
}
gp.needsUpdate=true;ground.geometry.computeVertexNormals();
// world
TrackBuilder.build(this.scene);
Scenery.build(this.scene,TrackBuilder);
Particles.init(this.scene);
const carParts=buildCar();
this.carMesh=carParts.group;this.carWheels=carParts.wheels;
this.scene.add(this.carMesh);
Input.init(this);
HUD.init(this);
Race.init(TrackBuilder);
Car.reset(TrackBuilder);
CameraRig.update(1,this.camera,Car,0);
AudioSynth.init();
addEventListener("resize",()=>this.onResize());
// audio unlock on first interaction
["pointerdown","keydown"].forEach(ev=>addEventListener(ev,()=>AudioSynth.resume(),{once:true}));
this.startCountdown();
requestAnimationFrame(t=>this.loop(t));
},
onResize(){
this.camera.aspect=innerWidth/innerHeight;this.camera.updateProjectionMatrix();
this.renderer.setSize(innerWidth,innerHeight);
},
startCountdown(){
Race.fullRestart();Car.reset(TrackBuilder);
this.countdownT=3.0;this.cdShown=4;
HUD.showCountdown("3");
},
restart(){ if(Race.state==="paused")this.togglePause(true); this.startCountdown(); },
togglePause(forceOff){
if(Race.state==="finished")return;
const menu=document.getElementById("pauseMenu");
if(Race.state==="paused"||forceOff){Race.state="racing";menu.style.display="none";}
else if(Race.state==="racing"){Race.state="paused";menu.style.display="flex";}
},
toggleCam(){ CameraRig.toggle();HUD.setCam(CameraRig.mode); },
finish(){
Race.state="finished";
document.getElementById("finishStats").innerHTML=
"TOTAL TIME <b>"+fmtTime(Race.raceTime)+"</b><br>"+
"BEST LAP <b>"+fmtTime(Race.best)+"</b><br>"+
"LAPS "+Race.lapTimes.map(fmtTime).join(" · ");
document.getElementById("finishScreen").style.display="flex";
AudioSynth.finishTone();
AudioSynth.engine(0,0);AudioSynth.skid(0,false);
},
/** collisions vs scenery obstacle circles */
collide(fx){
for(const o of Scenery.obstacles){
const dx=Car.pos.x-o.x,dz=Car.pos.z-o.z;
const rr=o.r+1.3,d2=dx*dx+dz*dz;
if(d2<rr*rr&&d2>1e-6){
const d=Math.sqrt(d2),nx=dx/d,nz=dz/d;
Car.pos.x=o.x+nx*rr;Car.pos.z=o.z+nz*rr;
const vn=Car.vel.x*nx+Car.vel.z*nz;
if(vn<0){
Car.vel.x-=1.6*vn*nx;Car.vel.z-=1.6*vn*nz; // reflect w/ bounce
Car.vel.multiplyScalar(.45);
const inten=Math.min(.6,-vn/25);
if(inten>.08){AudioSynth.crash(inten);
for(let i=0;i<6;i++)Particles.spawnSpark(Car.pos.clone().add(new THREE.Vector3(nx,0.4,nz)));}
}
}
}
},
loop(t){
requestAnimationFrame(tt=>this.loop(tt));
const dt=Math.min((t-this.lastT)/1000||.016,.05);this.dt=dt;this.lastT=t;
if(Race.state==="countdown"){
this.countdownT-=dt;
const n=Math.ceil(this.countdownT);
if(n!==this.cdShown&&n>=1){this.cdShown=n;HUD.showCountdown(String(n));AudioSynth.beep(440,.18,.3);}
if(this.countdownT<=0){
Race.state="racing";HUD.hideCountdown();
HUD.showCountdown("GO!");setTimeout(()=>HUD.hideCountdown(),700);
AudioSynth.beep(880,.4,.35);
Race.lapStart=0;Race.raceTime=0;
}
}
const fx=Car.update(dt,TrackBuilder) || {speed:0,drift:0,vF:0,vR:0};
if(Race.state==="racing"||Race.state==="finished"){ /* physics idle when finished */ }
if(Race.state==="countdown"||Race.state==="paused"){
// freeze car during countdown/pause
Car.vel.multiplyScalar(Race.state==="paused"?0:1);
} else {
this.collide(fx);
Race.update(Car,this);
}
/* --- visual FX driven by telemetry --- */
const speedN=clamp(Math.abs(fx.vF)/Car.MAXSPEED,0,1);
if(Race.state==="racing"){
// wheels roll & steer visually
this.carMesh.position.copy(Car.pos);
this.carMesh.rotation.y=Car.heading;
this.carMesh.rotation.z=lerp(this.carMesh.rotation.z,clamp(-fx.vR*.02,-.15,.15),dt*5);
this.carMesh.rotation.x=lerp(this.carMesh.rotation.x,clamp(-(Input.throttle? .012:0)+(Input.brake?.018:0),-.03,.03),dt*5);
for(let i=0;i<4;i++){
const w=this.carWheels[i];
w.children.forEach(m=>m.rotation.x=Car.wheelSpin);
if(i<2)w.rotation.y=Car.steerVis*.5;
}
// dust & skids when drifting / off-road
const drifting=fx.drift>4||(Input.handbrake&&speedN>.2)||(!fx.onTrack&&speedN>.25);
if(drifting){
const fwd=new THREE.Vector3(Math.sin(Car.heading),0,Math.cos(Car.heading));
const right=new THREE.Vector3(fwd.z,0,-fwd.x);
for(const s of [-1,1]){
const wp=Car.pos.clone().addScaledVector(fwd,1.4).addScaledVector(right,s*1.05);
Particles.spawnSkid(wp,Car.heading);
if(Math.random()<.5)Particles.spawnDust(wp,rand(.8,1.8));
}
AudioSynth.skid(clamp(fx.drift/10+(fx.onTrack?0:.5),0,1),!fx.onTrack||fx.surface===1);
}else{
AudioSynth.skid(speedN*.15*(fx.onTrack?0:1),!fx.onTrack);
if(!fx.onTrack&&Math.random()<.4)
Particles.spawnDust(Car.pos.clone().addScaledVector(new THREE.Vector3(Math.sin(Car.heading),0,Math.cos(Car.heading)),-1.4),1.4);
}
// engine sound
const kmh=Car.vel.length()*3.6;
const gears=[0,20,45,75,110,150,999];let gi=0;while(kmh>gears[gi+1])gi++;
const rpm=clamp(.25+.75*((kmh-gears[gi])/(gears[gi+1]-gears[gi])),0,1);
AudioSynth.engine(gi===0&&Input.throttle? .5:rpm,(Input.throttle?1:0)*.6+speedN*.4);
}else if(Race.state==="finished"){
AudioSynth.engine(0,0);AudioSynth.skid(0,false);
this.carMesh.position.copy(Car.pos);this.carMesh.rotation.y=Car.heading;
}
Particles.update(dt);
CameraRig.update(dt,this.camera,Car,Car.vel.length());
HUD.update(dt,Car,Race);
this.renderer.render(this.scene,this.camera);
}
};
/* boot */
window.addEventListener("load",()=>Game.init());
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"stealth/ox-alpha","provider":"openrouter","persona":"full","sessionId":"ff85cb80-3a82-47d7-bb63-5e907f468219","tokensIn":390750,"tokensOut":17456,"tokensTotal":408206,"cost":0,"turns":14,"toolCalls":15,"failedToolCalls":0,"timestamp":"2026-08-21T23:13:31.803Z"} -->