You write simulations where the interesting behavior is supposed to emerge rather than be animated by hand.
Build me a single HTML page: a side-view escalator carrying 15 to 20 people. Then the escalator reverses, at high speed.
data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — sandboxed frame, they throw.html block.One paragraph, before the file: how a person is represented, what makes one lose balance, and what happens when two of them occupy the same place.
The single fenced html block.
Having written it, what does the page do when the escalator reverses? Name anything you special-cased or nudged. Say what it does badly — I am going to press the button and watch.
Each person is 11 point masses (head, neck, hip, two knees, two feet, two elbows, two hands) joined by rigid sticks and integrated with Verlet at 480 Hz under gravity. Upright figures have four "muscles", all force- or rate-limited:
Feet grip the treads only through Coulomb friction and the risers, so balance is lost when the support moves faster than a step can recover. Once the torso passes about 66°, the hip sinks below 42% of standing height, or no foot has touched ground for 0.35 s, the muscles switch off and the figure is a ragdoll. Two bodies in the same place are resolved by point-versus-capsule contacts (head, neck, hip, knees and feet against the other's torso, thighs, shins and head): they are pushed apart by inverse mass, closing velocity is removed, and sliding is damped. A fallen figure is therefore a solid obstacle that feet catch on and sliding bodies stack against. A figure gets up only when it is nearly at rest relative to the surface under it, nothing is in the column above it, and there is a free patch of ground within leg reach.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Escalator, reversed</title>
<style>
html,body{margin:0;height:100%;background:#0e1114;overflow:hidden;font:14px/1.35 system-ui,-apple-system,"Segoe UI",sans-serif;color:#dfe5ec}
#wrap{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
#stage{position:relative}
canvas{display:block}
#ui{position:absolute;left:14px;top:12px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
button{font:600 14px system-ui,-apple-system,"Segoe UI",sans-serif;border:0;border-radius:6px;padding:9px 14px;cursor:pointer;color:#fff}
#rev{background:#c9342b;box-shadow:0 2px 0 #7d1c16}
#rev.on{background:#2f8f57;box-shadow:0 2px 0 #1c5a36}
#rst{background:#39424d;box-shadow:0 2px 0 #20262d}
button:active{transform:translateY(1px)}
#stat{background:rgba(10,13,16,.6);padding:6px 10px;border-radius:6px;font-variant-numeric:tabular-nums}
</style>
</head>
<body>
<div id="wrap"><div id="stage">
<canvas id="cv"></canvas>
<div id="ui">
<button id="rev" title="Space">Throw into reverse</button>
<button id="rst">Reset</button>
<span id="stat"></span>
</div>
</div></div>
<script>
(function(){
'use strict';
var W=1200,H=680;
var cv=document.getElementById('cv'),ctx=cv.getContext('2d');
var stage=document.getElementById('stage'),btn=document.getElementById('rev'),rst=document.getElementById('rst'),stat=document.getElementById('stat');
/* ---------- world ---------- */
var G=520; /* px/s^2 (about 51 px per metre) */
var FLOOR_Y=610,X0=250,D=20,RISE=11.5,FB=2,M=30,FT=2;
var NS=FB+M+FT,X1=X0+NS*D,TOP_Y=FLOOR_Y-M*RISE,XR0=X0+FB*D,XR1=X0+(FB+M)*D;
var XMIN=-130;
var V_FWD=32,V_REV=-300,V_ACC=2400;
var SUB=8,ITER=3,h=1/(60*SUB),h2=h*h;
var MU_FLOOR=0.8,MU_BELT=0.55,FOOT_FR=2.5;
var PPK=0.5,PPF=0.15;
var VMAX=1500*h,VMAX2=VMAX*VMAX,DAMP=0.9994;
var KPH=400,KDH=20,KPT=900,KDT=30,CRAWL=220;
var UP=0,DOWN=1,RISING=2;
var N_PEOPLE=20,HR=44;
var BONES=[[1,2,5],[2,3,3.2],[2,4,3.2],[3,5,3],[4,6,3],[1,0,5.5]];
var CAND=[0,9,-9,18,-18,28,-28];
var beltV=V_FWD,beltO=0,beltN=0,beltD=0,reversed=false,simT=0;
var people=[];
function rnd(a,b){return a+Math.random()*(b-a);}
function stepH(xs){var u=(xs-XR0)/D;return (u<0?0:u>M?M:u)*RISE;}
/* surface query -> globals */
var sTop=0,sVx=0,sVy=0,sXs=0,sBelt=false;
function surf(x){
if(x<X0){sTop=FLOOR_Y;sVx=0;sVy=0;sBelt=false;return;}
if(x>=X1){sTop=TOP_Y;sVx=0;sVy=0;sBelt=false;return;}
var k=Math.floor((x-X0-beltO)/D),xs=X0+beltO+k*D;
sXs=xs;sBelt=true;sTop=FLOOR_Y-stepH(xs);sVx=beltV;
sVy=(xs>XR0&&xs<XR1)?-beltV*RISE/D:0;
}
function fixTx(tx){
if(tx>X0&&tx<X1){
var rel=((tx-X0-beltO)%D+D)%D;
if(rel>D-4.5)tx+=D-rel+4.5;else if(rel<4.5)tx+=4.5-rel;
}
return tx;
}
/* ---------- particles & constraints ---------- */
function mkPt(m,r){return{x:0,y:0,px:0,py:0,m:m,w:1/m,r:r,fr:1,g:0,ax:0,ay:0};}
function mkLeg(f,k){return{f:f,k:k,sw:false,st:0,sT:0.25,sx:0,sy:0,tx:0,lift:5,rest:30,air:0};}
function integ(p){
var vx=(p.x-p.px)*DAMP,vy=(p.y-p.py)*DAMP;
var s2=vx*vx+vy*vy;
if(s2>VMAX2){var k=VMAX/Math.sqrt(s2);vx*=k;vy*=k;}
p.px=p.x;p.py=p.y;
p.x+=vx+p.ax*h2;p.y+=vy+(G+p.ay)*h2;
p.ax=0;p.ay=0;
}
function stick(a,b,len){
var dx=b.x-a.x,dy=b.y-a.y,d=Math.sqrt(dx*dx+dy*dy);
if(d<1e-6){d=1e-6;dx=0;dy=1e-6;}
var k=(d-len)/d/(a.w+b.w);
a.x+=dx*k*a.w;a.y+=dy*k*a.w;b.x-=dx*k*b.w;b.y-=dy*k*b.w;
}
function minDist(a,b,len){
var dx=b.x-a.x,dy=b.y-a.y,d2=dx*dx+dy*dy;
if(d2>=len*len)return;
var d=Math.sqrt(d2);if(d<1e-6){d=1e-6;dx=0;dy=1e-6;}
var k=(d-len)/d/(a.w+b.w);
a.x+=dx*k*a.w;a.y+=dy*k*a.w;b.x-=dx*k*b.w;b.y-=dy*k*b.w;
}
function dist(a,b){var dx=b.x-a.x,dy=b.y-a.y;return Math.sqrt(dx*dx+dy*dy);}
function segD2(x,y,a,b){
var ex=b.x-a.x,ey=b.y-a.y,l2=ex*ex+ey*ey;
var t=l2>1e-9?((x-a.x)*ex+(y-a.y)*ey)/l2:0;t=t<0?0:t>1?1:t;
var dx=x-(a.x+ex*t),dy=y-(a.y+ey*t);return dx*dx+dy*dy;
}
/* particle vs floor / moving staircase */
function collide(p){
var r=p.r,hit=false;
if(p.x<XMIN+r){var v0=p.x-p.px;p.x=XMIN+r;p.px=p.x-(v0>0?v0:0);}
var x=p.x;
surf(x);
if(sBelt){
var xr=sXs+D,topR=FLOOR_Y-stepH(xr);
if(topR<sTop-0.01&&x+r>xr){
if(p.y>topR){ /* riser face */
var vxo=p.x-p.px,wv=beltV*h;
p.x=xr-r;p.px=p.x-(vxo<wv?vxo:wv);
}else{ /* step nose */
var dx=x-xr,dy=p.y-topR,d2=dx*dx+dy*dy;
if(d2<r*r&&d2>1e-8){
var d=Math.sqrt(d2),nx=dx/d,ny=dy/d,k=r-d;
p.x+=nx*k;p.y+=ny*k;p.px+=nx*k;p.py+=ny*k;
var cvx=beltV*h,cvy=((xr>XR0&&xr<XR1)?-beltV*RISE/D:0)*h;
var vn=(p.x-p.px-cvx)*nx+(p.y-p.py-cvy)*ny;
if(vn<0){p.px+=vn*nx;p.py+=vn*ny;}
hit=true;
}
}
}
}
var pen=p.y+r-sTop;
if(pen>0){
var vyo=p.y-p.py,sv=sVy*h;
p.y-=pen;p.py=p.y-(vyo<sv?vyo:sv);
var rel=(p.x-p.px)-sVx*h,lim=(sBelt?MU_BELT:MU_FLOOR)*p.fr*pen; /* Coulomb: friction <= mu * normal correction */
if(rel>lim)rel=lim;else if(rel<-lim)rel=-lim;
p.px+=rel;
hit=true;
}
if(hit)p.g=1;
}
/* points of A against capsules of B */
function ppc(A,B){
var ap=A.p,bp=B.p,bs=B.s;
for(var i=0;i<7;i++){
var p=ap[i],rp=p.r;
for(var j=0;j<6;j++){
var bn=BONES[j],a=bp[bn[0]],b=bp[bn[1]];
var ex=b.x-a.x,ey=b.y-a.y,l2=ex*ex+ey*ey;
var t=l2>1e-9?((p.x-a.x)*ex+(p.y-a.y)*ey)/l2:0;t=t<0?0:t>1?1:t;
var dx=p.x-(a.x+ex*t),dy=p.y-(a.y+ey*t);
var R=rp+bn[2]*bs,d2=dx*dx+dy*dy;
if(d2>=R*R)continue;
var d=Math.sqrt(d2),nx,ny;
if(d<1e-5){nx=0;ny=-1;d=0;}else{nx=dx/d;ny=dy/d;}
var ov=(R-d)*PPK,u=1-t,ws=u*u*a.w+t*t*b.w,wt=p.w+ws;
var rvx=(p.x-p.px)-(u*(a.x-a.px)+t*(b.x-b.px));
var rvy=(p.y-p.py)-(u*(a.y-a.py)+t*(b.y-b.py));
var kp=ov*p.w/wt,ka=ov*u*a.w/wt,kb=ov*t*b.w/wt;
p.x+=nx*kp;p.y+=ny*kp;p.px+=nx*kp;p.py+=ny*kp; /* separation carries no velocity */
a.x-=nx*ka;a.y-=ny*ka;a.px-=nx*ka;a.py-=ny*ka;
b.x-=nx*kb;b.y-=ny*kb;b.px-=nx*kb;b.py-=ny*kb;
var vn=rvx*nx+rvy*ny,tvx=rvx-vn*nx,tvy=rvy-vn*ny;
var jx=-tvx*PPF,jy=-tvy*PPF;
if(vn<0){jx-=vn*nx;jy-=vn*ny;} /* inelastic along the normal */
jx/=wt;jy/=wt;
p.px-=jx*p.w;p.py-=jy*p.w;
a.px+=jx*u*a.w;a.py+=jy*u*a.w;
b.px+=jx*t*b.w;b.py+=jy*t*b.w;
}
}
}
/* ---------- people ---------- */
var SKIN=['#f1c9a5','#e0ac69','#c68642','#8d5524','#ffdbac','#a86b3c'];
var HAIR=['#1b1b1b','#3b2a1a','#6b4a2b','#b08d57','#8a8a8a','#7a2e1d'];
function makePerson(){
var s=rnd(0.9,1.08);
var p=[mkPt(5,6.5*s),mkPt(22,4.5*s),mkPt(20,5*s),mkPt(6,3*s),mkPt(6,3*s),mkPt(4,3*s),mkPt(4,3*s),
mkPt(2.5,2.2*s),mkPt(2.5,2.2*s),mkPt(1.5,2.2*s),mkPt(1.5,2.2*s)];
var leg=38*s,hue=Math.floor(rnd(0,360)),ph=Math.random()<0.6?rnd(205,235):rnd(20,45),pl=rnd(24,40);
var P={p:p,s:s,neckL:10*s,torso:30*s,leg:leg,Hs:0.945*leg,Lmax:0.985*leg,Lmin:9*s,ua:14*s,fa:14*s,
legs:[mkLeg(p[5],p[3]),mkLeg(p[6],p[4])],
state:UP,t:0,tone:1,facing:1,lean:0,nsup:2,svx:0,airT:0,stepCool:0,idle:0,calmT:0,minDown:1,crawl:0,
pat:0,push:0,riseX:0,hvx:0,hvy:0,nvx:0,nvy:0,lhx:0,lhy:0,lnx:0,lny:0,bx0:0,bx1:0,by0:0,by1:0,
tau:rnd(0.15,0.21),ts:rnd(0.2,0.27),aHip:rnd(210,320),aTorso:rnd(900,1300),vdes:rnd(64,84),
patience:rnd(1.5,4),waitX:rnd(25,X0-70),
cBody:'hsl('+hue+',58%,58%)',cBodyD:'hsl('+hue+',45%,40%)',
cLeg:'hsl('+ph+',22%,'+pl+'%)',cLegD:'hsl('+ph+',22%,'+(pl-10)+'%)',
cSkin:SKIN[Math.floor(rnd(0,SKIN.length))],cHair:HAIR[Math.floor(rnd(0,HAIR.length))]};
P.headMin=0.97*(P.torso+P.neckL);
P.sticks=[[0,1,P.neckL],[1,2,P.torso],[2,3,19*s],[2,4,19*s],[3,5,19*s],[4,6,19*s],[1,7,P.ua],[1,8,P.ua],[7,9,P.fa],[8,10,P.fa]];
return P;
}
function pose(P,x,fy,vx,vy){
var s=P.s,p=P.p,f=P.facing;
function set(i,X,Y){var q=p[i];q.x=X;q.y=Y;q.px=X-vx*h;q.py=Y-vy*h;q.ax=0;q.ay=0;q.g=0;}
var footY=fy-p[5].r,hipY=footY-P.Hs,my=(footY+hipY)/2,ny=hipY-P.torso;
set(5,x-4.5*s,footY);set(6,x+4.5*s,footY);set(2,x,hipY);
set(3,x-2.2*s+4*f*s,my);set(4,x+2.2*s+4*f*s,my);
set(1,x,ny);set(0,x,ny-P.neckL);
set(7,x-2*s,ny+P.ua);set(8,x+2*s,ny+P.ua);set(9,x-3*s,ny+P.ua+P.fa);set(10,x+3*s,ny+P.ua+P.fa);
for(var i=0;i<2;i++){var L=P.legs[i];L.sw=false;L.air=0;L.rest=dist(p[2],L.f);L.f.fr=FOOT_FR;}
P.state=UP;P.t=0;P.tone=1;P.lean=0;P.airT=0;P.stepCool=0.2;P.idle=0;P.calmT=0;P.crawl=0;P.pat=0;P.push=0;P.nsup=2;P.svx=vx;
P.hvx=vx;P.hvy=vy;P.nvx=vx;P.nvy=vy;
P.lhx=p[2].x-vx/60;P.lhy=p[2].y-vy/60;P.lnx=p[1].x-vx/60;P.lny=p[1].y-vy/60;
bbox(P);
}
function respawn(P){
var x=-30;
for(var k=0;k<4;k++){
var ok=true;
for(var i=0;i<people.length;i++){var Q=people[i];if(Q!==P&&Math.abs(Q.p[2].x-x)<24){ok=false;break;}}
if(ok)break;x-=24;
}
P.facing=1;pose(P,x,FLOOR_Y,0,0);
}
function bbox(P){
var p=P.p,a=1e9,b=-1e9,c=1e9,d=-1e9;
for(var i=0;i<7;i++){var q=p[i];if(q.x<a)a=q.x;if(q.x>b)b=q.x;if(q.y<c)c=q.y;if(q.y>d)d=q.y;}
P.bx0=a-9;P.bx1=b+9;P.by0=c-9;P.by1=d+9;
}
function goDown(P,k){
P.state=DOWN;P.t=0;P.calmT=0;P.tone=0;P.crawl=0;P.lean=0;P.minDown=rnd(0.9,2.4)*k;
for(var i=0;i<2;i++){P.legs[i].sw=false;P.legs[i].f.fr=1;}
}
function startStep(P,L,tx,T,reach){
var hx=P.p[2].x,r=reach*P.s;
tx=tx<hx-r?hx-r:tx>hx+r?hx+r:tx;
tx=fixTx(tx);
L.sw=true;L.st=0;L.sT=T;L.sx=L.f.x;L.sy=L.f.y;L.tx=tx;
surf(tx);
var up=L.sy-(sTop-L.f.r);
L.lift=5*P.s+(up>0?up*0.5:0);
}
function startRise(P){
P.state=RISING;P.t=0;P.tone=0;P.calmT=0;P.airT=0;P.lean=0;P.crawl=0;
for(var i=0;i<2;i++){var L=P.legs[i];L.f.fr=FOOT_FR;startStep(P,L,P.riseX+(i?4.5:-4.5)*P.s,0.3,30);}
}
function clearAbove(P,x,gy){
var s=P.s,x0=x-10*s,x1=x+10*s,y0=gy-80*s,y1=P.p[2].y-7*s;
for(var n=0;n<people.length;n++){
var Q=people[n];if(Q===P)continue;
if(Q.bx1<x0||Q.bx0>x1)continue;
var q=Q.p;
for(var i=0;i<7;i++){var a=q[i];if(a.x>x0&&a.x<x1&&a.y>y0&&a.y<y1)return false;}
var mx=(q[1].x+q[2].x)*0.5,my=(q[1].y+q[2].y)*0.5;
if(mx>x0&&mx<x1&&my>y0&&my<y1)return false;
}
return true;
}
function feetFree(P,x,gy){
var s=P.s,fy=gy-3*s,R2=81*s*s;
for(var n=0;n<people.length;n++){
var Q=people[n];if(Q===P)continue;
if(Q.bx1<x-20||Q.bx0>x+20)continue;
var q=Q.p;
for(var j=0;j<6;j++){var b=BONES[j];if(segD2(x,fy,q[b[0]],q[b[1]])<R2)return false;}
}
return true;
}
function findSpot(P){
var hip=P.p[2],s=P.s;
for(var c=0;c<CAND.length;c++){
var x=hip.x+CAND[c]*s;
surf(x);var gy=sTop;
if(Math.abs(sVx-P.hvx)>60)continue; /* footing must not be rushing past */
if(Math.abs(gy-(hip.y+5*s))>30*s)continue;
if(feetFree(P,x,gy)&&clearAbove(P,x,gy))return x;
}
return null;
}
/* muscles: runs every substep, only produces limited forces and strut lengths */
function control(P){
var p=P.p,hip=p[2],neck=p[1],s=P.s;
if(P.state===DOWN){if(P.crawl){hip.ax+=P.crawl*CRAWL;neck.ax+=P.crawl*CRAWL;}return;}
var L0=P.legs[0],L1=P.legs[1];
var g0=!L0.sw&&L0.air<0.05,g1=!L1.sw&&L1.air<0.05,n=0,sx=0,sy=-1e9,i,L,e;
if(g0){n++;sx+=L0.f.x;sy=L0.f.y;}
if(g1){n++;sx+=L1.f.x;if(L1.f.y>sy)sy=L1.f.y;}
P.nsup=n;
var rate=(P.state===RISING?40:120)*s*h;
if(n>0){
sx/=n;surf(sx);P.svx=sVx;
var Tx=sx+P.lean*4*s,Ty=sy-P.Hs;
for(i=0;i<2;i++){
L=P.legs[i];if(L.sw)continue;
var des=P.Lmax;
if(L.air<0.05){var dx=Tx-L.f.x,dy=Ty-L.f.y;des=Math.sqrt(dx*dx+dy*dy);des=des<P.Lmin?P.Lmin:des>P.Lmax?P.Lmax:des;}
e=des-L.rest;L.rest+=e>rate?rate:e<-rate?-rate:e;
}
var a=KPH*(Tx-hip.x)-KDH*((hip.x-hip.px)/h-sVx),am=P.aHip*P.tone;
a=a>am?am:a<-am?-am:a;
hip.ax+=a;
var ra=a*hip.m/n; /* reaction goes into the planted feet */
if(g0)L0.f.ax-=ra*L0.f.w;
if(g1)L1.f.ax-=ra*L1.f.w;
}else{
surf(hip.x);P.svx=sVx;
for(i=0;i<2;i++){L=P.legs[i];if(L.sw)continue;e=P.Lmax-L.rest;L.rest+=e>rate?rate:e<-rate?-rate:e;}
}
var th=P.lean*0.1;
var tx=hip.x+Math.sin(th)*P.torso,ty=hip.y-Math.cos(th)*P.torso;
var ax=KPT*(tx-neck.x)-KDT*((neck.x-neck.px)-(hip.x-hip.px))/h;
var ay=KPT*(ty-neck.y)-KDT*((neck.y-neck.py)-(hip.y-hip.py))/h;
var tm=P.aTorso*P.tone*(n>0?1:0.25),mag=Math.sqrt(ax*ax+ay*ay);
if(mag>tm){ax*=tm/mag;ay*=tm/mag;}
neck.ax+=ax;neck.ay+=ay;
var r=neck.m/hip.m;hip.ax-=ax*r;hip.ay-=ay*r;
}
function swing(L){
var f=L.f;
L.st+=h;
surf(L.sx);L.sx+=sVx*h;L.sy+=sVy*h;
surf(L.tx);L.tx+=sVx*h;
var ty=sTop-f.r,pr=L.st/L.sT,qx,qy;
if(pr<1){var e=pr*pr*(3-2*pr);qx=L.sx+(L.tx-L.sx)*e;qy=L.sy+(ty-L.sy)*e-L.lift*Math.sin(Math.PI*pr);}
else{qx=L.tx;qy=ty+2;}
var dx=qx-f.px,dy=qy-f.py,d=Math.sqrt(dx*dx+dy*dy),mx=(650+Math.abs(sVx))*h;
if(d>mx){dx*=mx/d;dy*=mx/d;}
f.x=f.px+dx;f.y=f.py+dy;
}
function solve(P){
var p=P.p,st=P.sticks,i;
for(i=0;i<st.length;i++){var c=st[i];stick(p[c[0]],p[c[1]],c[2]);}
if(P.state!==DOWN){
for(i=0;i<2;i++){
var L=P.legs[i];
if(!L.sw)stick(p[2],L.f,L.rest);
var mid=(p[2].x+L.f.x)*0.5;
if((L.k.x-mid)*P.facing<1)L.k.x+=P.facing*0.35;
}
stick(p[0],p[2],P.torso+P.neckL);
}else minDist(p[0],p[2],P.headMin);
for(i=0;i<11;i++)collide(p[i]);
}
function post(P){
var any=false;
for(var i=0;i<2;i++){
var L=P.legs[i],f=L.f;
if(f.g)L.air=0;else L.air+=h;
if(L.sw){
var pr=L.st/L.sT;
if((pr>0.55&&f.g)||pr>1.7){L.sw=false;L.rest=Math.min(dist(P.p[2],f),P.leg*0.995);P.stepCool=0.03;}
}else if(f.g)any=true;
}
if(any)P.airT=0;else P.airT+=h;
}
/* decisions: 60 Hz, identical for everyone */
function think(P,dt){
var p=P.p,hip=p[2],neck=p[1],i;
for(i=0;i<11;i++){if(!(isFinite(p[i].x)&&isFinite(p[i].y))){respawn(P);return;}}
P.t+=dt;
P.hvx=(hip.x-P.lhx)/dt;P.hvy=(hip.y-P.lhy)/dt;P.nvx=(neck.x-P.lnx)/dt;P.nvy=(neck.y-P.lny)/dt;
P.lhx=hip.x;P.lhy=hip.y;P.lnx=neck.x;P.lny=neck.y;
if(hip.x>W+45){respawn(P);return;}
surf(hip.x);
var gy=sTop,gvx=sVx,gvy=sVy;
var tilt=Math.atan2(neck.x-hip.x,hip.y-neck.y);
var L0=P.legs[0],L1=P.legs[1];
if(P.state===DOWN){
P.crawl=0;
if(P.t>P.minDown){
var calm=Math.abs(P.hvx-gvx)<55&&Math.abs(P.hvy-gvy)<55&&Math.abs(P.nvx-gvx)<80&&Math.abs(P.nvy-gvy)<80;
var ok=false;
if(calm){
var sx=null;
if(gy-hip.y<26*P.s)sx=findSpot(P);
if(sx!==null){P.riseX=sx;ok=true;}
else if(clearAbove(P,hip.x,gy))P.crawl=hip.x>=X1-10?1:-1;
}
P.calmT=ok?P.calmT+dt:0;
if(P.calmT>0.25)startRise(P);
}
return;
}
if(P.state===RISING){
P.tone=Math.min(1,P.t/0.5);
if(!L0.sw&&!L1.sw){
var fy=Math.max(L0.f.y,L1.f.y);
if(fy-hip.y>0.88*P.Hs&&Math.abs(tilt)<0.45){P.state=UP;P.tone=1;P.idle=0;P.stepCool=0.1;P.t=0;return;}
}
if(P.t>3||(P.t>0.7&&P.airT>0.35))goDown(P,0.5);
return;
}
/* UP */
if(Math.abs(tilt)>1.15||gy-hip.y<0.42*P.Hs||P.airT>0.35){goDown(P,1);return;}
var x=hip.x,want=0,fwd=!reversed&&beltV>0;
if(x>=X1-6)want=1;
else if(x<=X0+14){
if(fwd)want=1;
else{var dw=P.waitX-x;want=Math.abs(dw)>12?(dw>0?1:-1):0;}
}
if(want!==0){
var blocked=false,bdown=false,top=p[0].y-6;
for(var n=0;n<people.length;n++){
var Q=people[n];if(Q===P)continue;
if(Q.bx1<x-34||Q.bx0>x+34)continue;
for(i=0;i<7;i++){
var q=Q.p[i],dd=(q.x-x)*want;
if(dd>3&&dd<22*P.s&&q.y>top&&q.y<gy+3){blocked=true;if(Q.state===DOWN)bdown=true;}
}
}
if(blocked){
if(P.push<=0){
if(bdown)P.pat+=dt;
if(P.pat>P.patience){P.push=1.6;P.pat=0;}else want=0;
}
}else if(P.pat>0)P.pat-=dt*0.5;
}
if(P.push>0)P.push-=dt;
if(want!==0)P.facing=want;else if(x<X0&&P.idle>1)P.facing=1;
var vrel=P.hvx-P.svx,drive=0;
if(want!==0){drive=(P.vdes-vrel*want)/P.vdes;drive=(drive<0?0:drive>1?1:drive)*want;}
P.lean+=(drive-P.lean)*Math.min(1,dt*10);
if(want===0&&Math.abs(vrel)<25)P.idle+=dt;else P.idle=0;
P.stepCool-=dt;
if(!L0.sw&&!L1.sw&&P.nsup>0&&P.stepCool<=0){
var a=L0.f.x,b=L1.f.x,lo=a<b?a:b,hi=a<b?b:a;
var c=x+vrel*P.tau+P.lean*7*P.s,dir=0;
if(c>hi-1.5)dir=1;else if(c<lo+1.5)dir=-1;
if(dir){
var L=dir>0?(a<=b?L0:L1):(a>=b?L0:L1),O=L===L0?L1:L0;
var tx=c+dir*(4*P.s+Math.min(10*P.s,0.07*Math.abs(vrel)));
if((tx-O.f.x)*dir<6*P.s)tx=O.f.x+dir*6*P.s;
startStep(P,L,tx,Math.abs(vrel)>130?P.ts*0.7:P.ts,17);
P.idle=0;
}else if(P.idle>0.5&&hi-lo>13*P.s){
var rear=((a<b)===(P.facing>0))?L0:L1,front=rear===L0?L1:L0;
startStep(P,rear,front.f.x-P.facing*8*P.s,P.ts*1.2,17);
P.idle=0.2;
}
}
}
/* ---------- simulation step ---------- */
function tick(){
var i,j,s,it,n=people.length,P;
simT+=1/60;
for(i=0;i<n;i++)bbox(people[i]);
for(i=0;i<n;i++)think(people[i],1/60);
var tgt=reversed?V_REV:V_FWD;
for(s=0;s<SUB;s++){
if(beltV<tgt){beltV+=V_ACC*h;if(beltV>tgt)beltV=tgt;}
else if(beltV>tgt){beltV-=V_ACC*h;if(beltV<tgt)beltV=tgt;}
beltO+=beltV*h;beltD+=beltV*h;
if(beltO>=D){beltO-=D;beltN++;}else if(beltO<0){beltO+=D;beltN--;}
for(i=0;i<n;i++){
P=people[i];var p=P.p;
control(P);
for(j=0;j<11;j++){integ(p[j]);p[j].g=0;}
if(P.state!==DOWN){if(P.legs[0].sw)swing(P.legs[0]);if(P.legs[1].sw)swing(P.legs[1]);}
bbox(P);
}
for(i=0;i<n;i++){
var A=people[i];
for(j=i+1;j<n;j++){
var B=people[j];
if(A.bx0<B.bx1&&A.bx1>B.bx0&&A.by0<B.by1&&A.by1>B.by0){ppc(A,B);ppc(B,A);}
}
}
for(it=0;it<ITER;it++)for(i=0;i<n;i++)solve(people[i]);
for(i=0;i<n;i++)post(people[i]);
}
}
function init(){
people=[];beltV=V_FWD;beltO=0;beltN=0;beltD=0;reversed=false;simT=0;
for(var i=0;i<N_PEOPLE;i++){
var P=makePerson();people.push(P);
var x=X0+24+(i+0.5)*(X1-X0-48)/N_PEOPLE;
surf(x);
var cx=sBelt?sXs+D/2:x;
surf(cx);
pose(P,cx,sTop,sVx,sVy);
}
syncBtn();
}
/* ---------- drawing ---------- */
var sc=1;
function resize(){
var dpr=window.devicePixelRatio||1,k=Math.min(window.innerWidth/W,window.innerHeight/H);
stage.style.width=cv.style.width=(W*k)+'px';
stage.style.height=cv.style.height=(H*k)+'px';
cv.width=Math.max(1,Math.round(W*k*dpr));cv.height=Math.max(1,Math.round(H*k*dpr));
sc=cv.width/W;
}
function limb(g,a,b,c,col,lw){
g.strokeStyle=col;g.lineWidth=lw;g.beginPath();g.moveTo(a.x,a.y);g.lineTo(b.x,b.y);g.lineTo(c.x,c.y);g.stroke();
}
function shoe(g,k,f,fac,s){
var dx=f.x-k.x,dy=f.y-k.y,d=Math.sqrt(dx*dx+dy*dy)||1,ux=dx/d,uy=dy/d;
var bx=f.x+ux*1.5*s,by=f.y+uy*1.5*s;
g.beginPath();g.moveTo(bx,by);g.lineTo(bx+uy*fac*5.5*s,by-ux*fac*5.5*s);g.stroke();
}
function drawPerson(g,P){
var p=P.p,s=P.s,f=P.facing;
limb(g,p[1],p[7],p[9],P.cBodyD,3.2*s);
limb(g,p[2],p[3],p[5],P.cLegD,4.4*s);
g.strokeStyle='#15181c';g.lineWidth=3.6*s;shoe(g,p[3],p[5],f,s);
g.strokeStyle=P.cSkin;g.lineWidth=3.4*s;g.beginPath();g.moveTo(p[1].x,p[1].y);g.lineTo(p[0].x,p[0].y);g.stroke();
g.strokeStyle=P.cBody;g.lineWidth=7*s;g.beginPath();g.moveTo(p[2].x,p[2].y);g.lineTo(p[1].x,p[1].y);g.stroke();
limb(g,p[2],p[4],p[6],P.cLeg,4.4*s);
g.strokeStyle='#22262b';g.lineWidth=3.6*s;shoe(g,p[4],p[6],f,s);
var hx=p[0].x,hy=p[0].y,r=6.5*s;
var ux=hx-p[1].x,uy=hy-p[1].y,ul=Math.sqrt(ux*ux+uy*uy)||1;ux/=ul;uy/=ul;
g.fillStyle=P.cSkin;g.beginPath();g.arc(hx,hy,r,0,6.2832);g.fill();
var th=Math.atan2(uy,ux)-f*0.5;
g.fillStyle=P.cHair;g.beginPath();g.arc(hx,hy,r+0.4,th-1.3,th+1.3);g.closePath();g.fill();
g.fillStyle='#1a1a1a';g.beginPath();g.arc(hx-uy*f*3*s-ux*0.3*s,hy+ux*f*3*s-uy*0.3*s,0.9*s,0,6.2832);g.fill();
limb(g,p[1],p[8],p[10],P.cBody,3.2*s);
g.fillStyle=P.cSkin;g.beginPath();g.arc(p[10].x,p[10].y,2*s,0,6.2832);g.fill();
}
function draw(){
var g=ctx,i,x;
g.setTransform(sc,0,0,sc,0,0);
var wg=g.createLinearGradient(0,0,0,H);wg.addColorStop(0,'#2d3640');wg.addColorStop(1,'#1c2229');
g.fillStyle=wg;g.fillRect(0,0,W,H);
g.fillStyle='rgba(255,255,255,0.03)';
for(x=60;x<W;x+=110)g.fillRect(x,0,1.5,FLOOR_Y);
g.fillStyle='rgba(255,244,214,0.75)';
for(x=120;x<W;x+=260)g.fillRect(x,16,110,5);
/* under the mezzanine */
g.fillStyle='rgba(0,0,0,0.28)';g.fillRect(X1+14,TOP_Y+16,W-X1-14,FLOOR_Y-TOP_Y-16);
g.fillStyle='#39414b';g.fillRect(W-110,TOP_Y+16,28,FLOOR_Y-TOP_Y-16);
/* direction sign */
var flash=reversed&&(Math.floor(simT*4)%2===0);
g.fillStyle='#0f1317';g.fillRect(36,300,176,54);
g.strokeStyle='#4b5560';g.lineWidth=2;g.strokeRect(36,300,176,54);
g.fillStyle=reversed?(flash?'#ff4a3d':'#7a1f19'):'#3ddc84';
g.beginPath();
if(reversed){g.moveTo(54,318);g.lineTo(78,318);g.lineTo(66,338);}else{g.moveTo(54,338);g.lineTo(78,338);g.lineTo(66,318);}
g.closePath();g.fill();
g.font='bold 20px system-ui,-apple-system,"Segoe UI",sans-serif';g.textBaseline='middle';
g.fillText(reversed?'REVERSE':'UP',90,328);
/* floors */
g.fillStyle='#59616c';g.fillRect(X1,TOP_Y,W-X1,16);
g.fillStyle='#8b95a3';g.fillRect(X1,TOP_Y,W-X1,2);
g.fillStyle='#474e58';g.fillRect(0,FLOOR_Y,W,H-FLOOR_Y);
g.fillStyle='#7d8794';g.fillRect(0,FLOOR_Y,W,2);
g.fillStyle='rgba(0,0,0,0.18)';
for(x=30;x<W;x+=80)g.fillRect(x,FLOOR_Y+2,1.5,H-FLOOR_Y);
/* truss */
var lx=(XR1+16)-(FLOOR_Y-TOP_Y-54)*D/RISE;
g.fillStyle='#2a3038';g.strokeStyle='#14181d';g.lineWidth=2;
g.beginPath();g.moveTo(XR0-4,FLOOR_Y);g.lineTo(XR1,TOP_Y);g.lineTo(X1+14,TOP_Y);g.lineTo(X1+14,TOP_Y+54);
g.lineTo(XR1+16,TOP_Y+54);g.lineTo(lx,FLOOR_Y);g.closePath();g.fill();g.stroke();
/* steps */
g.save();g.beginPath();g.rect(X0,0,X1-X0,H);g.clip();
for(var k=-1;k<=NS;k++){
var xs=X0+beltO+k*D,top=FLOOR_Y-stepH(xs),id=(((k-beltN)%2)+2)%2;
g.fillStyle=id?'#9aa1ab':'#868d97';g.fillRect(xs,top,D+0.5,RISE+7);
g.fillStyle='#cfd4db';g.fillRect(xs,top,D,2);
g.fillStyle='#e0b83a';g.fillRect(xs,top,3.5,2);
g.fillStyle='rgba(0,0,0,0.28)';g.fillRect(xs,top+2,1.5,RISE+5);
}
g.restore();
g.fillStyle='#c8a530';g.fillRect(X0-14,FLOOR_Y-1,16,3);g.fillRect(X1-2,TOP_Y-1,16,3);
/* people */
g.lineCap='round';g.lineJoin='round';
for(i=0;i<people.length;i++)drawPerson(g,people[i]);
/* near balustrade */
g.fillStyle='rgba(170,215,255,0.085)';g.strokeStyle='rgba(200,230,255,0.22)';g.lineWidth=1;
g.beginPath();g.moveTo(X0-30,FLOOR_Y-HR);g.lineTo(XR0-8,FLOOR_Y-HR);g.lineTo(XR1-8,TOP_Y-HR);g.lineTo(X1+30,TOP_Y-HR);
g.lineTo(X1+30,TOP_Y-3);g.lineTo(XR1,TOP_Y-3);g.lineTo(XR0,FLOOR_Y-3);g.lineTo(X0-30,FLOOR_Y-3);g.closePath();g.fill();g.stroke();
g.strokeStyle='#0d0f12';g.lineWidth=6;
g.beginPath();g.arc(X0-30,FLOOR_Y-HR+9,9,Math.PI/2,-Math.PI/2,false);
g.lineTo(XR0-8,FLOOR_Y-HR);g.lineTo(XR1-8,TOP_Y-HR);g.lineTo(X1+30,TOP_Y-HR);
g.arc(X1+30,TOP_Y-HR+9,9,-Math.PI/2,Math.PI/2,false);g.stroke();
g.strokeStyle='rgba(255,255,255,0.28)';g.lineWidth=2;g.setLineDash([4,26]);g.lineDashOffset=-beltD;
g.beginPath();g.moveTo(X0-30,FLOOR_Y-HR);g.lineTo(XR0-8,FLOOR_Y-HR);g.lineTo(XR1-8,TOP_Y-HR);g.lineTo(X1+30,TOP_Y-HR);g.stroke();
g.setLineDash([]);
g.fillStyle='#3b434d';g.fillRect(X0-42,FLOOR_Y-9,24,9);g.fillRect(X1+18,TOP_Y-9,24,9);
}
/* ---------- ui / loop ---------- */
function syncBtn(){
btn.textContent=reversed?'Put it back':'Throw into reverse';
btn.className=reversed?'on':'';
}
function toggle(){reversed=!reversed;syncBtn();}
btn.addEventListener('click',function(){toggle();btn.blur();});
rst.addEventListener('click',function(){init();rst.blur();});
window.addEventListener('keydown',function(e){if(e.code==='Space'||e.key===' '){e.preventDefault();toggle();}});
window.addEventListener('resize',resize);
var last=0,acc=0,fc=0;
function loop(now){
if(!last)last=now;
var dt=(now-last)/1000;last=now;if(dt>0.1)dt=0.1;acc+=dt;
var n=0;
while(acc>=1/60&&n<3){tick();acc-=1/60;n++;}
if(n===3)acc=0;
draw();
if((fc++%10)===0){
var up=0,dn=0,rs=0,on=0;
for(var i=0;i<people.length;i++){
var P=people[i];
if(P.state===UP){up++;if(P.p[2].x>X0&&P.p[2].x<X1)on++;}else if(P.state===DOWN)dn++;else rs++;
}
stat.textContent='belt '+(beltV>=0?'+':'\u2212')+Math.abs(beltV/51).toFixed(1)+' m/s \u00b7 on feet '+up+' ('+on+' riding) \u00b7 down '+dn+' \u00b7 getting up '+rs;
}
requestAnimationFrame(loop);
}
resize();init();requestAnimationFrame(loop);
})();
</script>
</body>
</html>
I wrote this without running it, so what follows is what the rules produce on paper, not a report of a watched run. The gait and pile behaviour depend on constants I could not tune by eye.
On reverse. The belt goes from +32 px/s to −300 px/s (about 5.9 m/s at this scale) in roughly 0.14 s.
Special-cased or nudged.
What it does badly.