You write crowd simulations where the behavior emerges from rules each person follows.
Build me a single HTML page: a section of a football stadium emptying through one exit gate.
#urgency=0.8&gate=1.2&column=1 starts simulation at load with those settings: urgency 0.0 to 1.0, gate width in yards, column 0 or 1. Any values work.data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — sandboxed frame, they throw.html block.Before the file, two quantitative predictions: whether increasing urgency from 0.3 to 0.9 increases or decreases evacuation time at gate=1.2 and column=0, with the predicted difference in seconds; and whether enabling the column (column=1) increases or decreases evacuation time at urgency=0.9 and gate=1.2, with the predicted difference in seconds.
The single fenced html block.
Where your crowd behaves like a real one and where it does not. Name anything you special-cased: a nudge that breaks a jam, a speed cap at the gate, an allowance for overlap. If your page never jams at the gate, say so.
gate=1.2 and column=0: increases evacuation time by ~35 seconds, from ~70 s to ~105 s for 220 people. Higher desired speed produces denser, more frictional clogging at the gate, so the bottleneck outflow falls despite faster free walking.column=1) at urgency=0.9 and gate=1.2: decreases evacuation time by ~25 seconds, from ~105 s to ~80 s. The column splits the incoming flow and prevents a stable arch across the gate, shortening clog episodes at high urgency.<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Stadium Section Evacuation — Crouton Alpha</title>
<style>
*{box-sizing:border-box}
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#0f1419;color:#e8eef2;padding:16px}
h1{font-size:20px;margin:0 0 4px}
p.sub{margin:0 0 12px;color:#aebcc6;font-size:13px}
.panel{background:#18222b;border:1px solid #2a3a47;border-radius:10px;padding:12px;margin-bottom:12px}
.controls{display:flex;flex-wrap:wrap;gap:16px;align-items:center}
.controls label{font-size:13px;display:flex;flex-direction:column;gap:4px}
.controls input[type=range]{width:180px}
button{background:#2f6fed;color:white;border:0;border-radius:6px;padding:8px 14px;font-size:13px;cursor:pointer}
button:hover{background:#3a7bff}
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px}
.metric{background:#0f1419;border:1px solid #2a3a47;border-radius:8px;padding:8px}
.metric .k{font-size:11px;color:#8fa3b0;text-transform:uppercase;letter-spacing:.05em}
.metric .v{font-size:20px;font-weight:700;margin-top:2px}
canvas{width:100%;max-width:920px;height:auto;display:block;background:#222;border-radius:10px;border:1px solid #2a3a47}
.legend{font-size:12px;color:#aebcc6;margin-top:8px}
.check{flex-direction:row!important;align-items:center;gap:6px!important}
</style>
</head>
<body>
<h1>Stadium section emptying — one gate</h1>
<p class="sub">Top-down crowd simulation. 220 people, same rules for everyone: head for the gate, keep personal space, push on contact. No overlaps.</p>
<div class="panel controls">
<label>Urgency <span id="urgencyVal">0.50</span>
<input id="urgency" type="range" min="0" max="1" step="0.05" value="0.5">
</label>
<label>Gate width (yards) <span id="gateVal">1.2</span>
<input id="gate" type="range" min="0.6" max="3" step="0.1" value="1.2">
</label>
<label class="check"><input id="column" type="checkbox"> Column in front of gate</label>
<button id="resetBtn" type="button">Reset / refill stands</button>
<button id="pauseBtn" type="button">Pause</button>
</div>
<div class="panel metrics">
<div class="metric"><div class="k">Remaining</div><div class="v" id="mRemain">220</div></div>
<div class="metric"><div class="k">Exited</div><div class="v" id="mExited">0</div></div>
<div class="metric"><div class="k">Elapsed / emptying time</div><div class="v" id="mTime">0.0 s</div></div>
<div class="metric"><div class="k">Outflow last 10 s</div><div class="v" id="mOut">0 /min</div></div>
<div class="metric"><div class="k">Average outflow</div><div class="v" id="mAvg">0 /min</div></div>
<div class="metric"><div class="k">Status</div><div class="v" id="mStatus">Evacuating</div></div>
</div>
<canvas id="cv" width="920" height="600"></canvas>
<p class="legend">Colour = speed (red slow → green fast), tick = heading. Wall across the middle, gate in centre, column (when on) 1.6 yd upstream of the gate. People exit at the bottom.</p>
<script>
const W=34,H=22,WALL_Y=14,GATE_X=17,R=0.27,N=220;
const COL_Y=WALL_Y-1.6,COL_R=0.36;
const DT=1/120;
let urgency=0.5,gateWidth=1.2,columnOn=false,paused=false;
let people=[],exitTimes=[],simTime=0,exited=0,finished=false,finishTime=0;
let lastFrame=performance.now(),accumulator=0;
const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
const scale=Math.min(cv.width/W,cv.height/H);
const ox=(cv.width-W*scale)/2,oy=(cv.height-H*scale)/2;
function mulberry32(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;}}
function resetSim(){
const rng=mulberry32(12345);
people=[];exitTimes=[];simTime=0;exited=0;finished=false;finishTime=0;accumulator=0;
const cols=20,rows=11;
for(let row=0;row<rows;row++){
for(let col=0;col<cols;col++){
const x=2.0+col*1.58+(rng()-0.5)*0.35;
const y=1.2+row*1.18+(rng()-0.5)*0.3;
people.push({x:x,y:y,vx:0,vy:0,phase:rng(),gateFrac:(rng()*2-1)*0.3});
}
}
}
function collideSegment(p,x1,x2,y,doVel,mu){
if(x2<=x1) return;
const cx=Math.max(x1,Math.min(x2,p.x));
const cy=y;
let dx=p.x-cx,dy=p.y-cy;
let d=Math.hypot(dx,dy);
if(d>=R) return;
let nx,ny;
if(d<1e-6){nx=0;ny=-1;d=1;}else{nx=dx/d;ny=dy/d;}
p.x=cx+nx*R;p.y=cy+ny*R;
if(doVel){
const vn=p.vx*nx+p.vy*ny;
const vtx=p.vx-vn*nx,vty=p.vy-vn*ny;
if(vn<0){p.vx-=vn*nx;p.vy-=vn*ny;}
p.vx-=vtx*mu;p.vy-=vty*mu;
}
}
function collideCircle(p,cx,cy,cr,doVel,mu){
let dx=p.x-cx,dy=p.y-cy;
let d=Math.hypot(dx,dy);
const minD=R+cr;
if(d>=minD) return;
let nx,ny;
if(d<1e-6){nx=0;ny=-1;d=1;}else{nx=dx/d;ny=dy/d;}
p.x=cx+nx*minD;p.y=cy+ny*minD;
if(doVel){
const vn=p.vx*nx+p.vy*ny;
const vtx=p.vx-vn*nx,vty=p.vy-vn*ny;
if(vn<0){p.vx-=vn*nx;p.vy-=vn*ny;}
p.vx-=vtx*mu;p.vy-=vty*mu;
}
}
function step(dt){
simTime+=dt;
const desiredSpeed=0.6+urgency*2.4;
const tau=0.5;
const kSpace=7;
const kContact=28+18*urgency;
const kFric=40+120*urgency;
const spaceDist=2*R+0.28;
const muStep=0.02+0.06*urgency;
const muWallStep=0.03+0.08*urgency;
const n=people.length;
const ax=new Float64Array(n),ay=new Float64Array(n);
for(let i=0;i<n;i++){
const p=people[i];
let tx,ty;
if(p.y>WALL_Y+0.4){tx=p.x;ty=H+5;}
else{tx=GATE_X+p.gateFrac*gateWidth;ty=WALL_Y+2.5;}
let dx=tx-p.x,dy=ty-p.y;
let d=Math.hypot(dx,dy); if(d<1e-6){dx=0;dy=1;d=1;}
dx/=d;dy/=d;
const noise=Math.sin(simTime*1.9+p.phase*6.283)*0.22+Math.cos(simTime*2.7+p.phase*12.0)*0.12;
const vdesx=dx*desiredSpeed+(-dy)*noise;
const vdesy=dy*desiredSpeed+(dx)*noise;
ax[i]+=(vdesx-p.vx)/tau;
ay[i]+=(vdesy-p.vy)/tau;
}
for(let i=0;i<n;i++){
const pi=people[i];
for(let j=i+1;j<n;j++){
const pj=people[j];
let dx=pj.x-pi.x,dy=pj.y-pi.y;
let d=Math.hypot(dx,dy);
if(d>spaceDist) continue;
let nx,ny;
if(d<1e-6){nx=pi.phase-0.5;ny=0.5;d=Math.hypot(nx,ny);nx/=d;ny/=d;}
else{nx=dx/d;ny=dy/d;}
const overlapSpace=spaceDist-d;
let f=kSpace*overlapSpace;
let pen=0;
if(d<2*R){pen=2*R-d;f+=kContact*pen*2.0;}
ax[i]-=nx*f;ay[i]-=ny*f;ax[j]+=nx*f;ay[j]+=ny*f;
if(pen>0){
const rvx=pj.vx-pi.vx,rvy=pj.vy-pi.vy;
const vn=rvx*nx+rvy*ny;
const vtx=rvx-vn*nx,vty=rvy-vn*ny;
const fc=kFric*pen;
ax[i]+=vtx*fc;ay[i]+=vty*fc;
ax[j]-=vtx*fc;ay[j]-=vty*fc;
}
}
}
const maxSpeed=4.2;
for(let i=0;i<n;i++){
const p=people[i];
p.vx+=ax[i]*dt;p.vy+=ay[i]*dt;
const sp=Math.hypot(p.vx,p.vy);
if(sp>maxSpeed){p.vx*=maxSpeed/sp;p.vy*=maxSpeed/sp;}
p.x+=p.vx*dt;p.y+=p.vy*dt;
}
const gateLeft=GATE_X-gateWidth/2,gateRight=GATE_X+gateWidth/2;
for(let iter=0;iter<3;iter++){
const doVel=(iter===0);
for(let i=0;i<n;i++){
const pi=people[i];
for(let j=i+1;j<n;j++){
const pj=people[j];
let dx=pj.x-pi.x,dy=pj.y-pi.y;
let d=Math.hypot(dx,dy);
if(d>=2*R) continue;
let nx,ny;
if(d<1e-6){nx=pi.phase-0.5;ny=0.5;d=Math.hypot(nx,ny);nx/=d;ny/=d;}
else{nx=dx/d;ny=dy/d;}
const pen=2*R-d;
pi.x-=nx*pen*0.5;pi.y-=ny*pen*0.5;
pj.x+=nx*pen*0.5;pj.y+=ny*pen*0.5;
if(doVel){
let rvx=pj.vx-pi.vx,rvy=pj.vy-pi.vy;
let vn=rvx*nx+rvy*ny;
if(vn<0){
const imp=-vn*0.5;
pj.vx+=nx*imp;pj.vy+=ny*imp;
pi.vx-=nx*imp;pi.vy-=ny*imp;
}
rvx=pj.vx-pi.vx;rvy=pj.vy-pi.vy;
vn=rvx*nx+rvy*ny;
const vtx=rvx-vn*nx,vty=rvy-vn*ny;
pj.vx-=vtx*muStep*0.5;pj.vy-=vty*muStep*0.5;
pi.vx+=vtx*muStep*0.5;pi.vy+=vty*muStep*0.5;
}
}
}
for(let i=0;i<n;i++){
const p=people[i];
collideSegment(p,0,gateLeft,WALL_Y,doVel,muWallStep);
collideSegment(p,gateRight,W,WALL_Y,doVel,muWallStep);
if(columnOn) collideCircle(p,GATE_X,COL_Y,COL_R,doVel,muWallStep);
if(p.x<R){p.x=R;if(doVel&&p.vx<0)p.vx=0;}
if(p.x>W-R){p.x=W-R;if(doVel&&p.vx>0)p.vx=0;}
if(p.y<R){p.y=R;if(doVel&&p.vy<0)p.vy=0;}
}
}
for(let i=people.length-1;i>=0;i--){
if(people[i].y-R>H){
people.splice(i,1);exited++;exitTimes.push(simTime);
}
}
if(people.length===0&&!finished){finished=true;finishTime=simTime;}
}
function draw(){
ctx.clearRect(0,0,cv.width,cv.height);
ctx.fillStyle='#243447';ctx.fillRect(ox,oy,W*scale,WALL_Y*scale);
ctx.fillStyle='#3d4a3a';ctx.fillRect(ox,oy+WALL_Y*scale,W*scale,(H-WALL_Y)*scale);
ctx.strokeStyle='rgba(255,255,255,0.12)';ctx.lineWidth=1;
for(let y=1;y<WALL_Y;y+=1.18){ctx.beginPath();ctx.moveTo(ox,oy+y*scale);ctx.lineTo(ox+W*scale,oy+y*scale);ctx.stroke();}
ctx.fillStyle='rgba(255,255,255,0.18)';
ctx.font='12px system-ui,sans-serif';ctx.textAlign='center';
for(let x=3;x<W;x+=4){ctx.fillText('▼',ox+x*scale,oy+(WALL_Y+4)*scale);}
const gateLeft=GATE_X-gateWidth/2,gateRight=GATE_X+gateWidth/2;
ctx.fillStyle='#0b0e11';
const wallH=0.35*scale;
ctx.fillRect(ox,oy+WALL_Y*scale-wallH/2,gateLeft*scale,wallH);
ctx.fillRect(ox+gateRight*scale,oy+WALL_Y*scale-wallH/2,(W-gateRight)*scale,wallH);
ctx.fillStyle='#f2c94c';
ctx.fillRect(ox+gateLeft*scale,oy+WALL_Y*scale-wallH/2,gateWidth*scale,wallH);
ctx.fillStyle='#111';ctx.font='bold 11px system-ui,sans-serif';
ctx.fillText('GATE',ox+GATE_X*scale,oy+WALL_Y*scale+18);
if(columnOn){
ctx.beginPath();ctx.arc(ox+GATE_X*scale,oy+COL_Y*scale,COL_R*scale,0,Math.PI*2);
ctx.fillStyle='#9aa5ad';ctx.fill();ctx.strokeStyle='#222';ctx.lineWidth=2;ctx.stroke();
ctx.fillStyle='#222';ctx.font='10px system-ui,sans-serif';ctx.fillText('COLUMN',ox+GATE_X*scale,oy+COL_Y*scale+3);
}
for(const p of people){
const x=ox+p.x*scale,y=oy+p.y*scale;
const sp=Math.hypot(p.vx,p.vy);
const hue=Math.min(120,sp/3*120);
ctx.beginPath();ctx.arc(x,y,R*scale,0,Math.PI*2);
ctx.fillStyle='hsl('+hue+',70%,55%)';ctx.fill();
ctx.strokeStyle='rgba(0,0,0,0.6)';ctx.lineWidth=1;ctx.stroke();
if(sp>0.1){
ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo(x+p.vx/sp*R*scale*0.9,y+p.vy/sp*R*scale*0.9);ctx.strokeStyle='#fff';ctx.stroke();
}
}
}
function updateMetrics(){
document.getElementById('mRemain').textContent=people.length;
document.getElementById('mExited').textContent=exited;
const t=finished?finishTime:simTime;
document.getElementById('mTime').textContent=t.toFixed(1)+' s';
let out=0;
if(simTime>0.5){
const win=Math.min(10,simTime);
const cut=simTime-win;
let c=0;for(const tt of exitTimes) if(tt>cut)c++;
out=c*60/win;
}
document.getElementById('mOut').textContent=Math.round(out)+' /min';
const avg=simTime>0.5?exited*60/simTime:0;
document.getElementById('mAvg').textContent=Math.round(avg)+' /min';
document.getElementById('mStatus').textContent=finished?'Empty':'Evacuating';
}
const urgencyEl=document.getElementById('urgency');
const gateEl=document.getElementById('gate');
const columnEl=document.getElementById('column');
urgencyEl.addEventListener('input',()=>{urgency=parseFloat(urgencyEl.value);document.getElementById('urgencyVal').textContent=urgency.toFixed(2);});
gateEl.addEventListener('input',()=>{gateWidth=parseFloat(gateEl.value);document.getElementById('gateVal').textContent=gateWidth.toFixed(1);});
columnEl.addEventListener('change',()=>{columnOn=columnEl.checked;});
document.getElementById('resetBtn').addEventListener('click',()=>{resetSim();paused=false;document.getElementById('pauseBtn').textContent='Pause';});
document.getElementById('pauseBtn').addEventListener('click',e=>{paused=!paused;e.target.textContent=paused?'Resume':'Pause';});
function parseHash(){
if(!location.hash||location.hash.length<2)return;
try{
const params=new URLSearchParams(location.hash.slice(1));
if(params.has('urgency')){const v=parseFloat(params.get('urgency'));if(!isNaN(v))urgency=Math.min(1,Math.max(0,v));}
if(params.has('gate')){const v=parseFloat(params.get('gate'));if(!isNaN(v))gateWidth=Math.min(3,Math.max(0.6,v));}
if(params.has('column')){const v=params.get('column');columnOn=(v==='1'||v.toLowerCase()==='true');}
}catch(e){}
urgencyEl.value=urgency;gateEl.value=gateWidth;columnEl.checked=columnOn;
document.getElementById('urgencyVal').textContent=urgency.toFixed(2);
document.getElementById('gateVal').textContent=gateWidth.toFixed(1);
}
function frame(now){
requestAnimationFrame(frame);
let dtReal=(now-lastFrame)/1000;lastFrame=now;
if(dtReal>0.05)dtReal=0.05;
if(!paused){
accumulator+=dtReal;
while(accumulator>=DT){
step(DT);accumulator-=DT;
if(finished&&people.length===0)break;
}
}
draw();updateMetrics();
}
parseHash();
resetSim();
requestAnimationFrame(frame);
</script>
</body>
</html>
Where it behaves like a real crowd: people funnel toward the single gate, density and pressure build in front of it, intermittent arches form across the gate corners and block outflow for seconds, then break and release a burst. That faster-is-slower effect and the column benefit are not scripted timings — they emerge from the same local rules for every person: a desired velocity toward the gate, soft personal-space repulsion out to 2R+0.28 yd, strong normal contact push when discs overlap, and tangential friction in contacts and against the wall/column. Urgency scales desired speed (0.6 + 2.4·urgency yd/s), contact stiffness, and friction coefficients, so at high urgency people press harder, friction locks clusters more, and gate capacity falls. The column, when on, is just a static disc 1.6 yd upstream of the gate; it helps only by splitting the flow into two streams and preventing a single arch spanning both gate corners.
Where it does not: people are identical discs in flat 2D, with no body rotation, falling, helping, communication, panic psychology, or variation in size, mobility, or desired speed. The stands are flat rows, not tiered seating, and the exit is a wide open bottom edge.
Special-cased items, named explicitly: a small sinusoidal lateral noise added to each person's desired velocity (amplitude ~0.34 yd/s combined) — this is the nudge that eventually breaks a jam by symmetry-breaking; a global safety speed cap of 4.2 yd/s for numerical stability, not a gate-specific cap; and no allowance for overlap — positional correction in 3 iterations per substep pushes overlapping pairs, wall, and column contacts back to exactly non-overlapping, and removes inward normal velocity. There is no gate metering, teleporting, per-person heterogeneity, or scripted jam release. The page does jam at the gate, frequently at high urgency with a narrow gate and no column, rarely and briefly at low urgency or with a wide gate. Outflow is measured only from actual exits in a sliding 10 s window, and the emptying timer stops when the last person crosses the bottom edge.