Rally
muse spark 1.2 · 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,593 bytes · SHA-256 0dece41d9f7c
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>RALLY FURY — 3D Procedural</title>
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:'Segoe UI',system-ui,Arial,sans-serif}
body{background:#0a0e12;overflow:hidden;color:#fff;user-select:none}
#c{position:fixed;inset:0;width:100%;height:100%;display:block}
#hud{position:fixed;inset:0;pointer-events:none}
.topbar{position:absolute;top:0;left:0;right:0;height:64px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;background:linear-gradient(180deg,rgba(0,0,0,.75),rgba(0,0,0,0));}
.logo{font-weight:900;letter-spacing:2px;font-size:22px;line-height:1}
.logo b{color:#ff3b30}
.logo span{font-weight:600;font-size:11px;letter-spacing:4px;color:#ffd23f;display:block}
.top-center{display:flex;gap:10px;align-items:center}
.pill{background:rgba(0,0,0,.6);border:1px solid rgba(255,255,255,.15);backdrop-filter:blur(8px);border-radius:999px;padding:7px 14px;display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700}
.pill b{color:#ffd23f;font-size:16px}
.pill small{opacity:.7;font-weight:600}
.btn{pointer-events:auto;cursor:pointer;background:#ff3b30;color:#fff;border:none;padding:9px 16px;border-radius:8px;font-weight:800;letter-spacing:.5px;box-shadow:0 4px 14px rgba(255,59,48,.4)}
.btn:active{transform:scale(.97)}
.bottom{position:absolute;bottom:0;left:0;right:0;padding:14px 16px;display:flex;justify-content:space-between;align-items:end;gap:14px;background:linear-gradient(0deg,rgba(0,0,0,.75),transparent)}
.left-hud{display:flex;gap:14px;align-items:end}
.speedo{width:150px;background:rgba(10,14,18,.85);border:1px solid rgba(255,255,255,.12);border-radius:18px;padding:12px 14px;backdrop-filter:blur(10px)}
.speedo .label{font-size:10px;letter-spacing:2px;opacity:.6;font-weight:800}
.speedo .speed{font-size:42px;font-weight:900;line-height:1;letter-spacing:-2px}
.speedo .speed i{font-style:normal;font-size:14px;opacity:.6;letter-spacing:0}
.bar{height:6px;background:rgba(255,255,255,.15);border-radius:999px;overflow:hidden;margin-top:8px}
.bar>div{height:100%;background:linear-gradient(90deg,#ff3b30,#ffd23f);width:0%;transition:width .1s}
.gear{font-size:11px;font-weight:800;margin-top:6px;letter-spacing:1px}
.gear b{color:#ffd23f}
.lapbox{background:rgba(10,14,18,.85);border:1px solid rgba(255,255,255,.12);border-radius:18px;padding:12px 14px;min-width:180px;backdrop-filter:blur(10px)}
.lapbox .row{display:flex;justify-content:space-between;font-size:11px;font-weight:700;opacity:.9;margin:3px 0}
.lapbox .row b{color:#fff}
.lapbox .big{font-size:18px;font-weight:900;letter-spacing:-.5px}
#minimap{width:170px;height:170px;background:rgba(10,14,18,.92);border:1px solid rgba(255,255,255,.18);border-radius:16px;overflow:hidden;position:relative;box-shadow:0 10px 30px rgba(0,0,0,.5)}
#minimap canvas{width:100%;height:100%;display:block}
#minimap .m-label{position:absolute;top:8px;left:8px;font-size:9px;letter-spacing:2px;font-weight:900;opacity:.7}
.controls-hint{position:absolute;left:50%;top:72px;transform:translateX(-50%);background:rgba(0,0,0,.55);border:1px solid rgba(255,255,255,.12);padding:6px 12px;border-radius:999px;font-size:11px;font-weight:600;display:flex;gap:12px}
.controls-hint kbd{background:rgba(255,255,255,.15);padding:2px 6px;border-radius:4px;font-size:10px}
.center{position:absolute;inset:0;display:grid;place-items:center;background:rgba(6,10,14,.78);backdrop-filter:blur(6px);pointer-events:auto}
.card{background:#11161c;border:1px solid rgba(255,255,255,.12);border-radius:20px;padding:22px;width:min(520px,92vw);text-align:center;box-shadow:0 20px 60px rgba(0,0,0,.6)}
.card h1{font-size:32px;font-weight:950;letter-spacing:-1px}
.card h1 i{color:#ff3b30;font-style:normal}
.card p{opacity:.7;font-size:13px;line-height:1.5;margin:10px 0 16px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:12px 0}
.stat{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:10px}
.stat b{display:block;font-size:18px}
.stat span{font-size:10px;letter-spacing:1px;opacity:.6;font-weight:800}
.rowBtns{display:flex;gap:10px;justify-content:center;margin-top:14px}
.btn-ghost{background:transparent;border:1px solid rgba(255,255,255,.2);color:#fff}
.countdown{position:absolute;inset:0;display:grid;place-items:center;pointer-events:none}
.countdown span{font-size:140px;font-weight:950;line-height:1;text-shadow:0 10px 40px rgba(0,0,0,.8);transform:scale(.8);opacity:0;transition:.2s}
.countdown.show span{transform:scale(1);opacity:1}
#touch{position:fixed;inset:0;pointer-events:none;display:none}
@media(max-width:800px){#touch{display:block}.controls-hint{display:none}.speedo{width:120px}.speedo .speed{font-size:32px}#minimap{width:130px;height:130px}}
.touch-group{position:absolute;bottom:18px;display:flex;gap:10px;pointer-events:auto}
.touch-group.left{left:14px}
.touch-group.right{right:14px;flex-direction:column}
.tbtn{width:56px;height:56px;border-radius:50%;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);backdrop-filter:blur(8px);color:#fff;font-weight:900;display:grid;place-items:center;touch-action:none}
.tbtn:active{background:#ff3b30}
.tbtn.wide{width:84px;border-radius:28px;font-size:12px;letter-spacing:1px}
#toast{position:absolute;top:80px;left:50%;transform:translateX(-50%);background:#ffd23f;color:#111;padding:8px 14px;border-radius:999px;font-weight:900;font-size:12px;letter-spacing:1px;display:none}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud">
<div class="topbar">
<div class="logo"><div>RALLY <b>FURY</b></div><span>DIRT • GRAVEL • GLORY</span></div>
<div class="top-center">
<div class="pill"><small>LAP</small> <b id="hudLap">1 / 3</b></div>
<div class="pill"><small>TIME</small> <b id="hudTime">00:00.00</b></div>
<div class="pill"><small>BEST</small> <b id="hudBest">--:--.--</b></div>
</div>
<button class="btn" id="pauseBtn" style="pointer-events:auto">⏸ PAUSE [P]</button>
</div>
<div class="controls-hint"><span><kbd>W</kbd>/<kbd>↑</kbd> Gas</span><span><kbd>S</kbd>/<kbd>↓</kbd> Brake</span><span><kbd>A</kbd><kbd>D</kbd> Steer</span><span><kbd>SPACE</kbd> Handbrake</span><span><kbd>C</kbd> Camera</span><span><kbd>R</kbd> Restart</span></div>
<div id="toast">CHECKPOINT MISSED!</div>
<div class="bottom">
<div class="left-hud">
<div class="speedo">
<div class="label">SPEED & RPM</div>
<div class="speed"><span id="hudSpeed">0</span> <i>km/h</i></div>
<div class="bar"><div id="rpmBar"></div></div>
<div class="gear">GEAR <b id="hudGear">1</b> • <span id="hudSurface" style="opacity:.6">ASPHALT</span></div>
</div>
<div class="lapbox">
<div class="row"><span>Current Lap</span><b id="lapTime">00:00.00</b></div>
<div class="row"><span>Last Lap</span><b id="lastLap">--:--.--</b></div>
<div class="row" style="margin-top:6px;border-top:1px solid rgba(255,255,255,.1);padding-top:6px"><span class="big">TOTAL</span><b class="big" id="totalTime">00:00.00</b></div>
</div>
</div>
<div id="minimap"><div class="m-label">TRACK MAP</div><canvas id="mini" width="340" height="340"></canvas></div>
</div>
<div class="countdown" id="countdown"><span id="cdText">3</span></div>
</div>
<div id="touch">
<div class="touch-group left">
<div class="tbtn" id="tLeft">◀</div>
<div class="tbtn" id="tRight">▶</div>
</div>
<div class="touch-group right">
<div class="tbtn wide" id="tGas">GAS ▲</div>
<div style="display:flex;gap:10px">
<div class="tbtn" id="tBrake">▼</div>
<div class="tbtn" style="background:rgba(255,59,48,.85)" id="tHand">HB</div>
</div>
</div>
</div>
<!-- START SCREEN -->
<div class="center" id="startScreen">
<div class="card">
<h1>RALLY <i>FURY</i></h1>
<p>Procedural low-poly rally. Stay on track, hit every checkpoint, chase your best lap. Asphalt is fast — grass, gravel & mud will kill your grip. Use handbrake to drift hairpins!</p>
<div class="grid2">
<div class="stat"><b>3 LAPS</b><span>CLOSED CIRCUIT</span></div>
<div class="stat"><b>~1.4 KM</b><span>DIRT & ASPHALT</span></div>
<div class="stat"><b>DRIFT</b><span>HOLD SPACE</span></div>
<div class="stat"><b>FOG + SHADOWS</b><span>60 FPS TARGET</span></div>
</div>
<div class="rowBtns">
<button class="btn" id="playBtn" style="flex:1">▶ START ENGINE</button>
<button class="btn btn-ghost" id="camBtn">CAM: CHASE</button>
</div>
<p style="font-size:11px;margin-top:12px;opacity:.5">No images, no samples — all geometry & audio synthesized live.</p>
</div>
</div>
<!-- PAUSE -->
<div class="center" id="pauseScreen" style="display:none">
<div class="card">
<h1>PAUSED</h1>
<p>Take a breather. Rally waits for no one.</p>
<div class="rowBtns">
<button class="btn" id="resumeBtn">RESUME</button>
<button class="btn btn-ghost" id="restartBtn">RESTART [R]</button>
</div>
</div>
</div>
<!-- FINISH -->
<div class="center" id="finishScreen" style="display:none">
<div class="card" style="width:min(560px,94vw)">
<h1>FINISHED! 🏁</h1>
<div class="grid2">
<div class="stat"><b id="finTotal">00:00.00</b><span>TOTAL TIME</span></div>
<div class="stat"><b id="finBest">00:00.00</b><span>BEST LAP</span></div>
</div>
<div id="lapList" style="text-align:left;background:rgba(255,255,255,.06);border-radius:12px;padding:12px;margin-top:12px;font-size:13px;line-height:1.6"></div>
<div class="rowBtns">
<button class="btn" id="againBtn">RACE AGAIN</button>
<button class="btn btn-ghost" id="shareBtn">COPY TIME</button>
</div>
</div>
</div>
<script>
/* ========= RALLY FURY - Single file procedural ========= */
const T = THREE;
let renderer, scene, camera, ambient, sun, fog;
const canvas = document.getElementById('c');
let W = innerWidth, H = innerHeight;
// ——— Game State ———
const STATE = {
running:false, paused:false, countdown:false, finished:false,
lap:1, totalLaps:3,
lapTime:0, totalTime:0, bestLap:null, lastLap:null, lapTimes:[],
checkpointsPassed:0, nextCp:0,
t:0.02, // track param 0-1
surface:'ASPHALT',
camMode:'chase' // chase | cinematic
};
let input = {f:0,b:0,steer:0,hand:false};
// ——— Audio ———
let audioCtx, engineOsc, engineGain, engineFilter;
function initAudio(){
if(audioCtx) return;
audioCtx = new (window.AudioContext||window.webkitAudioContext)();
engineOsc = audioCtx.createOscillator();
engineGain = audioCtx.createGain();
engineFilter = audioCtx.createBiquadFilter();
engineFilter.type='lowpass'; engineFilter.frequency.value=900;
engineOsc.type='sawtooth'; engineOsc.frequency.value=60;
engineGain.gain.value=0;
engineOsc.connect(engineFilter).connect(engineGain).connect(audioCtx.destination);
engineOsc.start();
}
function beep(freq,dur,vol=0.2){
if(!audioCtx) return;
const o=audioCtx.createOscillator(), g=audioCtx.createGain();
o.type='square'; o.frequency.value=freq; g.gain.value=vol;
o.connect(g).connect(audioCtx.destination); o.start();
g.gain.exponentialRampToValueAtTime(0.0001,audioCtx.currentTime+dur);
o.stop(audioCtx.currentTime+dur);
}
function crashSound(){
if(!audioCtx) return;
const o=audioCtx.createOscillator(), g=audioCtx.createGain(), f=audioCtx.createBiquadFilter();
f.type='bandpass'; f.frequency.value=600;
o.type='sawtooth'; o.frequency.setValueAtTime(300,audioCtx.currentTime);
o.frequency.exponentialRampToValueAtTime(40,audioCtx.currentTime+0.25);
g.gain.setValueAtTime(0.4,audioCtx.currentTime); g.gain.exponentialRampToValueAtTime(0.01,audioCtx.currentTime+0.3);
o.connect(f).connect(g).connect(audioCtx.destination); o.start(); o.stop(audioCtx.currentTime+0.3);
}
// ——— Scene Setup ———
function init3D(){
renderer = new T.WebGLRenderer({canvas, antialias:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.setSize(W,H);
renderer.shadowMap.enabled=true; renderer.shadowMap.type=T.PCFSoftShadowMap;
scene = new T.Scene();
scene.background = new T.Color(0x87c4e8);
fog = new T.Fog(0xd6e8f5, 180, 620);
scene.fog = fog;
camera = new T.PerspectiveCamera(72, W/H, 0.1, 1200);
ambient = new T.HemisphereLight(0xffffff, 0x3a5a2a, 0.9);
scene.add(ambient);
sun = new T.DirectionalLight(0xfff3d0, 1.2);
sun.position.set(120,180,60);
sun.castShadow=true;
sun.shadow.mapSize.set(2048,2048);
sun.shadow.camera.near=10; sun.shadow.camera.far=600;
sun.shadow.camera.left=-200; sun.shadow.camera.right=200; sun.shadow.camera.top=200; sun.shadow.camera.bottom=-200;
scene.add(sun);
// sky dome
const skyGeo = new T.SphereGeometry(800,32,32);
const skyMat = new T.MeshBasicMaterial({color:0x87c4e8, side:T.BackSide});
const sky = new T.Mesh(skyGeo, skyMat);
scene.add(sky);
// distant mountains as ring
const mtnGeo = new T.ConeGeometry(30,80,5);
const mtnMat = new T.MeshLambertMaterial({color:0x5a7a8a});
for(let i=0;i<28;i++){
const a=i/28*Math.PI*2;
const r=520+Math.random()*40;
const m=new T.Mesh(mtnGeo,mtnMat);
m.position.set(Math.cos(a)*r, -10+Math.random()*10, Math.sin(a)*r);
m.scale.set(1+Math.random(),1,1+Math.random());
scene.add(m);
}
}
init3D();
// ——— Track ———
const trackPoints = [
new T.Vector3(0,0,0),
new T.Vector3(110,6, -40),
new T.Vector3(190,10, 30),
new T.Vector3(210,14, 120),
new T.Vector3(150,9, 200),
new T.Vector3(40,3, 210),
new T.Vector3(-80,1, 150),
new T.Vector3(-140,4, 40),
new T.Vector3(-110,8, -80),
new T.Vector3(-20,5, -140),
];
const curve = new T.CatmullRomCurve3(trackPoints,true,'catmullrom',0.25);
curve.closed=true;
const TRACK_SEG = 400;
const TRACK_W = 18; // width
const pts = curve.getPoints(TRACK_SEG);
const curveLengths = curve.getLengths(TRACK_SEG);
// precompute centerline and normals
let trackData=[]; // {p, tangent, left, right, elev}
for(let i=0;i<=TRACK_SEG;i++){
const t=i/TRACK_SEG;
const p=curve.getPointAt(t);
const tang=curve.getTangentAt(t).normalize();
const up=new T.Vector3(0,1,0);
const left=new T.Vector3().crossVectors(up,tang).normalize(); // left dir
// hills: elevation already in points, add micro dips
const elev = p.y + Math.sin(t*Math.PI*6)*1.2;
p.y=elev;
trackData.push({p:p.clone(), tang, left, t});
}
// Build track mesh (ribbon)
function buildTrackMesh(){
const geo = new T.BufferGeometry();
const verts=[], uvs=[], indices=[];
const roadMatCount = TRACK_SEG;
for(let i=0;i<=TRACK_SEG;i++){
const d=trackData[i%TRACK_SEG];
const L = d.p.clone().addScaledVector(d.left, TRACK_W/2);
const R = d.p.clone().addScaledVector(d.left, -TRACK_W/2);
// slightly raise road to avoid z-fighting
L.y+=0.3; R.y+=0.3;
verts.push(L.x,L.y,L.z, R.x,R.y,R.z);
uvs.push(0,i/TRACK_SEG*20, 1,i/TRACK_SEG*20);
}
for(let i=0;i<TRACK_SEG;i++){
const a=i*2, b=a+1, c=a+2, d=a+3;
indices.push(a,b,c, b,d,c);
}
geo.setAttribute('position', new T.Float32BufferAttribute(verts,3));
geo.setAttribute('uv', new T.Float32BufferAttribute(uvs,2));
geo.setIndex(indices);
geo.computeVertexNormals();
const mat = new T.MeshLambertMaterial({color:0x3b3a38}); // dirt asphalt
const mesh = new T.Mesh(geo, mat);
mesh.receiveShadow=true;
scene.add(mesh);
// shoulders (gravel)
const sGeo=new T.BufferGeometry();
const sVerts=[], sIdx=[];
const shoulderW=6;
for(let i=0;i<=TRACK_SEG;i++){
const d=trackData[i%TRACK_SEG];
const L1=d.p.clone().addScaledVector(d.left, TRACK_W/2);
const L2=d.p.clone().addScaledVector(d.left, TRACK_W/2+shoulderW);
const R1=d.p.clone().addScaledVector(d.left, -TRACK_W/2);
const R2=d.p.clone().addScaledVector(d.left, -TRACK_W/2-shoulderW);
L1.y+=0.15; L2.y+=0.12; R1.y+=0.15; R2.y+=0.12;
sVerts.push(L1.x,L1.y,L1.z, L2.x,L2.y,L2.z, R1.x,R1.y,R1.z, R2.x,R2.y,R2.z);
}
for(let i=0;i<TRACK_SEG;i++){
const o=i*4;
// left
sIdx.push(o,o+1,o+4, o+1,o+5,o+4);
// right
sIdx.push(o+2,o+6,o+3, o+3,o+6,o+7);
}
sGeo.setAttribute('position', new T.Float32BufferAttribute(sVerts,3));
sGeo.setIndex(sIdx); sGeo.computeVertexNormals();
const sMat=new T.MeshLambertMaterial({color:0x9a8c7a});
const sMesh=new T.Mesh(sGeo,sMat); sMesh.receiveShadow=true; scene.add(sMesh);
// center dashed line
for(let i=0;i<TRACK_SEG;i+=8){
const d=trackData[i];
if(i%16===0){
const box=new T.BoxGeometry(1.2,0.08,4);
const m=new T.Mesh(box,new T.MeshLambertMaterial({color:0xffffff}));
m.position.copy(d.p); m.position.y+=0.4;
// orient along tangent
const ang=Math.atan2(d.tang.x,d.tang.z);
m.rotation.y=ang;
scene.add(m);
}
}
// ground plane huge
const gGeo=new T.PlaneGeometry(1200,1200,20,20);
const gMat=new T.MeshLambertMaterial({color:0x6a9a4a});
const ground=new T.Mesh(gGeo,gMat);
ground.rotation.x=-Math.PI/2; ground.position.y=-1.5;
ground.receiveShadow=true; scene.add(ground);
// checkpoint gates invisible debug (we show banners)
}
buildTrackMesh();
// ——— Scenery ———
const obstacles=[];
function addTree(pos,scale=1){
const grp=new T.Group();
const trunk=new T.Mesh(new T.CylinderGeometry(0.6*scale,0.9*scale,5*scale,6), new T.MeshLambertMaterial({color:0x6b4a2b}));
trunk.position.y=2.5*scale; trunk.castShadow=true;
const cone1=new T.Mesh(new T.ConeGeometry(4*scale,7*scale,6), new T.MeshLambertMaterial({color:0x1e4a22}));
cone1.position.y=7*scale; cone1.castShadow=true;
const cone2=cone1.clone(); cone2.scale.set(0.8,0.8,0.8); cone2.position.y=10*scale;
grp.add(trunk,cone1,cone2);
grp.position.copy(pos); scene.add(grp);
obstacles.push({pos:grp.position.clone(), r:3.2*scale, type:'tree', mesh:grp});
}
function addRock(pos){
const geo=new T.DodecahedronGeometry(1.8+Math.random()*1.2,0);
const mat=new T.MeshLambertMaterial({color:0x8a8d93});
const m=new T.Mesh(geo,mat); m.position.copy(pos); m.position.y+=0.8; m.rotation.set(Math.random(),Math.random(),Math.random());
m.castShadow=true; scene.add(m);
obstacles.push({pos:m.position.clone(), r:2.2, type:'rock', mesh:m});
}
function addBanner(pos, tang){
const poleGeo=new T.CylinderGeometry(0.15,0.15,5,6);
const poleMat=new T.MeshLambertMaterial({color:0xdddddd});
const p1=new T.Mesh(poleGeo,poleMat); const p2=p1.clone();
p1.position.set(-4,2.5,0); p2.position.set(4,2.5,0);
const bannerGeo=new T.PlaneGeometry(8,2);
const bannerMat=new T.MeshLambertMaterial({color: i=>0xff3b30, side:T.DoubleSide});
// create canvas texture for banner text would be overkill — keep color blocks
const colors=[0xff3b30,0xffd23f,0xffffff];
const banner=new T.Mesh(bannerGeo,new T.MeshLambertMaterial({color:colors[Math.floor(Math.random()*3)], side:T.DoubleSide}));
banner.position.y=4;
const g=new T.Group(); g.add(p1,p2,banner);
g.position.copy(pos); g.position.y+=0.2;
const ang=Math.atan2(tang.x,tang.z); g.rotation.y=ang;
scene.add(g);
}
function addBarrier(pos, tang){
const geo=new T.BoxGeometry(6,1.2,0.6);
const mat=new T.MeshLambertMaterial({color:0xd0d0d0});
const m=new T.Mesh(geo,mat); m.position.copy(pos); m.position.y+=0.6;
m.rotation.y=Math.atan2(tang.x,tang.z); m.castShadow=true; scene.add(m);
obstacles.push({pos:m.position.clone(), r:3.5, type:'barrier', mesh:m});
}
// scatter
for(let i=0;i<TRACK_SEG;i+=6){
const d=trackData[i];
const off = (Math.random()>0.5?1:-1)*(TRACK_W/2+8+Math.random()*40);
const p=d.p.clone().addScaledVector(d.left, off);
p.y=-1.2;
if(Math.random()<0.6) addTree(p, 0.8+Math.random()*0.7);
else if(Math.random()<0.5) addRock(p);
else addTree(p,0.6);
// occasional roadside objects
if(i%42===0){
const side = TRACK_W/2+2;
const bpos=d.p.clone().addScaledVector(d.left, side);
bpos.y=0; addBarrier(bpos,d.tang);
const bpos2=d.p.clone().addScaledVector(d.left, -side);
bpos2.y=0; addBarrier(bpos2,d.tang);
}
if(i%70===0){
const bp=d.p.clone().addScaledVector(d.left, TRACK_W/2+4);
bp.y=0; addBanner(bp,d.tang);
}
}
// ——— Car ———
const car = new T.Group();
scene.add(car);
const bodyGeo=new T.BoxGeometry(4.2,1.1,7.2);
const bodyMat=new T.MeshPhongMaterial({color:0xff2b2b, shininess:80});
const body=new T.Mesh(bodyGeo,bodyMat); body.position.y=1.2; body.castShadow=true; car.add(body);
const cabinGeo=new T.BoxGeometry(3.6,0.9,3.2);
const cabinMat=new T.MeshPhongMaterial({color:0x1a1f26});
const cabin=new T.Mesh(cabinGeo,cabinMat); cabin.position.set(0,1.95,-0.4); cabin.castShadow=true; car.add(cabin);
const spoilerGeo=new T.BoxGeometry(4.6,0.2,0.8);
const spoiler=new T.Mesh(spoilerGeo,new T.MeshPhongMaterial({color:0x111111})); spoiler.position.set(0,1.6,-3.2); car.add(spoiler);
const wheelGeo=new T.CylinderGeometry(0.7,0.7,0.9,12);
const wheelMat=new T.MeshLambertMaterial({color:0x111111});
const wheels=[];
[[-2,0.55,2.2],[2,0.55,2.2],[-2,0.55,-2.2],[2,0.55,-2.2]].forEach(p=>{
const w=new T.Mesh(wheelGeo,wheelMat); w.rotation.z=Math.PI/2; w.position.set(p[0],p[1],p[2]); w.castShadow=true; car.add(w); wheels.push(w);
});
const headL=new T.Mesh(new T.SphereGeometry(0.35,8,8), new T.MeshBasicMaterial({color:0xffffcc}));
headL.position.set(-1.2,1.0,3.6); car.add(headL);
const headR=headL.clone(); headR.position.x=1.2; car.add(headR);
const tailL=new T.Mesh(new T.SphereGeometry(0.3,8,8), new T.MeshBasicMaterial({color:0xff1111}));
tailL.position.set(-1.3,1.0,-3.6); car.add(tailL);
const tailR=tailL.clone(); tailR.position.x=1.3; car.add(tailR);
// car physics vars
let carPos = trackData[0].p.clone(); carPos.y+=0.6;
let carYaw = Math.atan2(trackData[1].p.x - trackData[0].p.x, trackData[1].p.z - trackData[0].p.z);
let carSpeed=0, carVel = new T.Vector2(0,0); // x,z velocity
let drift=0, skidTime=0;
car.position.copy(carPos); car.rotation.y=carYaw;
// skid marks
const skidGroup=new T.Group(); scene.add(skidGroup);
function addSkid(pos, yaw){
const g=new T.PlaneGeometry(0.7,1.6);
const m=new T.Mesh(g,new T.MeshBasicMaterial({color:0x222222, transparent:true, opacity:0.65, side:T.DoubleSide}));
m.rotation.x=-Math.PI/2; m.rotation.z=yaw; m.position.copy(pos); m.position.y=0.05; scene.add(m);
setTimeout(()=>{m.material.opacity-=0.1; if(m.material.opacity<=0) scene.remove(m)}, 1800);
}
// dust particles
const dust=[]; const dustGeo=new T.SphereGeometry(0.25,6,6); const dustMat=new T.MeshBasicMaterial({color:0xc9b896, transparent:true, opacity:0.7});
function spawnDust(){
const m=new T.Mesh(dustGeo,dustMat.clone()); m.position.copy(carPos); m.position.y+=0.4; m.position.x+=(Math.random()-0.5)*2; m.position.z+=(Math.random()-0.5)*2;
m.userData={v:new T.Vector3((Math.random()-0.5)*2,1+Math.random()*2,(Math.random()-0.5)*2), life:0.6};
scene.add(m); dust.push(m);
}
// ——— Minimap ———
const miniCanvas=document.getElementById('mini'), miniCtx=miniCanvas.getContext('2d');
function drawMini(){
const w=340,h=340, pad=18;
miniCtx.clearRect(0,0,w,h);
// compute bounds
let minX=Infinity,maxX=-Infinity,minZ=Infinity,maxZ=-Infinity;
trackData.forEach(d=>{minX=Math.min(minX,d.p.x); maxX=Math.max(maxX,d.p.x); minZ=Math.min(minZ,d.p.z); maxZ=Math.max(maxZ,d.p.z);});
const sx=(w-pad*2)/(maxX-minX), sz=(h-pad*2)/(maxZ-minZ), s=Math.min(sx,sz);
const ox=(w-(maxX-minX)*s)/2 - minX*s, oz=(h-(maxZ-minZ)*s)/2 - minZ*s;
const X=x=> x*s+ox, Z=z=> z*s+oz;
// road
miniCtx.lineWidth=14; miniCtx.strokeStyle='#2a2a2a'; miniCtx.lineCap='round'; miniCtx.lineJoin='round';
miniCtx.beginPath();
trackData.forEach((d,i)=>{ if(i===0) miniCtx.moveTo(X(d.p.x),Z(d.p.z)); else miniCtx.lineTo(X(d.p.x),Z(d.p.z));});
miniCtx.closePath(); miniCtx.stroke();
miniCtx.lineWidth=6; miniCtx.strokeStyle='#4a4a4a'; miniCtx.stroke();
// center line dashes
miniCtx.setLineDash([4,6]); miniCtx.strokeStyle='rgba(255,255,255,.5)'; miniCtx.lineWidth=1.2; miniCtx.stroke(); miniCtx.setLineDash([]);
// start/finish
const sp=trackData[0].p; miniCtx.fillStyle='#ffd23f'; miniCtx.fillRect(X(sp.x)-6,Z(sp.z)-2,12,4);
// checkpoints
const CP=12; for(let i=0;i<CP;i++){ const idx=Math.floor(i/CP*TRACK_SEG); const p=trackData[idx].p; miniCtx.fillStyle= i < STATE.nextCp ? 'rgba(255,255,255,.25)' : '#ff3b30'; miniCtx.beginPath(); miniCtx.arc(X(p.x),Z(p.z),3,0,Math.PI*2); miniCtx.fill();}
// player
miniCtx.fillStyle='#ff3b30'; miniCtx.strokeStyle='#fff'; miniCtx.lineWidth=2;
miniCtx.beginPath(); miniCtx.arc(X(carPos.x),Z(carPos.z),6,0,Math.PI*2); miniCtx.fill(); miniCtx.stroke();
// heading
miniCtx.strokeStyle='#ff3b30'; miniCtx.lineWidth=2; miniCtx.beginPath(); miniCtx.moveTo(X(carPos.x),Z(carPos.z));
miniCtx.lineTo(X(carPos.x+Math.sin(carYaw)*10), Z(carPos.z+Math.cos(carYaw)*10)); miniCtx.stroke();
}
// ——— Utils ———
function closestTrackParam(pos){
let best=0, bestD=Infinity, bestIdx=0;
for(let i=0;i<trackData.length;i++){
const d=trackData[i].p.distanceTo(pos);
if(d<bestD){bestD=d; best=i/trackData.length; bestIdx=i;}
}
// refine between neighbors
return {t:best, dist:bestD, idx:bestIdx, data:trackData[bestIdx]};
}
function formatTime(s){
if(s==null) return '--:--.--';
const m=Math.floor(s/60), sec=s%60;
return `${String(m).padStart(2,'0')}:${sec.toFixed(2).padStart(5,'0')}`;
}
// ——— Input ———
const keys={};
addEventListener('keydown',e=>{
keys[e.key.toLowerCase()]=true;
if(e.key==='p'||e.key==='P') togglePause();
if(e.key==='r'||e.key==='R') restartRace();
if(e.key==='c'||e.key==='C'){ STATE.camMode = STATE.camMode==='chase'?'cinematic':'chase'; document.getElementById('camBtn').textContent='CAM: '+STATE.camMode.toUpperCase(); beep(700,0.08,0.12);}
if(e.code==='Space') e.preventDefault();
});
addEventListener('keyup',e=> keys[e.key.toLowerCase()]=false);
function pollInput(){
const left = keys['arrowleft']||keys['a'], right=keys['arrowright']||keys['d'];
const up = keys['arrowup']||keys['w'], down=keys['arrowsdown']||keys['s']||keys['arrowdown'];
input.steer = (right?1:0)+(left?-1:0);
input.f = up?1:0;
input.b = down?1:0;
input.hand = keys[' ']||false;
// touch overrides
if(touchState.left) input.steer=-1; else if(touchState.right) input.steer=1;
if(touchState.gas) input.f=1;
if(touchState.brake) input.b=1;
if(touchState.hand) input.hand=true;
}
const touchState={left:false,right:false,gas:false,brake:false,hand:false};
function bindTouch(id,key){
const el=document.getElementById(id);
el.addEventListener('touchstart',e=>{e.preventDefault(); touchState[key]=true;});
el.addEventListener('touchend',e=>{e.preventDefault(); touchState[key]=false;});
el.addEventListener('mousedown',()=>touchState[key]=true);
el.addEventListener('mouseup',()=>touchState[key]=false);
el.addEventListener('mouseleave',()=>touchState[key]=false);
}
bindTouch('tLeft','left'); bindTouch('tRight','right'); bindTouch('tGas','gas'); bindTouch('tBrake','brake'); bindTouch('tHand','hand');
// ——— Physics ———
let prevClosestIdx=0;
function updatePhysics(dt){
if(!STATE.running || STATE.paused || STATE.finished) return;
pollInput();
const accelInput = input.f - input.b*0.7;
const steerInput = input.steer;
// surface detection
const ct = closestTrackParam(carPos);
const lateralDist = ct.dist;
const onTrack = lateralDist < TRACK_W/2;
const onShoulder = lateralDist < TRACK_W/2+5;
let grip=1, maxSpd=78, surf='ASPHALT';
if(!onTrack){
if(onShoulder){ grip=0.55; maxSpd=48; surf='GRAVEL';}
else { grip=0.32; maxSpd=30; surf='MUD';}
} else {
// vary asphalt/dirt patches
if(Math.sin(ct.t*Math.PI*8)>0.6){ grip=0.85; maxSpd=68; surf='DIRT';}
}
STATE.surface=surf;
// engine & drag
const enginePower = 62 * (input.hand?0.7:1);
let targetAccel = accelInput * enginePower;
// reverse
if(input.b && carSpeed<5) targetAccel = -28;
// drag & rolling
const drag = 0.04 + (onTrack?0.01:0.08);
const friction = onTrack? 2.2 : 5.5;
// steering — speed dependent
const speedFactor = Math.min(1, Math.abs(carSpeed)/50);
const steerEff = (1 - speedFactor*0.55) * (input.hand?1.6:1);
const steerAngle = steerInput * 1.05 * steerEff; // radians per sec factor
// drifting
let isDrifting = Math.abs(steerInput)>0.5 && Math.abs(carSpeed)>18 && ( !onTrack || input.hand);
if(isDrifting){ grip*=0.55; drift = Math.max(drift, Math.abs(steerInput)); } else drift*=0.93;
// integrate yaw
const yawRate = steerAngle * (Math.abs(carSpeed)/12 + 0.6) * grip;
carYaw += yawRate * dt;
// forward vector
const fwdX = Math.sin(carYaw), fwdZ=Math.cos(carYaw);
// accelerate along forward
let speedAlong = carVel.dot(new T.Vector2(fwdX,fwdZ));
speedAlong += targetAccel * dt;
// apply drag
speedAlong -= speedAlong * drag * dt * 6;
// friction braking when no input
if(accelInput===0) speedAlong -= Math.sign(speedAlong)* friction * dt;
// clamp
if(surf==='MUD') speedAlong = Math.max(-18, Math.min(maxSpd, speedAlong));
else speedAlong = Math.max(-20, Math.min(maxSpd, speedAlong));
// lateral slip
const velMag = carVel.length();
const lateral = new T.Vector2(-fwdZ, fwdX); // left
const latSpeed = carVel.dot(lateral);
const latDamp = isDrifting? 2.2 : 7.5 * grip;
const newLat = latSpeed - latSpeed * latDamp * dt;
// recompose velocity
const fwdVel = new T.Vector2(fwdX,fwdZ).multiplyScalar(speedAlong);
const latVel = lateral.clone().multiplyScalar(newLat);
carVel.copy(fwdVel).add(latVel);
// position integrate
carPos.x += carVel.x * dt;
carPos.z += carVel.y * dt;
// follow elevation
const ct2 = closestTrackParam(carPos);
const desiredY = ct2.data.p.y + 0.65;
carPos.y += (desiredY - carPos.y)* 6 * dt;
carSpeed = carVel.length() * 3.2; // scale to km/h feel
skidTime = isDrifting? skidTime+dt : 0;
// collisions
for(const o of obstacles){
const d = Math.hypot(carPos.x - o.pos.x, carPos.z - o.pos.z);
if(d < o.r+2.0){
const nx=(carPos.x - o.pos.x)/d, nz=(carPos.z - o.pos.z)/d;
carPos.x = o.pos.x + nx*(o.r+2.1);
carPos.z = o.pos.z + nz*(o.r+2.1);
// bounce
const dot = carVel.x*nx + carVel.y*nz;
carVel.x -= 1.7*dot*nx;
carVel.y -= 1.7*dot*nz;
carVel.multiplyScalar(0.55);
crashSound();
// spark particle quickly
spawnDust(); spawnDust();
// flash
document.getElementById('toast').textContent='CRASH!';
document.getElementById('toast').style.display='block';
setTimeout(()=>document.getElementById('toast').style.display='none',400);
}
}
// checkpoint / lap logic - 12 checkpoints
const CP_COUNT=12;
const curCpT = STATE.nextCp / CP_COUNT;
// compute track t nearest
const t = ct2.t;
// distance along track circular
let diff = t - curCpT;
if(diff < -0.5) diff+=1;
if(diff > 0.5) diff-=1;
if(Math.abs(diff) < 0.06 && ct2.dist < TRACK_W){ // passed gate
STATE.nextCp++;
if(STATE.nextCp >= CP_COUNT){
// waiting for lap line
}
beep(900,0.06,0.12);
}
// lap crossing at t ~0
const prevT = prevClosestIdx / trackData.length;
// detect wrap
if(prevT > 0.8 && t < 0.2){ // crossed start line forward
if(STATE.nextCp >= CP_COUNT){
// valid lap
const lapTime = STATE.lapTime;
STATE.lapTimes.push(lapTime);
STATE.lastLap = lapTime;
if(STATE.bestLap===null || lapTime < STATE.bestLap) STATE.bestLap = lapTime;
STATE.lap++;
STATE.lapTime=0;
STATE.nextCp=0;
if(STATE.lap > STATE.totalLaps){
finishRace();
} else {
beep(1200,0.18,0.22);
}
} else {
// cheat - show warning
const toast=document.getElementById('toast');
toast.textContent='MISSED CHECKPOINT!'; toast.style.display='block';
setTimeout(()=>toast.style.display='none',900);
beep(200,0.25,0.15);
}
}
prevClosestIdx = ct2.idx;
// update car mesh
car.position.copy(carPos);
car.rotation.y = carYaw;
// lean
car.rotation.z = -steerInput * (Math.abs(carSpeed)/70) * 0.35 - (isDrifting? steerInput*0.25:0);
// wheel spin
wheels.forEach((w,i)=>{
w.rotation.x += carSpeed*0.08*dt * (i<2?1:1);
if(i<2) w.rotation.y = steerInput*0.45;
});
// effects
if(isDrifting && onTrack && Math.abs(carSpeed)>15){
if(Math.random()<0.6) addSkid(new T.Vector3(carPos.x,0.06,carPos.z), carYaw);
if(Math.random()<0.4) spawnDust();
}
if(!onTrack && Math.abs(carSpeed)>10 && Math.random()<0.5) spawnDust();
// update timers
STATE.lapTime += dt;
STATE.totalTime += dt;
// dust life
for(let i=dust.length-1;i>=0;i--){
const d=dust[i]; d.position.addScaledVector(d.userData.v, dt); d.userData.life-=dt; d.material.opacity = d.userData.life/0.6; d.scale.setScalar(1 + (0.6-d.userData.life)*2);
if(d.userData.life<=0){ scene.remove(d); dust.splice(i,1); }
}
}
// ——— Camera ———
let camTarget = new T.Vector3(), camPos = new T.Vector3();
function updateCamera(dt){
const fwdX=Math.sin(carYaw), fwdZ=Math.cos(carYaw);
let dist, height, lookH;
if(STATE.camMode==='chase'){ dist=13; height=4.8; lookH=1.2; }
else { dist=22; height=11; lookH=0.5; }
// dynamic lag based on speed
const speedLag = Math.min(1, Math.abs(carSpeed)/80);
const targetPos = new T.Vector3(carPos.x - fwdX*(dist + speedLag*4), carPos.y + height, carPos.z - fwdZ*(dist + speedLag*4));
// slight side offset when steering
targetPos.x += Math.cos(carYaw)* input.steer * 2.2;
targetPos.z -= Math.sin(carYaw)* input.steer * 2.2;
camPos.lerp(targetPos, 1 - Math.pow(0.002, dt)); // smooth
camTarget.lerp(new T.Vector3(carPos.x, carPos.y+lookH, carPos.z), 1 - Math.pow(0.002, dt));
camera.position.copy(camPos);
camera.lookAt(camTarget);
}
// ——— HUD ———
function updateHUD(){
document.getElementById('hudSpeed').textContent=Math.round(Math.abs(carSpeed));
document.getElementById('rpmBar').style.width= Math.min(100, (Math.abs(carSpeed)/78*100 + Math.abs(input.steer)*10))+'%';
const gear = carSpeed<18?1: carSpeed<38?2: carSpeed<58?3: carSpeed<78?4:5;
document.getElementById('hudGear').textContent= carSpeed<2? 'N' : gear;
document.getElementById('hudSurface').textContent=STATE.surface;
document.getElementById('hudLap').textContent= `${Math.min(STATE.lap,STATE.totalLaps)} / ${STATE.totalLaps}`;
document.getElementById('hudTime').textContent=formatTime(STATE.totalTime);
document.getElementById('hudBest').textContent=formatTime(STATE.bestLap);
document.getElementById('lapTime').textContent=formatTime(STATE.lapTime);
document.getElementById('lastLap').textContent=formatTime(STATE.lastLap);
document.getElementById('totalTime').textContent=formatTime(STATE.totalTime);
}
// ——— Loop ———
let last=performance.now();
function animate(){
requestAnimationFrame(animate);
const now=performance.now(); let dt=(now-last)/1000; last=now;
dt=Math.min(dt,0.033);
if(STATE.running && !STATE.paused && !STATE.finished && !STATE.countdown){
updatePhysics(dt);
// engine audio
if(audioCtx && engineOsc){
const rpm = 0.25 + Math.abs(carSpeed)/78*0.9 + Math.abs(input.f)*0.2;
engineOsc.frequency.setTargetAtTime(60 + rpm*340, audioCtx.currentTime, 0.08);
engineGain.gain.setTargetAtTime(STATE.running? 0.14 + rpm*0.12 : 0, audioCtx.currentTime, 0.08);
engineFilter.frequency.setTargetAtTime(500 + rpm*1800, audioCtx.currentTime, 0.08);
// skid sound via gain modulation
if(drift>0.3 && Math.abs(carSpeed)>20){
if(!window.skidGain){
window.skidOsc=audioCtx.createOscillator(); window.skidGain=audioCtx.createGain(); window.skidFilt=audioCtx.createBiquadFilter();
window.skidOsc.type='sawtooth'; window.skidOsc.frequency.value=180; window.skidFilt.type='bandpass'; window.skidFilt.frequency.value=1400;
window.skidOsc.connect(window.skidFilt).connect(window.skidGain).connect(audioCtx.destination); window.skidOsc.start(); window.skidGain.gain.value=0;
}
window.skidGain.gain.setTargetAtTime(0.12, audioCtx.currentTime, 0.05);
} else if(window.skidGain) window.skidGain.gain.setTargetAtTime(0, audioCtx.currentTime, 0.1);
}
}
updateCamera(dt);
drawMini();
updateHUD();
renderer.render(scene, camera);
}
animate();
// ——— Countdown / Flow ———
function startCountdown(){
STATE.countdown=true; STATE.running=false;
const cdEl=document.getElementById('countdown'), txt=document.getElementById('cdText');
cdEl.classList.add('show');
let c=3; txt.textContent=c; beep(600,0.18,0.2);
const iv=setInterval(()=>{
c--;
if(c>0){ txt.textContent=c; txt.style.color='#fff'; beep(600,0.18,0.2); cdEl.classList.remove('show'); void cdEl.offsetWidth; cdEl.classList.add('show'); }
else if(c===0){ txt.textContent='GO!'; txt.style.color='#ffd23f'; beep(1000,0.32,0.25); cdEl.classList.remove('show'); void cdEl.offsetWidth; cdEl.classList.add('show'); }
else { clearInterval(iv); cdEl.classList.remove('show'); STATE.countdown=false; STATE.running=true; STATE.paused=false; }
}, 800);
}
function restartRace(){
// reset
STATE.lap=1; STATE.lapTime=0; STATE.totalTime=0; STATE.bestLap=null; STATE.lastLap=null; STATE.lapTimes=[]; STATE.nextCp=1; // require first cp? start at 1
STATE.finished=false; STATE.paused=false;
carPos.copy(trackData[0].p); carPos.y+=0.6;
carYaw=Math.atan2(trackData[5].p.x - trackData[0].p.x, trackData[5].p.z - trackData[0].p.z);
carVel.set(0,0); carSpeed=0;
prevClosestIdx=0;
document.getElementById('finishScreen').style.display='none';
document.getElementById('pauseScreen').style.display='none';
// clear dust/skids
dust.forEach(d=>scene.remove(d)); dust.length=0;
// remove skid meshes (they timeout)
startCountdown();
}
function togglePause(){
if(STATE.finished || STATE.countdown) return;
if(!STATE.running) return;
STATE.paused=!STATE.paused;
document.getElementById('pauseScreen').style.display= STATE.paused?'grid':'none';
}
function finishRace(){
STATE.finished=true; STATE.running=false;
beep(800,0.12,0.2); setTimeout(()=>beep(1000,0.4,0.22),140); setTimeout(()=>beep(1200,0.6,0.22),320);
document.getElementById('finTotal').textContent=formatTime(STATE.totalTime);
document.getElementById('finBest').textContent=formatTime(STATE.bestLap);
const list=document.getElementById('lapList');
list.innerHTML = STATE.lapTimes.map((t,i)=>`<div style="display:flex;justify-content:space-between;border-bottom:1px solid rgba(255,255,255,.08);padding:4px 0"><span>Lap ${i+1}</span><b>${formatTime(t)} ${t===STATE.bestLap?'⭐ BEST':''}</b></div>`).join('');
document.getElementById('finishScreen').style.display='grid';
}
// ——— Events ———
document.getElementById('playBtn').addEventListener('click',()=>{
initAudio();
document.getElementById('startScreen').style.display='none';
restartRace();
});
document.getElementById('camBtn').addEventListener('click',()=>{
STATE.camMode = STATE.camMode==='chase'?'cinematic':'chase';
document.getElementById('camBtn').textContent='CAM: '+STATE.camMode.toUpperCase();
});
document.getElementById('pauseBtn').addEventListener('click',togglePause);
document.getElementById('resumeBtn').addEventListener('click',togglePause);
document.getElementById('restartBtn').addEventListener('click',restartRace);
document.getElementById('againBtn').addEventListener('click',restartRace);
document.getElementById('shareBtn').addEventListener('click',()=>{
const txt=`RALLY FURY — Total ${formatTime(STATE.totalTime)} | Best ${formatTime(STATE.bestLap)}`;
navigator.clipboard.writeText(txt); beep(900,0.1,0.15);
const b=document.getElementById('shareBtn'); b.textContent='COPIED!'; setTimeout(()=>b.textContent='COPY TIME',1200);
});
addEventListener('resize',()=>{
W=innerWidth; H=innerHeight;
renderer.setSize(W,H); camera.aspect=W/H; camera.updateProjectionMatrix();
});
// init minimap once
drawMini();
// set initial cam
camPos.set(carPos.x, carPos.y+12, carPos.z-18);
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2","provider":"openrouter","persona":"basic","sessionId":"19d65899-205c-4b76-96f7-eaf161426659","tokensIn":1057,"tokensOut":13964,"tokensTotal":15021,"cost":0.06054395,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-08-06T15:30:58.987Z"} -->