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 a feet point travelling along the escalator path with a centre of mass a fixed distance above it at lean angle a, integrated as an inverted pendulum with a clamped ankle torque, friction-limited feet that try to follow step speed, and a stepping reflex when lean grows; balance is lost when step acceleration, especially on reversal, creates a pseudo-torque exceeding ankle capacity, or collisions kick angular velocity, pushing |a| past about 1 rad, at which point the figure becomes a sliding capsule on the surface; when two figures overlap their centres are pushed apart along the path, velocities are exchanged with a small restitution, and the impact adds angular velocity to both, while a down figure occupies a length of path that standing figures' feet collide with, tripping them, and a down figure only rises when its sliding speed is low and no neighbour is within reach.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Escalator Reversal</title>
<style>
body{margin:0;font-family:system-ui,Arial,sans-serif;background:#e8eef4;color:#16202a}
.wrap{max-width:1000px;margin:12px auto;padding:0 12px}
h1{font-size:20px;margin:8px 0}
canvas{width:100%;height:auto;display:block;background:#dfe9f2;border:1px solid #9fb0c0;border-radius:8px}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0}
button{font-size:16px;padding:10px 16px;border-radius:8px;border:1px solid #5a6d80;background:#fff;cursor:pointer}
button.primary{background:#c0392b;color:#fff;border-color:#922b21}
button.primary.rev{background:#2471a3;border-color:#1a5276}
#status{font-weight:600}
.note{font-size:13px;color:#445}
</style>
</head>
<body>
<div class="wrap">
<h1>Side-view escalator — reversal simulation</h1>
<canvas id="c" width="960" height="540"></canvas>
<div class="row">
<button id="rev" class="primary">Reverse escalator</button>
<button id="reset">Reset</button>
<span id="status">Escalator: UP</span>
<span id="count" class="note"></span>
</div>
<div class="note">18 people ride up. Press Reverse to throw the steps into fast reverse. Press again to restore. No timeline: falls, collisions and pile-ups come from the same per-person rules. Down people are obstacles; they get up only when sliding has stopped and there is room.</div>
</div>
<script>
const canvas=document.getElementById('c'),ctx=canvas.getContext('2d');
const A={x:200,y:430},B={x:720,y:170};
const idx=B.x-A.x,idy=B.y-A.y,inclineLen=Math.hypot(idx,idy),cosT=idx/inclineLen,sinT=-idy/inclineLen;
const bottomLen=170,topLen=210,totalLen=bottomLen+inclineLen+topLen;
const L=36,G=900;
function clamp(v,a,b){return v<a?a:v>b?b:v}
function sign(v){return v<0?-1:1}
function pointAt(s){
s=clamp(s,0,totalLen);
if(s<bottomLen)return{x:30+s,y:430,tx:1,ty:0,region:0};
if(s<bottomLen+inclineLen){const l=s-bottomLen;return{x:A.x+l*cosT,y:A.y-l*sinT,tx:cosT,ty:-sinT,region:1};}
const l=s-bottomLen-inclineLen;return{x:B.x+l,y:170,tx:1,ty:0,region:2};
}
let escTarget=55,escSpeed=55,escAccel=1700,stepOffset=0,reversed=false;
let persons=[],nextId=0;
function spawn(s){
persons.push({id:nextId++,hue:Math.floor(Math.random()*360),s:s,vf:escSpeed,a:(Math.random()-0.5)*0.04,ap:0,state:'stand',stepCD:Math.random()*0.25,downTimer:0,riseTimer:0,riseFrom:0,s_c:s,n_c:11,vs:0,vn:0,headDir:1,phase:Math.random()*6.28});
}
function init(){
persons=[];nextId=0;escTarget=55;escSpeed=55;reversed=false;stepOffset=0;
for(let i=0;i<18;i++)spawn(bottomLen+28+i*30+Math.random()*8);
document.getElementById('rev').textContent='Reverse escalator';
document.getElementById('rev').className='primary';
}
function comOf(p){
if(p.state==='down'){const pt=pointAt(p.s_c),nx=pt.ty,ny=-pt.tx;return{x:pt.x+nx*p.n_c,y:pt.y+ny*p.n_c};}
const pt=pointAt(p.s);return{x:pt.x+L*Math.sin(p.a),y:pt.y-L*Math.cos(p.a)};
}
function velCom(p){
if(p.state==='down'){const pt=pointAt(p.s_c),nx=pt.ty,ny=-pt.tx;return{x:pt.tx*p.vs+nx*p.vn,y:pt.ty*p.vs+ny*p.vn};}
const pt=pointAt(p.s);return{x:pt.tx*p.vf+L*p.ap*Math.cos(p.a),y:pt.ty*p.vf+L*p.ap*Math.sin(p.a)};
}
function transitionToDown(p){
const pt=pointAt(p.s),com=comOf(p);
const dx=com.x-pt.x,dy=com.y-pt.y,nx=pt.ty,ny=-pt.tx;
p.s_c=p.s+dx*pt.tx+dy*pt.ty;
p.n_c=Math.max(11,dx*nx+dy*ny);
p.vs=p.vf+L*p.ap*(Math.cos(p.a)*pt.tx+Math.sin(p.a)*pt.ty);
p.vn=L*p.ap*(Math.cos(p.a)*nx+Math.sin(p.a)*ny);
p.headDir=p.a>=0?1:-1;p.state='down';p.downTimer=0;
}
function applyVelDelta(p,dvx,dvy){
if(p.state==='down'){
const pt=pointAt(p.s_c),nx=pt.ty,ny=-pt.tx;
p.vs+=dvx*pt.tx+dvy*pt.ty;p.vn+=dvx*nx+dvy*ny;
}else{
const pt=pointAt(p.s);
p.vf+=dvx*pt.tx+dvy*pt.ty;
if(p.state!=='rising')p.ap+=(dvx*Math.cos(p.a)+dvy*Math.sin(p.a))/L*0.7;
}
}
function setS(p,delta){
if(p.state==='down')p.s_c=clamp(p.s_c+delta,6,totalLen-6);
else p.s=clamp(p.s+delta,2,totalLen-2);
}
function isBlocked(p,dir,stepLen){
for(const o of persons){if(o===p)continue;const os=o.state==='down'?o.s_c:o.s;const ds=(os-p.s)*dir;const gap=o.state==='down'?30:18;if(ds>0&&ds<stepLen+gap)return true;}
return false;
}
function spaceClear(p){
for(const o of persons){if(o===p)continue;const os=o.state==='down'?o.s_c:o.s;if(Math.abs(os-p.s_c)<40)return false;}
return true;
}
function updatePerson(p,h){
if(p.state==='stand'){
const pt0=pointAt(p.s),prevVf=p.vf;
if(pt0.region===1){
const mu=0.55-Math.min(0.25,Math.abs(p.a)*0.3),maxA=mu*G,dv=escSpeed-p.vf,mc=maxA*h;
p.vf=Math.abs(dv)<=mc?escSpeed:p.vf+sign(dv)*mc;
}else{
const dv=42-p.vf,mc=700*h;p.vf=Math.abs(dv)<=mc?42:p.vf+sign(dv)*mc;
}
const a_s=(p.vf-prevVf)/h,pt=pointAt(p.s);
const grav=(G/L)*Math.sin(p.a);
const pseudo=-((a_s*pt.tx)/L)*Math.cos(p.a);
let ankle=clamp(-(34*p.a+7*p.ap),-11,11);
const add=grav+pseudo+ankle-0.2*p.ap;
p.ap+=add*h;p.a+=p.ap*h;p.s+=p.vf*h;p.stepCD-=h;
if((Math.abs(p.a)>0.38||Math.abs(p.ap)>1.4)&&p.stepCD<=0){
const dir=p.a!==0?sign(p.a):sign(p.ap),stepLen=20+22*Math.min(1,Math.abs(p.a));
if(!isBlocked(p,dir,stepLen)){
const com=comOf(p);p.s=clamp(p.s+dir*stepLen,2,totalLen-2);
const nf=pointAt(p.s),dx=com.x-nf.x,dy=com.y-nf.y;
p.a=clamp(Math.atan2(dx,-dy),-1.2,1.2);p.ap*=0.35;p.vf+=dir*18;p.stepCD=0.28+Math.random()*0.12;
}else p.stepCD=0.12;
}
if(Math.abs(p.a)>1.05||(Math.abs(p.a)>0.85&&Math.abs(p.ap)>3))transitionToDown(p);
}else if(p.state==='down'){
p.downTimer+=h;const pt=pointAt(p.s_c);
const g_t=G*pt.ty,g_n=G*(-pt.tx);
p.vn+=g_n*h;p.n_c+=p.vn*h;
if(p.n_c<11){
p.n_c=11;p.vn=p.vn<-20?-p.vn*0.2:0;
p.vs+=g_t*h;
const carrier=pt.region===1?escSpeed:0,dv=carrier-p.vs,mc=500*h;
p.vs=Math.abs(dv)<=mc?carrier:p.vs+sign(dv)*mc;
}else p.vs+=g_t*h;
p.s_c+=p.vs*h;
if(p.s_c<6){p.s_c=6;p.vs=Math.abs(p.vs)*0.3}
if(p.s_c>totalLen-6){p.s_c=totalLen-6;p.vs=-Math.abs(p.vs)*0.3}
if(p.downTimer>1.2&&Math.abs(p.vs-(pt.region===1?escSpeed:0))<15&&p.n_c<=12&&spaceClear(p)){
p.state='rising';p.riseTimer=0;p.riseFrom=p.headDir*1.35;p.s=p.s_c-p.headDir*6;p.vf=p.vs;p.a=p.riseFrom;p.ap=0;
}
}else if(p.state==='rising'){
p.riseTimer+=h;const t=Math.min(1,p.riseTimer/0.9),sm=t*t*(3-2*t);
p.a=p.riseFrom*(1-sm);
const pt=pointAt(p.s),target=pt.region===1?escSpeed:0,dv=target-p.vf,mc=600*h;
p.vf=Math.abs(dv)<=mc?target:p.vf+sign(dv)*mc;
p.s+=p.vf*h;p.s_c=p.s+p.headDir*6;
if(t>=1){p.state='stand';p.a=0;p.ap=0;p.stepCD=0.2;}
}
}
function collide(){
for(let i=0;i<persons.length;i++)for(let j=i+1;j<persons.length;j++){
const a=persons[i],b=persons[j],ca=comOf(a),cb=comOf(b);
let dx=cb.x-ca.x,dy=cb.y-ca.y,dist=Math.hypot(dx,dy);
let minD=(a.state==='down'||b.state==='down')?30:25;
if(dist<minD&&dist>0.01){
const nx=dx/dist,ny=dy/dist,ov=minD-dist;
const wa=a.state==='down'?0.3:0.7,wb=b.state==='down'?0.3:0.7,tot=wa+wb;
const pa=pointAt(a.state==='down'?a.s_c:a.s),pb=pointAt(b.state==='down'?b.s_c:b.s);
setS(a,(-nx*ov*wa/tot)*pa.tx+(-ny*ov*wa/tot)*pa.ty);
setS(b,(nx*ov*wb/tot)*pb.tx+(ny*ov*wb/tot)*pb.ty);
const va=velCom(a),vb=velCom(b),rel=(vb.x-va.x)*nx+(vb.y-va.y)*ny;
if(rel<0){
const imp=-rel*0.55;
applyVelDelta(a,-nx*imp*0.7,-ny*imp*0.7);applyVelDelta(b,nx*imp*0.7,ny*imp*0.7);
if(a.state!=='down')a.ap+=-nx*imp*0.035;
if(b.state!=='down')b.ap+=nx*imp*0.035;
if(a.state==='rising'&&imp>25)transitionToDown(a);
if(b.state==='rising'&&imp>25)transitionToDown(b);
}
}
if(a.state!=='down'&&b.state!=='down'){
const ds=b.s-a.s;
if(Math.abs(ds)<19&&ds!==0){
const s=sign(ds),ov=19-Math.abs(ds);a.s-=s*ov/2;b.s+=s*ov/2;
const avg=(a.vf+b.vf)/2;a.vf=avg+(a.vf-avg)*0.4;b.vf=avg+(b.vf-avg)*0.4;
}
}
if(a.state==='down'&&b.state==='down'){
const ds=b.s_c-a.s_c;
if(Math.abs(ds)<36&&ds!==0){const s=sign(ds),ov=36-Math.abs(ds);a.s_c-=s*ov/2;b.s_c+=s*ov/2;}
}
}
for(const p of persons){
if(p.state==='down')continue;
for(const d of persons){
if(d.state!=='down')continue;
const ds=p.s-d.s_c;
if(Math.abs(ds)<34){
const s=ds>=0?1:-1,ov=34-Math.abs(ds);
p.s=clamp(p.s+s*ov*0.8,2,totalLen-2);d.s_c=clamp(d.s_c-s*ov*0.2,6,totalLen-6);
const approach=-(p.vf-d.vs)*s;
if(approach>5){
const imp=approach*0.7;
p.vf+=s*imp*0.6;d.vs-=s*imp*0.4;
p.ap+=(-s)*(imp*0.05+0.5);
if(imp>60)p.ap+=(-s)*2.0;
if(p.state==='rising'&&imp>25)transitionToDown(p);
}
}
}
}
}
function drawPerson(p){
const shirt=`hsl(${p.hue},70%,45%)`;
ctx.lineCap='round';
if(p.state==='down'){
const pt=pointAt(p.s_c),nx=pt.ty,ny=-pt.tx,tx=pt.tx,ty=pt.ty;
const cx=pt.x+nx*p.n_c,cy=pt.y+ny*p.n_c,hd=p.headDir;
const hip={x:cx-tx*hd*12,y:cy-ty*hd*12},head={x:cx+tx*hd*20,y:cy+ty*hd*20},feet={x:cx-tx*hd*24,y:cy-ty*hd*24},knee={x:cx-tx*hd*18,y:cy-ty*hd*18+ny*8};
ctx.strokeStyle='#222';ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(hip.x,hip.y);ctx.lineTo(knee.x,knee.y);ctx.lineTo(feet.x,feet.y);ctx.stroke();
ctx.strokeStyle=shirt;ctx.lineWidth=5;ctx.beginPath();ctx.moveTo(hip.x,hip.y);ctx.lineTo(head.x,head.y);ctx.stroke();
const sh={x:hip.x+(head.x-hip.x)*0.7,y:hip.y+(head.y-hip.y)*0.7};
ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(sh.x,sh.y);ctx.lineTo(sh.x+nx*12+tx*hd*6,sh.y+ny*12+ty*hd*6);ctx.stroke();
ctx.fillStyle='#f2c9a0';ctx.beginPath();ctx.arc(head.x,head.y,7,0,6.29);ctx.fill();ctx.strokeStyle='#333';ctx.lineWidth=1;ctx.stroke();
}else{
const pt=pointAt(p.s),a=p.a,dx=Math.sin(a),dy=-Math.cos(a);
const hip={x:pt.x+dx*20,y:pt.y+dy*20},neck={x:pt.x+dx*42,y:pt.y+dy*42},head={x:pt.x+dx*50,y:pt.y+dy*50};
const stance=13+Math.min(16,Math.abs(a)*18+Math.abs(p.ap)*3);
const f1=pointAt(p.s+stance/2),f2=pointAt(p.s-stance/2);
ctx.strokeStyle='#222';ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(hip.x,hip.y);ctx.lineTo(f1.x,f1.y);ctx.moveTo(hip.x,hip.y);ctx.lineTo(f2.x,f2.y);ctx.stroke();
ctx.strokeStyle=shirt;ctx.lineWidth=5;ctx.beginPath();ctx.moveTo(hip.x,hip.y);ctx.lineTo(neck.x,neck.y);ctx.stroke();
const sh={x:hip.x+dx*34,y:hip.y+dy*34};
ctx.lineWidth=3;
if(Math.abs(a)>0.25||Math.abs(p.ap)>0.8){
const ph=performance.now()/90+p.phase;
const a1=a+Math.sin(ph)*0.9-0.6,a2=a+Math.cos(ph*1.3)*0.9+0.6;
ctx.beginPath();ctx.moveTo(sh.x,sh.y);ctx.lineTo(sh.x+Math.sin(a1)*17,sh.y-Math.cos(a1)*17);ctx.moveTo(sh.x,sh.y);ctx.lineTo(sh.x+Math.sin(a2)*17,sh.y-Math.cos(a2)*17);ctx.stroke();
}else{
ctx.beginPath();ctx.moveTo(sh.x,sh.y);ctx.lineTo(sh.x+Math.sin(a-0.3)*16,sh.y-Math.cos(a-0.3)*16);ctx.moveTo(sh.x,sh.y);ctx.lineTo(sh.x+Math.sin(a+0.3)*16,sh.y-Math.cos(a+0.3)*16);ctx.stroke();
}
ctx.fillStyle='#f2c9a0';ctx.beginPath();ctx.arc(head.x,head.y,7,0,6.29);ctx.fill();ctx.strokeStyle='#333';ctx.lineWidth=1;ctx.stroke();
}
}
function draw(){
ctx.fillStyle='#dfe9f2';ctx.fillRect(0,0,960,540);
ctx.fillStyle='#c8d6e2';ctx.fillRect(0,0,960,120);
ctx.fillStyle='#8a9bb0';
const dnx=-cosT,dny=sinT;
ctx.beginPath();ctx.moveTo(A.x,A.y);ctx.lineTo(B.x,B.y);ctx.lineTo(B.x+dnx*34,B.y+dny*34);ctx.lineTo(A.x+dnx*34,A.y+dny*34);ctx.closePath();ctx.fill();
ctx.strokeStyle='#333';ctx.lineWidth=4;ctx.beginPath();ctx.moveTo(A.x,A.y);ctx.lineTo(B.x,B.y);ctx.stroke();
ctx.strokeStyle='#555';ctx.lineWidth=3;
const spacing=26,off=((stepOffset%spacing)+spacing)%spacing;
for(let l=-spacing+off;l<inclineLen;l+=spacing){
const x=A.x+l*cosT,y=A.y-l*sinT;
ctx.beginPath();ctx.moveTo(x-cosT*10,y+sinT*10);ctx.lineTo(x+cosT*10,y-sinT*10);ctx.stroke();
}
ctx.strokeStyle='#333';ctx.lineWidth=4;
ctx.beginPath();ctx.moveTo(30,430);ctx.lineTo(200,430);ctx.moveTo(720,170);ctx.lineTo(930,170);ctx.stroke();
ctx.fillStyle='#aebfcf';ctx.fillRect(30,430,170,20);ctx.fillRect(720,170,210,20);
const mx=(A.x+B.x)/2,my=(A.y+B.y)/2-46,dir=escSpeed>=0?1:-1;
ctx.fillStyle=reversed?'#c0392b':'#2471a3';ctx.font='bold 16px system-ui';ctx.textAlign='center';
ctx.fillText(reversed?'▼ REVERSING DOWN ▼':'▲ UP ▲',mx,my);
ctx.beginPath();ctx.moveTo(mx-30*dir,my+8);ctx.lineTo(mx+30*dir,my+8);ctx.lineTo(mx+18*dir,my+2);ctx.moveTo(mx+30*dir,my+8);ctx.lineTo(mx+18*dir,my+14);ctx.strokeStyle=ctx.fillStyle;ctx.lineWidth=3;ctx.stroke();
const sorted=[...persons].sort((a,b)=>(a.state==='down'?a.s_c:a.s)-(b.state==='down'?b.s_c:b.s));
for(const p of sorted)drawPerson(p);
const down=persons.filter(p=>p.state==='down').length;
document.getElementById('status').textContent=`Escalator: ${reversed?'REVERSED DOWN':'UP'} ${Math.round(escSpeed)} px/s`;
document.getElementById('count').textContent=`${persons.length} people, ${down} down`;
}
let last=performance.now();
function frame(now){
let dt=Math.min(0.033,(now-last)/1000);last=now;
const sub=4,h=dt/sub;
for(let k=0;k<sub;k++){
const dv=escTarget-escSpeed,mc=escAccel*h;
escSpeed=Math.abs(dv)<=mc?escTarget:escSpeed+sign(dv)*mc;
stepOffset+=escSpeed*h;
for(const p of persons)updatePerson(p,h);
collide();
}
persons=persons.filter(p=>!(p.s>totalLen-3&&p.state!=='down'));
if(persons.length<18){
let clear=true;for(const p of persons)if(p.s<30)clear=false;
if(clear)spawn(6);
}
draw();requestAnimationFrame(frame);
}
document.getElementById('rev').onclick=()=>{
reversed=!reversed;escTarget=reversed?-135:55;
const b=document.getElementById('rev');b.textContent=reversed?'Restore escalator':'Reverse escalator';b.className=reversed?'primary rev':'primary';
};
document.getElementById('reset').onclick=init;
init();requestAnimationFrame(frame);
</script>
</body>
</html>
When you press Reverse, the step speed target changes from +55 to -135 px/s and the actual step speed is driven toward it at 1700 px/s², reaching full reverse in about 0.11 s. Feet can only follow at friction-limited acceleration, so they slip relative to the steps, the pivot acceleration creates a forward pseudo-torque larger than the clamped ankle torque, lean and angular velocity grow, people take blocked or unblocked forward steps, collide centre-to-centre and feet-to-down-body, and most cross the fall threshold and become sliding capsules. Those capsules are carried by friction toward step speed plus slope gravity, slide to the bottom landing, act as 34 px obstacles that trip arriving feet, and chain collisions push the pile. A down person only starts the 0.9 s rise when down for over 1.2 s, nearly stopped relative to its carrier, and with no neighbour within 40 px; a strong hit during rising sends it back down.
Special-cased or nudged, same for everyone: escalator speeds and the 1700 px/s² change rate are chosen constants; ankle stiffness/damping/clamp, friction limits, step length/cooldown, fall angle, collision radii/weights, and the feet-versus-down trip check are hand-tuned; rising is interpolated, not fully dynamic; landing walk speed is fixed at 42 px/s and people are recycled at the top and respawned at the bottom to keep 18; arm flailing in the drawing is cosmetic, driven by lean/angular velocity. Randomness is only hue, initial spacing/lean, and step phase — no per-person fall timing.
What it does badly: motion is essentially one-dimensional along the path, so people cannot pass, sidestep, or form a two-dimensional heap, and down bodies do not stack in height. Heads and arms do not physically collide, only centres and feet. Stepping teleports the feet and recomputes lean, which can look like a lunge, and blocked steps just retry. The bottom-landing transition can produce an exaggerated backward stumble, and people carried down to the bottom will walk right and re-enter the reversed escalator in a loop. Rising can clip a neighbour that moves in mid-rise unless the hit is strong. Dense piles can jitter from the positional push-out, and the moving step ticks are cosmetic — slip is measured against step speed, not against individual steps under each foot.