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
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
You write crowd simulations where the behavior emerges from rules each person follows.
Build me a single HTML page: a section of a football stadium emptying through one exit gate.
#urgency=0.8&gate=1.2&column=1 starts simulation at load with those settings: urgency 0.0 to 1.0, gate width in yards, column 0 or 1. Any values work.data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — sandboxed frame, they throw.html block.Before the file, two quantitative predictions: whether increasing urgency from 0.3 to 0.9 increases or decreases evacuation time at gate=1.2 and column=0, with the predicted difference in seconds; and whether enabling the column (column=1) increases or decreases evacuation time at urgency=0.9 and gate=1.2, with the predicted difference in seconds.
The single fenced html block.
Where your crowd behaves like a real one and where it does not. Name anything you special-cased: a nudge that breaks a jam, a speed cap at the gate, an allowance for overlap. If your page never jams at the gate, say so.
Assuming a crowd of 220 people (mean shoulder diameter ~0.54 yards) exiting a stadium section through a 1.2-yard gate:
0.3 to 0.9 (gate=1.2, column=0) increases evacuation time by approximately +25 seconds (from roughly ~95 s at urgency=0.3 to ~120 s at urgency=0.9). At 0.3, personal-space repulsion keeps enough buffer that people zipper cleanly into the 1.2-yard opening with minimal contact friction. At 0.9, higher desired speed and contact pushing compress the funnel to zero gap, causing frequent 3-person arches across the threshold where normal contact impulses lock Coulomb friction and intermittently stall outflow ("faster is slower").column=1) at urgency=0.9 and gate=1.2 decreases evacuation time by approximately -20 seconds (from roughly ~120 s to ~100 s). Placed 1.65 yards upstream of the gate (0.45-yard radius, leaving ~1.30-yard clearances on both sides), the column bears much of the direct longitudinal crowd pressure from the stands and splits the flow into two lower-pressure streams, reducing normal contact forces and arch locking at the gate jambs.<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stadium Section Evacuation — Crowd Simulation</title>
<style>
:root {
--bg: #0b1017;
--panel: #121a24;
--panel-border: #223042;
--text: #e6edf5;
--muted: #8ea0b5;
--accent: #38bdf8;
--warn: #f59e0b;
--danger: #ef4444;
--ok: #22c55e;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow: hidden;
}
.app {
display: grid;
grid-template-rows: auto 1fr;
height: 100vh;
width: 100vw;
}
header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 20px;
padding: 10px 18px;
background: var(--panel);
border-bottom: 1px solid var(--panel-border);
}
.brand {
display: flex;
align-items: baseline;
gap: 10px;
}
.brand h1 {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text);
}
.brand span {
font-size: 12px;
color: var(--muted);
}
.metrics {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.metric {
background: #0c131c;
border: 1px solid var(--panel-border);
border-radius: 8px;
padding: 6px 12px;
min-width: 128px;
}
.metric-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.metric-value {
font-size: 19px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.2;
color: var(--accent);
}
.metric-sub {
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.metric.timer .metric-value { color: #f8fafc; }
.metric.done .metric-value { color: var(--ok); }
.spark-wrap {
display: flex;
align-items: center;
gap: 8px;
background: #0c131c;
border: 1px solid var(--panel-border);
border-radius: 8px;
padding: 5px 10px;
}
#sparkline {
display: block;
width: 110px;
height: 32px;
}
.workspace {
display: grid;
grid-template-columns: 1fr 270px;
min-height: 0;
}
.stage {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
min-width: 0;
min-height: 0;
background: radial-gradient(circle at 35% 50%, #111b27 0%, #090e14 80%);
}
canvas#sim {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
border-radius: 10px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55);
border: 1px solid #1e2b3b;
background: #0d151f;
}
aside.controls {
background: var(--panel);
border-left: 1px solid var(--panel-border);
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
.ctrl-group {
display: flex;
flex-direction: column;
gap: 6px;
padding-bottom: 14px;
border-bottom: 1px solid #1c2836;
}
.ctrl-header {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 13px;
font-weight: 600;
}
.ctrl-val {
font-variant-numeric: tabular-nums;
color: var(--accent);
font-weight: 700;
}
.ctrl-hint {
font-size: 11px;
color: var(--muted);
line-height: 1.35;
}
input[type="range"] {
width: 100%;
accent-color: var(--accent);
cursor: pointer;
height: 6px;
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.switch {
position: relative;
display: inline-block;
width: 48px;
height: 26px;
flex-shrink: 0;
}
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute;
inset: 0;
cursor: pointer;
background: #243345;
border-radius: 999px;
transition: background 0.18s ease;
border: 1px solid #33475e;
}
.slider::before {
content: "";
position: absolute;
height: 18px;
width: 18px;
left: 3px;
top: 3px;
background: #e2e8f0;
border-radius: 50%;
transition: transform 0.18s ease;
}
.switch input:checked + .slider {
background: #0284c7;
border-color: var(--accent);
}
.switch input:checked + .slider::before {
transform: translateX(22px);
}
.btn-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
button {
appearance: none;
border: 1px solid #32465d;
background: #1a2736;
color: var(--text);
padding: 9px 12px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.05s;
}
button:hover { background: #223347; border-color: #486484; }
button:active { transform: translateY(1px); }
button.primary {
background: #0369a1;
border-color: #0ea5e9;
}
button.primary:hover { background: #0284c7; }
.legend {
margin-top: auto;
font-size: 11px;
color: var(--muted);
display: flex;
flex-direction: column;
gap: 6px;
background: #0e151e;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #1d2a3a;
}
.legend-row {
display: flex;
align-items: center;
gap: 8px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
@media (max-width: 860px) {
.workspace {
grid-template-columns: 1fr;
grid-template-rows: 1fr auto;
}
aside.controls {
border-left: none;
border-top: 1px solid var(--panel-border);
max-height: 38vh;
}
}
</style>
</head>
<body>
<div class="app">
<header>
<div class="brand">
<h1>Section 104 — Stadium Exit</h1>
<span>220 spectators · hard-contact crowd dynamics</span>
</div>
<div class="metrics">
<div class="metric" id="outflowCard">
<div class="metric-label">Outflow Rate</div>
<div class="metric-value" id="outflowVal">0 <span style="font-size:12px;font-weight:500;">ppl/min</span></div>
<div class="metric-sub" id="avgOutflowVal">Avg: 0.0 ppl/min</div>
</div>
<div class="spark-wrap" title="Outflow rate over time (people/min)">
<div>
<div class="metric-label">Flow History</div>
<div class="metric-sub" id="peakVal">Peak: 0</div>
</div>
<canvas id="sparkline" width="220" height="64"></canvas>
</div>
<div class="metric timer" id="timerCard">
<div class="metric-label">Evacuation Time</div>
<div class="metric-value" id="timerVal">0.0 s</div>
<div class="metric-sub" id="countVal">Exited: 0 / 220</div>
</div>
</div>
</header>
<div class="workspace">
<main class="stage">
<canvas id="sim"></canvas>
</main>
<aside class="controls">
<div class="ctrl-group">
<div class="ctrl-header">
<label for="urgencyInput">Urgency</label>
<span class="ctrl-val" id="urgencyVal">0.50</span>
</div>
<input id="urgencyInput" type="range" min="0" max="1" step="0.01" value="0.50">
<div class="ctrl-hint">Scales desired walking/running speed and active pushing force against neighbors.</div>
</div>
<div class="ctrl-group">
<div class="ctrl-header">
<label for="gateInput">Gate Width</label>
<span class="ctrl-val" id="gateVal">1.6 yd</span>
</div>
<input id="gateInput" type="range" min="0.6" max="5.0" step="0.1" value="1.6">
<div class="ctrl-hint">Clear vomitory opening in yards (average shoulder diameter ~0.54 yd).</div>
</div>
<div class="ctrl-group">
<div class="toggle-row">
<div>
<div style="font-size:13px;font-weight:600;">Gate Column</div>
<div class="ctrl-hint">Single pillar 1.65 yd in front of exit</div>
</div>
<label class="switch" aria-label="Toggle gate column">
<input id="columnInput" type="checkbox">
<span class="slider"></span>
</label>
</div>
</div>
<div class="btn-row">
<button id="resetBtn" class="primary" type="button">Reset Stands</button>
<button id="pauseBtn" type="button">Pause</button>
</div>
<div class="legend">
<div style="font-weight:600;color:var(--text);margin-bottom:2px;">Pressure & Scale</div>
<div class="legend-row">
<span class="dot" style="background:#38bdf8;"></span>
<span>Free movement / personal space</span>
</div>
<div class="legend-row">
<span class="dot" style="background:#f59e0b;"></span>
<span>Moderate contact compression</span>
</div>
<div class="legend-row">
<span class="dot" style="background:#ef4444;"></span>
<span>High physical crush & friction</span>
</div>
</div>
</aside>
</div>
</div>
<script>
(() => {
"use strict";
// World units: yards
const WORLD_W = 28.0;
const WORLD_H = 20.0;
const WALL_X = 22.0;
const WALL_THICK = 0.35;
const GATE_Y = WORLD_H * 0.5;
const COL_X = WALL_X - 1.65;
const COL_Y = GATE_Y;
const COL_R = 0.45;
const N_COLS = 11;
const N_ROWS = 20;
const TOTAL_PEOPLE = N_COLS * N_ROWS; // 220
const DT_FIXED = 1 / 60;
const SUBSTEPS = 2;
const DT_SUB = DT_FIXED / SUBSTEPS;
const SOLVER_ITERS = 8;
// Spatial hash
const CELL = 0.8;
const GRID_W = Math.ceil((WORLD_W + 4.0) / CELL);
const GRID_H = Math.ceil((WORLD_H + 2.0) / CELL);
const gridHead = new Int32Array(GRID_W * GRID_H);
const gridNext = new Int32Array(TOTAL_PEOPLE);
// Simulation state
const state = {
urgency: 0.5,
gateWidth: 1.6,
column: false,
running: true,
simTime: 0,
finished: false,
finalTime: 0,
exitedCount: 0,
exitTimes: [],
flowHistory: [],
lastSampleTime: 0,
peakFlow: 0
};
// Seeded PRNG (Mulberry32) so comparisons across parameters start identically
function makeRng(seed) {
let s = seed >>> 0;
return () => {
s += 0x6D2B79F5;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Palette for spectator attire (muted stadium colors)
const ATTIRE = [
"#3b82f6", "#60a5fa", "#93c5fd", "#64748b",
"#cbd5e1", "#e2e8f0", "#1d4ed8", "#38bdf8"
];
// Agent arrays (SoA / objects)
let people = [];
// Pre-allocated contact buffer for Coulomb friction
const MAX_CONTACTS = TOTAL_PEOPLE * 10;
const cA = new Int32Array(MAX_CONTACTS);
const cB = new Int32Array(MAX_CONTACTS);
const cNx = new Float64Array(MAX_CONTACTS);
const cNy = new Float64Array(MAX_CONTACTS);
const cImp = new Float64Array(MAX_CONTACTS);
let contactCount = 0;
const wNx = new Float64Array(TOTAL_PEOPLE);
const wNy = new Float64Array(TOTAL_PEOPLE);
const wImp = new Float64Array(TOTAL_PEOPLE);
function clamp(v, lo, hi) {
return v < lo ? lo : (v > hi ? hi : v);
}
function initStands() {
const rng = makeRng(202505);
people = [];
for (let c = 0; c < N_COLS; c++) {
const baseX = 2.1 + c * 1.42;
for (let r = 0; r < N_ROWS; r++) {
const idx = c * N_ROWS + r;
const baseY = 1.85 + r * 0.855;
const radius = 0.255 + rng() * 0.03; // diameter 0.51..0.57 yd (mean ~0.54 yd)
const jx = (rng() - 0.5) * 0.24;
const jy = (rng() - 0.5) * 0.16;
people.push({
id: idx,
x: baseX + jx,
y: baseY + jy,
vx: 0,
vy: 0,
r: radius,
exited: false,
gone: false,
pressure: 0,
jitterX: 0,
jitterY: 0,
jitterTimer: rng() * 0.25,
seed: Math.floor(rng() * 1e9),
color: ATTIRE[Math.floor(rng() * ATTIRE.length)],
heading: 0
});
}
}
state.simTime = 0;
state.finished = false;
state.finalTime = 0;
state.exitedCount = 0;
state.exitTimes = [];
state.flowHistory = [];
state.lastSampleTime = 0;
state.peakFlow = 0;
updateMetricsUI();
}
function rebuildGrid() {
gridHead.fill(-1);
for (let i = 0; i < people.length; i++) {
const p = people[i];
if (p.gone) continue;
const gx = clamp(Math.floor((p.x + 1.0) / CELL), 0, GRID_W - 1);
const gy = clamp(Math.floor((p.y + 1.0) / CELL), 0, GRID_H - 1);
const cell = gy * GRID_W + gx;
gridNext[i] = gridHead[cell];
gridHead[cell] = i;
}
}
function desiredDirection(p, gateTop, gateBot) {
if (p.x >= WALL_X + WALL_THICK * 0.3) {
return [1.0, 0.0];
}
const margin = Math.min(p.r * 1.08, state.gateWidth * 0.45);
const lo = gateTop + margin;
const hi = gateBot - margin;
const ty = lo <= hi ? clamp(p.y, lo, hi) : GATE_Y;
const tx = WALL_X + 0.45;
let gx = tx;
let gy = ty;
// Lookahead steering around column when enabled
if (state.column && p.x < COL_X + COL_R * 0.4) {
const dx = tx - p.x;
const dy = ty - p.y;
const dist = Math.hypot(dx, dy);
if (dist > 1e-5) {
const ux = dx / dist;
const uy = dy / dist;
const cx = COL_X - p.x;
const cy = COL_Y - p.y;
const proj = cx * ux + cy * uy;
if (proj > 0 && proj < dist) {
const perpX = cx - proj * ux;
const perpY = cy - proj * uy;
const perpDist = Math.hypot(perpX, perpY);
const avoidR = COL_R + p.r + 0.16;
if (perpDist < avoidR) {
const side = Math.abs(p.y - COL_Y) < 1e-3
? ((p.id & 1) ? 1 : -1)
: (p.y >= COL_Y ? 1 : -1);
gx = COL_X + 0.1;
gy = COL_Y + side * (COL_R + p.r + 0.32);
}
}
}
}
const vx = gx - p.x;
const vy = gy - p.y;
const len = Math.hypot(vx, vy);
if (len < 1e-6) return [1.0, 0.0];
return [vx / len, vy / len];
}
function resolveRect(p, x1, y1, x2, y2) {
const cx = clamp(p.x, x1, x2);
const cy = clamp(p.y, y1, y2);
const dx = p.x - cx;
const dy = p.y - cy;
const d2 = dx * dx + dy * dy;
if (d2 >= p.r * p.r) return 0;
let nx, ny, pen;
if (d2 > 1e-10) {
const d = Math.sqrt(d2);
nx = dx / d;
ny = dy / d;
pen = p.r - d;
} else {
// Center inside rect: push out nearest face
const dl = Math.abs(p.x - x1);
const dr = Math.abs(x2 - p.x);
const dt = Math.abs(p.y - y1);
const db = Math.abs(y2 - p.y);
const m = Math.min(dl, dr, dt, db);
if (m === dl) { nx = -1; ny = 0; pen = dl + p.r; }
else if (m === dr) { nx = 1; ny = 0; pen = dr + p.r; }
else if (m === dt) { nx = 0; ny = -1; pen = dt + p.r; }
else { nx = 0; ny = 1; pen = db + p.r; }
}
p.x += nx * pen;
p.y += ny * pen;
return pen;
}
function stepSubstep(dt) {
const u = state.urgency;
const v0 = 0.90 + 2.90 * u; // 0.90 .. 3.80 yd/s
const tau = 0.45 - 0.15 * u; // 0.45 .. 0.30 s
const aSoc = 2.60 - 1.40 * u; // 2.60 .. 1.20
const bSoc = 0.28 - 0.10 * u; // 0.28 .. 0.18 yd
const fPushMag = 1.5 + 14.0 * u * u; // 1.50 .. 15.50 (urgency scales contact force)
const jitterAmp = 0.55 + 0.65 * u;
const gateTop = GATE_Y - state.gateWidth * 0.5;
const gateBot = GATE_Y + state.gateWidth * 0.5;
rebuildGrid();
const dirX = new Float64Array(TOTAL_PEOPLE);
const dirY = new Float64Array(TOTAL_PEOPLE);
const fx = new Float64Array(TOTAL_PEOPLE);
const fy = new Float64Array(TOTAL_PEOPLE);
// 1. Driving force + smooth stride/balance variation
for (let i = 0; i < people.length; i++) {
const p = people[i];
if (p.gone) continue;
const [ex, ey] = desiredDirection(p, gateTop, gateBot);
dirX[i] = ex;
dirY[i] = ey;
// Update smooth stride sway every ~0.25 s
p.jitterTimer -= dt;
if (p.jitterTimer <= 0) {
p.jitterTimer += 0.22 + ((p.seed & 255) / 255) * 0.08;
p.seed = (Math.imul(p.seed, 1664525) + 1013904223) >>> 0;
const a1 = ((p.seed & 65535) / 65535) * Math.PI * 2;
const m1 = (((p.seed >>> 16) & 65535) / 65535);
p.jitterX = Math.cos(a1) * m1 * jitterAmp;
p.jitterY = Math.sin(a1) * m1 * jitterAmp;
}
fx[i] = (v0 * ex - p.vx) / tau + p.jitterX;
fy[i] = (v0 * ey - p.vy) / tau + p.jitterY;
}
// 2. Personal space repulsion + active pushing in contact
for (let i = 0; i < people.length; i++) {
const pi = people[i];
if (pi.gone) continue;
const gx = clamp(Math.floor((pi.x + 1.0) / CELL), 0, GRID_W - 1);
const gy = clamp(Math.floor((pi.y + 1.0) / CELL), 0, GRID_H - 1);
for (let oy = -1; oy <= 1; oy++) {
const ny = gy + oy;
if (ny < 0 || ny >= GRID_H) continue;
for (let ox = -1; ox <= 1; ox++) {
const nx = gx + ox;
if (nx < 0 || nx >= GRID_W) continue;
let j = gridHead[ny * GRID_W + nx];
while (j !== -1) {
if (j > i) {
const pj = people[j];
const dx = pj.x - pi.x;
const dy = pj.y - pi.y;
const d2 = dx * dx + dy * dy;
const rSum = pi.r + pj.r;
const cutoff = rSum + 1.1;
if (d2 < cutoff * cutoff) {
let d = Math.sqrt(d2);
let ux, uy;
if (d > 1e-6) {
ux = dx / d;
uy = dy / d;
} else {
ux = 1; uy = 0; d = 0;
}
const gap = d - rSum;
// Social personal space force
if (gap < 1.1) {
const soc = aSoc * Math.exp(-Math.max(gap, -0.05) / bSoc);
fx[i] -= soc * ux;
fy[i] -= soc * uy;
fx[j] += soc * ux;
fy[j] += soc * uy;
}
// Active physical pushing when someone is directly in the way
if (gap < 0.035) {
const pushIJ = dirX[i] * ux + dirY[i] * uy;
if (pushIJ > 0) {
const fp = fPushMag * pushIJ;
fx[j] += fp * ux;
fy[j] += fp * uy;
fx[i] -= fp * ux * 0.45;
fy[i] -= fp * uy * 0.45;
}
const pushJI = -(dirX[j] * ux + dirY[j] * uy);
if (pushJI > 0) {
const fp = fPushMag * pushJI;
fx[i] -= fp * ux;
fy[i] -= fp * uy;
fx[j] += fp * ux * 0.45;
fy[j] += fp * uy * 0.45;
}
}
}
}
j = gridNext[j];
}
}
}
}
// 3. Unconstrained integration
const oldX = new Float64Array(TOTAL_PEOPLE);
const oldY = new Float64Array(TOTAL_PEOPLE);
for (let i = 0; i < people.length; i++) {
const p = people[i];
if (p.gone) continue;
oldX[i] = p.x;
oldY[i] = p.y;
p.vx += fx[i] * dt;
p.vy += fy[i] * dt;
const spd = Math.hypot(p.vx, p.vy);
if (spd > 6.5) {
const s = 6.5 / spd;
p.vx *= s;
p.vy *= s;
}
p.x += p.vx * dt;
p.y += p.vy * dt;
}
// 4. Hard non-overlap constraint solver (Gauss-Seidel)
contactCount = 0;
const pairMap = new Map();
wImp.fill(0);
wNx.fill(0);
wNy.fill(0);
for (let iter = 0; iter < SOLVER_ITERS; iter++) {
rebuildGrid();
for (let i = 0; i < people.length; i++) {
const pi = people[i];
if (pi.gone) continue;
const gx = clamp(Math.floor((pi.x + 1.0) / CELL), 0, GRID_W - 1);
const gy = clamp(Math.floor((pi.y + 1.0) / CELL), 0, GRID_H - 1);
for (let oy = -1; oy <= 1; oy++) {
const ny = gy + oy;
if (ny < 0 || ny >= GRID_H) continue;
for (let ox = -1; ox <= 1; ox++) {
const nx = gx + ox;
if (nx < 0 || nx >= GRID_W) continue;
let j = gridHead[ny * GRID_W + nx];
while (j !== -1) {
if (j > i) {
const pj = people[j];
const dx = pj.x - pi.x;
const dy = pj.y - pi.y;
const rSum = pi.r + pj.r;
const d2 = dx * dx + dy * dy;
if (d2 < rSum * rSum) {
let ux, uy, pen;
if (d2 > 1e-10) {
const d = Math.sqrt(d2);
ux = dx / d;
uy = dy / d;
pen = rSum - d;
} else {
const ang = (i * 1.618) % (Math.PI * 2);
ux = Math.cos(ang);
uy = Math.sin(ang);
pen = rSum;
}
const corr = pen * 0.5;
pi.x -= ux * corr;
pi.y -= uy * corr;
pj.x += ux * corr;
pj.y += uy * corr;
const key = i * TOTAL_PEOPLE + j;
let idx = pairMap.get(key);
if (idx === undefined) {
if (contactCount < MAX_CONTACTS) {
idx = contactCount++;
cA[idx] = i;
cB[idx] = j;
cNx[idx] = ux;
cNy[idx] = uy;
cImp[idx] = pen;
pairMap.set(key, idx);
}
} else {
cNx[idx] = ux;
cNy[idx] = uy;
cImp[idx] += pen;
}
}
}
j = gridNext[j];
}
}
}
}
// Walls & column constraints
for (let i = 0; i < people.length; i++) {
const p = people[i];
if (p.gone) continue;
if (p.x < 1.0 + p.r) {
wImp[i] += (1.0 + p.r) - p.x;
wNx[i] = 1; wNy[i] = 0;
p.x = 1.0 + p.r;
}
if (p.y < 1.0 + p.r) {
wImp[i] += (1.0 + p.r) - p.y;
wNx[i] = 0; wNy[i] = 1;
p.y = 1.0 + p.r;
}
if (p.y > WORLD_H - 1.0 - p.r) {
wImp[i] += p.y - (WORLD_H - 1.0 - p.r);
wNx[i] = 0; wNy[i] = -1;
p.y = WORLD_H - 1.0 - p.r;
}
// Upper and lower wall blocks leaving clear opening [gateTop, gateBot]
const preX = p.x, preY = p.y;
const penTop = resolveRect(p, WALL_X, 0, WALL_X + WALL_THICK, Math.max(0, gateTop));
if (penTop > 0) {
const d = Math.hypot(p.x - preX, p.y - preY);
if (d > 1e-8) {
wNx[i] = (p.x - preX) / d;
wNy[i] = (p.y - preY) / d;
}
wImp[i] += penTop;
}
const preX2 = p.x, preY2 = p.y;
const penBot = resolveRect(p, WALL_X, Math.min(WORLD_H, gateBot), WALL_X + WALL_THICK, WORLD_H);
if (penBot > 0) {
const d = Math.hypot(p.x - preX2, p.y - preY2);
if (d > 1e-8) {
wNx[i] = (p.x - preX2) / d;
wNy[i] = (p.y - preY2) / d;
}
wImp[i] += penBot;
}
// Column obstacle
if (state.column) {
const dx = p.x - COL_X;
const dy = p.y - COL_Y;
const minD = COL_R + p.r;
const d2 = dx * dx + dy * dy;
if (d2 < minD * minD) {
const d = Math.sqrt(d2);
const ux = d > 1e-6 ? dx / d : -1;
const uy = d > 1e-6 ? dy / d : 0;
const pen = minD - d;
p.x = COL_X + ux * minD;
p.y = COL_Y + uy * minD;
wNx[i] = ux;
wNy[i] = uy;
wImp[i] += pen;
}
}
}
}
// 5. PBD velocity derivation + Coulomb tangential friction + stress tracking
const stressAccum = new Float64Array(TOTAL_PEOPLE);
for (let i = 0; i < people.length; i++) {
const p = people[i];
if (p.gone) continue;
p.vx = (p.x - oldX[i]) / dt;
p.vy = (p.y - oldY[i]) / dt;
stressAccum[i] = wImp[i] / dt;
}
const muPP = 0.44;
const muPW = 0.40;
for (let k = 0; k < contactCount; k++) {
const i = cA[k];
const j = cB[k];
const pi = people[i];
const pj = people[j];
const nx = cNx[k];
const ny = cNy[k];
const tx = -ny;
const ty = nx;
const jn = cImp[k] / dt;
stressAccum[i] += jn;
stressAccum[j] += jn;
const rvx = pj.vx - pi.vx;
const rvy = pj.vy - pi.vy;
const vt = rvx * tx + rvy * ty;
const maxF = muPP * jn;
const jt = clamp(0.5 * vt, -maxF, maxF);
pi.vx += jt * tx;
pi.vy += jt * ty;
pj.vx -= jt * tx;
pj.vy -= jt * ty;
}
for (let i = 0; i < people.length; i++) {
const p = people[i];
if (p.gone) continue;
if (wImp[i] > 0) {
const tx = -wNy[i];
const ty = wNx[i];
const vt = p.vx * tx + p.vy * ty;
const maxF = muPW * (wImp[i] / dt);
const jt = clamp(vt, -maxF, maxF);
p.vx -= jt * tx;
p.vy -= jt * ty;
}
// Smooth visual stress indicator and heading
const instantStress = clamp(stressAccum[i] * 0.85, 0, 1);
p.pressure = p.pressure * 0.84 + instantStress * 0.16;
const spd = Math.hypot(p.vx, p.vy);
if (spd > 0.25) {
p.heading = Math.atan2(p.vy, p.vx);
} else {
p.heading = Math.atan2(dirY[i], dirX[i]);
}
// Exit crossing check
if (!p.exited && p.x >= WALL_X + WALL_THICK * 0.5) {
p.exited = true;
state.exitedCount++;
state.exitTimes.push(state.simTime);
if (state.exitedCount === TOTAL_PEOPLE && !state.finished) {
state.finished = true;
state.finalTime = state.simTime;
}
}
if (p.x > WORLD_W + 1.0) {
p.gone = true;
}
}
}
function stepFrame() {
if (!state.running) return;
let allGone = true;
for (let i = 0; i < people.length; i++) {
if (!people[i].gone) { allGone = false; break; }
}
if (allGone && state.finished) return;
for (let s = 0; s < SUBSTEPS; s++) {
state.simTime += DT_SUB;
stepSubstep(DT_SUB);
}
if (state.simTime - state.lastSampleTime >= 0.5) {
state.lastSampleTime = state.simTime;
const rate = computeLiveOutflow();
if (rate > state.peakFlow) state.peakFlow = rate;
if (!state.finished) {
state.flowHistory.push(rate);
if (state.flowHistory.length > 160) state.flowHistory.shift();
}
}
updateMetricsUI();
}
function computeLiveOutflow() {
if (state.exitTimes.length === 0) return 0;
const WIN = 5.0;
const firstT = state.exitTimes[0];
const elapsedSinceFirst = Math.max(1.0, state.simTime - firstT);
const windowLen = Math.min(WIN, elapsedSinceFirst);
const cutoff = state.simTime - windowLen;
let cnt = 0;
for (let i = state.exitTimes.length - 1; i >= 0; i--) {
if (state.exitTimes[i] >= cutoff) cnt++;
else break;
}
return (cnt / windowLen) * 60;
}
// DOM & UI references
const canvas = document.getElementById("sim");
const ctx = canvas.getContext("2d");
const sparkCanvas = document.getElementById("sparkline");
const sparkCtx = sparkCanvas.getContext("2d");
const urgencyInput = document.getElementById("urgencyInput");
const urgencyVal = document.getElementById("urgencyVal");
const gateInput = document.getElementById("gateInput");
const gateVal = document.getElementById("gateVal");
const columnInput = document.getElementById("columnInput");
const resetBtn = document.getElementById("resetBtn");
const pauseBtn = document.getElementById("pauseBtn");
const outflowVal = document.getElementById("outflowVal");
const avgOutflowVal = document.getElementById("avgOutflowVal");
const peakVal = document.getElementById("peakVal");
const timerVal = document.getElementById("timerVal");
const countVal = document.getElementById("countVal");
const timerCard = document.getElementById("timerCard");
function updateMetricsUI() {
const tShown = state.finished ? state.finalTime : state.simTime;
const liveRate = state.finished
? (state.finalTime > 0 ? (TOTAL_PEOPLE / state.finalTime) * 60 : 0)
: computeLiveOutflow();
const avgRate = tShown > 0.2 ? (state.exitedCount / tShown) * 60 : 0;
outflowVal.innerHTML = `${Math.round(liveRate)} <span style="font-size:12px;font-weight:500;">ppl/min</span>`;
avgOutflowVal.textContent = `Avg: ${avgRate.toFixed(1)} ppl/min`;
peakVal.textContent = `Peak: ${Math.round(state.peakFlow)}`;
timerVal.textContent = `${tShown.toFixed(1)} s`;
countVal.textContent = `Exited: ${state.exitedCount} / ${TOTAL_PEOPLE}`;
timerCard.classList.toggle("done", state.finished);
drawSparkline();
}
function drawSparkline() {
const w = sparkCanvas.width;
const h = sparkCanvas.height;
sparkCtx.clearRect(0, 0, w, h);
const hist = state.flowHistory;
if (hist.length < 2) return;
const maxV = Math.max(120, state.peakFlow * 1.1);
sparkCtx.strokeStyle = "rgba(56, 189, 248, 0.9)";
sparkCtx.lineWidth = 2;
sparkCtx.beginPath();
for (let i = 0; i < hist.length; i++) {
const x = (i / (hist.length - 1)) * (w - 4) + 2;
const y = h - 4 - (hist[i] / maxV) * (h - 8);
if (i === 0) sparkCtx.moveTo(x, y);
else sparkCtx.lineTo(x, y);
}
sparkCtx.stroke();
sparkCtx.lineTo(w - 2, h - 2);
sparkCtx.lineTo(2, h - 2);
sparkCtx.closePath();
sparkCtx.fillStyle = "rgba(56, 189, 248, 0.14)";
sparkCtx.fill();
}
function syncControlsFromState() {
urgencyInput.value = state.urgency.toFixed(2);
urgencyVal.textContent = state.urgency.toFixed(2);
const g = state.gateWidth;
if (g < parseFloat(gateInput.min)) gateInput.min = g.toFixed(1);
if (g > parseFloat(gateInput.max)) gateInput.max = Math.ceil(g).toFixed(1);
gateInput.value = g.toFixed(1);
gateVal.textContent = `${g.toFixed(1)} yd`;
columnInput.checked = state.column;
pauseBtn.textContent = state.running ? "Pause" : "Resume";
}
function applyHashSettings() {
const raw = window.location.hash.replace(/^#/, "");
if (!raw) return false;
const params = new URLSearchParams(raw);
let changed = false;
if (params.has("urgency")) {
const u = parseFloat(params.get("urgency"));
if (Number.isFinite(u)) {
state.urgency = clamp(u, 0, 1);
changed = true;
}
}
if (params.has("gate")) {
const g = parseFloat(params.get("gate"));
if (Number.isFinite(g)) {
state.gateWidth = clamp(g, 0.4, 16.0);
changed = true;
}
}
if (params.has("column")) {
const c = params.get("column").trim().toLowerCase();
state.column = (c === "1" || c === "true");
changed = true;
}
return changed;
}
urgencyInput.addEventListener("input", () => {
state.urgency = clamp(parseFloat(urgencyInput.value) || 0, 0, 1);
urgencyVal.textContent = state.urgency.toFixed(2);
});
gateInput.addEventListener("input", () => {
state.gateWidth = clamp(parseFloat(gateInput.value) || 0.6, 0.4, 16.0);
gateVal.textContent = `${state.gateWidth.toFixed(1)} yd`;
});
columnInput.addEventListener("change", () => {
state.column = columnInput.checked;
});
resetBtn.addEventListener("click", () => {
initStands();
state.running = true;
pauseBtn.textContent = "Pause";
});
pauseBtn.addEventListener("click", () => {
state.running = !state.running;
pauseBtn.textContent = state.running ? "Pause" : "Resume";
});
window.addEventListener("hashchange", () => {
if (applyHashSettings()) {
syncControlsFromState();
initStands();
state.running = true;
pauseBtn.textContent = "Pause";
}
});
// Rendering
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(300, Math.floor(rect.width * dpr));
const h = Math.max(200, Math.floor(rect.height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
}
function lerpColor(a, b, t) {
const ar = (a >> 16) & 255, ag = (a >> 8) & 255, ab = a & 255;
const br = (b >> 16) & 255, bg = (b >> 8) & 255, bb = b & 255;
const r = Math.round(ar + (br - ar) * t);
const g = Math.round(ag + (bg - ag) * t);
const bl = Math.round(ab + (bb - ab) * t);
return `rgb(${r},${g},${bl})`;
}
function draw() {
resizeCanvas();
const cw = canvas.width;
const ch = canvas.height;
ctx.clearRect(0, 0, cw, ch);
const pad = 14;
const scale = Math.min((cw - pad * 2) / WORLD_W, (ch - pad * 2) / WORLD_H);
const offX = (cw - WORLD_W * scale) * 0.5;
const offY = (ch - WORLD_H * scale) * 0.5;
ctx.save();
ctx.translate(offX, offY);
ctx.scale(scale, scale);
// Concourse outside exit (floodlit turf/tunnel apron)
ctx.fillStyle = "#10221c";
ctx.fillRect(WALL_X, 1.0, WORLD_W - WALL_X, WORLD_H - 2.0);
ctx.strokeStyle = "rgba(74, 222, 128, 0.14)";
ctx.lineWidth = 0.05;
for (let x = WALL_X + 1.5; x < WORLD_W; x += 1.5) {
ctx.beginPath();
ctx.moveTo(x, 1.0);
ctx.lineTo(x, WORLD_H - 1.0);
ctx.stroke();
}
// Stadium stands floor
ctx.fillStyle = "#161f2b";
ctx.fillRect(1.0, 1.0, WALL_X - 1.0, WORLD_H - 2.0);
// Tiered seating rows (concrete steps & bench stripes)
for (let c = 0; c < N_COLS; c++) {
const rx = 1.65 + c * 1.42;
ctx.fillStyle = c % 2 === 0 ? "#1a2533" : "#17212e";
ctx.fillRect(rx, 1.25, 1.38, WORLD_H - 2.5);
// Bench strip
ctx.fillStyle = "#253447";
ctx.fillRect(rx + 0.15, 1.5, 0.24, WORLD_H - 3.0);
// Step edge highlight
ctx.fillStyle = "rgba(255,255,255,0.04)";
ctx.fillRect(rx + 1.35, 1.25, 0.04, WORLD_H - 2.5);
}
// Apron / landing leading to gate
ctx.fillStyle = "#1d2a3a";
ctx.fillRect(17.5, 1.0, WALL_X - 17.5, WORLD_H - 2.0);
// Painted section label & directional floor arrows
ctx.save();
ctx.fillStyle = "rgba(226, 232, 240, 0.10)";
ctx.font = "700 1.35px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("SEC 104", 19.6, 3.2);
ctx.restore();
const gateTop = GATE_Y - state.gateWidth * 0.5;
const gateBot = GATE_Y + state.gateWidth * 0.5;
// Gate threshold safety stripes
ctx.fillStyle = "rgba(245, 158, 11, 0.14)";
ctx.fillRect(WALL_X - 0.5, gateTop, WALL_THICK + 0.9, state.gateWidth);
ctx.strokeStyle = "rgba(245, 158, 11, 0.55)";
ctx.lineWidth = 0.05;
ctx.setLineDash([0.18, 0.14]);
ctx.beginPath();
ctx.moveTo(WALL_X + WALL_THICK * 0.5, gateTop);
ctx.lineTo(WALL_X + WALL_THICK * 0.5, gateBot);
ctx.stroke();
ctx.setLineDash([]);
// Enclosure walls (left, top, bottom)
ctx.fillStyle = "#334155";
ctx.fillRect(0.6, 0.6, WORLD_W - 0.6, 0.4); // top
ctx.fillRect(0.6, WORLD_H - 1.0, WORLD_W - 0.6, 0.4); // bottom
ctx.fillRect(0.6, 0.6, 0.4, WORLD_H - 1.2); // left
// Exit wall segments
ctx.fillStyle = "#475569";
if (gateTop > 1.0) {
ctx.fillRect(WALL_X, 0.6, WALL_THICK, gateTop - 0.6);
}
if (gateBot < WORLD_H - 1.0) {
ctx.fillRect(WALL_X, gateBot, WALL_THICK, (WORLD_H - 1.0) - gateBot);
}
// Yellow hazard caps on gate jambs
ctx.fillStyle = "#f59e0b";
ctx.fillRect(WALL_X, gateTop - 0.12, WALL_THICK, 0.12);
ctx.fillRect(WALL_X, gateBot, WALL_THICK, 0.12);
// Column obstacle (when enabled)
if (state.column) {
ctx.save();
ctx.fillStyle = "rgba(0,0,0,0.45)";
ctx.beginPath();
ctx.arc(COL_X + 0.08, COL_Y + 0.1, COL_R, 0, Math.PI * 2);
ctx.fill();
const grad = ctx.createRadialGradient(
COL_X - 0.12, COL_Y - 0.12, 0.06,
COL_X, COL_Y, COL_R
);
grad.addColorStop(0, "#94a3b8");
grad.addColorStop(0.7, "#475569");
grad.addColorStop(1, "#334155");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(COL_X, COL_Y, COL_R, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "#f59e0b";
ctx.lineWidth = 0.06;
ctx.stroke();
ctx.restore();
}
// People
for (let i = 0; i < people.length; i++) {
const p = people[i];
if (p.gone) continue;
// Shadow
ctx.fillStyle = "rgba(0, 0, 0, 0.35)";
ctx.beginPath();
ctx.arc(p.x + 0.04, p.y + 0.05, p.r, 0, Math.PI * 2);
ctx.fill();
// Body color shifts with physical contact pressure
let bodyColor = p.color;
if (p.pressure > 0.18) {
const t = clamp((p.pressure - 0.18) / 0.72, 0, 1);
bodyColor = t < 0.5
? lerpColor(0x38bdf8, 0xf59e0b, t * 2)
: lerpColor(0xf59e0b, 0xef4444, (t - 0.5) * 2);
}
ctx.fillStyle = bodyColor;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * 0.96, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(15, 23, 42, 0.75)";
ctx.lineWidth = 0.035;
ctx.stroke();
// Head dot slightly offset in heading direction
const hx = p.x + Math.cos(p.heading) * p.r * 0.28;
const hy = p.y + Math.sin(p.heading) * p.r * 0.28;
ctx.fillStyle = "#0f172a";
ctx.beginPath();
ctx.arc(hx, hy, p.r * 0.38, 0, Math.PI * 2);
ctx.fill();
}
// Gate width dimension callout
ctx.save();
const dimX = WALL_X + WALL_THICK + 0.55;
ctx.strokeStyle = "#f8fafc";
ctx.fillStyle = "#f8fafc";
ctx.lineWidth = 0.045;
ctx.beginPath();
ctx.moveTo(dimX, gateTop);
ctx.lineTo(dimX, gateBot);
ctx.moveTo(dimX - 0.15, gateTop);
ctx.lineTo(dimX + 0.15, gateTop);
ctx.moveTo(dimX - 0.15, gateBot);
ctx.lineTo(dimX + 0.15, gateBot);
ctx.stroke();
ctx.font = "600 0.52px sans-serif";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(`${state.gateWidth.toFixed(1)} yd`, dimX + 0.22, GATE_Y);
// Yard scale bar (bottom-right)
const sbX = WALL_X + 0.6;
const sbY = WORLD_H - 1.55;
ctx.lineWidth = 0.07;
ctx.beginPath();
ctx.moveTo(sbX, sbY);
ctx.lineTo(sbX + 4.0, sbY);
ctx.moveTo(sbX, sbY - 0.15);
ctx.lineTo(sbX, sbY + 0.15);
ctx.moveTo(sbX + 4.0, sbY - 0.15);
ctx.lineTo(sbX + 4.0, sbY + 0.15);
ctx.stroke();
ctx.font = "600 0.45px sans-serif";
ctx.textAlign = "center";
ctx.fillText("4 yards", sbX + 2.0, sbY - 0.35);
ctx.restore();
ctx.restore();
}
// Fixed-timestep main loop
let lastTs = 0;
let accum = 0;
function loop(ts) {
if (!lastTs) lastTs = ts;
const elapsed = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (state.running) {
accum += elapsed;
while (accum >= DT_FIXED) {
stepFrame();
accum -= DT_FIXED;
}
}
draw();
requestAnimationFrame(loop);
}
// Boot
applyHashSettings();
syncControlsFromState();
initStands();
requestAnimationFrame(loop);
})();
</script>
</body>
</html>
Where the crowd behaves like a real one:
0.0–0.4), personal-space repulsion prevents tight contact before the gate, letting people merge smoothly into a narrow exit. At high urgency (0.8–1.0), active pushing overwhelms personal space, packing people body-to-body so normal impulses trigger Coulomb tangential friction (mu = 0.44 person-person, mu = 0.40 person-wall). Multiple people wedge simultaneously into 2- and 3-person arches across narrow gates (1.2 yd), creating intermittent clogging and bursty outflow.Where it does not:
Special-cased behaviors:
COL_R + r + 0.16 yd of the column steers temporarily toward the closer tangent side of the pillar rather than walking dead-center into it.0.55 + 0.65 * urgency yd/s², refreshed every ~0.22–0.30 s) representing asynchronous stepping and weight shifting. There is no gate-specific anti-jam nudge, no speed cap at the gate (only a global physical velocity sanity cap of 6.5 yd/s), and no intentional overlap allowance (residual overlap under heavy compression is sub-millimeter).