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 rider is a coloured stick figure backed by the same simulated body: a centre in an escalator-track coordinate and height-above-step coordinate, velocity, posture radius/mass, and, while upright, an inverted-pendulum lean with angular velocity; foot friction tries to match the locally supporting surface (moving tread or a body underneath), while rapid motor acceleration, slip, collision shocks, or loss of postural control drive the pendulum past its tip angle and turn the rider into a low, long “down” obstacle. When two body proxies overlap, a small contact solver separates them in the track/vertical metric, applies inelastic and frictional impulses, passes shock into any upright rider’s balance equation, and lets down riders slide, jam, support, or be pushed in a pile; no rider has a person-specific fall flag or scripted fate.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Reverse Escalator Simulation</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100%;
background: #080b10;
color: #e9eef7;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
body {
display: grid;
place-items: center;
overflow: hidden;
}
#wrap {
width: min(1100px, 100vw);
padding: 8px 10px 10px;
}
#bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
flex-wrap: wrap;
}
button {
appearance: none;
border: 1px solid #4f6783;
background: linear-gradient(#25364b, #172333);
color: #f2f7ff;
font: 700 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
letter-spacing: .055em;
padding: 10px 14px;
border-radius: 9px;
cursor: pointer;
box-shadow: 0 2px 0 #06080c, inset 0 1px 0 rgba(255,255,255,.12);
}
button:hover { filter: brightness(1.14); }
button:active { transform: translateY(1px); box-shadow: 0 1px 0 #06080c; }
button.danger {
border-color: #a94b3f;
background: linear-gradient(#702b27, #3d1718);
}
#reset {
border-color: #3d4a5b;
background: #151c27;
font-weight: 600;
letter-spacing: 0;
}
#readout {
margin-left: auto;
color: #aebdd0;
font: 12px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: nowrap;
}
#stage {
width: 100%;
aspect-ratio: 1100 / 680;
}
canvas {
display: block;
width: 100%;
height: 100%;
border: 1px solid #263348;
border-radius: 12px;
background: #101722;
box-shadow: 0 18px 60px rgba(0,0,0,.45);
}
#legend {
margin-top: 7px;
color: #7f8da0;
font-size: 12px;
}
</style>
</head>
<body>
<div id="wrap">
<div id="bar">
<button id="toggle">THROW INTO REVERSE</button>
<button id="reset">reset riders</button>
<span id="readout"></span>
</div>
<div id="stage"><canvas id="cv" width="1100" height="680"></canvas></div>
<div id="legend">Side view. The button changes only the motor target; the crowd, contacts, falls, and recoveries are simulated.</div>
</div>
<script>
(() => {
"use strict";
const W = 1100, H = 680;
const canvas = document.getElementById("cv");
const ctx = canvas.getContext("2d");
const toggleBtn = document.getElementById("toggle");
const resetBtn = document.getElementById("reset");
const readout = document.getElementById("readout");
let DPR = 1;
function resizeCanvas() {
DPR = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(W * DPR);
canvas.height = Math.round(H * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
const S = { x: 88, y: 548 };
const T = { x: 1004, y: 206 };
const DX = T.x - S.x, DY = T.y - S.y;
const L = Math.hypot(DX, DY);
const EX = DX / L, EY = DY / L;
const N = 18;
const NORMAL_V = 38;
const REVERSE_V = -455;
const MOTOR_A_LIMIT = 7600;
const UP_R = 11.5;
const DOWN_R = 18.5;
const UP_FLOOR = 30;
const DOWN_FLOOR = 12.5;
const COM_H = 34;
const G = 1500;
const FALL_ANGLE = 0.98;
const UP_TRACTION = 8.0;
const UP_MAX_A = 2200;
const DOWN_TRACTION = 2.15;
const DOWN_MAX_A = 980;
const CONTACT_ITERS = 6;
let mode = "normal";
let beltV = NORMAL_V;
let targetV = NORMAL_V;
let motorA = 0;
let motorPhase = 0;
let people = [];
let seed = 1234567;
let last = performance.now();
let acc = 0;
let readoutLast = -1;
function rnd() {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296;
}
function clamp(x, a, b) { return x < a ? a : (x > b ? b : x); }
function mod(a, n) { return ((a % n) + n) % n; }
function track(u) { return { x: S.x + EX * u, y: S.y + EY * u }; }
function radius(p) { return p.state === "up" ? UP_R : DOWN_R; }
function floorZ(p) { return p.state === "up" ? UP_FLOOR : DOWN_FLOOR; }
function mass(p) { return p.state === "down" ? 1.35 : 1.0; }
function minU(p) { return radius(p) / EX + 8; }
function colorFor(i) {
return "hsl(" + ((188 + i * 37) % 360) + ", 74%, " + (58 + (i % 3) * 5) + "%)";
}
function setModeUI() {
const reversing = mode === "reverse";
toggleBtn.textContent = reversing ? "RESTORE NORMAL ASCENT" : "THROW INTO REVERSE";
toggleBtn.classList.toggle("danger", reversing);
}
function resetPeople() {
seed = 1234567;
mode = "normal";
beltV = NORMAL_V;
targetV = NORMAL_V;
motorA = 0;
motorPhase = 0;
people = [];
const lo = 92, hi = L - 185;
const pitch = (hi - lo) / (N - 1);
for (let i = 0; i < N; i++) {
people.push({
id: i,
u: lo + pitch * i + (rnd() - 0.5) * 2.0,
v: NORMAL_V,
z: UP_FLOOR,
vz: 0,
theta: (rnd() - 0.5) * 0.035,
omega: (rnd() - 0.5) * 0.02,
state: "up",
fallSign: 1,
recovery: 0,
color: colorFor(i),
supportVel: NORMAL_V,
hasSupport: true,
hasFloor: true,
floorVote: false,
svSum: 0,
svW: 0,
contactPressure: 0,
hitBalance: 0,
hitDir: 0
});
}
setModeUI();
}
function setDown(p, sign) {
if (p.state === "down") return;
p.state = "down";
p.fallSign = sign || (Math.sign(p.theta) || 1);
p.theta = p.fallSign * 1.42;
p.omega = 0;
p.recovery = 0;
p.vz -= 80;
p.z = Math.max(DOWN_FLOOR + 8, p.z - 10);
}
function standUp(p) {
p.state = "up";
p.z = Math.max(UP_FLOOR, p.z + (UP_FLOOR - DOWN_FLOOR) + 2);
p.vz = 0;
p.theta = (rnd() - 0.5) * 0.08;
p.omega = 0;
p.recovery = 0;
p.v += (rnd() - 0.5) * 5;
}
function stepMotor(dt) {
const before = beltV;
beltV += clamp(targetV - beltV, -MOTOR_A_LIMIT * dt, MOTOR_A_LIMIT * dt);
motorA = (beltV - before) / dt;
motorPhase += beltV * dt;
}
function integrate(dt) {
for (const p of people) {
const hasFloorish = p.hasFloor || p.z <= floorZ(p) + 2.0;
const sv = p.hasSupport ? p.supportVel : (hasFloorish ? beltV : 0);
const useMotorJerk = hasFloorish ? 1 : 0;
if (p.state === "up") {
const slip = sv - p.v;
const drive = clamp(
UP_TRACTION * slip + useMotorJerk * 0.22 * motorA,
-UP_MAX_A,
UP_MAX_A
);
p.v += drive * dt;
const aFoot = drive + (hasFloorish ? 0.34 : 0.11) * motorA;
const slipNow = Math.abs(sv - p.v);
const authority = clamp(1 - slipNow / 260 - Math.abs(aFoot) / 5200, 0, 1);
const control = clamp(-95 * p.theta - 13 * p.omega, -48, 48) * authority;
const angularAcc =
(G / COM_H) * Math.sin(p.theta) +
(EX * aFoot / COM_H) * Math.cos(p.theta) +
control;
p.omega += angularAcc * dt;
p.theta += p.omega * dt;
if (Math.abs(p.theta) > FALL_ANGLE) setDown(p, Math.sign(p.theta) || 1);
} else {
const slip = sv - p.v;
const drive = clamp(
DOWN_TRACTION * slip + useMotorJerk * 0.12 * motorA,
-DOWN_MAX_A,
DOWN_MAX_A
);
p.v += drive * dt;
}
p.vz -= G * dt;
p.u += p.v * dt;
p.z += p.vz * dt;
}
}
function recycleNormalRiders() {
if (mode !== "normal") return;
for (const p of people) {
if (p.u <= L + 45) continue;
let bestU = null, bestScore = -Infinity;
for (let c = 0; c < 12; c++) {
const u = 28 + c * 28;
let score = Infinity;
for (const q of people) {
if (q === p) continue;
const du = (q.u - u) * EX;
const dz = q.z - UP_FLOOR;
const d = Math.hypot(du, dz);
score = Math.min(score, d - (radius(q) + UP_R));
}
if (score > bestScore) { bestScore = score; bestU = u; }
}
if (bestScore > 4) {
p.u = bestU;
p.v = beltV;
p.vz = 0;
p.z = UP_FLOOR;
p.state = "up";
p.theta = (rnd() - 0.5) * 0.04;
p.omega = (rnd() - 0.5) * 0.02;
p.recovery = 0;
p.hasFloor = true;
p.hasSupport = true;
p.supportVel = beltV;
p.contactPressure = 0;
} else {
p.u = L + 52;
p.v = 0;
}
}
}
function resetContacts(p) {
p.svSum = 0;
p.svW = 0;
p.contactPressure = 0;
p.hasFloor = false;
p.floorVote = false;
p.hasSupport = false;
p.hitBalance = 0;
p.hitDir = 0;
}
function enforceFloorAndWalls(p, dt) {
const fz = floorZ(p);
if (p.z < fz || p.z <= fz + 0.8) {
const penetration = Math.max(0, fz - p.z);
p.z = Math.max(p.z, fz);
if (p.vz < 0) p.vz = 0;
p.hasFloor = true;
if (!p.floorVote) {
p.floorVote = true;
p.svSum += beltV * 7;
p.svW += 7;
p.contactPressure += G * dt * 0.55 + penetration * 0.4;
}
}
const low = minU(p);
if (p.u < low) {
const impact = Math.max(0, -p.v);
p.u = low;
if (p.v < 0) p.v = 0;
if (impact > 0) {
p.contactPressure += impact * 0.16;
p.hitBalance += impact * 0.75;
p.hitDir -= impact;
}
}
const high = L + 55;
if (p.u > high) {
const impact = Math.max(0, p.v);
p.u = high;
if (p.v > 0) p.v = 0;
if (impact > 0) {
p.contactPressure += impact * 0.10;
p.hitBalance += impact * 0.45;
p.hitDir += impact;
}
}
}
function collidePair(p, q) {
const rp = radius(p), rq = radius(q);
const rsum = rp + rq;
let du = (q.u - p.u) * EX;
let dz = q.z - p.z;
let d2 = du * du + dz * dz;
if (d2 >= rsum * rsum) return;
let d = Math.sqrt(d2);
let nx, ny;
if (d < 0.0001) {
d = 0.0001;
du = d;
dz = 0;
nx = 1;
ny = 0;
} else {
nx = du / d;
ny = dz / d;
}
const overlap = rsum - d;
const imp = 1 / mass(p), imq = 1 / mass(q);
const invSum = imp + imq;
const corr = overlap * 0.82;
p.u -= (corr * nx / EX) * (imp / invSum);
q.u += (corr * nx / EX) * (imq / invSum);
p.z -= (corr * ny) * (imp / invSum);
q.z += (corr * ny) * (imq / invSum);
const dvx = (q.v - p.v) * EX;
const dvz = q.vz - p.vz;
const relN = dvx * nx + dvz * ny;
let J = 0;
if (relN < 0) {
const restitution = (p.state === "down" && q.state === "down") ? 0.02 : 0.07;
J = -(1 + restitution) * relN / invSum;
p.v -= J * nx / EX * imp;
q.v += J * nx / EX * imq;
p.vz -= J * ny * imp;
q.vz += J * ny * imq;
const tx = -ny, ty = nx;
const relT = dvx * tx + dvz * ty;
const Ft = clamp(-relT / invSum * 0.28, -0.45 * Math.abs(J), 0.45 * Math.abs(J));
p.v -= Ft * tx / EX * imp;
q.v += Ft * tx / EX * imq;
p.vz -= Ft * ty * imp;
q.vz += Ft * ty * imq;
}
const shock =
Math.abs(relN < 0 ? relN : 0) +
Math.max(0, J) * 0.7 +
overlap * 8;
p.contactPressure += shock * 0.08 + overlap * 0.7;
q.contactPressure += shock * 0.08 + overlap * 0.7;
p.hitBalance += shock * 0.35;
q.hitBalance += shock * 0.35;
p.hitDir += -nx * shock;
q.hitDir += nx * shock;
if (ny > 0.22) {
const w = ny * 1.5;
q.svSum += p.v * w;
q.svW += w;
} else if (ny < -0.22) {
const w = (-ny) * 1.5;
p.svSum += q.v * w;
p.svW += w;
} else {
const w = 0.25;
p.svSum += q.v * w;
p.svW += w;
q.svSum += p.v * w;
q.svW += w;
}
}
function solveContacts(dt) {
for (const p of people) resetContacts(p);
for (let k = 0; k < CONTACT_ITERS; k++) {
for (const p of people) enforceFloorAndWalls(p, dt);
for (let i = 0; i < people.length; i++) {
for (let j = i + 1; j < people.length; j++) {
collidePair(people[i], people[j]);
}
}
}
for (const p of people) enforceFloorAndWalls(p, dt);
for (const p of people) {
p.contactPressure = Math.min(p.contactPressure, 2000);
p.supportVel = p.svW > 0 ? p.svSum / p.svW : beltV;
p.hasSupport = p.svW > 0 || p.hasFloor;
if (p.state === "up" && p.hitBalance > 0) {
let dir;
if (Math.abs(p.hitDir) > 0.001) dir = Math.sign(p.hitDir);
else dir = p.v >= p.supportVel ? 1 : -1;
if (dir === 0) dir = 1;
p.omega += clamp(p.hitBalance * 0.006, 0, 4.2) * dir;
}
if (p.state === "up" && Math.abs(p.theta) > FALL_ANGLE) {
setDown(p, Math.sign(p.theta) || 1);
}
}
}
function hasRoom(p) {
if (p.u > L - 8) return false;
const standCenterZ = p.z + (UP_FLOOR - DOWN_FLOOR);
for (const q of people) {
if (q === p) continue;
if (q.z < p.z - 5) continue;
const du = (q.u - p.u) * EX;
const dz = q.z - standCenterZ;
const d = Math.hypot(du, dz);
const need = UP_R + radius(q) + 8;
if (d < need) return false;
}
return true;
}
function updateRecovery(dt) {
for (const p of people) {
if (p.state !== "down") continue;
const sv = p.hasSupport ? p.supportVel : beltV;
const slip = Math.abs(sv - p.v);
const lowSurfaceSpeed = Math.abs(sv) < 170;
const footOK =
p.hasSupport &&
Math.abs(p.vz) < 55 &&
slip < 58 &&
lowSurfaceSpeed &&
Math.abs(motorA) < 1200 &&
p.contactPressure < 160;
const roomOK = hasRoom(p);
if (footOK && roomOK) {
p.recovery += dt * (1.0 + Math.max(0, (160 - p.contactPressure) / 160) * 0.5);
} else {
p.recovery = Math.max(0, p.recovery - dt * 1.4);
}
if (p.recovery > 0.72) standUp(p);
}
}
function simulate(dt) {
stepMotor(dt);
integrate(dt);
recycleNormalRiders();
solveContacts(dt);
updateRecovery(dt);
}
function drawBackground() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, "#111c29");
g.addColorStop(0.55, "#0c121b");
g.addColorStop(1, "#070a0f");
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.strokeStyle = "rgba(135,165,205,.055)";
ctx.lineWidth = 1;
for (let x = 40; x < W; x += 110) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x - 60, H);
ctx.stroke();
}
ctx.strokeStyle = "rgba(135,165,205,.035)";
for (let y = 90; y < H; y += 90) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(W, y);
ctx.stroke();
}
ctx.restore();
ctx.fillStyle = "rgba(0,0,0,.22)";
ctx.fillRect(0, S.y + 82, W, H - S.y - 82);
ctx.strokeStyle = "rgba(255,255,255,.06)";
ctx.beginPath();
ctx.moveTo(0, S.y + 82);
ctx.lineTo(W, S.y + 82);
ctx.stroke();
}
function drawStringerAndGlass() {
ctx.save();
ctx.lineCap = "butt";
ctx.beginPath();
ctx.moveTo(S.x - 52, S.y + 38);
ctx.lineTo(T.x + 46, T.y + 38);
ctx.lineWidth = 96;
ctx.strokeStyle = "#101824";
ctx.stroke();
ctx.lineWidth = 3;
ctx.strokeStyle = "#2b3a4d";
ctx.beginPath();
ctx.moveTo(S.x - 52, S.y - 8);
ctx.lineTo(T.x + 46, T.y - 8);
ctx.stroke();
const glass = ctx.createLinearGradient(0, 0, W, H);
glass.addColorStop(0, "rgba(90,145,190,.10)");
glass.addColorStop(1, "rgba(90,145,190,.025)");
ctx.beginPath();
ctx.moveTo(S.x - 58, S.y - 57);
ctx.lineTo(T.x + 50, T.y - 57);
ctx.lineTo(T.x + 50, T.y + 18);
ctx.lineTo(S.x - 58, S.y + 18);
ctx.closePath();
ctx.fillStyle = glass;
ctx.fill();
ctx.strokeStyle = "rgba(150,190,230,.13)";
ctx.lineWidth = 1.5;
ctx.stroke();
for (let x = 180; x < 980; x += 175) {
const u = (x - S.x) / EX;
const p = track(u);
ctx.strokeStyle = "rgba(150,190,230,.055)";
ctx.beginPath();
ctx.moveTo(p.x, p.y - 55);
ctx.lineTo(p.x + 14, p.y + 15);
ctx.stroke();
}
ctx.restore();
}
function drawSteps() {
const pitch = 33;
const tread = 43;
const riser = 18;
const total = L + pitch * 2;
ctx.save();
for (let k = -3; k < L / pitch + 4; k++) {
const u = mod(k * pitch + motorPhase, total) - pitch;
if (u < -pitch || u > L + pitch) continue;
const p = track(u);
const edgeFade = (u < 0 || u > L) ? 0.32 : 1;
ctx.globalAlpha = edgeFade;
ctx.fillStyle = "#29384a";
ctx.fillRect(p.x - tread / 2, p.y - 1, tread, 7);
ctx.fillStyle = "#151d28";
ctx.fillRect(p.x + tread / 2 - 6, p.y + 5, 6, riser);
ctx.strokeStyle = "#4a6077";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(p.x - tread / 2, p.y - 0.5);
ctx.lineTo(p.x + tread / 2, p.y - 0.5);
ctx.moveTo(p.x + tread / 2, p.y + 5);
ctx.lineTo(p.x + tread / 2, p.y + riser);
ctx.stroke();
ctx.strokeStyle = "rgba(180,210,235,.16)";
ctx.beginPath();
ctx.moveTo(p.x - tread / 2 + 7, p.y + 2);
ctx.lineTo(p.x + tread / 2 - 10, p.y + 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
ctx.restore();
}
function drawLandingsAndRail() {
ctx.save();
ctx.fillStyle = "#0a1018";
ctx.fillRect(0, S.y, S.x + 66, 17);
ctx.fillRect(T.x - 58, T.y, W - T.x + 58, 17);
ctx.fillStyle = "#35445a";
ctx.fillRect(0, S.y - 2, S.x + 66, 3);
ctx.fillRect(T.x - 58, T.y - 2, W - T.x + 58, 3);
ctx.strokeStyle = "#62748a";
ctx.lineWidth = 1;
for (let x = S.x - 18; x < S.x + 52; x += 7) {
ctx.beginPath();
ctx.moveTo(x, S.y + 2);
ctx.lineTo(x + 5, S.y + 14);
ctx.stroke();
}
for (let x = T.x - 52; x < T.x + 30; x += 7) {
ctx.beginPath();
ctx.moveTo(x, T.y + 2);
ctx.lineTo(x + 5, T.y + 14);
ctx.stroke();
}
ctx.lineCap = "round";
ctx.lineWidth = 9;
ctx.strokeStyle = "#1d2a3a";
ctx.beginPath();
ctx.moveTo(S.x - 58, S.y - 57);
ctx.lineTo(T.x + 50, T.y - 57);
ctx.stroke();
ctx.lineWidth = 2;
ctx.strokeStyle = "rgba(145,180,215,.65)";
ctx.stroke();
const pitch = 82, total = L + pitch;
for (let k = 0; k < L / pitch + 2; k++) {
const u = mod(k * pitch + motorPhase * 0.8, total) - pitch / 2;
if (u < 0 || u > L) continue;
const p = track(u);
const a = Math.atan2(EY, EX) + (beltV < 0 ? Math.PI : 0);
ctx.save();
ctx.translate(p.x, p.y - 57);
ctx.rotate(a);
ctx.strokeStyle = mode === "reverse" ? "rgba(255,105,78,.92)" : "rgba(92,220,224,.72)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(-7, -5);
ctx.lineTo(4, 0);
ctx.lineTo(-7, 5);
ctx.stroke();
ctx.restore();
}
ctx.restore();
}
function drawStick(p, foot, hip, shoulder, head, f1, f2, hand1, hand2, color, lw) {
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = "round";
ctx.lineJoin = "round";
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.moveTo(hip.x, hip.y); ctx.lineTo(shoulder.x, shoulder.y);
ctx.moveTo(shoulder.x, shoulder.y); ctx.lineTo(hand1.x, hand1.y);
ctx.moveTo(shoulder.x, shoulder.y); ctx.lineTo(hand2.x, hand2.y);
ctx.stroke();
}
function drawUpright(p) {
const base = track(p.u);
const support = Math.max(0, p.z - UP_FLOOR);
const foot = { x: base.x, y: base.y - support };
const lean = clamp(p.theta, -1.15, 1.15);
const sn = Math.sin(lean), cs = Math.cos(lean);
const hip = { x: foot.x + sn * 16, y: foot.y - cs * 16 };
const shoulder = { x: foot.x + sn * 31, y: foot.y - cs * 31 };
const head = { x: foot.x + sn * 40, y: foot.y - cs * 40 };
const slip = p.v - p.supportVel;
const stride = clamp(slip * 0.045 + p.omega * 2.4, -8, 8);
const f1 = { x: foot.x - 7 + stride, y: foot.y };
const f2 = { x: foot.x + 7 - stride, y: foot.y };
const swing = clamp(-p.omega * 4 - slip * 0.025, -12, 12);
const hand1 = { x: shoulder.x - 12 + swing, y: shoulder.y + 9 };
const hand2 = { x: shoulder.x + 12 - swing, y: shoulder.y + 7 };
ctx.save();
ctx.fillStyle = "rgba(0,0,0,.28)";
ctx.beginPath();
ctx.ellipse(foot.x, foot.y + 4, 13 + Math.abs(lean) * 3, 3.2, 0, 0, Math.PI * 2);
ctx.fill();
drawStick(p, foot, hip, shoulder, head, f1, f2, hand1, hand2, "rgba(0,0,0,.72)", 5.4);
drawStick(p, foot, hip, shoulder, head, f1, f2, hand1, hand2, p.color, 2.8);
ctx.fillStyle = "#0b0f16";
ctx.strokeStyle = "rgba(0,0,0,.8)";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(head.x, head.y, 6.2, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = p.color;
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
}
function drawDown(p) {
const base = track(p.u);
const c = { x: base.x, y: base.y - p.z };
const R = DOWN_R;
const dir = { x: EX, y: EY };
const perp = { x: -dir.y, y: dir.x };
const a = { x: c.x - dir.x * R, y: c.y - dir.y * R };
const b = { x: c.x + dir.x * R, y: c.y + dir.y * R };
const sign = p.fallSign || 1;
const head = { x: c.x + dir.x * R * sign * 0.88, y: c.y + dir.y * R * sign * 0.88 };
const wobble = clamp(p.v * 0.01 + p.omega + Math.sin(p.id * 1.7) * 2, -7, 7);
const l1 = { x: c.x - dir.x * R * 0.35 + perp.x * (9 + wobble), y: c.y - dir.y * R * 0.35 + perp.y * (9 + wobble) };
const l2 = { x: c.x + dir.x * R * 0.28 - perp.x * (10 - wobble), y: c.y + dir.y * R * 0.28 - perp.y * (10 - wobble) };
const h1 = { x: c.x + perp.x * (8 - wobble), y: c.y + perp.y * (8 - wobble) };
const h2 = { x: c.x - perp.x * (6 + wobble), y: c.y - perp.y * (6 + wobble) };
ctx.save();
ctx.fillStyle = "rgba(0,0,0," + clamp(0.26 - p.z * 0.002, 0.06, 0.24) + ")";
ctx.beginPath();
ctx.ellipse(base.x, base.y + 3, 20, 3.5, 0, 0, Math.PI * 2);
ctx.fill();
function limbLines(color, lw) {
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(c.x - dir.x * R * 0.2, c.y - dir.y * R * 0.2); ctx.lineTo(l1.x, l1.y);
ctx.moveTo(c.x + dir.x * R * 0.15, c.y + dir.y * R * 0.15); ctx.lineTo(l2.x, l2.y);
ctx.moveTo(c.x - dir.x * R * 0.35, c.y - dir.y * R * 0.35); ctx.lineTo(h1.x, h1.y);
ctx.moveTo(c.x + dir.x * R * 0.25, c.y + dir.y * R * 0.25); ctx.lineTo(h2.x, h2.y);
ctx.stroke();
}
limbLines("rgba(0,0,0,.72)", 5.2);
limbLines(p.color, 2.5);
function capsule(color, lw) {
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
capsule("rgba(0,0,0,.75)", 14);
capsule(p.color, 9);
ctx.fillStyle = "#0b0f16";
ctx.strokeStyle = "rgba(0,0,0,.8)";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(head.x, head.y, 6.4, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = p.color;
ctx.lineWidth = 2;
ctx.stroke();
if (p.recovery > 0.04) {
const t = clamp(p.recovery / 0.72, 0, 1);
ctx.strokeStyle = "rgba(115,255,170,.8)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(c.x, c.y, R + 5, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * t);
ctx.stroke();
}
ctx.restore();
}
function drawPerson(p) {
if (p.state === "up") drawUpright(p);
else drawDown(p);
}
function drawCanvasHUD() {
ctx.save();
ctx.font = "700 12px system-ui, -apple-system, Segoe UI, sans-serif";
ctx.textBaseline = "top";
ctx.fillStyle = "rgba(8,12,18,.62)";
ctx.fillRect(16, 16, 270, 30);
ctx.strokeStyle = "rgba(150,180,215,.15)";
ctx.strokeRect(16.5, 16.5, 269, 29);
ctx.fillStyle = mode === "reverse" ? "#ff9b82" : "#9ce9eb";
ctx.fillText(mode === "reverse" ? "MOTOR: HIGH-SPEED REVERSE" : "MOTOR: NORMAL ASCENT", 27, 24);
ctx.restore();
}
function draw() {
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.clearRect(0, 0, W, H);
drawBackground();
drawStringerAndGlass();
drawSteps();
drawLandingsAndRail();
const ordered = people.slice().sort((a, b) => (a.z - b.z) || (a.u - b.u));
for (const p of ordered) drawPerson(p);
drawCanvasHUD();
}
function updateReadout(now) {
if (now - readoutLast < 100) return;
readoutLast = now;
const down = people.reduce((n, p) => n + (p.state === "down" ? 1 : 0), 0);
const up = people.length - down;
const spd = Math.round(beltV);
readout.textContent =
people.length + " riders • " + up + " upright / " + down + " down • " +
(mode === "reverse" ? "HIGH REVERSE" : "normal up") + " • " +
(spd > 0 ? "+" : "") + spd + " px/s";
}
function frame(now) {
const elapsed = Math.min(0.05, Math.max(0, (now - last) / 1000 || 0));
last = now;
acc += elapsed;
const h = 1 / 120;
let steps = 0;
while (acc >= h && steps < 12) {
simulate(h);
acc -= h;
steps++;
}
if (steps >= 12) acc = 0;
draw();
updateReadout(now);
requestAnimationFrame(frame);
}
toggleBtn.addEventListener("click", () => {
if (mode === "normal") {
mode = "reverse";
targetV = REVERSE_V;
} else {
mode = "normal";
targetV = NORMAL_V;
}
setModeUI();
});
resetBtn.addEventListener("click", () => {
resetPeople();
});
resetPeople();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
Pressing the main button changes only the motor target from a slow uphill +38 px/s to a high-speed downhill −455 px/s, with a fast 7600 px/s² motor ramp; pressing it again targets the slow uphill speed again. The moving steps, rail chevrons, floor traction, balance equations, and contact impulses all read that same motor velocity. The abrupt acceleration and slip make every upright inverted pendulum lose postural authority through the same threshold, so the riders tip into larger, lower down bodies rather than being assigned fall times. Then their own friction with the tread, gravity to the local step height, bottom comb-wall constraint, and body-body contacts produce the sliding, bunching, collision shocks, and piled rows/stacks. A down body is a wide circular/capsule proxy, so it blocks arrivals behind it and can support bodies above it; after restore, or in any calm gap with low slip, low surface speed, low contact pressure, and enough room, a down rider accumulates a recovery state and stands back up.
Special cases and nudges: the top and bottom ends are global boundary conditions; in normal mode, riders who leave the top are re-fed near the bottom as clean standing commuters, while reverse mode uses the top edge as a stop and the bottom is a fixed comb wall. The reset button restores the regular initial state. I also tuned global values — reverse speed, motor ramp, traction, fall angle, restitution/friction, and recovery thresholds — so the chosen outcome is reliably violent; high-speed tread is treated as poor footing for recovery. There are no rider-ID fall rules, no per-person keyframes, no disaster timeline, and no scripted pile. Colours, tiny seeded initial jitter, and drawing variation are cosmetic only.
It does badly what a real physics engine would do better: the crowd is a tilted track-plus-height approximation rather than full 2D articulated rigid bodies; gravity is not decomposed as true sloping-plane mechanics because escalator treads are horizontal; legs and arms are illustrative poses, not solved limbs; contacts can look sticky, gloppy, or briefly overlapped under heavy compression; recovery can be abrupt; and the top re-feed is obviously artificial if you watch for many normal-mode cycles.