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.
A person is a small rigid capsule (a center, linear velocity, angle, angular velocity, mass/inertia, radius and half-length) with a finite posture controller and one of three modes: standing, down, or getting up. A standing figure loses balance when supported-foot slip, belt acceleration, or collision impulses give it more lean/rotation/impact than its controller can counter; a down figure becomes a low, wide capsule that still obeys the same contact, friction, collision and wall rules as everyone else. When two capsules overlap, a custom impulse solver separates them, applies normal and tangential impulses at the nearest sampled points, records the impact as a possible fall cause, and lets the resulting rotation and sliding decide what happens next.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Reverse Escalator</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body {
margin: 0; height: 100%; overflow: hidden;
background: #070a0f; color: #dfe9f7;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
#wrap {
position: relative; width: 100vw; height: 100vh;
display: flex; align-items: center; justify-content: center;
}
canvas {
display: block;
width: min(100vw, 1200px, 171.428vh);
height: auto;
background: #0b1018;
box-shadow: 0 0 60px rgba(0,0,0,.75);
}
#ui {
position: absolute;
left: clamp(12px, 3vw, 40px);
top: clamp(12px, 3vw, 34px);
width: min(390px, 88vw);
pointer-events: none;
}
#toggle {
pointer-events: auto;
appearance: none;
border: 1px solid rgba(170,205,240,.45);
border-radius: 10px;
background: linear-gradient(#21324a, #111a28);
color: #f0f7ff;
font-weight: 750;
letter-spacing: .08em;
padding: 13px 18px;
cursor: pointer;
box-shadow: 0 8px 28px rgba(0,0,0,.45);
}
#toggle:hover { filter: brightness(1.16); }
#toggle:active { transform: translateY(1px); }
#toggle.hot {
border-color: rgba(255,139,96,.85);
background: linear-gradient(#6b2826, #2b1014);
color: #fff0e8;
}
#readout {
margin-top: 10px;
padding: 9px 12px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,.10);
background: rgba(7,11,18,.68);
color: rgba(224,235,250,.88);
font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
#readout b { color: #ffd18a; }
#legend {
margin-top: 7px;
color: rgba(210,225,245,.55);
font-size: 12px;
}
</style>
</head>
<body>
<div id="wrap">
<canvas id="cv" width="1200" height="700" aria-label="Side-view escalator simulation"></canvas>
<div id="ui">
<button id="toggle" type="button">THROW INTO REVERSE</button>
<div id="readout"></div>
<div id="legend">Space also toggles the drive. People are capsules, not keyframes.</div>
</div>
</div>
<script>
(() => {
"use strict";
const canvas = document.getElementById("cv");
const ctx = canvas.getContext("2d");
const W = 1200, H = 700;
const 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);
const btn = document.getElementById("toggle");
const readout = document.getElementById("readout");
const PI = Math.PI;
const DT = 1 / 60;
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const wrap = a => {
while (a > PI) a -= 2 * PI;
while (a < -PI) a += 2 * PI;
return a;
};
const mod = (a, n) => ((a % n) + n) % n;
let seed = 0x4a6b21;
const rnd = () => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return seed / 4294967296;
};
const rr = (a, b) => a + (b - a) * rnd();
const lower = { x: 150, y: 565 };
const upper = { x: 1040, y: 260 };
const railDX = upper.x - lower.x;
const railDY = upper.y - lower.y;
const railLen = Math.hypot(railDX, railDY);
const tx = railDX / railLen;
const ty = railDY / railLen;
const slope = railDY / railDX;
const railAngle = Math.atan2(ty, tx);
const pitch = 54;
const treadW = 62;
const riserH = 20;
const leftWall = 70;
const rightWall = 1135;
const NORMAL = 34;
const REVERSE = -435;
const MAX_BELT_ACCEL = 2050;
const UPRIGHT = -PI / 2;
const G = 1100;
const FRICTION = 0.86;
const COLLISION_FRICTION = 0.20;
const KP = 24, KD = 7.5, CTRL_ALPHA = 34;
const DOWN_KP = 10, DOWN_KD = 4.5, DOWN_ALPHA = 18;
const GET_KP = 34, GET_KD = 8, GET_ALPHA = 46;
const FALL_LEAN = 0.72;
const FALL_OMEGA = 3.7;
const FALL_IMPACT = 105;
let reversed = false;
let targetV = NORMAL;
let beltV = NORMAL;
let beltTravel = 0;
let beltAccel = 0;
function surfaceAt(x) {
if (x < lower.x) return { kind: 0, y: lower.y, svx: 0, svy: 0 };
if (x > upper.x) return { kind: 2, y: upper.y, svx: 0, svy: 0 };
return {
kind: 1,
y: lower.y + slope * (x - lower.x),
svx: beltV * tx,
svy: beltV * ty
};
}
const bodies = [];
function makeBody(i, n) {
const s = 70 + i * ((railLen * 0.72 - 70) / (n - 1)) + rr(-4, 4);
const x = lower.x + tx * s;
const r = rr(7.2, 8.8);
const h = rr(20.5, 23.5);
const m = rr(1.15, 1.65);
const invM = 1 / m;
const invI = 1 / (m * (4 * h * h / 12 + r * r / 2));
const footY = surfaceAt(x).y - r;
return {
c: { x, y: footY - h },
v: { x: NORMAL * tx, y: NORMAL * ty },
phi: UPRIGHT,
omega: 0,
h, r, m, invM, invI,
tone: rnd(),
mode: "up",
lying: railAngle,
rest: 0,
getAge: 0,
contact: 0,
onRamp: false,
lastOnRamp: true,
slip: 0,
lastSlip: 0,
impact: 0
};
}
for (let i = 0; i < 18; i++) bodies.push(makeBody(i, 18));
function nearestLie(phi) {
const choices = [railAngle, railAngle + PI, railAngle - PI];
let best = choices[0], bestD = Infinity;
for (const c of choices) {
const d = Math.abs(wrap(phi - c));
if (d < bestD) { bestD = d; best = c; }
}
return best;
}
function makeDown(b) {
if (b.mode === "down") return;
b.mode = "down";
b.lying = nearestLie(b.phi);
b.rest = 0;
b.getAge = 0;
b.omega = clamp(b.omega, -8, 8);
}
function startGetUp(b) {
b.mode = "getup";
b.getAge = 0;
b.rest = 0;
b.omega *= 0.35;
b.v.y -= 40;
}
function ends(b) {
const ux = Math.cos(b.phi), uy = Math.sin(b.phi);
return {
low: { x: b.c.x - ux * b.h, y: b.c.y - uy * b.h },
high: { x: b.c.x + ux * b.h, y: b.c.y + uy * b.h },
u: { x: ux, y: uy }
};
}
function pointOn(e, t) {
return {
x: e.low.x + (e.high.x - e.low.x) * t,
y: e.low.y + (e.high.y - e.low.y) * t
};
}
function pointVel(b, p) {
const rx = p.x - b.c.x, ry = p.y - b.c.y;
return { x: b.v.x - b.omega * ry, y: b.v.y + b.omega * rx };
}
function applyImpulse(b, jx, jy, p) {
b.v.x += jx * b.invM;
b.v.y += jy * b.invM;
const rx = p.x - b.c.x, ry = p.y - b.c.y;
b.omega += (rx * jy - ry * jx) * b.invI;
}
function effMass(b, p, nx, ny) {
const rx = p.x - b.c.x, ry = p.y - b.c.y;
const rn = rx * ny - ry * nx;
return b.invM + rn * rn * b.invI;
}
function applySurfaceContact(b, p, sup, dt) {
const nx = 0, ny = -1;
const fx = 1, fy = 0;
const v0 = pointVel(b, p);
const rvx = v0.x - sup.svx;
const rvy = v0.y - sup.svy;
const vn = rvx * nx + rvy * ny;
const denN = effMass(b, p, nx, ny);
let jn = 0;
if (vn < 0) {
jn = Math.min(-vn / denN, 160);
applyImpulse(b, jn * nx, jn * ny, p);
}
const v1 = pointVel(b, p);
const vt = (v1.x - sup.svx) * fx + (v1.y - sup.svy) * fy;
const denT = effMass(b, p, fx, fy);
let jt = -vt / denT;
const supportBias = b.m * G * dt * 0.8;
const maxF = FRICTION * (jn