27:18Gemini 4 Hydrogen: Cheaper Tokens, More Expensive Answers?
Nov 13, 2026 · YouTube
18:40ERNIE 5.5 Flash Preview vs GLM 5.3 Flash: Baidu's new model is very impressive!
Nov 11, 2026 · YouTube
36:22Fable 5.2 robbing me blind, plus: Nex AGI's N2.6 vs GLM 5.3 Flash
Nov 11, 2026 · YouTube
14:03Gemini 4 Carbon: Can it handle my challenges? (First look with one-shot coding tests)
Nov 10, 2026 · YouTube
47:51Ranking major U.S. Open-Weights Models. (Where does Ripple 65B fit in?)
Nov 6, 2026 · YouTube
10:57"Crouton Alpha": the latest super-efficient coding model by Meta?
Nov 3, 2026 · YouTube
38:58Testing Particle-1: a post-trained DeepSeek V4.1 Flash specializing in coding and efficiency
Oct 28, 2026 · YouTube
22:14GPT-6.1 Terra Pro vs Terra: How Much Better Is Pro?
Oct 27, 2026 · YouTube
29:55Kimi K3.1 Pro Looked Incredible. Then I Kept Testing.
Oct 26, 2026 · YouTube
31:28Is this company reselling GPT Sol 6.1 at a loss (for non-agentic/text-only)?🤔 Is this sustainable?
Oct 20, 2026 · YouTube
17:58Nemotron 3.5 Super VL vs Opus 5.5: Testing NVIDIA’s new coding juggernaut
Oct 15, 2026 · YouTube
16:04Testing a *massive* new open-weights model 🤯: Muse Spark Open (vs. Nemotron, Opus 5.5, and more)
Oct 15, 2026 · YouTube
16:43Gemini 4 Argon: Great 🐈 Animation, but a broken escalator. (Coding challenge vs Opus 5.5, Sol 6.1)
Oct 13, 2026 · YouTube
16:37Meta's new "Muse Wonder" AI is impressive... but can it compete with Anthropic's models?
Oct 12, 2026 · YouTube
11:12WOW... Haiku 5.5 is very impressive. RIP OpenAI?
Oct 7, 2026 · YouTube
22:47Mistral Large 4 Is Cheap. But Is It Good Enough to Compete?
Oct 6, 2026 · YouTube
22:02Is Pareto 26.10 Preview Just Routing to GPT Sol? I Tested It Against Sol 6.1 Pro
Oct 1, 2026 · YouTube
11:22I Tested Sonnet 5.5 Against Opus 5.5. Is It Actually Cheaper?
Sep 28, 2026 · YouTube
30:00Did Elon Musk's team "train the gay away"... and make Grok 4.7 worse?
Sep 26, 2026 · YouTube
19:21I Tested the Stealth "Space Bunny Alpha" Model Before It Went Offline
Sep 23, 2026 · YouTube
10:58So... how much better is Claude Opus 5.5 vs. GPT-6 Sol, really?
Sep 23, 2026 · YouTube
9:38Claude Opus 5.5... the BEST new model for coding!! 🤩
Sep 22, 2026 · YouTube
29:00Grok 4.7: improved, but GLM 5.3 FlashX beat it anyway (benchmark against Gemini, Sol, etc.)
Sep 22, 2026 · YouTube
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 simulated as a 3-node articulated physical chain—feet (F), hips (H), and torso/head (T)—integrated with sub-stepped semi-implicit Euler and Position-Based Dynamics (PBD) distance constraints for the legs (F–H) and spine (H–T), plus a minimum F–T separation so bodies compress and bend rather than collapsing to a point. While upright, a person keeps balance solely through equal-and-opposite PD torque forces between F and H (and H and T) clamped to finite human muscle strength; because ankle torque pushes against the step through Coulomb friction (μ = 0.88 for shoes vs. 0.44 for clothing once down), a violent reversal yanks F backward faster than H and T can follow, exceeding muscle limits and tipping the body past a critical angle into a limp/bracing fallen state where gravity, step geometry, and inertia take over. Every limb segment (F–H and H–T) is a physical capsule that collides against all other people's capsules using closest-point-on-segment PBD projection and tangential friction, so bodies block one another, stack vertically into piles, and knock standing riders' legs out from under them; a fallen person begins pushing themselves upright again only after impact momentum settles, at least one lower node rests on solid footing, and no other person's capsule occupies the vertical headroom above them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Escalator Reversal — Emergent Crowd Physics</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%;
height: 100%;
background: #10141b;
color: #e6ebf2;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
user-select: none;
}
.hud {
width: min(1120px, 96vw);
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
margin-bottom: 8px;
background: #171d27;
border: 1px solid #283244;
border-radius: 10px;
}
.title-block {
display: flex;
flex-direction: column;
gap: 2px;
}
.title-block h1 {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #f3f6fa;
}
.stats {
font-size: 12px;
color: #93a1b8;
font-variant-numeric: tabular-nums;
}
.controls {
display: flex;
align-items: center;
gap: 10px;
}
.speed-pill {
font-size: 12px;
font-weight: 600;
padding: 6px 10px;
border-radius: 6px;
background: #0f131a;
border: 1px solid #2a3446;
font-variant-numeric: tabular-nums;
min-width: 148px;
text-align: center;
color: #5eead4;
}
.speed-pill.rev {
color: #fca5a5;
border-color: #7f1d1d;
background: #231115;
}
button {
cursor: pointer;
border: none;
border-radius: 8px;
font-family: inherit;
font-weight: 700;
font-size: 13px;
letter-spacing: 0.03em;
transition: transform 0.08s ease, background 0.15s ease, box-shadow 0.15s ease;
}
button:active { transform: translateY(1px); }
#toggleBtn {
padding: 10px 18px;
background: #dc2626;
color: #fff;
box-shadow: 0 0 0 1px rgba(255,255,255,0.12) inset, 0 4px 14px rgba(220,38,38,0.35);
}
#toggleBtn:hover { background: #ef4444; }
#toggleBtn.is-reversed {
background: #0d9488;
box-shadow: 0 0 0 1px rgba(255,255,255,0.12) inset, 0 4px 14px rgba(13,148,136,0.35);
}
#toggleBtn.is-reversed:hover { background: #14b8a6; }
#resetBtn {
padding: 10px 13px;
background: #242d3d;
color: #cbd5e1;
border: 1px solid #354156;
}
#resetBtn:hover { background: #2f3b50; color: #fff; }
.stage-wrap {
width: min(1120px, 96vw);
aspect-ratio: 1120 / 600;
position: relative;
border-radius: 12px;
overflow: hidden;
border: 1px solid #283244;
background: #131822;
box-shadow: 0 18px 48px rgba(0,0,0,0.45);
}
canvas {
width: 100%;
height: 100%;
display: block;
}
</style>
</head>
<body>
<div class="hud">
<div class="title-block">
<h1>Escalator Reversal Simulation</h1>
<div class="stats" id="statsText">18 riders · 18 upright · 0 down</div>
</div>
<div class="controls">
<div class="speed-pill" id="speedPill">+0.7 m/s ▴ UP</div>
<button id="toggleBtn" type="button">THROW INTO REVERSE</button>
<button id="resetBtn" type="button">Reset</button>
</div>
</div>
<div class="stage-wrap">
<canvas id="sim"></canvas>
</div>
<script>
(() => {
'use strict';
const canvas = document.getElementById('sim');
const ctx = canvas.getContext('2d');
const toggleBtn = document.getElementById('toggleBtn');
const resetBtn = document.getElementById('resetBtn');
const speedPill = document.getElementById('speedPill');
const statsText = document.getElementById('statsText');
const W = 1120;
const H = 600;
// Escalator geometry
const X_WALL_L = 22;
const X_COMB_BOT = 195;
const X_BOT = 255;
const X_TOP = 855;
const X_COMB_TOP = 915;
const X_WALL_R = 1096;
const Y_BOT = 505;
const Y_TOP = 175;
const SLOPE = (Y_BOT - Y_TOP) / (X_TOP - X_BOT); // 0.55 (~28.8 deg)
const STEP_DX = 30;
const STEP_DY = STEP_DX * SLOPE; // 16.5
// Physics constants
const GRAVITY = 560;
const SUBSTEPS = 4;
const PBD_ITERS = 5;
const V_FORWARD = 46; // px/s horizontal (~0.7 m/s diagonal)
const V_REVERSE = -172; // px/s horizontal (~2.7 m/s backward)
const RAMP_TO_REV = 0.19; // seconds to snap into reverse
const RAMP_TO_FWD = 0.50; // seconds to restore forward
const PEOPLE_COUNT = 18;
const PALETTE = [
'#3b82f6', '#e11d48', '#10b981', '#f59e0b', '#8b5cf6',
'#06b6d4', '#f97316', '#84cc16', '#ec4899', '#6366f1',
'#14b8a6', '#eab308', '#ef4444', '#64748b', '#a855f7',
'#0ea5e9', '#d97706', '#22c55e'
];
const SKIN_TONES = ['#f2c7a5', '#e4b08c', '#c98e6b', '#9c6644', '#f7d5ba', '#d49d78'];
let isReversed = false;
let beltVx = V_FORWARD;
let beltPhase = 0;
let simTime = 0;
let people = [];
function clamp(v, lo, hi) {
return v < lo ? lo : (v > hi ? hi : v);
}
// Exact stair height & surface velocity at x (for visual feet clamping & spawns)
function stepTreadY(k) {
const raw = Y_BOT - (k * STEP_DX + beltPhase) * SLOPE;
return clamp(raw, Y_TOP, Y_BOT);
}
function surfaceYAt(x) {
if (x <= X_BOT) return Y_BOT;
if (x >= X_TOP) return Y_TOP;
const k = Math.floor((x - X_BOT - beltPhase) / STEP_DX);
return stepTreadY(k);
}
function beltVelocityAt(x, y) {
if (x < X_COMB_BOT || x > X_COMB_TOP) return { vx: 0, vy: 0 };
if (y >= Y_BOT - 1.5 || y <= Y_TOP + 1.5) return { vx: beltVx, vy: 0 };
return { vx: beltVx, vy: -SLOPE * beltVx };
}
// Collide circle (x, y, r) against the moving staircase polyline + concourse floors & walls.
// Returns { x, y, hit, nx, ny, surfVx, surfVy, penSum }
function collideWorld(x, y, r) {
let cx = x, cy = y;
let bestPen = 0;
let bestNx = 0, bestNy = -1;
let bestSurfVx = 0, bestSurfVy = 0;
let hit = false;
// Walls
if (cx - r < X_WALL_L) {
const pen = X_WALL_L - (cx - r);
cx += pen;
if (pen > bestPen) { bestPen = pen; bestNx = 1; bestNy = 0; bestSurfVx = 0; bestSurfVy = 0; hit = true; }
}
if (cx + r > X_WALL_R) {
const pen = (cx + r) - X_WALL_R;
cx -= pen;
if (pen > bestPen) { bestPen = pen; bestNx = -1; bestNy = 0; bestSurfVx = 0; bestSurfVy = 0; hit = true; }
}
// Flat left landing
if (cx < X_BOT - STEP_DX) {
const floorY = Y_BOT;
if (cy + r > floorY) {
const pen = (cy + r) - floorY;
cy -= pen;
const bv = beltVelocityAt(cx, floorY);
if (pen > bestPen) { bestPen = pen; bestNx = 0; bestNy = -1; bestSurfVx = bv.vx; bestSurfVy = 0; hit = true; }
}
return { x: cx, y: cy, hit, nx: bestNx, ny: bestNy, surfVx: bestSurfVx, surfVy: bestSurfVy, pen: bestPen };
}
// Flat right landing
if (cx > X_TOP + STEP_DX) {
const floorY = Y_TOP;
if (cy + r > floorY) {
const pen = (cy + r) - floorY;
cy -= pen;
const bv = beltVelocityAt(cx, floorY);
if (pen > bestPen) { bestPen = pen; bestNx = 0; bestNy = -1; bestSurfVx = bv.vx; bestSurfVy = 0; hit = true; }
}
return { x: cx, y: cy, hit, nx: bestNx, ny: bestNy, surfVx: bestSurfVx, surfVy: bestSurfVy, pen: bestPen };
}
// Nearby stair steps (treads + risers)
const kCenter = Math.floor((cx - X_BOT - beltPhase) / STEP_DX);
for (let k = kCenter - 1; k <= kCenter + 2; k++) {
const xL = X_BOT + beltPhase + k * STEP_DX;
const xR = xL + STEP_DX;
const yK = stepTreadY(k);
const yPrev = stepTreadY(k - 1);
// 1) Horizontal tread segment [xL, xR] at yK
const tx = clamp(cx, xL, xR);
const ty = yK;
let dx = cx - tx, dy = cy - ty;
let d2 = dx * dx + dy * dy;
if (d2 < r * r) {
const d = Math.sqrt(d2);
let nx, ny, pen;
if (d < 1e-5) {
nx = 0; ny = -1; pen = r;
} else {
nx = dx / d; ny = dy / d; pen = r - d;
}
// Only push from above/front if center is above tread or near corner
if (cy <= yK + r * 0.65) {
cx += nx * pen;
cy += ny * pen;
const bv = beltVelocityAt(tx, yK);
if (pen > bestPen) {
bestPen = pen; bestNx = nx; bestNy = ny;
bestSurfVx = bv.vx; bestSurfVy = bv.vy; hit = true;
}
}
}
// 2) Vertical riser segment at xL between [yK, yPrev]
if (yPrev > yK + 0.5) {
const rx = xL;
const ry = clamp(cy, yK, yPrev);
dx = cx - rx; dy = cy - ry;
d2 = dx * dx + dy * dy;
if (d2 < r * r) {
const d = Math.sqrt(d2);
let nx, ny, pen;
if (d < 1e-5) {
nx = -1; ny = 0; pen = r;
} else {
nx = dx / d; ny = dy / d; pen = r - d;
}
if (cx <= xL + r * 0.65) {
cx += nx * pen;
cy += ny * pen;
const bv = beltVelocityAt(rx, ry);
if (pen > bestPen) {
bestPen = pen; bestNx = nx; bestNy = ny;
bestSurfVx = bv.vx; bestSurfVy = bv.vy; hit = true;
}
}
}
}
// Safety floor guard if center fell deep below step
if (cx >= xL && cx <= xR && cy > yK && cy - yK < STEP_DY + r) {
const pen = (cy + r) - yK;
if (pen > 0 && pen < r * 2.2) {
cy = yK - r;
const bv = beltVelocityAt(cx, yK);
if (pen > bestPen) {
bestPen = pen; bestNx = 0; bestNy = -1;
bestSurfVx = bv.vx; bestSurfVy = bv.vy; hit = true;
}
}
}
}
return { x: cx, y: cy, hit, nx: bestNx, ny: bestNy, surfVx: bestSurfVx, surfVy: bestSurfVy, pen: bestPen };
}
function createPerson(id, x, vxInit) {
const scale = 0.93 + ((id * 37) % 15) * 0.01;
const legLen = 22 * scale;
const torsoLen = 21 * scale;
const massScale = 0.88 + ((id * 29) % 13) * 0.02;
const groundY = surfaceYAt(x);
const fy = groundY - 7;
const hy = fy - legLen;
const ty = hy - torsoLen;
return {
id,
color: PALETTE[id % PALETTE.length],
skin: SKIN_TONES[id % SKIN_TONES.length],
scale,
legLen,
torsoLen,
rF: 7,
rH: 8.5,
rT: 8.5,
mF: 0.30 * massScale,
mH: 0.40 * massScale,
mT: 0.30 * massScale,
strength: 0.90 + ((id * 19) % 11) * 0.02,
fx: x, fy, fvx: vxInit, fvy: 0,
hx: x, hy, hvx: vxInit, hvy: 0,
tx: x, ty, tvx: vxInit, tvy: 0,
// Previous positions for PBD velocity derivation
ofx: x, ofy: fy, ohx: x, ohy: hy, otx: x, oty: ty,
mode: 'upright', // 'upright' | 'fallen' | 'getting_up'
fellTimer: 0,
getUpTimer: 0,
stepTimer: 0,
footContact: true,
hipContact: false,
groundVx: vxInit,
groundVy: 0,
facing: 1,
walkCycle: (id * 1.3) % (Math.PI * 2),
lastImpact: 0
};
}
function initSim() {
isReversed = false;
beltVx = V_FORWARD;
beltPhase = 0;
simTime = 0;
people = [];
// 18 people: 2 walking onto bottom landing, 15 standing calmly on steps, 1 stepping off top
const placements = [
{ x: 95, v: 42 },
{ x: 165, v: 44 },
{ x: 270, v: V_FORWARD },
{ x: 300, v: V_FORWARD },
{ x: 332, v: V_FORWARD },
{ x: 362, v: V_FORWARD },
{ x: 394, v: V_FORWARD },
{ x: 452, v: V_FORWARD },
{ x: 482, v: V_FORWARD },
{ x: 514, v: V_FORWARD },
{ x: 544, v: V_FORWARD },
{ x: 602, v: V_FORWARD },
{ x: 634, v: V_FORWARD },
{ x: 664, v: V_FORWARD },
{ x: 696, v: V_FORWARD },
{ x: 754, v: V_FORWARD },
{ x: 786, v: V_FORWARD },
{ x: 885, v: 46 }
];
for (let i = 0; i < PEOPLE_COUNT; i++) {
const p = placements[i];
const jitter = ((i * 17) % 7) - 3;
people.push(createPerson(i, p.x + jitter, p.v));
}
syncUI();
}
function syncUI() {
toggleBtn.textContent = isReversed ? 'PUT BACK TO NORMAL' : 'THROW INTO REVERSE';
toggleBtn.classList.toggle('is-reversed', isReversed);
speedPill.classList.toggle('rev', beltVx < -5);
}
toggleBtn.addEventListener('click', () => {
isReversed = !isReversed;
syncUI();
});
resetBtn.addEventListener('click', () => {
initSim();
});
window.addEventListener('keydown', (e) => {
if (e.code === 'Space') {
e.preventDefault();
isReversed = !isReversed;
syncUI();
}
});
// Segment-segment closest points (Ericson)
function solveCapsulePair(
ax0, ay0, ax1, ay1, wa0, wa1, rA,
bx0, by0, bx1, by1, wb0, wb1, rB,
out
) {
const d1x = ax1 - ax0, d1y = ay1 - ay0;
const d2x = bx1 - bx0, d2y = by1 - by0;
const rx = ax0 - bx0, ry = ay0 - by0;
const a = d1x * d1x + d1y * d1y;
const e = d2x * d2x + d2y * d2y;
const f = d2x * rx + d2y * ry;
let s = 0, t = 0;
if (a <= 1e-6 && e <= 1e-6) {
s = 0; t = 0;
} else if (a <= 1e-6) {
s = 0; t = clamp(f / e, 0, 1);
} else {
const c = d1x * rx + d1y * ry;
if (e <= 1e-6) {
t = 0; s = clamp(-c / a, 0, 1);
} else {
const b = d1x * d2x + d1y * d2y;
const denom = a * e - b * b;
s = denom !== 0 ? clamp((b * f - c * e) / denom, 0, 1) : 0;
t = (b * s + f) / e;
if (t < 0) {
t = 0; s = clamp(-c / a, 0, 1);
} else if (t > 1) {
t = 1; s = clamp((b - c) / a, 0, 1);
}
}
}
const cax = ax0 + d1x * s, cay = ay0 + d1y * s;
const cbx = bx0 + d2x * t, cby = by0 + d2y * t;
const dx = cax - cbx, dy = cay - cby;
const minDist = rA + rB;
const distSq = dx * dx + dy * dy;
if (distSq >= minDist * minDist) return false;
const dist = Math.sqrt(distSq);
let nx, ny;
if (dist < 1e-5) {
nx = (ax0 >= bx0 ? 1 : -1) * 0.96;
ny = -0.28;
} else {
nx = dx / dist;
ny = dy / dist;
}
const pen = minDist - dist;
const oms = 1 - s, omt = 1 - t;
const wSum = wa0 * oms * oms + wa1 * s * s + wb0 * omt * omt + wb1 * t * t;
if (wSum <= 1e-8) return false;
const lambda = (pen / wSum) * 0.92;
out.dax0 = wa0 * oms * lambda * nx;
out.day0 = wa0 * oms * lambda * ny;
out.dax1 = wa1 * s * lambda * nx;
out.day1 = wa1 * s * lambda * ny;
out.dbx0 = -wb0 * omt * lambda * nx;
out.dby0 = -wb0 * omt * lambda * ny;
out.dbx1 = -wb1 * t * lambda * nx;
out.dby1 = -wb1 * t * lambda * ny;
out.s = s; out.t = t;
out.nx = nx; out.ny = ny; out.pen = pen;
return true;
}
function hasHeadroom(p) {
const ax = p.fx * 0.4 + p.hx * 0.6;
const ay = Math.min(p.fy, p.hy);
for (let j = 0; j < people.length; j++) {
const q = people[j];
if (q === p) continue;
// Check q's nodes & midpoints above p
const ptsX = [q.fx, (q.fx + q.hx) * 0.5, q.hx, (q.hx + q.tx) * 0.5, q.tx];
const ptsY = [q.fy, (q.fy + q.hy) * 0.5, q.hy, (q.hy + q.ty) * 0.5, q.ty];
for (let k = 0; k < 5; k++) {
if (Math.abs(ptsX[k] - ax) < 19 && ptsY[k] < ay - 2 && ptsY[k] > ay - 46 * p.scale) {
return false;
}
}
}
return true;
}
function neighborAhead(p, dir) {
let minGap = 999;
let isObstacle = false;
for (let j = 0; j < people.length; j++) {
const q = people[j];
if (q === p) continue;
const qx = (q.hx + q.tx) * 0.5;
const qy = (q.hy + q.fy) * 0.5;
const dx = (qx - p.hx) * dir;
const dy = Math.abs(qy - p.hy);
if (dx > 0 && dx < 46 && dy < 34) {
if (dx < minGap) {
minGap = dx;
isObstacle = q.mode !== 'upright';
}
}
}
return { gap: minGap, isObstacle };
}
const capOut = {
dax0: 0, day0: 0, dax1: 0, day1: 0,
dbx0: 0, dby0: 0, dbx1: 0, dby1: 0,
s: 0, t: 0, nx: 0, ny: 0, pen: 0
};
const activeContacts = [];
function stepPhysics(dt) {
// 1. Update escalator belt velocity & phase
const targetVx = isReversed ? V_REVERSE : V_FORWARD;
const rampTime = isReversed ? RAMP_TO_REV : RAMP_TO_FWD;
const maxDelta = Math.abs(V_REVERSE - V_FORWARD) / rampTime * dt;
if (beltVx < targetVx) beltVx = Math.min(targetVx, beltVx + maxDelta);
else if (beltVx > targetVx) beltVx = Math.max(targetVx, beltVx - maxDelta);
beltPhase += beltVx * dt;
// Wrap beltPhase cleanly by STEP_DX so indices stay bounded
if (beltPhase >= STEP_DX) beltPhase -= STEP_DX;
else if (beltPhase < 0) beltPhase += STEP_DX;
// 2. Per-person active control & external forces
for (let i = 0; i < people.length; i++) {
const p = people[i];
p.lastImpact = Math.max(0, p.lastImpact - dt * 120);
// Geometry metrics
const totalH = Math.hypot(p.tx - p.fx, p.ty - p.fy) || 1;
const upright = (p.fy - p.ty) / totalH; // +1 upright, 0 horizontal, -1 upside down
const footToHipY = p.fy - p.hy;
const hipToHeadY = p.hy - p.ty;
// Mode transitions
if (p.mode === 'upright') {
if (upright < 0.50 || footToHipY < -2 || hipToHeadY < -2) {
p.mode = 'fallen';
p.fellTimer = 0;
}
} else if (p.mode === 'fallen') {
p.fellTimer += dt;
const avgVx = (p.fvx + p.hvx + p.tvx) / 3;
const avgVy = (p.fvy + p.hvy + p.tvy) / 3;
const relVx = Math.abs(avgVx - p.groundVx);
const relVy = Math.abs(avgVy - p.groundVy);
if (
p.fellTimer > 0.65 &&
(p.footContact || p.hipContact) &&
relVx < 36 && relVy < 36 &&
p.lastImpact < 25 &&
hasHeadroom(p)
) {
p.mode = 'getting_up';
p.getUpTimer = 0;
}
} else if (p.mode === 'getting_up') {
p.getUpTimer += dt;
if (p.lastImpact > 65 || (!hasHeadroom(p) && upright < 0.65) || p.getUpTimer > 1.8) {
p.mode = 'fallen';
p.fellTimer = 0.15;
} else if (upright > 0.86 && footToHipY > p.legLen * 0.65 && hipToHeadY > p.torsoLen * 0.65) {
p.mode = 'upright';
}
}
// Gravity
p.fvy += GRAVITY * dt;
p.hvy += GRAVITY * dt;
p.tvy += GRAVITY * dt;
if (p.mode === 'upright') {
// Determine desired walking speed relative to surface
let targetWalkVx = 0;
const onBotLanding = p.fx < X_BOT - 6;
const onTopLanding = p.fx > X_TOP + 6;
if (!isReversed && beltVx > 10) {
if (onBotLanding || onTopLanding) {
const ahead = neighborAhead(p, 1);
if (ahead.gap > 34) targetWalkVx = 44;
else if (ahead.gap > 22) targetWalkVx = 18;
else targetWalkVx = 0;
}
} else {
// If reversed and standing safely on stationary bottom concourse, step back out of comb zone
if (p.fx > X_COMB_BOT - 12 && p.fx < X_BOT - 4 && p.footContact) {
const ahead = neighborAhead(p, -1);
if (ahead.gap > 24) targetWalkVx = -26;
}
}
// Recovery stepping when stumbling while still mostly upright
p.stepTimer = Math.max(0, p.stepTimer - dt);
const leanX = p.hx - p.fx;
if (p.footContact && Math.abs(leanX) > 6.5 && upright > 0.58 && p.stepTimer <= 0) {
const stepDir = leanX > 0 ? 1 : -1;
// Kick foot quickly beneath hips to try to catch balance
p.fvy -= 55;
p.fvx += stepDir * 68;
p.stepTimer = 0.24;
}
// Step-up lift when walking onto emerging stair riser
if (targetWalkVx > 10 && p.footContact && p.fx > X_BOT - 24 && p.fx < X_TOP) {
const nextY = surfaceYAt(p.fx + 10);
if (nextY < p.fy + p.rF - 4) {
p.fvy -= 42;
}
}
// Walking drive through foot against ground
if (p.footContact && targetWalkVx !== 0) {
const curRelVx = p.fvx - p.groundVx;
const drive = clamp((targetWalkVx - curRelVx) * 6.5, -160, 160);
p.fvx += drive * dt;
}
// Finite muscular balance PD control (equal & opposite forces: requires ground friction!)
const desiredLean = targetWalkVx !== 0 ? Math.sign(targetWalkVx) * 2.4 : 0;
const maxBalH = 112 * p.strength * p.mH / 0.40;
const maxBalT = 82 * p.strength * p.mT / 0.30;
if (p.footContact) {
const errH = (p.hx - p.fx) - desiredLean;
const verrH = p.hvx - p.fvx;
const fH = clamp(-19 * errH - 4.8 * verrH, -maxBalH, maxBalH);
p.hvx += (fH / p.mH) * dt;
p.fvx -= (fH / p.mF) * dt;
}
const errT = (p.tx - p.hx) - desiredLean * 0.6;
const verrT = p.tvx - p.hvx;
const fT = clamp(-16 * errT - 3.8 * verrT, -maxBalT, maxBalT);
p.tvx += (fT / p.mT) * dt;
p.hvx -= (fT / p.mH) * dt;
// Vertical posture stiffness (keep legs & torso extended while standing)
if (p.footContact) {
const dyLeg = (p.fy - p.hy) - p.legLen;
const fLiftH = clamp(-26 * dyLeg - 5 * (p.hvy - p.fvy), -180, 180);
p.hvy += (fLiftH / p.mH) * dt;
p.fvy -= (fLiftH / p.mF) * dt;
}
const dyTorso = (p.hy - p.ty) - p.torsoLen;
const fLiftT = clamp(-22 * dyTorso - 4 * (p.tvy - p.hvy), -140, 140);
p.tvy += (fLiftT / p.mT) * dt;
p.hvy -= (fLiftT / p.mH) * dt;
// Walk cycle animation phase
const relFootVx = p.fvx - p.groundVx;
if (Math.abs(relFootVx) > 4) {
p.walkCycle += (relFootVx * 0.16) * dt;
}
} else if (p.mode === 'getting_up') {
// Tuck feet toward hips and push hips & head upward
const pullFx = clamp((p.hx - p.fx) * 8, -90, 90);
p.fvx += pullFx * dt;
p.hvx -= pullFx * 0.5 * dt;
if (p.footContact || p.hipContact) {
const upH = clamp((p.fy - p.legLen - p.hy) * 14 - 4 * p.hvy, -240, 60);
p.hvy += upH * dt;
p.tvy += clamp((p.hy - p.torsoLen - p.ty) * 14 - 4 * p.tvy, -220, 60) * dt;
const alignT = clamp((p.hx - p.tx) * 10 - 3 * (p.tvx - p.hvx), -80, 80);
p.tvx += alignT * dt;
}
} else {
// Fallen: mild joint damping only (muscles are limp/bracing)
const damp = Math.exp(-3.5 * dt);
const avgVx = (p.fvx + p.hvx + p.tvx) / 3;
const avgVy = (p.fvy + p.hvy + p.tvy) / 3;
p.fvx = avgVx + (p.fvx - avgVx) * damp;
p.hvx = avgVx + (p.hvx - avgVx) * damp;
p.tvx = avgVx + (p.tvx - avgVx) * damp;
p.fvy = avgVy + (p.fvy - avgVy) * damp;
p.hvy = avgVy + (p.hvy - avgVy) * damp;
p.tvy = avgVy + (p.tvy - avgVy) * damp;
}
// Save old positions & integrate predicted positions
p.ofx = p.fx; p.ofy = p.fy;
p.ohx = p.hx; p.ohy = p.hy;
p.otx = p.tx; p.oty = p.ty;
p.fx += p.fvx * dt; p.fy += p.fvy * dt;
p.hx += p.hvx * dt; p.hy += p.hvy * dt;
p.tx += p.tvx * dt; p.ty += p.tvy * dt;
p.footContact = false;
p.hipContact = false;
}
// 3. PBD Constraint Solver Iterations
activeContacts.length = 0;
for (let iter = 0; iter < PBD_ITERS; iter++) {
// A. Internal skeleton distance constraints
for (let i = 0; i < people.length; i++) {
const p = people[i];
const wF = 1 / p.mF, wH = 1 / p.mH, wT = 1 / p.mT;
// Leg constraint F-H (allows slight knee compression when down/crouched)
let dx = p.hx - p.fx, dy = p.hy - p.fy;
let d = Math.hypot(dx, dy) || 1e-5;
const minLeg = p.mode === 'upright' ? p.legLen * 0.88 : p.legLen * 0.62;
const maxLeg = p.legLen;
let targetLeg = d;
if (d < minLeg) targetLeg = minLeg;
else if (d > maxLeg) targetLeg = maxLeg;
if (targetLeg !== d) {
const diff = (d - targetLeg) / d;
const wSum = wF + wH;
p.fx += dx * diff * (wF / wSum);
p.fy += dy * diff * (wF / wSum);
p.hx -= dx * diff * (wH / wSum);
p.hy -= dy * diff * (wH / wSum);
}
// Torso constraint H-T
dx = p.tx - p.hx; dy = p.ty - p.hy;
d = Math.hypot(dx, dy) || 1e-5;
const diffT = (d - p.torsoLen) / d;
const wSumHT = wH + wT;
p.hx += dx * diffT * (wH / wSumHT);
p.hy += dy * diffT * (wH / wSumHT);
p.tx -= dx * diffT * (wT / wSumHT);
p.ty -= dy * diffT * (wT / wSumHT);
// Minimum F-T separation (prevents folding 180 degrees completely flat)
dx = p.tx - p.fx; dy = p.ty - p.fy;
d = Math.hypot(dx, dy) || 1e-5;
const minFT = 16 * p.scale;
if (d < minFT) {
const diffFT = (d - minFT) / d;
const wSumFT = wF + wT;
p.fx += dx * diffFT * (wF / wSumFT);
p.fy += dy * diffFT * (wF / wSumFT);
p.tx -= dx * diffFT * (wT / wSumFT);
p.ty -= dy * diffFT * (wT / wSumFT);
}
}
// B. Person-to-person capsule collisions
for (let i = 0; i < people.length; i++) {
const a = people[i];
const aMinX = Math.min(a.fx, a.hx, a.tx) - 18;
const aMaxX = Math.max(a.fx, a.hx, a.tx) + 18;
const aMinY = Math.min(a.fy, a.hy, a.ty) - 18;
const aMaxY = Math.max(a.fy, a.hy, a.ty) + 18;
const waF = 1 / a.mF, waH = 1 / a.mH, waT = 1 / a.mT;
for (let j = i + 1; j < people.length; j++) {
const b = people[j];
const bMinX = Math.min(b.fx, b.hx, b.tx) - 18;
const bMaxX = Math.max(b.fx, b.hx, b.tx) + 18;
if (aMaxX < bMinX || bMaxX < aMinX) continue;
const bMinY = Math.min(b.fy, b.hy, b.ty) - 18;
const bMaxY = Math.max(b.fy, b.hy, b.ty) + 18;
if (aMaxY < bMinY || bMaxY < aMinY) continue;
const wbF = 1 / b.mF, wbH = 1 / b.mH, wbT = 1 / b.mT;
// 4 segment pairs: (a.leg vs b.leg), (a.leg vs b.torso), (a.torso vs b.leg), (a.torso vs b.torso)
if (solveCapsulePair(
a.fx, a.fy, a.hx, a.hy, waF, waH, a.rF,
b.fx, b.fy, b.hx, b.hy, wbF, wbH, b.rF, capOut
)) {
a.fx += capOut.dax0; a.fy += capOut.day0; a.hx += capOut.dax1; a.hy += capOut.day1;
b.fx += capOut.dbx0; b.fy += capOut.dby0; b.hx += capOut.dbx1; b.hy += capOut.dby1;
if (iter === PBD_ITERS - 1) {
activeContacts.push({ i, j, segA: 0, segB: 0, s: capOut.s, t: capOut.t, nx: capOut.nx, ny: capOut.ny, pen: capOut.pen });
}
}
if (solveCapsulePair(
a.fx, a.fy, a.hx, a.hy, waF, waH, a.rF,
b.hx, b.hy, b.tx, b.ty, wbH, wbT, b.rT, capOut
)) {
a.fx += capOut.dax0; a.fy += capOut.day0; a.hx += capOut.dax1; a.hy += capOut.day1;
b.hx += capOut.dbx0; b.hy += capOut.dby0; b.tx += capOut.dbx1; b.ty += capOut.dby1;
if (iter === PBD_ITERS - 1) {
activeContacts.push({ i, j, segA: 0, segB: 1, s: capOut.s, t: capOut.t, nx: capOut.nx, ny: capOut.ny, pen: capOut.pen });
}
}
if (solveCapsulePair(
a.hx, a.hy, a.tx, a.ty, waH, waT, a.rT,
b.fx, b.fy, b.hx, b.hy, wbF, wbH, b.rF, capOut
)) {
a.hx += capOut.dax0; a.hy += capOut.day0; a.tx += capOut.dax1; a.ty += capOut.day1;
b.fx += capOut.dbx0; b.fy += capOut.dby0; b.hx += capOut.dbx1; b.hy += capOut.dby1;
if (iter === PBD_ITERS - 1) {
activeContacts.push({ i, j, segA: 1, segB: 0, s: capOut.s, t: capOut.t, nx: capOut.nx, ny: capOut.ny, pen: capOut.pen });
}
}
if (solveCapsulePair(
a.hx, a.hy, a.tx, a.ty, waH, waT, a.rT,
b.hx, b.hy, b.tx, b.ty, wbH, wbT, b.rT, capOut
)) {
a.hx += capOut.dax0; a.hy += capOut.day0; a.tx += capOut.dax1; a.ty += capOut.day1;
b.hx += capOut.dbx0; b.hy += capOut.dby0; b.tx += capOut.dbx1; b.ty += capOut.dby1;
if (iter === PBD_ITERS - 1) {
activeContacts.push({ i, j, segA: 1, segB: 1, s: capOut.s, t: capOut.t, nx: capOut.nx, ny: capOut.ny, pen: capOut.pen });
}
}
}
}
// C. World / staircase collisions for nodes + segment midpoints
for (let i = 0; i < people.length; i++) {
const p = people[i];
const cF = collideWorld(p.fx, p.fy, p.rF);
p.fx = cF.x; p.fy = cF.y;
if (cF.hit && cF.ny < -0.3) {
p.footContact = true;
p.groundVx = cF.surfVx;
p.groundVy = cF.surfVy;
}
const cH = collideWorld(p.hx, p.hy, p.rH);
p.hx = cH.x; p.hy = cH.y;
if (cH.hit && cH.ny < -0.3) {
p.hipContact = true;
if (!p.footContact) {
p.groundVx = cH.surfVx;
p.groundVy = cH.surfVy;
}
}
const cT = collideWorld(p.tx, p.ty, p.rT);
p.tx = cT.x; p.ty = cT.y;
// Midpoint checks prevent limbs from clipping sharp step corners
const mx1 = (p.fx + p.hx) * 0.5, my1 = (p.fy + p.hy) * 0.5;
const cM1 = collideWorld(mx1, my1, 6.5);
if (cM1.hit) {
const dx = cM1.x - mx1, dy = cM1.y - my1;
p.fx += dx * 0.5; p.fy += dy * 0.5;
p.hx += dx * 0.5; p.hy += dy * 0.5;
}
const mx2 = (p.hx + p.tx) * 0.5, my2 = (p.hy + p.ty) * 0.5;
const cM2 = collideWorld(mx2, my2, 7.0);
if (cM2.hit) {
const dx = cM2.x - mx2, dy = cM2.y - my2;
p.hx += dx * 0.5; p.hy += dy * 0.5;
p.tx += dx * 0.5; p.ty += dy * 0.5;
}
if (iter === PBD_ITERS - 1) {
p._cF = cF; p._cH = cH; p._cT = cT;
}
}
}
// 4. Derive post-PBD velocities + apply Coulomb floor friction & impact tracking
const invDt = 1 / dt;
for (let i = 0; i < people.length; i++) {
const p = people[i];
const newFvx = (p.fx - p.ofx) * invDt, newFvy = (p.fy - p.ofy) * invDt;
const newHvx = (p.hx - p.ohx) * invDt, newHvy = (p.hy - p.ohy) * invDt;
const newTvx = (p.tx - p.otx) * invDt, newTvy = (p.ty - p.oty) * invDt;
// Detect sudden velocity jumps (collisions) to reset stand-up cooldown naturally
const dv = Math.hypot(newHvx - p.hvx, newHvy - p.hvy) + Math.hypot(newTvx - p.tvx, newTvy - p.tvy);
if (dv > 70) {
p.lastImpact = Math.min(120, p.lastImpact + dv * 0.6);
if (p.mode === 'fallen' && dv > 110) p.fellTimer = Math.min(p.fellTimer, 0.15);
}
p.fvx = newFvx; p.fvy = newFvy;
p.hvx = newHvx; p.hvy = newHvy;
p.tvx = newTvx; p.tvy = newTvy;
// Floor / step Coulomb friction (shoes grip stronger than clothing)
applySurfaceFriction(p, 'f', p._cF, p.mode === 'upright' ? 0.90 : 0.65, dt);
applySurfaceFriction(p, 'h', p._cH, 0.44, dt);
applySurfaceFriction(p, 't', p._cT, 0.42, dt);
}
// 5. Person-to-person contact friction (keeps piles from sliding like ice)
for (let c = 0; c < activeContacts.length; c++) {
const ct = activeContacts[c];
const a = people[ct.i], b = people[ct.j];
const tx = -ct.ny, ty = ct.nx; // tangent
const avx = ct.segA === 0 ? a.fvx * (1 - ct.s) + a.hvx * ct.s : a.hvx * (1 - ct.s) + a.tvx * ct.s;
const avy = ct.segA === 0 ? a.fvy * (1 - ct.s) + a.hvy * ct.s : a.hvy * (1 - ct.s) + a.tvy * ct.s;
const bvx = ct.segB === 0 ? b.fvx * (1 - ct.t) + b.hvx * ct.t : b.hvx * (1 - ct.t) + b.tvx * ct.t;
const bvy = ct.segB === 0 ? b.fvy * (1 - ct.t) + b.hvy * ct.t : b.hvy * (1 - ct.t) + b.tvy * ct.t;
const relVt = (avx - bvx) * tx + (avy - bvy) * ty;
const damp = clamp(relVt * 0.28, -45, 45);
const impX = damp * tx, impY = damp * ty;
if (ct.segA === 0) {
a.fvx -= impX * (1 - ct.s); a.fvy -= impY * (1 - ct.s);
a.hvx -= impX * ct.s; a.hvy -= impY * ct.s;
} else {
a.hvx -= impX * (1 - ct.s); a.hvy -= impY * (1 - ct.s);
a.tvx -= impX * ct.s; a.tvy -= impY * ct.s;
}
if (ct.segB === 0) {
b.fvx += impX * (1 - ct.t); b.fvy += impY * (1 - ct.t);
b.hvx += impX * ct.t; b.hvy += impY * ct.t;
} else {
b.hvx += impX * (1 - ct.t); b.hvy += impY * (1 - ct.t);
b.tvx += impX * ct.t; b.tvy += impY * ct.t;
}
}
// 6. Seamless forward loop-around at off-screen exit (only when upright & going forward)
if (!isReversed && beltVx > 20) {
for (let i = 0; i < people.length; i++) {
const p = people[i];
if (p.mode === 'upright' && p.fx > 1068) {
// Verify bottom entry is clear before re-entering
let clear = true;
for (let j = 0; j < people.length; j++) {
if (j !== i && Math.min(people[j].fx, people[j].hx) < 88) {
clear = false;
break;
}
}
if (clear) {
const np = createPerson(p.id, 42, 42);
people[i] = np;
}
}
}
}
}
function applySurfaceFriction(p, node, contact, mu, dt) {
if (!contact || !contact.hit) return;
const vxKey = node + 'vx', vyKey = node + 'vy';
const relVx = p[vxKey] - contact.surfVx;
const relVy = p[vyKey] - contact.surfVy;
const tx = -contact.ny, ty = contact.nx;
const vt = relVx * tx + relVy * ty;
const maxFrictionDv = mu * GRAVITY * dt * 1.4;
const dv = clamp(-vt, -maxFrictionDv, maxFrictionDv);
p[vxKey] += tx * dv;
p[vyKey] += ty * dv;
}
// Rendering helpers
function drawCapsuleLimb(x0, y0, x1, y1, w, color) {
ctx.strokeStyle = color;
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
}
function solveKnee(hx, hy, fx, fy, l1, l2, bendDir) {
const dx = fx - hx, dy = fy - hy;
const d = clamp(Math.hypot(dx, dy), 2, l1 + l2 - 0.5);
const a = (l1 * l1 - l2 * l2 + d * d) / (2 * d);
const h = Math.sqrt(Math.max(0, l1 * l1 - a * a));
const mx = hx + (dx / d) * a;
const my = hy + (dy / d) * a;
// Perpendicular pointing in bendDir (right/uphill = +1)
let px = -dy / d, py = dx / d;
if (px * bendDir < 0) { px = -px; py = -py; }
return { x: mx + px * h, y: my + py * h };
}
function render() {
const dpr = window.devicePixelRatio || 1;
const rectW = canvas.clientWidth;
const rectH = canvas.clientHeight;
if (canvas.width !== Math.round(rectW * dpr) || canvas.height !== Math.round(rectH * dpr)) {
canvas.width = Math.round(rectW * dpr);
canvas.height = Math.round(rectH * dpr);
}
ctx.save();
ctx.scale(canvas.width / W, canvas.height / H);
// 1. Station background
const bgGrad = ctx.createLinearGradient(0, 0, 0, H);
bgGrad.addColorStop(0, '#131924');
bgGrad.addColorStop(1, '#0d1118');
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, W, H);
// Subtle architectural grid
ctx.strokeStyle = 'rgba(148, 163, 184, 0.045)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let gx = 0; gx < W; gx += 40) {
ctx.moveTo(gx, 0); ctx.lineTo(gx, H);
}
for (let gy = 0; gy < H; gy += 40) {
ctx.moveTo(0, gy); ctx.lineTo(W, gy);
}
ctx.stroke();
// Floor slabs (lower concourse & upper mezzanine)
ctx.fillStyle = '#1d2534';
ctx.fillRect(0, Y_BOT, X_BOT + 35, H - Y_BOT);
ctx.fillRect(X_TOP - 10, Y_TOP, W - (X_TOP - 10), H - Y_TOP);
// Diagonal truss body under escalator
ctx.fillStyle = '#18202e';
ctx.beginPath();
ctx.moveTo(X_COMB_BOT - 12, Y_BOT);
ctx.lineTo(X_BOT, Y_BOT);
ctx.lineTo(X_TOP, Y_TOP);
ctx.lineTo(X_COMB_TOP + 12, Y_TOP);
ctx.lineTo(X_COMB_TOP + 12, Y_TOP + 76);
ctx.lineTo(X_TOP + 20, Y_TOP + 76);
ctx.lineTo(X_BOT + 22, Y_BOT + 76);
ctx.lineTo(X_COMB_BOT - 12, Y_BOT + 76);
ctx.closePath();
ctx.fill();
// Truss diagonal structural struts
ctx.strokeStyle = '#253145';
ctx.lineWidth = 3;
ctx.beginPath();
for (let sx = X_BOT + 30; sx < X_TOP; sx += 60) {
const sy = Y_BOT - (sx - X_BOT) * SLOPE;
ctx.moveTo(sx, sy + 6);
ctx.lineTo(sx + 30, sy + 58);
ctx.moveTo(sx + 30, sy + 58);
ctx.lineTo(sx + 60, sy - 60 * SLOPE + 6);
}
ctx.stroke();
// Moving steps (treads, risers, yellow comb trim)
const kMin = Math.floor((X_COMB_BOT - X_BOT - beltPhase) / STEP_DX) - 1;
const kMax = Math.ceil((X_COMB_TOP - X_BOT - beltPhase) / STEP_DX) + 1;
for (let k = kMin; k <= kMax; k++) {
const xL = X_BOT + beltPhase + k * STEP_DX;
const xR = xL + STEP_DX;
if (xR < X_COMB_BOT || xL > X_COMB_TOP) continue;
const cL = Math.max(xL, X_COMB_BOT);
const cR = Math.min(xR, X_COMB_TOP);
const yK = stepTreadY(k);
const yPrev = stepTreadY(k - 1);
// Step riser
if (yPrev > yK + 0.5 && xL >= X_COMB_BOT && xL <= X_COMB_TOP) {
ctx.fillStyle = '#2b3547';
ctx.fillRect(xL, yK, 3, yPrev - yK + 2);
}
// Step body & grooved top tread
ctx.fillStyle = '#334155';
ctx.fillRect(cL, yK, cR - cL, 7);
ctx.fillStyle = '#64748b';
ctx.fillRect(cL, yK, cR - cL, 2.2);
// Yellow safety edge stripe
if (cR - cL > 4) {
ctx.fillStyle = '#eab308';
ctx.fillRect(cL, yK, Math.min(3.5, cR - cL), 2.5);
ctx.fillRect(Math.max(cL, cR - 3), yK, Math.min(3, cR - cL), 2.5);
}
}
// Floor surface lines & comb-plate caps
ctx.fillStyle = '#3a4659';
ctx.fillRect(0, Y_BOT, X_COMB_BOT, 4);
ctx.fillRect(X_COMB_TOP, Y_TOP, W - X_COMB_TOP, 4);
// Comb-plate markers
ctx.fillStyle = '#eab308';
ctx.fillRect(X_COMB_BOT - 4, Y_BOT - 1, 6, 5);
ctx.fillRect(X_COMB_TOP - 2, Y_TOP - 1, 6, 5);
// Concourse wall & beacon indicator
ctx.fillStyle = '#263042';
ctx.fillRect(0, 0, X_WALL_L, Y_BOT + 4);
ctx.fillStyle = isReversed ? '#ef4444' : '#10b981';
const pulse = isReversed ? (0.55 + 0.45 * Math.sin(simTime * 12)) : 0.85;
ctx.globalAlpha = pulse;
ctx.beginPath();
ctx.arc(X_COMB_BOT - 12, Y_BOT - 46, 5, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(X_COMB_TOP + 12, Y_TOP - 46, 5, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
// Directional wall sign
ctx.save();
ctx.translate(540, 220);
ctx.rotate(-Math.atan(SLOPE));
ctx.fillStyle = isReversed ? 'rgba(239, 68, 68, 0.16)' : 'rgba(94, 234, 212, 0.10)';
ctx.strokeStyle = isReversed ? 'rgba(239, 68, 68, 0.45)' : 'rgba(94, 234, 212, 0.30)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.roundRect(-80, -16, 160, 32, 6);
ctx.fill();
ctx.stroke();
ctx.fillStyle = isReversed ? '#fca5a5' : '#5eead4';
ctx.font = '700 12px ui-sans-serif, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(isReversed ? '<< HIGH-SPEED REVERSE <<' : '>> UPWARD >>', 0, 1);
ctx.restore();
// 2. Render people
let uprightCount = 0;
for (let i = 0; i < people.length; i++) {
const p = people[i];
if (p.mode === 'upright') uprightCount++;
// Soft shadow beneath person
const sy = surfaceYAt(p.fx);
const hAbove = clamp(sy - (p.fy + p.rF), 0, 60);
const shadowAlpha = 0.32 * (1 - hAbove / 60);
if (shadowAlpha > 0.02) {
ctx.fillStyle = `rgba(0,0,0,${shadowAlpha.toFixed(3)})`;
ctx.beginPath();
ctx.ellipse(p.fx, sy + 1, 10 * p.scale, 2.5, 0, 0, Math.PI * 2);
ctx.fill();
}
// Compute visual two-leg placement
const relVx = p.fvx - p.groundVx;
const stride = p.mode === 'upright' ? clamp(Math.abs(relVx) / 42, 0.18, 1) * 7.5 : 3.5;
const sOff = Math.sin(p.walkCycle) * stride;
const liftL = p.mode === 'upright' && Math.abs(relVx) > 6 ? Math.max(0, Math.cos(p.walkCycle)) * 4 : 0;
const liftR = p.mode === 'upright' && Math.abs(relVx) > 6 ? Math.max(0, -Math.cos(p.walkCycle)) * 4 : 0;
const lfx = p.fx + sOff;
const rfx = p.fx - sOff;
const lfy = Math.min(p.fy + 5 - liftL, surfaceYAt(lfx) - 1);
const rfy = Math.min(p.fy + 5 - liftR, surfaceYAt(rfx) - 1);
const thighL = p.legLen * 0.54;
const shinL = p.legLen * 0.54;
const kneeL = solveKnee(p.hx, p.hy, lfx, lfy, thighL, shinL, p.facing);
const kneeR = solveKnee(p.hx, p.hy, rfx, rfy, thighL, shinL, p.facing);
// Back leg & shoe
drawCapsuleLimb(p.hx, p.hy, kneeR.x, kneeR.y, 4.4 * p.scale, '#1e293b');
drawCapsuleLimb(kneeR.x, kneeR.y, rfx, rfy, 3.8 * p.scale, '#1e293b');
drawCapsuleLimb(rfx - 1, rfy, rfx + 4 * p.facing, rfy, 3.6 * p.scale, '#0f172a');
// Back arm
const shX = p.hx + (p.tx - p.hx) * 0.72;
const shY = p.hy + (p.ty - p.hy) * 0.72;
const lean = p.hx - p.fx;
const flail = p.mode === 'upright' ? clamp(Math.abs(lean) / 10, 0, 1) : 1;
const armSwing = p.mode === 'upright' ? Math.sin(p.walkCycle) * 5 * clamp(Math.abs(relVx) / 40, 0, 1) : 0;
const handBx = shX - armSwing + (p.mode === 'fallen' ? -6 : flail * 7 * Math.sign(lean || 1));
const handBy = shY + (p.mode === 'upright' ? 14 - flail * 8 : 9);
drawCapsuleLimb(shX, shY, handBx, handBy, 3.3 * p.scale, '#334155');
// Front leg & shoe
drawCapsuleLimb(p.hx, p.hy, kneeL.x, kneeL.y, 4.6 * p.scale, '#2b394f');
drawCapsuleLimb(kneeL.x, kneeL.y, lfx, lfy, 4.0 * p.scale, '#2b394f');
drawCapsuleLimb(lfx - 1, lfy, lfx + 4.5 * p.facing, lfy, 3.8 * p.scale, '#111827');
// Torso jacket
drawCapsuleLimb(p.hx, p.hy - 1, shX, shY, 8.2 * p.scale, p.color);
// Neck & Head
const headX = p.tx;
const headY = p.ty + 1;
ctx.fillStyle = p.skin;
ctx.beginPath();
ctx.arc(headX, headY, 5.4 * p.scale, 0, Math.PI * 2);
ctx.fill();
// Hair cap
const tAng = Math.atan2(p.ty - p.hy, p.tx - p.hx) + Math.PI * 0.5;
ctx.fillStyle = '#1e2430';
ctx.beginPath();
ctx.arc(headX, headY, 5.6 * p.scale, tAng - Math.PI * 0.85, tAng + Math.PI * 0.15);
ctx.fill();
// Front arm
const handFx = shX + armSwing + (p.mode === 'fallen' ? 8 : flail * 9 * Math.sign(lean || 1));
const handFy = shY + (p.mode === 'upright' ? 14 - flail * 10 : 7);
drawCapsuleLimb(shX, shY, handFx, handFy, 3.5 * p.scale, p.color);
}
// 3. Foreground glass balustrade & moving rubber handrail
ctx.fillStyle = 'rgba(148, 197, 255, 0.08)';
ctx.strokeStyle = 'rgba(148, 197, 255, 0.25)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(X_COMB_BOT - 8, Y_BOT);
ctx.lineTo(X_COMB_BOT - 8, Y_BOT - 44);
ctx.lineTo(X_BOT, Y_BOT - 44);
ctx.lineTo(X_TOP, Y_TOP - 44);
ctx.lineTo(X_COMB_TOP + 8, Y_TOP - 44);
ctx.lineTo(X_COMB_TOP + 8, Y_TOP);
ctx.lineTo(X_TOP, Y_TOP);
ctx.lineTo(X_BOT, Y_BOT);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Handrail rubber tube
ctx.strokeStyle = '#0b0e14';
ctx.lineWidth = 8;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(X_COMB_BOT - 12, Y_BOT - 44);
ctx.lineTo(X_BOT, Y_BOT - 44);
ctx.lineTo(X_TOP, Y_TOP - 44);
ctx.lineTo(X_COMB_TOP + 12, Y_TOP - 44);
ctx.stroke();
// Moving handrail ticks so reversal speed is unmistakably visible
ctx.strokeStyle = isReversed ? 'rgba(248, 113, 113, 0.55)' : 'rgba(203, 213, 225, 0.35)';
ctx.lineWidth = 2;
const tickSpacing = 30;
const offset = ((beltPhase % tickSpacing) + tickSpacing) % tickSpacing;
ctx.beginPath();
for (let hx = X_COMB_BOT - 6 + offset; hx <= X_COMB_TOP + 6; hx += tickSpacing) {
let hy;
if (hx < X_BOT) hy = Y_BOT - 44;
else if (hx > X_TOP) hy = Y_TOP - 44;
else hy = Y_BOT - (hx - X_BOT) * SLOPE - 44;
ctx.moveTo(hx - 2, hy - 3);
ctx.lineTo(hx + 2, hy + 3);
}
ctx.stroke();
ctx.restore();
// Update HUD readouts
const mps = (beltVx / Math.cos(Math.atan(SLOPE))) / 65;
if (beltVx >= 0) {
speedPill.textContent = `+${mps.toFixed(1)} m/s \u25B2 UP`;
} else {
speedPill.textContent = `${mps.toFixed(1)} m/s \u25BC\u25BC REV`;
}
const downCount = people.length - uprightCount;
statsText.textContent = `${people.length} riders \u00B7 ${uprightCount} upright \u00B7 ${downCount} down`;
}
let lastTs = 0;
function frame(ts) {
if (!lastTs) lastTs = ts;
const frameDt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
const subDt = frameDt / SUBSTEPS;
for (let s = 0; s < SUBSTEPS; s++) {
simTime += subDt;
stepPhysics(subDt);
}
render();
requestAnimationFrame(frame);
}
initSim();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
When you press THROW INTO REVERSE, the escalator snaps from +46 px/s up-right to -172 px/s down-left in 0.19 s (~1.1g horizontal deceleration). Because shoe friction pulls feet downhill while riders' hips and heads lag uphill, riders pitch forward onto the steps ahead of them (a few manage one frantic stumble step first), then slide and tumble downhill into the riders behind them. People reaching the bottom comb plate hit the stationary concourse floor—tripping anyone still semi-upright—and jam into a shifting, multi-layered pile on the lower landing and bottom steps. When you press PUT BACK TO NORMAL, the belt ramps back to +46 px/s over 0.50 s, spreading the pile apart as the escalator starts pulling fallen bodies uphill; riders on top of the pile or with clear headroom push themselves upright and resume riding and looping around.
Things special-cased or nudged:
x = 42) only while the escalator is running forward and the entry is clear; during reverse, riders stay in the world and hit the left concourse wall (x = 22).34 px ahead, and stepping back slightly out of the comb zone if standing on the bottom landing while reversed) and receive a small vertical foot hop (fvy -= 42) when walking onto an emerging 16.5 px riser so they don't stub their toes at the bottom comb plate.F–H and H–T are collided against the staircase so legs/torsos don't clip step corners, and shoe friction (0.90) is set higher than clothing friction (0.42–0.44) so upright feet grip violently on reversal while fallen bodies slide.What it does badly:
F toward H and pushes H and T upward using simple linear springs rather than full knee-and-hand push-off kinematics, so a person standing up from a weird horizontal orientation can look a bit like they are floating up onto their toes before snapping upright.