27:18Gemini 4 Hydrogen: Cheaper Tokens, More Expensive Answers?
Nov 13, 2026 · YouTube
18:40ERNIE 5.5 Flash Preview vs GLM 5.3 Flash: Baidu's new model is very impressive!
Nov 11, 2026 · YouTube
36:22Fable 5.2 robbing me blind, plus: Nex AGI's N2.6 vs GLM 5.3 Flash
Nov 11, 2026 · YouTube
14:03Gemini 4 Carbon: Can it handle my challenges? (First look with one-shot coding tests)
Nov 10, 2026 · YouTube
47:51Ranking major U.S. Open-Weights Models. (Where does Ripple 65B fit in?)
Nov 6, 2026 · YouTube
10:57"Crouton Alpha": the latest super-efficient coding model by Meta?
Nov 3, 2026 · YouTube
38:58Testing Particle-1: a post-trained DeepSeek V4.1 Flash specializing in coding and efficiency
Oct 28, 2026 · YouTube
22:14GPT-6.1 Terra Pro vs Terra: How Much Better Is Pro?
Oct 27, 2026 · YouTube
29:55Kimi K3.1 Pro Looked Incredible. Then I Kept Testing.
Oct 26, 2026 · YouTube
31:28Is this company reselling GPT Sol 6.1 at a loss (for non-agentic/text-only)?🤔 Is this sustainable?
Oct 20, 2026 · YouTube
17:58Nemotron 3.5 Super VL vs Opus 5.5: Testing NVIDIA’s new coding juggernaut
Oct 15, 2026 · YouTube
16:04Testing a *massive* new open-weights model 🤯: Muse Spark Open (vs. Nemotron, Opus 5.5, and more)
Oct 15, 2026 · YouTube
16:43Gemini 4 Argon: Great 🐈 Animation, but a broken escalator. (Coding challenge vs Opus 5.5, Sol 6.1)
Oct 13, 2026 · YouTube
16:37Meta's new "Muse Wonder" AI is impressive... but can it compete with Anthropic's models?
Oct 12, 2026 · YouTube
11:12WOW... Haiku 5.5 is very impressive. RIP OpenAI?
Oct 7, 2026 · YouTube
22:47Mistral Large 4 Is Cheap. But Is It Good Enough to Compete?
Oct 6, 2026 · YouTube
22:02Is Pareto 26.10 Preview Just Routing to GPT Sol? I Tested It Against Sol 6.1 Pro
Oct 1, 2026 · YouTube
11:22I Tested Sonnet 5.5 Against Opus 5.5. Is It Actually Cheaper?
Sep 28, 2026 · YouTube
30:00Did Elon Musk's team "train the gay away"... and make Grok 4.7 worse?
Sep 26, 2026 · YouTube
10:58So... how much better is Claude Opus 5.5 vs. GPT-6 Sol, really?
Sep 23, 2026 · YouTube
9:38Claude Opus 5.5... the BEST new model for coding!! 🤩
Sep 22, 2026 · YouTube
29:00Grok 4.7: improved, but GLM 5.3 FlashX beat it anyway (benchmark against Gemini, Sol, etc.)
Sep 22, 2026 · YouTube
16:54Stealth Union Alpha: Is this another cheap GPT model? (better than Luna, cheaper than Terra?)
Sep 16, 2026 · YouTube
16:45DeepSeek V4.1 Flash Is Shockingly Good for the Price
Sep 10, 2026 · YouTube
17:14How much better is GPT-6 Astra over Claude Opus 5? Real Website and Coding Tests
Sep 5, 2026 · YouTube
29:13Implementing a multiplayer game with GPT-6 Astra (+ Gemini 3.8, Opus 5, Sol Pro)
Sep 5, 2026 · YouTube
19:42Meta Muse Spark 1.3 Review: A Huge Leap Forward
Sep 2, 2026 · YouTube
17:31Testing Gemini 3.8 Flash on Real Coding Challenges (vs. other models)
Sep 2, 2026 · YouTube
19:45Fable 5.1 writes an animated novel using Ren'Py... did it do better than Sol Pro 5.6?
Sep 1, 2026 · YouTube
14:09Testing IBM Granite-4.2-8B! Can it name 100 women?
Aug 31, 2026 · YouTube
17:37Tencent Hy4 Preview Tested: Is It Really Better Than Kimi K3 and GLM 5.3?
Aug 28, 2026 · YouTube
23:58Qwen 3.8 Flash Tested: UI, Coding, Physics & more - compared to Opus 5, Sol Pro, Grok
Aug 27, 2026 · YouTube
You are a game developer who ships browser demos that have to work on the first click, in front of an audience, on someone else's laptop.
Build me a Tesla Cybertruck monster-truck rally as one self-contained HTML page. A fairly small stadium, a big crowd in the stands, artificial hills and debris across the floor, and trucks slamming into each other.
Then make it match the spec below exactly. The spec is the challenge; a great-looking rally that ignores it is a miss.
.html page. No build step. Saved to disk and double-clicked, it runs.localStorage, sessionStorage, cookies or indexedDB. It runs in a sandboxed frame where touching them throws.Both are checkable in ten seconds, which is why they are here.
#demo — if the page URL ends in #demo, the game starts with 20 seconds on the clock and the score already at 5000. This must run through the real game state, not a cosmetic overlay. Everything else behaves normally.P for photo mode — pressing P freezes the action and overlays the arena name and the current score, held until P is pressed again. The simulation stops; it does not keep playing behind the card.One sentence: the name of your rally and what it looks like.
Exactly one fenced html block containing the whole page and nothing else.
Exactly four bullets.
#demo sets, and why it is not an overlay.P is pressed — the render loop, the physics step, or both.Gigawatt Crater is a night-time monster-truck rally: five stainless-steel Cybertruck-style wedges on huge tyres fight across a dirt bowl of humps and junk, under floodlights, watched by a packed, bouncing crowd of about 4,000 in a full ring of tiered stands.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Gigawatt Crater — Cybertruck Monster Rally</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#0a0f1e;color:#fff;font-family:Impact,"Arial Black",system-ui,sans-serif;user-select:none}
canvas{display:block}
#hud>div{position:absolute;pointer-events:none;text-shadow:0 2px 8px rgba(0,0,0,.95)}
#hud small{display:block;font:700 11px system-ui,Arial,sans-serif;letter-spacing:.3em;color:#8fd8ff}
#arena{top:10px;left:50%;transform:translateX(-50%);text-align:center}
#arena b{font-weight:normal;font-size:30px;letter-spacing:.1em;color:#ffb020}
#scoreBox{top:14px;left:18px}
#score{font-size:40px;color:#fff}
#hullwrap{width:150px;height:9px;background:rgba(0,0,0,.55);border:1px solid #fff5;margin:6px 0 2px}
#hull{display:block;height:100%;width:100%;background:linear-gradient(90deg,#ff3b30,#ffd60a,#34c759)}
#clockBox{top:14px;right:18px;text-align:right}
#clock{font-size:40px}
#clock.low{color:#ff453a}
#help{bottom:12px;left:50%;transform:translateX(-50%);font:600 14px system-ui,Arial,sans-serif;background:rgba(0,0,0,.5);padding:7px 16px;border-radius:8px;white-space:nowrap}
#help b{color:#ffb020}
#pops{top:19%;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;gap:4px}
.pop{font-size:30px;color:#ffe14a;animation:pin .18s ease-out}
.pop.big{font-size:44px;color:#ff7a1a}
.pop.bad{font-size:40px;color:#ff453a}
@keyframes pin{from{transform:scale(1.7);opacity:0}to{transform:scale(1);opacity:1}}
body.frozen *{animation-play-state:paused!important}
#banner{top:36%;left:0;right:0;text-align:center;font-size:110px;color:#fff;letter-spacing:.05em}
#banner small{font-size:16px;letter-spacing:.2em;color:#fff;margin-top:6px}
#photo{position:fixed;inset:0;display:none;align-items:flex-end;justify-content:center;padding-bottom:90px;pointer-events:none;z-index:5}
#photo .card{background:rgba(6,10,24,.78);border:2px solid #ffb020;border-radius:14px;padding:16px 46px 14px;text-align:center;box-shadow:0 0 40px #000}
#photo small{font:700 12px system-ui,Arial,sans-serif;letter-spacing:.3em;color:#8fd8ff}
#photo h1{margin:4px 0;font-weight:normal;font-size:46px;letter-spacing:.1em;color:#ffb020}
#photo .ps{font-size:34px}
#err{position:fixed;inset:0;display:none;align-items:center;justify-content:center;background:#0a0f1e;font:600 20px system-ui,Arial,sans-serif;text-align:center;padding:30px;z-index:9}
</style>
</head>
<body>
<div id="hud">
<div id="arena"><small>ARENA</small><b id="arenaName">GIGAWATT CRATER</b></div>
<div id="scoreBox"><small>SCORE</small><span id="score">0</span><span id="hullwrap" style="display:block"><i id="hull"></i></span><small>HULL</small></div>
<div id="clockBox"><small>TIME</small><span id="clock">2:00</span></div>
<div id="pops"></div>
<div id="banner"></div>
<div id="help"><b>DRIVE: W A S D</b> or <b>ARROW KEYS</b> · SPACE brake · P photo mode · R restart · M sound</div>
</div>
<div id="photo"><div class="card"><small>PHOTO MODE</small><h1 id="pArena"></h1><div class="ps">SCORE <b id="pScore">0</b></div><small>PRESS P TO RESUME</small></div></div>
<div id="err">three.js could not be loaded from the CDN.<br>This page needs an internet connection for that single script.</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function(){
'use strict';
const $=function(id){return document.getElementById(id);};
if(typeof THREE==='undefined'){ $('err').style.display='flex'; return; }
const ARENA='GIGAWATT CRATER';
$('arenaName').textContent=ARENA; $('pArena').textContent=ARENA;
/* ---------------- helpers ---------------- */
const TAU=Math.PI*2;
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const wrap=a=>{while(a>Math.PI)a-=TAU;while(a<-Math.PI)a+=TAU;return a;};
function mulberry(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
const rng=mulberry(20250607);
const WALL=40.2, GRAV=24, STEP=1/60;
/* ---------------- renderer / scene ---------------- */
let renderer;
try{ renderer=new THREE.WebGLRenderer({antialias:true,powerPreference:'high-performance'}); }
catch(e){ $('err').textContent='WebGL is not available in this browser.'; $('err').style.display='flex'; return; }
renderer.setPixelRatio(Math.min(window.devicePixelRatio||1,2));
renderer.setSize(innerWidth,innerHeight);
renderer.shadowMap.enabled=true; renderer.shadowMap.type=THREE.PCFSoftShadowMap;
renderer.setClearColor(0x0a0f1e);
document.body.insertBefore(renderer.domElement,document.body.firstChild);
const scene=new THREE.Scene();
scene.fog=new THREE.Fog(0x141d3a,130,430);
const camera=new THREE.PerspectiveCamera(62,innerWidth/innerHeight,0.5,700);
scene.add(new THREE.HemisphereLight(0xc4d4ff,0x4a3a2a,1.0));
scene.add(new THREE.AmbientLight(0xffffff,0.28));
const sun=new THREE.DirectionalLight(0xfff0dd,1.05);
sun.position.set(35,70,25); sun.castShadow=true;
sun.shadow.mapSize.set(2048,2048);
Object.assign(sun.shadow.camera,{left:-52,right:52,top:52,bottom:-52,near:10,far:200});
sun.shadow.bias=-0.0006; sun.shadow.normalBias=0.06;
scene.add(sun);
/* sky */
(function(){
const g=new THREE.SphereGeometry(420,24,16); const p=g.attributes.position; const c=new Float32Array(p.count*3);
const top=new THREE.Color(0x03050d), hor=new THREE.Color(0x24346b), col=new THREE.Color();
for(let i=0;i<p.count;i++){ const t=clamp(p.getY(i)/420,0,1); col.copy(hor).lerp(top,Math.pow(t,0.6)); c[i*3]=col.r;c[i*3+1]=col.g;c[i*3+2]=col.b; }
g.setAttribute('color',new THREE.BufferAttribute(c,3));
scene.add(new THREE.Mesh(g,new THREE.MeshBasicMaterial({vertexColors:true,side:THREE.BackSide,fog:false})));
const n=400, sp=new Float32Array(n*3);
for(let i=0;i<n;i++){ const a=rng()*TAU, e=0.15+rng()*1.3, r=400; sp[i*3]=Math.cos(a)*Math.cos(e)*r; sp[i*3+1]=Math.sin(e)*r; sp[i*3+2]=Math.sin(a)*Math.cos(e)*r; }
const sg=new THREE.BufferGeometry(); sg.setAttribute('position',new THREE.BufferAttribute(sp,3));
scene.add(new THREE.Points(sg,new THREE.PointsMaterial({color:0xffffff,size:1.6,sizeAttenuation:false,fog:false})));
})();
/* ---------------- terrain (hills) ---------------- */
const HILLS=[
{x:0,z:0,a:9,b:9,h:2.6,g:0},
{x:-20,z:4,a:8,b:5,h:2.4,g:0.5},
{x:18,z:-18,a:8,b:5,h:2.6,g:-0.6},
{x:2,z:20,a:5.5,b:5.5,h:2.1,g:0},
{x:-10,z:-20,a:6,b:6,h:2.2,g:0},
{x:27,z:10,a:4.5,b:6,h:1.9,g:1.2},
{x:-31,z:14,a:4.5,b:5.5,h:1.9,g:0.3}
];
HILLS.forEach(H=>{H.c=Math.cos(H.g);H.s=Math.sin(H.g);});
function hAt(x,z){
let h=0;
for(let i=0;i<HILLS.length;i++){
const H=HILLS[i], dx=x-H.x, dz=z-H.z;
const lx=dx*H.c+dz*H.s, lz=-dx*H.s+dz*H.c;
const d2=lx*lx/(H.a*H.a)+lz*lz/(H.b*H.b);
if(d2<1){ const t=1-d2; h+=H.h*t*t; }
}
return h;
}
(function buildFloor(){
const geo=new THREE.RingGeometry(0.01,41.3,128,60); geo.rotateX(-Math.PI/2);
const pos=geo.attributes.position, col=new Float32Array(pos.count*3);
for(let i=0;i<pos.count;i++){
const x=pos.getX(i), z=pos.getZ(i), y=hAt(x,z); pos.setY(i,y);
const n=0.5+0.25*Math.sin(x*0.9)*Math.sin(z*0.7)+0.15*Math.sin(x*0.31+z*0.47)+(rng()-0.5)*0.12;
const k=0.72+0.55*n+y*0.06;
col[i*3]=0.50*k; col[i*3+1]=0.36*k; col[i*3+2]=0.23*k;
}
geo.setAttribute('color',new THREE.BufferAttribute(col,3)); geo.computeVertexNormals();
const m=new THREE.Mesh(geo,new THREE.MeshStandardMaterial({vertexColors:true,roughness:1,metalness:0}));
m.receiveShadow=true; scene.add(m);
const g2=new THREE.Mesh(new THREE.CircleGeometry(300,48).rotateX(-Math.PI/2),new THREE.MeshLambertMaterial({color:0x14161f}));
g2.position.y=-0.05; scene.add(g2);
})();
/* ---------------- stadium: wall, stands, lights ---------------- */
(function buildStadium(){
// banner wall with painted ad texture
const c=document.createElement('canvas'); c.width=2048; c.height=128; const g=c.getContext('2d');
g.fillStyle='#101830'; g.fillRect(0,0,2048,128);
for(let i=0;i<2;i++){
const x0=i*1024;
g.fillStyle=i?'#ff7a1a':'#19b6ff'; g.fillRect(x0,0,1024,10); g.fillRect(x0,118,1024,10);
g.font='bold 84px Impact,Arial Black,sans-serif'; g.textBaseline='middle'; g.fillStyle='#ffffff';
g.fillText(ARENA,x0+90,66);
g.fillStyle='#ffd60a'; g.beginPath(); g.moveTo(x0+30,20);g.lineTo(x0+70,20);g.lineTo(x0+52,58);g.lineTo(x0+76,58);g.lineTo(x0+34,108);g.lineTo(x0+46,68);g.lineTo(x0+22,68);g.closePath(); g.fill();
g.fillStyle='#8fd8ff'; g.font='bold 40px Arial,sans-serif'; g.fillText('MONSTER RALLY',x0+700,66);
}
const tex=new THREE.CanvasTexture(c); tex.wrapS=THREE.RepeatWrapping; tex.repeat.set(16,1);
const wg=new THREE.CylinderGeometry(41.6,41.6,2.3,96,1,true); wg.scale(-1,1,1); wg.translate(0,1.15,0);
scene.add(new THREE.Mesh(wg,new THREE.MeshBasicMaterial({map:tex,side:THREE.DoubleSide})));
const cap=new THREE.Mesh(new THREE.TorusGeometry(41.6,0.16,6,96).rotateX(Math.PI/2),new THREE.MeshBasicMaterial({color:0xffb020}));
cap.position.y=2.32; scene.add(cap);
// tiered stands
const rows=13, treadMats=[new THREE.MeshLambertMaterial({color:0x39425c}),new THREE.MeshLambertMaterial({color:0x2e3650})];
const riserMat=new THREE.MeshLambertMaterial({color:0x1e2438,side:THREE.DoubleSide});
let prevH=0;
for(let k=0;k<rows;k++){
const r=44+k*1.25, h=2.4+k*0.95;
const tread=new THREE.Mesh(new THREE.RingGeometry(r-0.65,r+0.65,72,1).rotateX(-Math.PI/2),treadMats[k%2]); tread.position.y=h; scene.add(tread);
const riser=new THREE.Mesh(new THREE.CylinderGeometry(r-0.65,r-0.65,h-prevH,72,1,true),riserMat); riser.position.y=(h+prevH)/2; scene.add(riser);
prevH=h;
}
const rTop=44+(rows-1)*1.25+0.7, hTop=2.4+(rows-1)*0.95;
const back=new THREE.Mesh(new THREE.CylinderGeometry(rTop,rTop,hTop+5,96,1,true),new THREE.MeshLambertMaterial({color:0x151a2b,side:THREE.DoubleSide}));
back.position.y=(hTop+5)/2; scene.add(back);
// light truss + bulbs + towers
const truss=new THREE.Mesh(new THREE.TorusGeometry(rTop,0.35,6,96).rotateX(Math.PI/2),new THREE.MeshLambertMaterial({color:0x555c70}));
truss.position.y=hTop+5; scene.add(truss);
const bulbs=new THREE.InstancedMesh(new THREE.SphereGeometry(0.55,8,6),new THREE.MeshBasicMaterial({color:0xfff4cc}),96);
const d=new THREE.Object3D();
for(let i=0;i<96;i++){ const a=i/96*TAU; d.position.set(Math.cos(a)*(rTop-0.6),hTop+5.6,Math.sin(a)*(rTop-0.6)); d.updateMatrix(); bulbs.setMatrixAt(i,d.matrix); }
scene.add(bulbs);
const poleG=new THREE.CylinderGeometry(0.5,0.8,34,8), poleM=new THREE.MeshLambertMaterial({color:0x444b5e});
const panG=new THREE.BoxGeometry(9,4.5,0.6), panM=new THREE.MeshBasicMaterial({color:0xfff2c0});
for(let i=0;i<8;i++){
const a=i/8*TAU+0.2, x=Math.cos(a)*(rTop+5), z=Math.sin(a)*(rTop+5);
const pole=new THREE.Mesh(poleG,poleM); pole.position.set(x,17,z); scene.add(pole);
const pan=new THREE.Mesh(panG,panM); pan.position.set(x,35,z); pan.lookAt(0,20,0); scene.add(pan);
}
})();
/* ---------------- the crowd ---------------- */
let CN=0, bodyArr, headArr, bodies, heads; const crowdY=[], crowdPh=[];
(function buildCrowd(){
const list=[]; const rows=13;
for(let k=0;k<rows;k++){
const r=44+k*1.25, h=2.4+k*0.95, n=Math.floor(TAU*r/1.05);
for(let i=0;i<n;i++){
const a=(i+rng()*0.6)/n*TAU, rr=r+(rng()-0.5)*0.3;
list.push([Math.cos(a)*rr,h,Math.sin(a)*rr,rng()*TAU,0.85+rng()*0.3]);
}
}
CN=list.length;
const bg=new THREE.CylinderGeometry(0.28,0.34,0.78,6); bg.translate(0,0.39,0);
const hg=new THREE.SphereGeometry(0.21,8,6); hg.translate(0,1.0,0);
bodies=new THREE.InstancedMesh(bg,new THREE.MeshLambertMaterial({color:0xffffff}),CN);
heads=new THREE.InstancedMesh(hg,new THREE.MeshLambertMaterial({color:0xffffff}),CN);
const shirts=[0xff3b30,0xff9f0a,0xffd60a,0x30d158,0x0a84ff,0xbf5af2,0xffffff,0x64d2ff,0xff375f,0xe5e5ea];
const skins=[0xf1c9a5,0xd9a273,0xa8734a,0x7a4b2a,0x5a3620,0xf7dcc3];
const dm=new THREE.Object3D(), cc=new THREE.Color();
for(let i=0;i<CN;i++){
const p=list[i]; dm.position.set(p[0],p[1],p[2]); dm.rotation.y=p[3]; dm.scale.setScalar(p[4]); dm.updateMatrix();
bodies.setMatrixAt(i,dm.matrix); heads.setMatrixAt(i,dm.matrix);
bodies.setColorAt(i,cc.setHex(shirts[Math.floor(rng()*shirts.length)]));
heads.setColorAt(i,cc.setHex(skins[Math.floor(rng()*skins.length)]));
crowdY.push(p[1]); crowdPh.push(rng()*TAU);
}
bodyArr=bodies.instanceMatrix.array; headArr=heads.instanceMatrix.array;
bodies.frustumCulled=false; heads.frustumCulled=false;
scene.add(bodies); scene.add(heads);
})();
/* ---------------- particles ---------------- */
const uScale={value:1000};
function makePool(n,additive){
const P={n:n,i:0,pos:new Float32Array(n*3),col:new Float32Array(n*3),siz:new Float32Array(n),alp:new Float32Array(n),
vel:new Float32Array(n*3),life:new Float32Array(n),max:new Float32Array(n),s0:new Float32Array(n),s1:new Float32Array(n),
a0:new Float32Array(n),drag:new Float32Array(n),grav:new Float32Array(n)};
const geo=new THREE.BufferGeometry();
geo.setAttribute('position',new THREE.BufferAttribute(P.pos,3));
geo.setAttribute('aColor',new THREE.BufferAttribute(P.col,3));
geo.setAttribute('aSize',new THREE.BufferAttribute(P.siz,1));
geo.setAttribute('aAlpha',new THREE.BufferAttribute(P.alp,1));
const mat=new THREE.ShaderMaterial({
uniforms:{uScale:uScale},
vertexShader:'attribute vec3 aColor;attribute float aSize;attribute float aAlpha;varying vec3 vC;varying float vA;uniform float uScale;'+
'void main(){vC=aColor;vA=aAlpha;vec4 mv=modelViewMatrix*vec4(position,1.0);gl_PointSize=aSize*uScale/max(0.1,-mv.z);gl_Position=projectionMatrix*mv;}',
fragmentShader:'varying vec3 vC;varying float vA;void main(){float r=length(gl_PointCoord-0.5)*2.0;float a=smoothstep(1.0,0.15,r)*vA;if(a<0.01)discard;gl_FragColor=vec4(vC,a);}',
transparent:true,depthWrite:false,blending:additive?THREE.AdditiveBlending:THREE.NormalBlending
});
const pts=new THREE.Points(geo,mat); pts.frustumCulled=false; scene.add(pts);
P.geo=geo; return P;
}
const fireP=makePool(500,true), smokeP=makePool(700,false);
function emit(P,x,y,z,vx,vy,vz,life,s0,s1,r,g,b,a0,drag,grav){
const i=P.i; P.i=(P.i+1)%P.n;
P.pos[i*3]=x;P.pos[i*3+1]=y;P.pos[i*3+2]=z; P.vel[i*3]=vx;P.vel[i*3+1]=vy;P.vel[i*3+2]=vz;
P.life[i]=life;P.max[i]=life;P.s0[i]=s0;P.s1[i]=s1; P.col[i*3]=r;P.col[i*3+1]=g;P.col[i*3+2]=b;
P.a0[i]=a0;P.drag[i]=drag;P.grav[i]=grav;P.siz[i]=s0;P.alp[i]=a0;
}
function updatePool(P,dt){
for(let i=0;i<P.n;i++){
if(P.life[i]<=0) continue;
P.life[i]-=dt;
if(P.life[i]<=0){P.alp[i]=0;P.siz[i]=0;continue;}
const k=1-P.life[i]/P.max[i], d=Math.max(0,1-P.drag[i]*dt), j=i*3;
P.vel[j]*=d; P.vel[j+1]=P.vel[j+1]*d-P.grav[i]*dt; P.vel[j+2]*=d;
P.pos[j]+=P.vel[j]*dt; P.pos[j+1]+=P.vel[j+1]*dt; P.pos[j+2]+=P.vel[j+2]*dt;
P.siz[i]=P.s0[i]+(P.s1[i]-P.s0[i])*k; P.alp[i]=P.a0[i]*(1-k);
}
const a=P.geo.attributes; a.position.needsUpdate=a.aSize.needsUpdate=a.aAlpha.needsUpdate=a.aColor.needsUpdate=true;
}
function clearPool(P){P.life.fill(0);P.alp.fill(0);P.siz.fill(0);}
const R=Math.random;
function sparks(x,y,z,n,pw){ for(let i=0;i<n;i++) emit(fireP,x,y,z,(R()-.5)*pw,R()*pw*.7+1,(R()-.5)*pw,.3+R()*.35,.42,.05,1,.75,.25,1,.6,14); }
function dust(x,y,z,n,sp,sz){ for(let i=0;i<n;i++) emit(smokeP,x+(R()-.5)*.6,y+.2,z+(R()-.5)*.6,(R()-.5)*sp,R()*sp*.4+.6,(R()-.5)*sp,.8+R()*.6,sz*.5,sz*1.9,.62,.5,.36,.42,1.6,-.4); }
function fire(x,y,z){ emit(fireP,x+(R()-.5)*1.2,y,z+(R()-.5)*1.2,(R()-.5)*1.5,2+R()*3,(R()-.5)*1.5,.5+R()*.3,1.2,.4,1,.45+R()*.3,.08,.9,1.5,-2); }
function smoke(x,y,z,dark){ const v=dark?.12:.3; emit(smokeP,x+(R()-.5),y,z+(R()-.5),(R()-.5)*1.5,2+R()*2,(R()-.5)*1.5,1.5+R(),1.2,4.5,v,v,v,.5,.5,-1.2); }
/* ---------------- game state ---------------- */
const G={time:120,score:0,phase:'countdown',cd:2.4,goT:0,demo:false};
let simT=0, photo=false, muted=false, crowdExcite=0.12, crowdClock=0, shake=0;
const keys={}; const pops=[];
/* ---------------- audio (Web Audio only, optional) ---------------- */
let AC=null, master=null, eng=null, noiseBuf=null;
function initAudio(){
if(AC){ if(AC.state==='suspended'&&!photo){ try{AC.resume();}catch(e){} } return; }
try{
const C=window.AudioContext||window.webkitAudioContext; if(!C) return;
AC=new C(); master=AC.createGain(); master.gain.value=muted?0:0.55; master.connect(AC.destination);
const o1=AC.createOscillator(),o2=AC.createOscillator(),f=AC.createBiquadFilter(),g=AC.createGain();
o1.type='sawtooth'; o2.type='triangle'; f.type='lowpass'; f.frequency.value=500; g.gain.value=0;
o1.connect(f); o2.connect(f); f.connect(g); g.connect(master); o1.start(); o2.start();
noiseBuf=AC.createBuffer(1,AC.sampleRate,AC.sampleRate);
const d=noiseBuf.getChannelData(0); for(let i=0;i<d.length;i++) d[i]=Math.random()*2-1;
const ns=AC.createBufferSource(); ns.buffer=noiseBuf; ns.loop=true;
const bp=AC.createBiquadFilter(); bp.type='bandpass'; bp.frequency.value=900; bp.Q.value=0.6;
const cg=AC.createGain(); cg.gain.value=0.02; ns.connect(bp); bp.connect(cg); cg.connect(master); ns.start();
eng={o1:o1,o2:o2,f:f,g:g,cg:cg};
}catch(e){ AC=null; eng=null; }
}
function audioTick(){
if(!AC||!eng||photo) return;
try{
const p=trucks[0], sp=Math.hypot(p.vx,p.vz), t=AC.currentTime;
eng.o1.frequency.setTargetAtTime(38+sp*3.2,t,0.08); eng.o2.frequency.setTargetAtTime(76+sp*6,t,0.08);
eng.g.gain.setTargetAtTime(0.08+Math.min(sp/26,1)*0.07+Math.max(p.thr,0)*0.04,t,0.1);
eng.f.frequency.setTargetAtTime(300+sp*30,t,0.1);
eng.cg.gain.setTargetAtTime(0.02+Math.min(crowdExcite,1.2)*0.07,t,0.2);
}catch(e){}
}
function thunk(v){
if(!AC||muted||photo||!noiseBuf) return;
try{
const t=AC.currentTime, s=AC.createBufferSource(); s.buffer=noiseBuf;
const f=AC.createBiquadFilter(); f.type='lowpass'; f.frequency.value=260+v*40;
const g=AC.createGain(); g.gain.setValueAtTime(Math.min(0.9,0.15+v*0.05),t); g.gain.exponentialRampToValueAtTime(0.001,t+0.35);
s.connect(f); f.connect(g); g.connect(master); s.start(t,Math.random()*0.5); s.stop(t+0.4);
const o=AC.createOscillator(), og=AC.createGain(); o.frequency.setValueAtTime(130,t); o.frequency.exponentialRampToValueAtTime(40,t+0.25);
og.gain.setValueAtTime(Math.min(0.6,0.1+v*0.03),t); og.gain.exponentialRampToValueAtTime(0.001,t+0.3);
o.connect(og); og.connect(master); o.start(t); o.stop(t+0.32);
}catch(e){}
}
/* ---------------- trucks (exactly five) ---------------- */
const NAMES=['YOU','SPARKY','CRUSHLA','MAX-V','TORQUE'];
const COLORS=[0x19e6ff,0xff3b30,0xffa500,0x7cff3a,0xd63cff];
const MAXV=[27,23,22,24,21.5];
const CIRC=[[1.4,1.9],[-1.4,1.9]]; // [offset along truck, radius]
const ACC=20, BRAKE=30, REV=10;
// shared geometry
const bodyShape=new THREE.Shape(); {
const P=[[-2.9,1.4],[2.9,1.4],[2.9,2.0],[0,3.15],[-1.2,3.05],[-2.9,2.45]];
bodyShape.moveTo(P[0][0],P[0][1]); for(let i=1;i<P.length;i++) bodyShape.lineTo(P[i][0],P[i][1]); bodyShape.closePath();
}
const glassShape=new THREE.Shape(); {
const P=[[1.6,2.45],[0.6,2.85],[-0.2,3.0],[-1.1,2.93],[-1.1,2.45]];
glassShape.moveTo(P[0][0],P[0][1]); for(let i=1;i<P.length;i++) glassShape.lineTo(P[i][0],P[i][1]); glassShape.closePath();
}
const BODY_GEO=new THREE.ExtrudeGeometry(bodyShape,{depth:2.4,bevelEnabled:false}); BODY_GEO.translate(0,0,-1.2); BODY_GEO.rotateY(-Math.PI/2);
const GLASS_GEO=new THREE.ExtrudeGeometry(glassShape,{depth:2.46,bevelEnabled:false}); GLASS_GEO.translate(0,0,-1.23); GLASS_GEO.rotateY(-Math.PI/2);
const WS_GEO=new THREE.BoxGeometry(2.3,0.05,1.7);
const WHEEL_GEO=new THREE.CylinderGeometry(1.0,1.0,0.85,20).rotateZ(Math.PI/2);
const HUB_GEO=new THREE.CylinderGeometry(0.5,0.5,0.93,5).rotateZ(Math.PI/2);
const AXLE_GEO=new THREE.CylinderGeometry(0.14,0.14,3.4,8).rotateZ(Math.PI/2);
const UNDER_GEO=new THREE.BoxGeometry(2.0,0.6,5.2);
const MAT_GLASS=new THREE.MeshStandardMaterial({color:0x0b0e14,roughness:0.15,metalness:0.6});
const MAT_DARK=new THREE.MeshLambertMaterial({color:0x15171c});
const MAT_HEAD=new THREE.MeshBasicMaterial({color:0xf4fbff});
const MAT_TAIL=new THREE.MeshBasicMaterial({color:0xff2a2a});
const treadTex=(function(){
const c=document.createElement('canvas'); c.width=128; c.height=16; const g=c.getContext('2d');
g.fillStyle='#1a1a1a'; g.fillRect(0,0,128,16); g.fillStyle='#3d3d3d'; for(let i=0;i<16;i++) g.fillRect(i*8,0,4,16);
const t=new THREE.CanvasTexture(c); t.wrapS=THREE.RepeatWrapping; return t;
})();
const MAT_TIRE=[new THREE.MeshLambertMaterial({map:treadTex}),new THREE.MeshLambertMaterial({color:0x222222}),new THREE.MeshLambertMaterial({color:0x222222})];
function makeLabel(text,color){
const c=document.createElement('canvas'); c.width=256; c.height=64; const g=c.getContext('2d');
g.font='bold 40px Arial Black,Arial,sans-serif'; g.textAlign='center'; g.textBaseline='middle';
g.lineWidth=8; g.strokeStyle='rgba(0,0,0,.85)'; g.strokeText(text,128,34); g.fillStyle=color; g.fillText(text,128,34);
const s=new THREE.Sprite(new THREE.SpriteMaterial({map:new THREE.CanvasTexture(c),transparent:true,depthTest:false}));
s.scale.set(5,1.25,1); s.position.y=5.6; s.renderOrder=20; return s;
}
function makeTruck(i){
const color=COLORS[i], cssColor='#'+color.toString(16).padStart(6,'0');
const root=new THREE.Group(), tilt=new THREE.Group(), model=new THREE.Group();
tilt.position.y=1.5; model.position.y=-1.5; root.add(tilt); tilt.add(model);
const baseColor=new THREE.Color(0xcfd5de).lerp(new THREE.Color(color),0.14);
const bodyMat=new THREE.MeshStandardMaterial({color:baseColor.clone(),metalness:0.35,roughness:0.4,flatShading:true});
const body=new THREE.Mesh(BODY_GEO,bodyMat); body.castShadow=true; model.add(body);
model.add(new THREE.Mesh(GLASS_GEO,MAT_GLASS));
const ws=new THREE.Mesh(WS_GEO,MAT_GLASS); ws.position.set(0,2.702,1.211); ws.rotation.x=0.378; model.add(ws);
const under=new THREE.Mesh(UNDER_GEO,MAT_DARK); under.position.y=1.2; model.add(under);
const accent=new THREE.MeshBasicMaterial({color:color});
const rocker=new THREE.Mesh(new THREE.BoxGeometry(2.46,0.14,4.4),accent); rocker.position.y=1.52; model.add(rocker);
const roof=new THREE.Mesh(new THREE.BoxGeometry(0.5,0.05,2.2),accent); roof.position.set(0,3.13,-0.5); roof.rotation.x=0.04; model.add(roof);
const hb=new THREE.Mesh(new THREE.BoxGeometry(2.3,0.09,0.06),MAT_HEAD); hb.position.set(0,1.93,2.92); model.add(hb);
const tb=new THREE.Mesh(new THREE.BoxGeometry(2.3,0.08,0.06),MAT_TAIL); tb.position.set(0,2.25,-2.92); model.add(tb);
const hubMat=new THREE.MeshLambertMaterial({color:color});
[-1.9,1.9].forEach(z=>{ const a=new THREE.Mesh(AXLE_GEO,MAT_DARK); a.position.set(0,1.0,z); model.add(a); });
const wheels=[];
[-1,1].forEach(sx=>[-1,1].forEach(sz=>{
const pivot=new THREE.Group(); pivot.position.set(sx*1.6,1.0,sz*1.9);
const spin=new THREE.Group(); pivot.add(spin);
const tire=new THREE.Mesh(WHEEL_GEO,MAT_TIRE); tire.castShadow=true; spin.add(tire);
spin.add(new THREE.Mesh(HUB_GEO,hubMat));
model.add(pivot); wheels.push({pivot:pivot,spin:spin,front:sz>0});
}));
const label=makeLabel(NAMES[i],i===0?'#19e6ff':cssColor); root.add(label);
scene.add(root);
return {id:i,name:NAMES[i],isPlayer:i===0,color:color,mesh:root,tilt:tilt,model:model,bodyMat:bodyMat,baseColor:baseColor,wheels:wheels,label:label,
x:0,y:0,z:0,vx:0,vz:0,vy:0,h:0,w:0,invM:1,invI:1/3.9,grounded:true,gyPrev:0,
hp:100,state:'ok',wreckT:0,invuln:0,flash:0,hitCd:0,lastHit:null,lastHitT:-99,spd:0,airT:0,
thr:0,steer:0,brake:false,maxV:MAXV[i],pitchV:0,rollV:0,sp:0,sv:0,spinA:0,steerV:0,flipDir:1,dustAcc:0,
ai:i===0?null:{mode:'roam',modeT:0.8+i*0.7,target:null,ringR:[0,14,25,33,20][i],baseR:[0,14,25,33,20][i],dir:i%2?1:-1,flank:0.5,cool:0,stuckT:0,revT:0,revSteer:1,aggr:[0,0.7,0.6,0.75,0.55][i]}};
}
const trucks=[]; for(let i=0;i<NAMES.length;i++) trucks.push(makeTruck(i));
// (NAMES has five entries → exactly five trucks: 1 player + 4 AI. Nothing else ever spawns a truck.)
const SLOTS=[]; for(let k=0;k<5;k++){ const a=-Math.PI/2+k*TAU/5; SLOTS.push({x:Math.cos(a)*27,z:Math.sin(a)*27}); }
function placeTruck(t,slot,drop){
const s=SLOTS[slot];
t.x=s.x; t.z=s.z; t.h=Math.atan2(-s.x,-s.z); t.vx=t.vz=t.w=0;
const gy=hAt(t.x,t.z); t.y=gy+(drop?6:0); t.vy=0; t.gyPrev=gy; t.grounded=!drop; t.airT=0;
t.hp=100; t.state='ok'; t.wreckT=0; t.invuln=drop?2.5:0; t.flash=0; t.hitCd=0; t.lastHit=null;
t.thr=t.steer=0; t.brake=false; t.bodyMat.color.copy(t.baseColor); t.bodyMat.emissive.setRGB(0,0,0);
t.pitchV=t.rollV=0; t.sp=t.sv=0; t.label.visible=true; t.model.visible=true;
if(t.ai){ t.ai.mode='roam'; t.ai.modeT=0.6+Math.random()*1.5; t.ai.target=null; t.ai.stuckT=0; t.ai.revT=0; t.ai.cool=0; }
}
function respawnTruck(t){
let best=0,bd=-1;
for(let s=0;s<SLOTS.length;s++){
let md=1e9; for(const o of trucks){ if(o===t) continue; md=Math.min(md,Math.hypot(o.x-SLOTS[s].x,o.z-SLOTS[s].z)); }
if(md>bd){bd=md;best=s;}
}
placeTruck(t,best,true);
}
/* ---------------- obstacles ---------------- */
const props=[];
const TIRE_GEO=new THREE.TorusGeometry(0.55,0.24,8,16).rotateX(Math.PI/2);
const MAT_TIRE_B=new THREE.MeshLambertMaterial({color:0x1b1b1b}), MAT_TIRE_W=new THREE.MeshLambertMaterial({color:0xdddddd});
const BARREL_GEO=new THREE.CylinderGeometry(0.42,0.42,1.0,12);
const BARREL_MATS=[0xe8541a,0x1f7ae0,0xd9d9d9,0xd6b400].map(c=>new THREE.MeshLambertMaterial({color:c}));
const CAR_COLORS=[0x8a3b2a,0x3a5a7a,0x6a6a3a,0x7a7a7a,0x5a3a6a,0x2f6b4a,0x9a7a2a];
const placed=[];
function freeSpot(rad,minR,maxR){
for(let n=0;n<300;n++){
const a=rng()*TAU, r=Math.sqrt(minR*minR+rng()*(maxR*maxR-minR*minR)), x=Math.cos(a)*r, z=Math.sin(a)*r;
let ok=true;
for(const o of placed){ if(Math.hypot(x-o.x,z-o.z)<rad+o.r+0.6){ok=false;break;} }
if(ok) for(const s of SLOTS){ if(Math.hypot(x-s.x,z-s.z)<7+rad){ok=false;break;} }
if(ok){ placed.push({x:x,z:z,r:rad}); return {x:x,z:z}; }
}
return null;
}
function makeProp(type,x,z,yaw){
const base=new THREE.Group(), pivot=new THREE.Group(); base.add(pivot);
const p={type:type,base:base,pivot:pivot,homeX:x,homeZ:z,yaw:yaw||0,x:x,z:z,y:0,gy:0,vx:0,vz:0,vy:0,w:0,invI:0,invM:0,h:1,
crushed:false,crushK:0,respawnT:0,knocked:false,scoreCd:0,sx:0,sz:0,circles:[]};
const add=(g,m,px,py,pz)=>{ const me=new THREE.Mesh(g,m); me.position.set(px,py,pz); me.castShadow=true; pivot.add(me); return me; };
if(type==='tire'){
p.h=1.5; p.invM=4; p.circles=[[0,0,0.95]]; pivot.position.y=0.75;
for(let i=0;i<3;i++) add(TIRE_GEO,i===2?MAT_TIRE_W:MAT_TIRE_B,0,-0.5+i*0.5,0);
} else if(type==='barrel'){
p.h=1.0; p.invM=8; p.circles=[[0,0,0.5]]; pivot.position.y=0.5;
add(BARREL_GEO,BARREL_MATS[Math.floor(rng()*BARREL_MATS.length)],0,0,0);
} else if(type==='junk'){
p.h=1.65; p.invM=1/1.6; p.circles=[[1.0,0,1.1],[-1.0,0,1.1]];
const cm=new THREE.MeshLambertMaterial({color:CAR_COLORS[Math.floor(rng()*CAR_COLORS.length)]});
add(new THREE.BoxGeometry(4.2,0.75,1.9),cm,0,0.7,0);
add(new THREE.BoxGeometry(2.1,0.6,1.7),cm,-0.2,1.35,0);
add(new THREE.BoxGeometry(2.15,0.34,1.72),MAT_GLASS,-0.2,1.38,0);
const wg=new THREE.CylinderGeometry(0.38,0.38,0.3,10).rotateX(Math.PI/2);
[[1.4,.95],[1.4,-.95],[-1.4,.95],[-1.4,-.95]].forEach(w=>add(wg,MAT_TIRE_B,w[0],0.38,w[1]));
} else {
p.h=1.0; p.invM=0; p.circles=[[-1.1,0,0.72],[0,0,0.72],[1.1,0,0.72]];
const gm=new THREE.MeshLambertMaterial({color:0xb5b5ac});
add(new THREE.BoxGeometry(3.4,0.35,1.0),gm,0,0.175,0);
add(new THREE.BoxGeometry(3.4,0.65,0.7),gm,0,0.675,0);
add(new THREE.BoxGeometry(3.42,0.1,0.72),new THREE.MeshLambertMaterial({color:0xe0a800}),0,0.98,0);
}
base.rotation.y=p.yaw; scene.add(base); props.push(p); return p;
}
function resetProp(p){
p.x=p.homeX; p.z=p.homeZ; p.vx=p.vz=p.vy=0; p.crushed=false; p.crushK=0; p.knocked=false; p.scoreCd=0; p.sx=p.sz=0;
p.y=p.gy=hAt(p.x,p.z); p.pivot.scale.set(1,1,1); p.pivot.rotation.set(0,0,0); p.base.visible=true;
p.base.position.set(p.x,p.y,p.z);
}
(function buildProps(){
for(let g=0;g<3;g++){ // concrete barrier lines
const s=freeSpot(6,6,33); if(!s) continue; const ang=rng()*Math.PI;
for(let i=-1;i<=1;i++){ const p=makeProp('concrete',s.x+Math.cos(ang)*i*3.6,s.z-Math.sin(ang)*i*3.6,ang); resetProp(p); }
}
for(let i=0;i<7;i++){ const s=freeSpot(3,4,34); if(s){ const p=makeProp('junk',s.x,s.z,rng()*TAU); resetProp(p); } }
for(let i=0;i<15;i++){ const s=freeSpot(1.2,3,36); if(s){ const p=makeProp('tire',s.x,s.z,rng()*TAU); resetProp(p); } }
for(let c=0;c<4;c++){
const s=freeSpot(3,4,34); if(!s) continue;
for(let i=0;i<5;i++){ const a=rng()*TAU, r=0.6+rng()*1.3; const p=makeProp('barrel',s.x+Math.cos(a)*r,s.z+Math.sin(a)*r,0); resetProp(p); }
}
})();
/* ---------------- HUD / popups ---------------- */
let cache={};
function setText(id,v){ if(cache[id]!==v){ cache[id]=v; $(id).textContent=v; } }
function updateHUD(){
setText('score',String(G.score));
const s=Math.ceil(G.time-1e-6), m=Math.floor(s/60);
setText('clock',m+':'+String(s%60).padStart(2,'0'));
$('clock').className=G.time<=10&&G.phase!=='countdown'?'low':'';
const hp=Math.max(0,trucks[0].hp); if(cache.hull!==hp){cache.hull=hp; $('hull').style.width=hp+'%';}
let b='', sub='';
if(G.phase==='countdown') b=String(Math.max(1,Math.ceil(G.cd/0.8)));
else if(G.phase==='play'&&G.goT>0) b='GO!';
else if(G.phase==='over'){ b='TIME!'; sub='<small>FINAL SCORE '+G.score+' · PRESS R OR ENTER TO ROLL AGAIN</small>'; }
const html=b+sub; if(cache.banner!==html){ cache.banner=html; $('banner').innerHTML=html; }
}
function popup(txt,cls){
const el=document.createElement('div'); el.className='pop '+(cls||''); el.textContent=txt; $('pops').appendChild(el);
pops.push({el:el,life:1.5});
while(pops.length>5){ const o=pops.shift(); o.el.remove(); }
}
function updatePops(dt){
for(let i=pops.length-1;i>=0;i--){ const p=pops[i]; p.life-=dt; if(p.life<=0){p.el.remove();pops.splice(i,1);continue;} p.el.style.opacity=Math.min(1,p.life/0.5).toFixed(2); }
}
function addScore(n,label,cls){
if(G.phase!=='play') return;
G.score+=n; popup('+'+n+' '+label,cls);
}
/* ---------------- collision model ---------------- */
const WALLBODY={x:0,z:0,vx:0,vz:0,w:0,invM:0,invI:0};
const pairCd=new Float32Array(25);
// 2D rigid-body impulse with yaw rotation. n points from A to B. returns closing speed (0 if separating).
function resolve(A,B,nx,nz,cx,cz,pen,e){
const invSum=A.invM+B.invM; if(invSum===0) return 0;
const rax=cx-A.x,raz=cz-A.z,rbx=cx-B.x,rbz=cz-B.z;
const vax=A.vx+A.w*raz, vaz=A.vz-A.w*rax, vbx=B.vx+B.w*rbz, vbz=B.vz-B.w*rbx;
const vn=(vbx-vax)*nx+(vbz-vaz)*nz;
const corr=Math.max(pen-0.01,0)*0.8/invSum;
A.x-=nx*corr*A.invM; A.z-=nz*corr*A.invM; B.x+=nx*corr*B.invM; B.z+=nz*corr*B.invM;
if(vn>=0) return 0;
const raN=raz*nx-rax*nz, rbN=rbz*nx-rbx*nz;
const k=invSum+raN*raN*A.invI+rbN*rbN*B.invI;
const j=-(1+e)*vn/k;
A.vx-=j*nx*A.invM; A.vz-=j*nz*A.invM; A.w-=j*raN*A.invI;
B.vx+=j*nx*B.invM; B.vz+=j*nz*B.invM; B.w+=j*rbN*B.invI;
return -vn;
}
function damage(t,amt,src){
if(t.state!=='ok'||t.invuln>0) return;
t.hp-=amt; if(src){t.lastHit=src;t.lastHitT=simT;}
if(t.hp<=0) wreck(t);
}
function wreck(t){
t.hp=0; t.state='wrecked'; t.wreckT=5; t.vy=Math.max(t.vy,8); t.grounded=false;
t.w+=(R()-.5)*8; t.flipDir=R()<.5?-1:1; t.thr=t.steer=0;
t.bodyMat.color.setHex(0x2a2a2a); t.bodyMat.emissive.setRGB(0,0,0); t.label.visible=false;
for(let i=0;i<28;i++) fire(t.x,t.y+1.5,t.z);
for(let i=0;i<16;i++) smoke(t.x,t.y+2,t.z,true);
sparks(t.x,t.y+1.5,t.z,30,14);
crowdExcite=Math.min(1.6,crowdExcite+0.8);
if(t.isPlayer){ popup('WRECKED!','bad'); shake=1; }
else if(t.lastHit&&t.lastHit.isPlayer&&simT-t.lastHitT<5){ addScore(1000,'WRECKED '+t.name+'!','big'); shake=Math.min(1,shake+0.5); }
else popup(t.name+' WRECKED','');
thunk(22);
}
function truckImpact(A,B,cs,cx,cz){
const key=A.id*5+B.id; if(pairCd[key]>0) return; pairCd[key]=0.3;
const den=A.spd+B.spd+0.5, fa=B.spd/den, fb=A.spd/den;
damage(A,cs*(0.8+1.6*fa)*0.9,B); damage(B,cs*(0.8+1.6*fb)*0.9,A);
A.flash=B.flash=1;
const hop=3.2+Math.min(cs*0.2,3);
if(A.state==='ok') A.vy=Math.max(A.vy,hop);
if(B.state==='ok') B.vy=Math.max(B.vy,hop);
sparks(cx,Math.max(A.y,B.y)+1.3,cz,Math.min(30,8+Math.round(cs*2)),Math.min(16,cs+4));
crowdExcite=Math.min(1.6,crowdExcite+cs*0.04);
thunk(cs);
if(A.isPlayer||B.isPlayer){
const me=A.isPlayer?A:B, o=A.isPlayer?B:A;
shake=Math.min(1,shake+cs*0.04);
if(cs>=4){ const share=0.4+0.6*me.spd/(me.spd+o.spd+0.5); const pts=Math.max(10,Math.round(cs*15*share/10)*10); addScore(pts,'RAM!'); }
}
[A,B].forEach(t=>{ if(t.ai&&t.ai.mode==='attack'&&cs>5){ t.ai.mode='retreat'; t.ai.modeT=1.6+R(); t.ai.dir*=-1; t.ai.cool=2.5; } });
}
function crushCar(p,t){
p.crushed=true; p.crushK=0; p.respawnT=14; p.vx=p.vz=0;
dust(p.x,p.y+.5,p.z,14,6,2.6); sparks(p.x,p.y+.8,p.z,12,8);
crowdExcite=Math.min(1.6,crowdExcite+0.25); thunk(12);
if(t.isPlayer){ addScore(150,'CRUSHED!'); shake=Math.min(1,shake+0.3); }
}
function propHit(p,t,cs){
if(p.type==='concrete'){
if(cs>3&&t.hitCd<=0){ t.hitCd=0.3; damage(t,cs*0.7,null); sparks(t.x,t.y+1,t.z,10,8); t.flash=1; thunk(cs); if(t.isPlayer) shake=Math.min(1,shake+cs*0.03); }
return;
}
p.knocked=true;
p.vy=Math.max(p.vy,Math.min(9,cs*(p.type==='junk'?0.1:0.4)+(p.type==='junk'?0:2)));
if(p.type!=='junk'){ p.sx=(R()-.5)*10; p.sz=(R()-.5)*10; }
if(p.scoreCd<=0){
p.scoreCd=0.6; dust(p.x,p.y+.3,p.z,4,4,1.4);
crowdExcite=Math.min(1.6,crowdExcite+0.02);
if(t.isPlayer&&cs>2){
const pts=p.type==='tire'?25:p.type==='barrel'?15:40;
if(p.type==='barrel'&&pops.length>=3){ if(G.phase==='play') G.score+=pts; }
else addScore(pts,p.type==='tire'?'TIRES':p.type==='barrel'?'BARREL':'BUMP');
}
if(cs>4) thunk(cs*0.5);
}
}
function collideAll(){
// truck vs truck: two circles each, impulse with spin
for(let i=0;i<5;i++) for(let j=i+1;j<5;j++){
const A=trucks[i],B=trucks[j];
if(Math.abs(A.y-B.y)>2.2) continue;
const ddx=B.x-A.x, ddz=B.z-A.z; if(ddx*ddx+ddz*ddz>64) continue;
const afx=Math.sin(A.h),afz=Math.cos(A.h),bfx=Math.sin(B.h),bfz=Math.cos(B.h);
let maxCs=0,mx=0,mz=0,hit=false;
for(let ia=0;ia<2;ia++) for(let ib=0;ib<2;ib++){
const ca=CIRC[ia],cb=CIRC[ib];
const ax=A.x+afx*ca[0],az=A.z+afz*ca[0],bx=B.x+bfx*cb[0],bz=B.z+bfz*cb[0];
let nx=bx-ax,nz=bz-az; const d2=nx*nx+nz*nz, rr=ca[1]+cb[1];
if(d2>=rr*rr) continue;
let d=Math.sqrt(d2); if(d<1e-4){nx=1;nz=0;d=1e-4;}else{nx/=d;nz/=d;}
const pen=rr-d, px=ax+nx*(ca[1]-pen/2), pz=az+nz*(ca[1]-pen/2);
const cs=resolve(A,B,nx,nz,px,pz,pen,0.5); hit=true;
if(cs>maxCs){maxCs=cs;mx=px;mz=pz;}
}
if(hit&&maxCs>2.5) truckImpact(A,B,maxCs,mx,mz);
}
// truck vs props
for(const t of trucks){
const sh=Math.sin(t.h),ch=Math.cos(t.h);
for(const p of props){
if(p.crushed) continue;
const dx=p.x-t.x,dz=p.z-t.z; if(dx*dx+dz*dz>64) continue;
if(t.y>p.y+p.h-0.15) continue; // truck is above it (flying / crushing over)
if(p.y>t.y+3.2) continue; // prop is flying overhead
const cy=Math.cos(p.yaw),sy=Math.sin(p.yaw); let done=false;
for(let ic=0;ic<2&&!done;ic++){
const c=CIRC[ic], tx=t.x+sh*c[0], tz=t.z+ch*c[0];
for(let k=0;k<p.circles.length;k++){
const pc=p.circles[k], px=p.x+pc[0]*cy+pc[1]*sy, pz=p.z-pc[0]*sy+pc[1]*cy;
let nx=px-tx,nz=pz-tz; const d2=nx*nx+nz*nz, rr=c[1]+pc[2];
if(d2>=rr*rr) continue;
let d=Math.sqrt(d2); if(d<1e-4){nx=1;nz=0;d=1e-4;}else{nx/=d;nz/=d;}
if(p.type==='junk'){
const approach=(t.vx-p.vx)*nx+(t.vz-p.vz)*nz;
if(approach>9||(!t.grounded&&t.vy<-3&&t.y-p.y<p.h+0.6)){ crushCar(p,t); t.vx*=0.94; t.vz*=0.94; done=true; break; }
}
const pen=rr-d, cs=resolve(t,p,nx,nz,tx+nx*(c[1]-pen/2),tz+nz*(c[1]-pen/2),pen,p.type==='concrete'?0.3:0.35);
if(cs>1.2) propHit(p,t,cs);
}
}
}
}
// truck vs wall
for(const t of trucks){
const sh=Math.sin(t.h),ch=Math.cos(t.h);
for(let ic=0;ic<2;ic++){
const c=CIRC[ic], cx=t.x+sh*c[0], cz=t.z+ch*c[0], d=Math.hypot(cx,cz), pen=d+c[1]-WALL;
if(pen>0&&d>0.01){
const nx=cx/d,nz=cz/d;
const cs=resolve(t,WALLBODY,nx,nz,cx+nx*c[1],cz+nz*c[1],pen,0.35);
if(cs>5&&t.hitCd<=0){
t.hitCd=0.3; sparks(cx+nx*c[1],t.y+1,cz+nz*c[1],10,8); thunk(cs*0.6);
crowdExcite=Math.min(1.6,crowdExcite+0.03);
if(t.isPlayer) shake=Math.min(1,shake+cs*0.025);
}
}
}
}
}
/* ---------------- AI ---------------- */
function aiDecide(t){
const ai=t.ai;
let attackers=0; for(const o of trucks) if(o!==t&&o.ai&&o.ai.mode==='attack') attackers++;
if(ai.cool<=0&&attackers<2&&Math.random()<ai.aggr){
let best=null,bs=-9;
for(const c of trucks){
if(c===t||c.state!=='ok') continue;
let by=0; for(const o of trucks) if(o!==t&&o.ai&&o.ai.mode==='attack'&&o.ai.target===c) by++;
const s=(c.isPlayer?1.0:0.55)-0.7*by-Math.hypot(c.x-t.x,c.z-t.z)/70+Math.random()*0.35;
if(s>bs){bs=s;best=c;}
}
if(best){ ai.mode='attack'; ai.target=best; ai.modeT=4+Math.random()*3; ai.flank=(Math.random()<.5?-1:1)*(0.3+Math.random()*0.7); return; }
}
ai.mode='roam'; ai.target=null; ai.modeT=2.5+Math.random()*3.5;
if(Math.random()<0.4) ai.dir*=-1;
ai.ringR=clamp(ai.baseR+(Math.random()-.5)*7,9,35);
}
function aiThink(t,dt){
const ai=t.ai;
if(t.state!=='ok'||G.phase==='countdown'){ t.thr=0;t.steer=0;t.brake=false;return; }
ai.modeT-=dt; ai.cool-=dt;
if(ai.modeT<=0) aiDecide(t);
let tx,tz,skip=null;
if(ai.mode==='attack'){
const tg=ai.target;
if(!tg||tg.state!=='ok'){ ai.modeT=0; }
else{
const dx=tg.x-t.x,dz=tg.z-t.z,d=Math.hypot(dx,dz), lead=Math.min(d/22,1)*0.7;
tx=tg.x+tg.vx*lead; tz=tg.z+tg.vz*lead;
const off=ai.flank*Math.min(d,14)*0.35; tx+=(-dz/(d+0.01))*off; tz+=(dx/(d+0.01))*off; skip=tg;
}
}
if(tx===undefined){
const a=Math.atan2(t.z,t.x)+ai.dir*0.7; tx=Math.cos(a)*ai.ringR; tz=Math.sin(a)*ai.ringR;
}
let dx=tx-t.x,dz=tz-t.z; const dl=Math.hypot(dx,dz)||1; dx/=dl; dz/=dl;
// separation: keeps the four rivals from bunching into one blob
for(const q of trucks){
if(q===t||q===skip) continue;
const ex=t.x-q.x,ez=t.z-q.z,e2=ex*ex+ez*ez;
if(e2<324&&e2>0.01){ const e=Math.sqrt(e2), w=(18-e)/18; dx+=ex/e*w*1.6; dz+=ez/e*w*1.6; }
}
const r=Math.hypot(t.x,t.z); if(r>32){ const w=(r-32)/6; dx-=t.x/r*w*1.5; dz-=t.z/r*w*1.5; }
const diff=wrap(Math.atan2(dx,dz)-t.h), ad=Math.abs(diff);
let steer=clamp(diff*2.4,-1,1), thr=ad<1.0?1:ad<1.8?0.55:0.25;
if(ai.mode!=='attack') thr*=0.85;
if(ai.revT>0){ ai.revT-=dt; thr=-1; steer=ai.revSteer; }
else{
if(t.spd<1.6&&thr>0){ ai.stuckT+=dt; if(ai.stuckT>1.1){ ai.revT=0.9+Math.random()*0.5; ai.revSteer=Math.random()<.5?-1:1; ai.stuckT=0; } }
else ai.stuckT=0;
}
t.thr=thr; t.steer=steer; t.brake=false;
}
/* ---------------- truck physics ---------------- */
function landFx(t,im){
dust(t.x,t.y+.2,t.z,Math.min(16,Math.round(im)),im*0.5,2.4);
t.sv-=im*0.5;
if(t.isPlayer){ shake=Math.min(1,shake+im*0.02); if(t.airT>0.8&&G.phase==='play') addScore(Math.round(t.airT*250/10)*10,'BIG AIR!','big'); }
if(im>9){ thunk(im*0.6); crowdExcite=Math.min(1.6,crowdExcite+0.05); }
}
function moveTruck(t,dt){
const ok=t.state==='ok', sh=Math.sin(t.h), ch=Math.cos(t.h);
let vf=t.vx*sh+t.vz*ch, vl=t.vx*ch-t.vz*sh;
const thr=ok?t.thr:0, str=ok?t.steer:0;
if(t.grounded){
if(thr>0) vf+=(vf<0?BRAKE:ACC*Math.max(0,1-vf/t.maxV))*thr*dt;
else if(thr<0) vf+=thr*(vf>0?BRAKE:ACC*0.7*Math.max(0,1+vf/REV))*dt;
if(ok&&t.brake){ vf*=Math.exp(-3*dt); vl*=Math.exp(-1.5*dt); }
vl*=Math.exp(-(ok?5.5:3)*dt);
vf*=Math.exp((ok?-0.15:-1.4)*dt);
if(thr===0) vf*=Math.exp(-0.5*dt);
} else { vf*=Math.exp(-0.05*dt); vl*=Math.exp(-0.3*dt); }
const tgtW=ok&&t.grounded?str*(2.0-1.0*Math.min(Math.abs(vf)/t.maxV,1))*clamp(vf/5,-1,1):0;
t.w+=(tgtW-t.w)*Math.min(1,(t.grounded?4:0.8)*dt);
t.vx=sh*vf+ch*vl; t.vz=ch*vf-sh*vl;
const sp=Math.hypot(t.vx,t.vz); if(sp>34){ t.vx*=34/sp; t.vz*=34/sp; }
t.h+=t.w*dt; t.x+=t.vx*dt; t.z+=t.vz*dt;
// vertical: ballistic, snapped to terrain, launched by crests
const gy=hAt(t.x,t.z), gv=(gy-t.gyPrev)/dt; t.gyPrev=gy;
t.vy-=GRAV*dt; t.y+=t.vy*dt;
let on=false;
if(t.y<=gy) on=true; else if(t.grounded&&t.vy<3&&t.y-gy<0.3) on=true;
if(on){
const was=t.grounded, im=-t.vy;
if(!was&&im>6) landFx(t,im);
if(!was&&im>9){ t.vy=im*0.25; t.y=gy; t.grounded=false; t.airT=0; }
else { t.y=gy; t.vy=Math.max(gv,0); t.grounded=true; if(!was) t.airT=0; }
} else { t.grounded=false; t.airT+=dt; }
t.spd=Math.hypot(t.vx,t.vz);
const fv=t.vx*sh+t.vz*ch;
t.spinA+=fv*dt/1.0;
t.steerV+=((ok?t.steer:0)*0.5-t.steerV)*Math.min(1,10*dt);
// dust from the rear tyres
if(t.grounded&&t.spd>5){
t.dustAcc+=t.spd*dt*0.5;
while(t.dustAcc>1){ t.dustAcc-=1; for(const s of [-1,1]) emit(smokeP,t.x-sh*1.9+ch*s*1.6,t.y+.3,t.z-ch*1.9-sh*s*1.6,(R()-.5)*1.5,.8+R(),(R()-.5)*1.5,.7+R()*.4,.6,2.2,.62,.5,.36,.3,1.5,-.4); }
}
// damage smoke / wreck fire
if(t.state==='wrecked'){
if(R()<0.6) fire(t.x,t.y+1.5,t.z);
if(R()<0.4) smoke(t.x,t.y+2,t.z,true);
} else if(t.hp<35&&R()<0.15) smoke(t.x+sh*2,t.y+2.4,t.z+ch*2,true);
}
function syncTruck(t,dt){
const m=t.mesh, sh=Math.sin(t.h), ch=Math.cos(t.h);
m.position.set(t.x,t.y,t.z); m.rotation.y=t.h;
let pT,rT;
if(t.state==='wrecked'){ pT=0; rT=t.flipDir*3.0; }
else if(t.grounded){
const hF=hAt(t.x+sh*2.4,t.z+ch*2.4),hB=hAt(t.x-sh*2.4,t.z-ch*2.4),hL=hAt(t.x+ch*1.7,t.z-sh*1.7),hR=hAt(t.x-ch*1.7,t.z+sh*1.7);
pT=Math.atan2(hF-hB,4.8); rT=Math.atan2(hL-hR,3.4);
} else {
const vf=t.vx*sh+t.vz*ch; pT=clamp(Math.atan2(t.vy,Math.max(Math.abs(vf),6))*0.9,-0.7,0.7); rT=0;
}
const k=Math.min(1,dt*(t.state==='wrecked'?3:9));
t.pitchV+=(pT-t.pitchV)*k; t.rollV+=(rT-t.rollV)*k;
t.tilt.rotation.set(-t.pitchV,0,t.rollV);
t.tilt.position.y=1.5+0.25*Math.min(1,Math.abs(t.rollV)/2.5);
t.sv+=(-70*t.sp-7*t.sv)*dt; t.sp=clamp(t.sp+t.sv*dt,-0.5,0.4);
t.model.position.y=-1.5+t.sp;
t.model.visible=!(t.invuln>0&&Math.floor(simT*12)%2===0);
t.flash=Math.max(0,t.flash-dt*5);
if(t.state==='ok') t.bodyMat.emissive.setRGB(t.flash*0.9,t.flash*0.2,0);
for(const w of t.wheels){ w.spin.rotation.x=t.spinA%TAU; if(w.front) w.pivot.rotation.y=t.steerV; }
const hpf=clamp(t.hp/100,0,1); t.label.material.color.setRGB(1,0.35+0.65*hpf,0.35+0.65*hpf);
}
/* ---------------- props update ---------------- */
function updateProps(dt){
for(const p of props){
if(p.crushed){
if(p.crushK<1){ p.crushK=Math.min(1,p.crushK+dt*7); p.pivot.scale.y=1-0.8*p.crushK; p.pivot.scale.x=1+0.15*p.crushK; p.pivot.scale.z=1+0.15*p.crushK; }
p.respawnT-=dt; if(p.respawnT<=0){ resetProp(p); dust(p.x,p.y+.5,p.z,6,3,2); }
continue;
}
if(p.invM===0) continue;
p.scoreCd-=dt;
const sp2=p.vx*p.vx+p.vz*p.vz;
if(sp2<0.0004&&p.vy===0&&p.y<=p.gy+0.0001) continue;
p.x+=p.vx*dt; p.z+=p.vz*dt;
const r=Math.hypot(p.x,p.z), lim=WALL-0.9;
if(r>lim){ const nx=p.x/r,nz=p.z/r; p.x=nx*lim; p.z=nz*lim; const vn=p.vx*nx+p.vz*nz; if(vn>0){ p.vx-=1.6*vn*nx; p.vz-=1.6*vn*nz; } }
const gy=hAt(p.x,p.z); p.gy=gy;
p.vy-=GRAV*dt; p.y+=p.vy*dt;
let air=true;
if(p.y<=gy){
if(p.vy<-4){ p.vy=-p.vy*0.3; dust(p.x,gy+.2,p.z,2,2,1.2); } else p.vy=0;
p.y=gy; const f=Math.exp(-1.6*dt); p.vx*=f; p.vz*=f; air=false;
if(p.vx*p.vx+p.vz*p.vz<0.01){ p.vx=p.vz=0; }
}
if(p.type==='tire'||p.type==='barrel'){
const rot=p.pivot.rotation;
if(air||sp2>4){ rot.x+=p.sx*dt; rot.z+=p.sz*dt; }
else{
const tz=(p.type==='barrel'&&p.knocked)?(Math.abs(rot.z)>0.1?Math.sign(rot.z)*Math.PI/2:0):0;
const kk=Math.min(1,6*dt); rot.x+=(0-(rot.x%TAU>Math.PI?rot.x%TAU-TAU:rot.x%TAU))*kk;
rot.z+=(tz-rot.z)*kk; p.sx*=0.9; p.sz*=0.9;
}
}
p.base.position.set(p.x,p.y,p.z);
}
}
/* ---------------- main simulation step ---------------- */
function step(dt){
simT+=dt;
if(G.phase==='countdown'){ G.cd-=dt; if(G.cd<=0){ G.phase='play'; G.goT=0.9; } }
else if(G.phase==='play'){ G.time-=dt; if(G.goT>0) G.goT-=dt; if(G.time<=0){ G.time=0; G.phase='over'; crowdExcite=1.4; } }
// inputs
const p=trucks[0];
if(G.phase==='play'&&p.state==='ok'){
const up=keys.KeyW||keys.ArrowUp, dn=keys.KeyS||keys.ArrowDown, lf=keys.KeyA||keys.ArrowLeft, rt=keys.KeyD||keys.ArrowRight;
p.thr=(up?1:0)-(dn?1:0); p.steer=(lf?1:0)-(rt?1:0); p.brake=!!keys.Space;
} else { p.thr=0; p.steer=0; p.brake=false; }
for(let i=1;i<5;i++) aiThink(trucks[i],dt);
for(const t of trucks){
t.hitCd-=dt; if(t.invuln>0) t.invuln-=dt;
if(t.state==='wrecked'){ t.wreckT-=dt; }
moveTruck(t,dt);
}
for(let i=0;i<25;i++) if(pairCd[i]>0) pairCd[i]-=dt;
collideAll();
for(const t of trucks) if(t.state==='wrecked'&&t.wreckT<=0) respawnTruck(t);
updateProps(dt);
updatePool(fireP,dt); updatePool(smokeP,dt);
crowdExcite=Math.max(0.12,crowdExcite-dt*0.3);
shake=Math.max(0,shake-dt*2.5);
updatePops(dt);
}
/* ---------------- camera & per-frame visuals ---------------- */
let camAng=0; const camBase=new THREE.Vector3();
function updateCamera(dt,snap){
const p=trucks[0];
const dA=wrap(p.h-camAng); camAng+=snap?dA:dA*Math.min(1,dt*3.2);
const sp=Math.hypot(p.vx,p.vz), dist=13+sp*0.12;
let tx=p.x-Math.sin(camAng)*dist, tz=p.z-Math.cos(camAng)*dist;
const rr=Math.hypot(tx,tz); if(rr>38.5){ tx*=38.5/rr; tz*=38.5/rr; }
const ty=Math.max(p.y,hAt(tx,tz))+6.5+sp*0.05;
const k=snap?1:1-Math.exp(-7*dt);
camBase.x+=(tx-camBase.x)*k; camBase.y+=(ty-camBase.y)*k; camBase.z+=(tz-camBase.z)*k;
const s=shake*shake*0.8;
camera.position.set(camBase.x+(Math.random()-.5)*s,camBase.y+(Math.random()-.5)*s,camBase.z+(Math.random()-.5)*s);
camera.position.y=Math.max(camera.position.y,hAt(camera.position.x,camera.position.z)+2);
camera.lookAt(p.x+Math.sin(camAng)*5,p.y+2.2,p.z+Math.cos(camAng)*5);
const fov=62+Math.min(sp,28)*0.35;
if(Math.abs(fov-camera.fov)>0.01){ camera.fov=fov; camera.updateProjectionMatrix(); }
}
function updateVisuals(dt){
for(const t of trucks) syncTruck(t,dt);
updateCamera(dt,false);
crowdClock+=dt*(5+crowdExcite*4);
const amp=0.06+Math.min(crowdExcite,1.4)*0.32;
for(let i=0;i<CN;i++){ const y=crowdY[i]+Math.abs(Math.sin(crowdClock+crowdPh[i]))*amp; bodyArr[i*16+13]=y; headArr[i*16+13]=y; }
bodies.instanceMatrix.needsUpdate=true; heads.instanceMatrix.needsUpdate=true;
updateHUD(); audioTick();
}
/* ---------------- reset / modes ---------------- */
function resetGame(){
const demo=/#demo$/.test(location.href);
G.demo=demo;
G.time=demo?20:120; // #demo: real clock state
G.score=demo?5000:0; // #demo: real score state
G.phase='countdown'; G.cd=2.4; G.goT=0;
trucks.forEach((t,i)=>placeTruck(t,i,false));
props.forEach(resetProp);
clearPool(fireP); clearPool(smokeP);
pops.forEach(o=>o.el.remove()); pops.length=0;
crowdExcite=0.12; shake=0; simT=0; crowdClock=0;
for(const t of trucks) syncTruck(t,0.016);
camAng=trucks[0].h; camBase.set(0,8,-40); updateCamera(0.016,true);
cache={}; updateHUD();
}
function togglePhoto(){
photo=!photo;
$('photo').style.display=photo?'flex':'none';
document.body.classList.toggle('frozen',photo);
if(photo){ $('pScore').textContent=String(G.score); if(AC){ try{AC.suspend();}catch(e){} } }
else { for(const k in keys) keys[k]=false; if(AC&&!muted){ try{AC.resume();}catch(e){} } last=performance.now(); }
}
/* ---------------- input ---------------- */
window.addEventListener('keydown',e=>{
const c=e.code;
if(c==='ArrowUp'||c==='ArrowDown'||c==='ArrowLeft'||c==='ArrowRight'||c==='Space') e.preventDefault();
initAudio();
if(c==='KeyP'){ if(!e.repeat) togglePhoto(); return; }
if(photo) return;
if(c==='KeyR'||(c==='Enter'&&G.phase==='over')){ if(!e.repeat) resetGame(); return; }
if(c==='KeyM'){ if(!e.repeat){ muted=!muted; if(master) master.gain.value=muted?0:0.55; } return; }
keys[c]=true;
});
window.addEventListener('keyup',e=>{ keys[e.code]=false; });
window.addEventListener('blur',()=>{ for(const k in keys) keys[k]=false; });
window.addEventListener('pointerdown',()=>{ try{window.focus();}catch(e){} initAudio(); });
window.addEventListener('hashchange',()=>{ if(photo) togglePhoto(); resetGame(); });
window.addEventListener('resize',()=>{
renderer.setSize(innerWidth,innerHeight); camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix();
});
/* ---------------- loop ---------------- */
let last=performance.now(), acc=0; const bufSize=new THREE.Vector2();
function frame(now){
requestAnimationFrame(frame);
const dt=Math.min((now-last)/1000,0.1); last=now;
if(!photo){ // photo mode: no physics step, no clocks, no AI, no particles, no crowd, no camera motion
acc+=dt; let n=0;
while(acc>=STEP&&n<5){ step(STEP); acc-=STEP; n++; }
if(n===5) acc=0;
updateVisuals(dt);
}
renderer.getDrawingBufferSize(bufSize);
uScale.value=bufSize.y/(2*Math.tan(camera.fov*Math.PI/360));
renderer.render(scene,camera); // still draws the (frozen) frame so resize/overlay stay correct
}
window.__rally={G:G,trucks:trucks,props:props,get photo(){return photo;}};
resetGame();
$('help').focus&&0;
requestAnimationFrame(t=>{ last=t; frame(t); });
})();
</script>
</body>
</html>
NAMES has five entries and that array is the only thing that creates trucks, so there is one player and four AI, and respawns reuse the same objects. The rivals don't clump because each has its own patrol ring (radius about 14, 20, 25 and 33 m) and direction. At most two of them may attack at once. Each attacker picks the target with the fewest chasers already on it, with a lateral flank offset. An attacker retreats and cools down after a hit. Every rival also gets a separation push from any truck within 18 m, except its own target, and wrecked trucks respawn in the emptiest spawn slot.resetGame() runs G.time = demo ? 20 : 120; G.score = demo ? 5000 : 0; where demo = /#demo$/.test(location.href). G.time and G.score are the same variables the countdown decrements, addScore() increments, the HUD prints and the game-over check reads. The 5000 is a starting value that scoring keeps adding to, and at 0 seconds the round really ends. A hashchange listener re-runs the reset, so appending #demo after load also works.P stops the physics step, because the fixed-step accumulator is not advanced and step() never runs. That also freezes everything driven by it: the clock, AI, particles, popups, crowd bounce, camera and per-frame visuals. Audio is suspended, and the CSS animations are paused. The requestAnimationFrame loop and the renderer.render call do keep running, only to redraw the identical frozen frame under the arena-name and score card. Pressing P again resumes and resets the timer so there is no time jump.v + ω×r), and applies a restitution impulse j = -(1+e)vₙ / (Σ invMass + Σ (r×n)²·invI). That gives linear knock-back and a yaw spin, plus positional push-out. The closing speed vₙ then drives everything else. Damage is split by each truck's share of the closing speed, and a truck at 0 HP is wrecked, flipped, burning and respawned 5 s later. The player is scored on ram speed, and gets a 1000-point bonus for a wreck the player caused. Junk cars are crushed above 9 m/s of closing speed or when landed on, and concrete barriers damage whoever hits them.