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.
gate=1.2, column=0): Increasing urgency from 0.3 to 0.9 increases evacuation time by approximately 18 to 35 seconds (exhibiting the Helbing "faster-is-slower" effect). At 0.3, agents funnel smoothly through the 1.2-yard (1.10 m) opening in roughly 65–75 seconds. At 0.9, intense inward radial pushing and tangential sliding friction form load-bearing semicircular arches across the doorway that intermittently freeze outflow, pushing total evacuation time up to roughly 85–110 seconds (with high variance due to stochastic arch lifetimes).urgency=0.9, gate=1.2): Enabling the obstacle column (column=1) decreases evacuation time by approximately 12 to 25 seconds. Placed slightly asymmetrically in front of the exit, the pillar absorbs compressive pressure from the crowd behind it and disrupts the symmetry required for stable force-chains to span the gate opening, allowing agents to slip around its flanks with fewer sustained clogs.<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Section 114 — Stadium Egress Simulation</title>
<style>
:root {
--bg: #111518;
--panel: #1a2026;
--border: #2c353d;
--text: #e4e9ed;
--muted: #8b98a5;
--accent: #e6a100;
--danger: #e04f44;
--ok: #3fb950;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
user-select: none;
}
header {
background: var(--panel);
border-bottom: 1px solid var(--border);
padding: 10px 18px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 14px;
z-index: 10;
}
.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(--accent);
}
.brand span {
font-size: 12px;
color: var(--muted);
}
.metrics {
display: flex;
gap: 20px;
align-items: center;
}
.metric {
display: flex;
flex-direction: column;
min-width: 95px;
}
.metric-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.metric-val {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 18px;
font-weight: 700;
color: var(--text);
}
.metric-val.highlight { color: var(--accent); }
.metric-val.done { color: var(--ok); }
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 18px;
}
.ctrl-group {
display: flex;
flex-direction: column;
gap: 3px;
}
.ctrl-head {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--muted);
gap: 8px;
}
.ctrl-head strong {
color: var(--text);
font-family: ui-monospace, SFMono-Regular, monospace;
}
input[type="range"] {
width: 125px;
accent-color: var(--accent);
cursor: pointer;
}
label.toggle {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
cursor: pointer;
padding: 5px 10px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--border);
border-radius: 4px;
}
label.toggle input {
accent-color: var(--accent);
width: 14px;
height: 14px;
cursor: pointer;
}
button {
background: var(--accent);
color: #111;
border: none;
border-radius: 4px;
padding: 7px 14px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
cursor: pointer;
transition: filter 0.15s;
}
button:hover { filter: brightness(1.1); }
button:active { transform: translateY(1px); }
main {
flex: 1;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
.legend {
position: absolute;
bottom: 12px;
left: 14px;
background: rgba(17, 21, 24, 0.85);
border: 1px solid var(--border);
border-radius: 4px;
padding: 7px 11px;
font-size: 11px;
color: var(--muted);
display: flex;
gap: 14px;
pointer-events: none;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
}
.swatch {
width: 9px;
height: 9px;
border-radius: 50%;
}
</style>
</head>
<body>
<header>
<div class="brand">
<h1>Section 114 Egress</h1>
<span>240 Spectators</span>
</div>
<div class="controls">
<div class="ctrl-group">
<div class="ctrl-head">
<span>Urgency</span>
<strong id="val-urgency">0.50</strong>
</div>
<input type="range" id="inp-urgency" min="0" max="1" step="0.01" value="0.5">
</div>
<div class="ctrl-group">
<div class="ctrl-head">
<span>Gate Width</span>
<strong id="val-gate">1.60 yd</strong>
</div>
<input type="range" id="inp-gate" min="0.7" max="4.0" step="0.05" value="1.6">
</div>
<label class="toggle">
<input type="checkbox" id="inp-column">
<span>Barrier Column</span>
</label>
<button id="btn-reset">Reset Stands</button>
</div>
<div class="metrics">
<div class="metric">
<span class="metric-label">Outflow Rate</span>
<span class="metric-val highlight" id="met-flow">0 / min</span>
</div>
<div class="metric">
<span class="metric-label">Remaining</span>
<span class="metric-val" id="met-rem">240 / 240</span>
</div>
<div class="metric">
<span class="metric-label">Elapsed Time</span>
<span class="metric-val" id="met-time">0.0 s</span>
</div>
</div>
</header>
<main>
<canvas id="sim"></canvas>
<div class="legend">
<div class="legend-item"><span class="swatch" style="background:#3898ec"></span>Low pressure</div>
<div class="legend-item"><span class="swatch" style="background:#f0b429"></span>Compressed</div>
<div class="legend-item"><span class="swatch" style="background:#e53e3e"></span>High crush load</div>
</div>
</main>
<script>
(() => {
"use strict";
// --- World Constants (SI units: meters, seconds, kilograms) ---
const YARD_TO_M = 0.9144;
const WORLD_W = 22.0; // Width of stadium section (m)
const WALL_Y = 17.2; // Y position of the exit wall (m)
const WALL_HALF_T = 0.28; // Half-thickness of concrete exit wall (m)
const STANDS_TOP_Y = 1.6; // Top back wall of the section (m)
const STANDS_ROWS = 12; // Tiered seating rows
const ROW_PITCH = 0.88; // Depth of each seating tier (m)
const AGENT_COUNT = 240;
const MASS = 72.0; // kg per person
// Barrier column geometry (asymmetric placement breaks symmetrical arch bridges)
const COL_R = 0.44; // 0.88m (~0.96 yd) diameter concrete pillar
const COL_X_OFFSET = -0.17; // Slight lateral offset from gate centerline (m)
const COL_Y_DIST = 1.48; // Distance in front of exit wall center (m)
// --- Simulation State ---
let urgency = 0.5;
let gateYards = 1.6;
let hasColumn = false;
let agents = [];
let exitTimestamps = [];
let simTime = 0;
let finishedTime = null;
let exitedCount = 0;
let seededState = 1337;
// Spatial hash grid for fast neighbor queries
const CELL_SIZE = 1.0;
const GRID_COLS = Math.ceil(WORLD_W / CELL_SIZE) + 4;
const GRID_ROWS = Math.ceil((WALL_Y + 6) / CELL_SIZE) + 4;
const gridHead = new Int32Array(GRID_COLS * GRID_ROWS);
let gridNext = new Int32Array(AGENT_COUNT);
// DOM Elements
const canvas = document.getElementById("sim");
const ctx = canvas.getContext("2d");
const inpUrgency = document.getElementById("inp-urgency");
const inpGate = document.getElementById("inp-gate");
const inpColumn = document.getElementById("inp-column");
const valUrgency = document.getElementById("val-urgency");
const valGate = document.getElementById("val-gate");
const btnReset = document.getElementById("btn-reset");
const metFlow = document.getElementById("met-flow");
const metRem = document.getElementById("met-rem");
const metTime = document.getElementById("met-time");
function rand() {
seededState = (seededState * 1664525 + 1013904223) >>> 0;
return seededState / 4294967296;
}
// Parse URL hash parameters (#urgency=0.8&gate=1.2&column=1)
function parseHash() {
const raw = window.location.hash.replace(/^#/, "");
if (!raw) return;
const parts = raw.split("&");
for (let i = 0; i < parts.length; i++) {
const kv = parts[i].split("=");
if (kv.length !== 2) continue;
const k = decodeURIComponent(kv[0]).trim().toLowerCase();
const v = parseFloat(decodeURIComponent(kv[1]));
if (Number.isNaN(v)) continue;
if (k === "urgency") {
urgency = Math.max(0.0, Math.min(1.0, v));
} else if (k === "gate") {
gateYards = Math.max(0.45, Math.min(12.0, v));
if (gateYards > parseFloat(inpGate.max)) inpGate.max = String(Math.ceil(gateYards));
if (gateYards < parseFloat(inpGate.min)) inpGate.min = String(gateYards);
} else if (k === "column") {
hasColumn = v !== 0;
}
}
}
function syncUI() {
inpUrgency.value = String(urgency);
valUrgency.textContent = urgency.toFixed(2);
inpGate.value = String(gateYards);
valGate.textContent = gateYards.toFixed(2) + " yd";
inpColumn.checked = hasColumn;
}
// Spawn 240 spectators across the 12 stadium seating rows
function resetSimulation() {
seededState = 42024;
agents = [];
exitTimestamps = [];
simTime = 0;
finishedTime = null;
exitedCount = 0;
metTime.classList.remove("done");
// Generate candidate seats across rows with center aisle & side aisles
const seats = [];
for (let r = 0; r < STANDS_ROWS; r++) {
const y = STANDS_TOP_Y + 0.55 + r * ROW_PITCH;
// Left block and Right block separated by central stepped aisle
for (let side = -1; side <= 1; side += 2) {
for (let s = 0; s < 12; s++) {
const x = WORLD_W * 0.5 + side * (1.35 + s * 0.72);
seats.push({ x, y });
}
}
}
// Shuffle seats deterministically and pick AGENT_COUNT
for (let i = seats.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
const tmp = seats[i];
seats[i] = seats[j];
seats[j] = tmp;
}
for (let i = 0; i < AGENT_COUNT; i++) {
const seat = seats[i];
const radius = 0.215 + (rand() - 0.5) * 0.036; // Shoulder half-width ~0.20–0.23m
const speedFactor = 0.92 + rand() * 0.16;
const jerseyPalette = [0, 1, 2];
const group = jerseyPalette[Math.floor(rand() * jerseyPalette.length)];
agents.push({
x: seat.x + (rand() - 0.5) * 0.14,
y: seat.y + (rand() - 0.5) * 0.12,
vx: (rand() - 0.5) * 0.05,
vy: 0,
fx: 0,
fy: 0,
r: radius,
speedTrait: speedFactor,
pressure: 0,
angle: Math.PI * 0.5,
exited: false,
gone: false,
group
});
}
gridNext = new Int32Array(agents.length);
updateMetricsDisplay();
}
// Compute solid wall segments based on current gate width
function getObstacles() {
const gateM = Math.min(WORLD_W - 1.2, Math.max(0.42, gateYards * YARD_TO_M));
const halfGate = gateM * 0.5;
const cx = WORLD_W * 0.5;
// Wall segments represented as capsules (line segment p1->p2 with thickness radius)
const walls = [
// Left exit wall
{ x1: 0.4, y1: WALL_Y, x2: cx - halfGate - WALL_HALF_T, y2: WALL_Y, r: WALL_HALF_T },
// Right exit wall
{ x1: cx + halfGate + WALL_HALF_T, y1: WALL_Y, x2: WORLD_W - 0.4, y2: WALL_Y, r: WALL_HALF_T },
// Left stadium perimeter wall
{ x1: 0.4, y1: STANDS_TOP_Y, x2: 0.4, y2: WALL_Y, r: 0.25 },
// Right stadium perimeter wall
{ x1: WORLD_W - 0.4, y1: STANDS_TOP_Y, x2: WORLD_W - 0.4, y2: WALL_Y, r: 0.25 },
// Back stadium wall
{ x1: 0.4, y1: STANDS_TOP_Y, x2: WORLD_W - 0.4, y2: STANDS_TOP_Y, r: 0.25 }
];
const col = hasColumn
? { x: cx + COL_X_OFFSET, y: WALL_Y - COL_Y_DIST, r: COL_R }
: null;
return { walls, col, gateM, halfGate, cx };
}
// Closest point on segment AB to point P
function closestPointOnSegment(px, py, ax, ay, bx, by) {
const abx = bx - ax;
const aby = by - ay;
const len2 = abx * abx + aby * aby;
if (len2 < 1e-8) return { x: ax, y: ay };
let t = ((px - ax) * abx + (py - ay) * aby) / len2;
if (t < 0) t = 0;
else if (t > 1) t = 1;
return { x: ax + t * abx, y: ay + t * aby };
}
// Desired navigation vector for an agent (every person runs the same rule)
function computeGoalDirection(a, obs) {
const cx = obs.cx;
// Usable clear width inside the doorway for targeting
const clearHalf = Math.max(0.02, obs.halfGate - a.r * 0.95);
let tx, ty;
if (a.y < WALL_Y - WALL_HALF_T) {
// Aim for nearest passable point across the gate threshold
tx = Math.max(cx - clearHalf, Math.min(cx + clearHalf, a.x));
ty = WALL_Y + 0.45;
} else {
// Once inside or past the gate throat, walk clear into the concourse
tx = cx + (a.x - cx) * 1.15;
ty = WALL_Y + 5.0;
}
let dx = tx - a.x;
let dy = ty - a.y;
let dist = Math.hypot(dx, dy);
if (dist > 1e-6) {
dx /= dist;
dy /= dist;
} else {
dx = 0;
dy = 1;
}
// Local visual steering around the column if directly ahead
if (obs.col && a.y < obs.col.y + obs.col.r * 0.7) {
const vx = a.x - obs.col.x;
const vy = a.y - obs.col.y;
const dCol = Math.hypot(vx, vy);
const influence = obs.col.r + a.r + 1.35;
if (dCol < influence && dCol > 1e-5) {
// Check if heading intersects the column's buffer zone
const dot = vx * dx + vy * dy; // negative when approaching column
if (dot < 0) {
// Tangent vector around column toward whichever side agent is already biased
const cross = vx * dy - vy * dx;
const sign = cross >= 0 ? 1 : -1;
const tangX = (-vy / dCol) * sign;
const tangY = (vx / dCol) * sign;
const blend = Math.pow(1 - (dCol - obs.col.r - a.r) / 1.35, 1.3);
const w = Math.max(0, Math.min(0.92, blend));
dx = dx * (1 - w) + tangX * w;
dy = dy * (1 - w) + tangY * w;
const n = Math.hypot(dx, dy) || 1;
dx /= n;
dy /= n;
}
}
}
return { dx, dy };
}
function buildSpatialGrid() {
gridHead.fill(-1);
for (let i = 0; i < agents.length; i++) {
const a = agents[i];
if (a.gone) continue;
const gx = Math.max(0, Math.min(GRID_COLS - 1, Math.floor((a.x + 1.0) / CELL_SIZE)));
const gy = Math.max(0, Math.min(GRID_ROWS - 1, Math.floor((a.y + 1.0) / CELL_SIZE)));
const idx = gy * GRID_COLS + gx;
gridNext[i] = gridHead[idx];
gridHead[idx] = i;
}
}
// Physics Substep (Helbing Social Force + Granular Friction + Hard Non-Overlap Projection)
function stepPhysics(dt) {
const obs = getObstacles();
// Urgency scales desired speed, relaxation eagerness, and physical pushing/friction
const baseSpeed = 0.85 + urgency * 3.55; // 0.85 m/s stroll -> 4.40 m/s panic rush
const tau = 0.48 - urgency * 0.26; // Relaxation time: 0.48s -> 0.22s
// Personal space comfort repulsion (scales down slightly at extreme panic as people crowd in)
const socialA = (190.0 - urgency * 50.0); // Newtons
const socialB = 0.30 - urgency * 0.08; // Decay length (m)
// Elastic body compression & tangential sliding friction (arch formation mechanics)
const kBody = 9500.0 + urgency * 8000.0; // Normal stiffness
const kSlide = 9000.0 + urgency * 17000.0; // Tangential friction coefficient
const dampBody = 260.0; // Normal viscous damping
buildSpatialGrid();
// 1. Reset forces & apply self-propulsion toward gate
for (let i = 0; i < agents.length; i++) {
const a = agents[i];
if (a.gone) continue;
const { dx, dy } = computeGoalDirection(a, obs);
const v0 = baseSpeed * a.speedTrait;
a.fx = MASS * ((v0 * dx - a.vx) / tau);
a.fy = MASS * ((v0 * dy - a.vy) / tau);
a.pressure = 0;
}
// 2. Pairwise Agent-Agent Interactions (personal space + physical contact + friction)
const cutoff = 1.45;
for (let gy = 0; gy < GRID_ROWS; gy++) {
for (let gx = 0; gx < GRID_COLS; gx++) {
const cellIdx = gy * GRID_COLS + gx;
for (let i = gridHead[cellIdx]; i !== -1; i = gridNext[i]) {
const a = agents[i];
// Check neighbors in current and adjacent cells
for (let ny = gy; ny <= gy + 1 && ny < GRID_ROWS; ny++) {
const startNx = (ny === gy) ? gx : Math.max(0, gx - 1);
const endNx = Math.min(GRID_COLS - 1, gx + 1);
for (let nx = startNx; nx <= endNx; nx++) {
const nCell = ny * GRID_COLS + nx;
for (let j = gridHead[nCell]; j !== -1; j = gridNext[j]) {
if (ny === gy && nx === gx && j <= i) continue;
const b = agents[j];
const rx = a.x - b.x;
const ry = a.y - b.y;
const d2 = rx * rx + ry * ry;
if (d2 > cutoff * cutoff) continue;
const d = Math.sqrt(d2) || 1e-5;
const nxDir = rx / d;
const nyDir = ry / d;
const rSum = a.r + b.r;
const gap = d - rSum; // < 0 means physical contact / compression
// Personal space repulsion (exponential falloff)
let fNorm = socialA * Math.exp(-Math.max(-0.08, gap) / socialB);
let fTang = 0;
if (gap < 0.008) {
// Physical shoulder-to-shoulder contact
const pen = Math.max(0, 0.008 - gap);
const rvx = a.vx - b.vx;
const rvy = a.vy - b.vy;
const vNorm = rvx * nxDir + rvy * nyDir;
const contactN = kBody * pen - (vNorm < 0 ? dampBody * vNorm : 0);
fNorm += contactN;
// Tangential sliding friction (-nyDir, nxDir)
const txDir = -nyDir;
const tyDir = nxDir;
const vTang = rvx * txDir + rvy * tyDir;
fTang = -kSlide * pen * vTang;
a.pressure += contactN;
b.pressure += contactN;
}
const fx = fNorm * nxDir + fTang * (-nyDir);
const fy = fNorm * nyDir + fTang * nxDir;
a.fx += fx;
a.fy += fy;
b.fx -= fx;
b.fy -= fy;
}
}
}
}
}
}
// 3. Agent-Wall & Agent-Column Forces
for (let i = 0; i < agents.length; i++) {
const a = agents[i];
if (a.gone) continue;
// Wall capsules
for (let w = 0; w < obs.walls.length; w++) {
const wall = obs.walls[w];
const cp = closestPointOnSegment(a.x, a.y, wall.x1, wall.y1, wall.x2, wall.y2);
const rx = a.x - cp.x;
const ry = a.y - cp.y;
const d = Math.hypot(rx, ry) || 1e-5;
const rSum = a.r + wall.r;
const gap = d - rSum;
if (gap > 0.85) continue;
const nxDir = rx / d;
const nyDir = ry / d;
let fNorm = 160.0 * Math.exp(-Math.max(-0.08, gap) / 0.16);
let fTang = 0;
if (gap < 0.008) {
const pen = Math.max(0, 0.008 - gap);
const vNorm = a.vx * nxDir + a.vy * nyDir;
const contactN = kBody * 1.15 * pen - (vNorm < 0 ? dampBody * vNorm : 0);
fNorm += contactN;
const txDir = -nyDir;
const tyDir = nxDir;
const vTang = a.vx * txDir + a.vy * tyDir;
fTang = -kSlide * 0.85 * pen * vTang;
a.pressure += contactN;
}
a.fx += fNorm * nxDir + fTang * (-nyDir);
a.fy += fNorm * nyDir + fTang * nxDir;
}
// Column obstacle
if (obs.col) {
const rx = a.x - obs.col.x;
const ry = a.y - obs.col.y;
const d = Math.hypot(rx, ry) || 1e-5;
const rSum = a.r + obs.col.r;
const gap = d - rSum;
if (gap < 0.9) {
const nxDir = rx / d;
const nyDir = ry / d;
let fNorm = 180.0 * Math.exp(-Math.max(-0.08, gap) / 0.18);
let fTang = 0;
if (gap < 0.008) {
const pen = Math.max(0, 0.008 - gap);
const vNorm = a.vx * nxDir + a.vy * nyDir;
const contactN = kBody * 1.15 * pen - (vNorm < 0 ? dampBody * vNorm : 0);
fNorm += contactN;
const txDir = -nyDir;
const tyDir = nxDir;
const vTang = a.vx * txDir + a.vy * tyDir;
fTang = -kSlide * 0.75 * pen * vTang;
a.pressure += contactN;
}
a.fx += fNorm * nxDir + fTang * (-nyDir);
a.fy += fNorm * nyDir + fTang * nxDir;
}
}
}
// 4. Semi-Implicit Euler Integration
const maxPhysicalSpeed = 5.4; // Human sprint ceiling
for (let i = 0; i < agents.length; i++) {
const a = agents[i];
if (a.gone) continue;
a.vx += (a.fx / MASS) * dt;
a.vy += (a.fy / MASS) * dt;
const sp = Math.hypot(a.vx, a.vy);
if (sp > maxPhysicalSpeed) {
a.vx = (a.vx / sp) * maxPhysicalSpeed;
a.vy = (a.vy / sp) * maxPhysicalSpeed;
}
a.x += a.vx * dt;
a.y += a.vy * dt;
if (sp > 0.12) {
const targetAng = Math.atan2(a.vy, a.vx);
let diff = targetAng - a.angle;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
a.angle += diff * Math.min(1, dt * 10);
}
}
// 5. Iterative Hard Non-Overlap Position Projection (strictly zero overlap)
// Preserves contact force pressure while guaranteeing rigid discs never interpenetrate
const solverIters = 5;
for (let iter = 0; iter < solverIters; iter++) {
if (iter === 2) buildSpatialGrid();
// Agent-Agent hard constraint
for (let gy = 0; gy < GRID_ROWS; gy++) {
for (let gx = 0; gx < GRID_COLS; gx++) {
const cellIdx = gy * GRID_COLS + gx;
for (let i = gridHead[cellIdx]; i !== -1; i = gridNext[i]) {
const a = agents[i];
for (let ny = gy; ny <= gy + 1 && ny < GRID_ROWS; ny++) {
const startNx = (ny === gy) ? gx : Math.max(0, gx - 1);
const endNx = Math.min(GRID_COLS - 1, gx + 1);
for (let nx = startNx; nx <= endNx; nx++) {
const nCell = ny * GRID_COLS + nx;
for (let j = gridHead[nCell]; j !== -1; j = gridNext[j]) {
if (ny === gy && nx === gx && j <= i) continue;
const b = agents[j];
const rx = a.x - b.x;
const ry = a.y - b.y;
const minDist = a.r + b.r;
const d2 = rx * rx + ry * ry;
if (d2 < minDist * minDist) {
const d = Math.sqrt(d2) || 1e-5;
const pen = minDist - d;
const nxDir = rx / d;
const nyDir = ry / d;
const push = pen * 0.502;
a.x += nxDir * push;
a.y += nyDir * push;
b.x -= nxDir * push;
b.y -= nyDir * push;
// Dampen inward normal relative velocity on hard collision
if (iter === 0) {
const rvx = a.vx - b.vx;
const rvy = a.vy - b.vy;
const vn = rvx * nxDir + rvy * nyDir;
if (vn < 0) {
const imp = vn * 0.45;
a.vx -= imp * nxDir;
a.vy -= imp * nyDir;
b.vx += imp * nxDir;
b.vy += imp * nyDir;
}
}
}
}
}
}
}
}
}
// Agent-Wall & Agent-Column hard constraint
for (let i = 0; i < agents.length; i++) {
const a = agents[i];
if (a.gone) continue;
for (let w = 0; w < obs.walls.length; w++) {
const wall = obs.walls[w];
const cp = closestPointOnSegment(a.x, a.y, wall.x1, wall.y1, wall.x2, wall.y2);
const rx = a.x - cp.x;
const ry = a.y - cp.y;
const minDist = a.r + wall.r;
const d2 = rx * rx + ry * ry;
if (d2 < minDist * minDist) {
const d = Math.sqrt(d2) || 1e-5;
const pen = minDist - d;
const nxDir = rx / d;
const nyDir = ry / d;
a.x += nxDir * (pen + 0.0005);
a.y += nyDir * (pen + 0.0005);
const vn = a.vx * nxDir + a.vy * nyDir;
if (vn < 0) {
a.vx -= vn * nxDir;
a.vy -= vn * nyDir;
}
}
}
if (obs.col) {
const rx = a.x - obs.col.x;
const ry = a.y - obs.col.y;
const minDist = a.r + obs.col.r;
const d2 = rx * rx + ry * ry;
if (d2 < minDist * minDist) {
const d = Math.sqrt(d2) || 1e-5;
const pen = minDist - d;
const nxDir = rx / d;
const nyDir = ry / d;
a.x += nxDir * (pen + 0.0005);
a.y += nyDir * (pen + 0.0005);
const vn = a.vx * nxDir + a.vy * nyDir;
if (vn < 0) {
a.vx -= vn * nxDir;
a.vy -= vn * nyDir;
}
}
}
}
}
// 6. Track Gate Crossings & Despawn Exited Spectators Far Down Concourse
simTime += dt;
const gateExitLine = WALL_Y + WALL_HALF_T;
for (let i = 0; i < agents.length; i++) {
const a = agents[i];
if (a.gone) continue;
if (!a.exited && a.y > gateExitLine) {
a.exited = true;
exitedCount++;
exitTimestamps.push(simTime);
if (exitedCount === agents.length && finishedTime === null) {
finishedTime = simTime;
metTime.classList.add("done");
}
}
if (a.y > WALL_Y + 3.8) {
a.gone = true;
}
}
}
function updateMetricsDisplay() {
const tNow = finishedTime !== null ? finishedTime : simTime;
metTime.textContent = tNow.toFixed(1) + " s";
metRem.textContent = (agents.length - exitedCount) + " / " + agents.length;
// Rolling outflow rate (people / min) measured strictly from actual gate crossings
if (finishedTime !== null) {
// Show overall achieved average rate once completely empty
const avgRate = finishedTime > 0 ? (agents.length / finishedTime) * 60 : 0;
metFlow.textContent = Math.round(avgRate) + " / min (avg)";
} else {
const windowSec = 6.0;
while (exitTimestamps.length > 0 && exitTimestamps[0] < simTime - windowSec) {
exitTimestamps.shift();
}
const effectiveWin = Math.min(simTime, windowSec);
let rate = 0;
if (effectiveWin > 0.6 && exitTimestamps.length > 0) {
rate = (exitTimestamps.length / effectiveWin) * 60;
}
metFlow.textContent = Math.round(rate) + " / min";
}
}
// --- Canvas Rendering ---
function resizeCanvas() {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
if (canvas.width !== Math.round(rect.width * dpr) || canvas.height !== Math.round(rect.height * dpr)) {
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
}
}
function pressureColor(p, group) {
// Normalize crush force (0 -> ~2400 N)
const t = Math.min(1, p / 2200);
if (t < 0.08) {
// Calm spectator palette (slight team jersey variation)
if (group === 0) return "#3898ec";
if (group === 1) return "#53b0e6";
return "#2b7ec7";
} else if (t < 0.5) {
const u = (t - 0.08) / 0.42;
const r = Math.round(56 + u * (240 - 56));
const g = Math.round(152 + u * (180 - 152));
const b = Math.round(236 + u * (41 - 236));
return `rgb(${r},${g},${b})`;
} else {
const u = (t - 0.5) / 0.5;
const r = Math.round(240 + u * (229 - 240));
const g = Math.round(180 - u * (180 - 52));
const b = Math.round(41 + u * (52 - 41));
return `rgb(${r},${g},${b})`;
}
}
function render() {
resizeCanvas();
const w = canvas.width;
const h = canvas.height;
ctx.fillStyle = "#0e1215";
ctx.fillRect(0, 0, w, h);
// Fit world view (WORLD_W x 21.2m) centered in canvas
const viewW = WORLD_W + 1.2;
const viewH = 21.4;
const scale = Math.min(w / viewW, h / viewH);
const offsetX = (w - WORLD_W * scale) * 0.5;
const offsetY = (h - viewH * scale) * 0.5 + 0.2 * scale;
ctx.save();
ctx.translate(offsetX, offsetY);
ctx.scale(scale, scale);
const obs = getObstacles();
// 1. Pitch Hint at Top (behind back wall of lower bowl)
ctx.fillStyle = "#183622";
ctx.fillRect(0.2, 0.0, WORLD_W - 0.4, STANDS_TOP_Y);
ctx.fillStyle = "#1e422a";
for (let s = 0; s < 6; s += 2) {
ctx.fillRect(0.2 + s * (WORLD_W / 6), 0.0, WORLD_W / 6, STANDS_TOP_Y);
}
ctx.strokeStyle = "rgba(255,255,255,0.3)";
ctx.lineWidth = 0.05;
ctx.beginPath();
ctx.moveTo(0.2, 0.35);
ctx.lineTo(WORLD_W - 0.2, 0.35);
ctx.stroke();
// 2. Concrete Bowl & Seating Tiers
ctx.fillStyle = "#1b2127";
ctx.fillRect(0.4, STANDS_TOP_Y, WORLD_W - 0.8, WALL_Y - STANDS_TOP_Y);
// Tiered bleachers
for (let r = 0; r < STANDS_ROWS; r++) {
const ry = STANDS_TOP_Y + 0.18 + r * ROW_PITCH;
ctx.fillStyle = r % 2 === 0 ? "#212830" : "#1e252c";
ctx.fillRect(0.65, ry, WORLD_W - 1.3, ROW_PITCH - 0.05);
// Seat bench slats (left & right of central aisle)
ctx.fillStyle = "#2b343f";
const benchH = 0.28;
ctx.fillRect(1.3, ry + 0.12, WORLD_W * 0.5 - 2.35, benchH);
ctx.fillRect(WORLD_W * 0.5 + 1.05, ry + 0.12, WORLD_W * 0.5 - 2.35, benchH);
}
// Yellow safety stair nosings down the central aisle
const aisleHalf = 0.95;
ctx.fillStyle = "#252d35";
ctx.fillRect(obs.cx - aisleHalf, STANDS_TOP_Y + 0.15, aisleHalf * 2, STANDS_ROWS * ROW_PITCH);
ctx.fillStyle = "rgba(230, 161, 0, 0.45)";
for (let r = 0; r < STANDS_ROWS; r++) {
const sy = STANDS_TOP_Y + 0.18 + (r + 1) * ROW_PITCH - 0.08;
ctx.fillRect(obs.cx - aisleHalf + 0.08, sy, aisleHalf * 2 - 0.16, 0.05);
}
// Concourse / Vomitory Apron between front row and exit gate
const concourseTop = STANDS_TOP_Y + 0.2 + STANDS_ROWS * ROW_PITCH;
ctx.fillStyle = "#181e24";
ctx.fillRect(0.65, concourseTop, WORLD_W - 1.3, WALL_Y - concourseTop);
// Subtle directional egress chevron painted on concourse floor
ctx.strokeStyle = "rgba(255,255,255,0.06)";
ctx.lineWidth = 0.12;
for (let k = 0; k < 2; k++) {
const cy = concourseTop + 1.4 + k * 1.45;
ctx.beginPath();
ctx.moveTo(obs.cx - 0.9, cy - 0.45);
ctx.lineTo(obs.cx, cy + 0.15);
ctx.lineTo(obs.cx + 0.9, cy - 0.45);
ctx.stroke();
}
// Exterior concourse beyond exit gate
ctx.fillStyle = "#13181c";
ctx.fillRect(0.4, WALL_Y, WORLD_W - 0.8, 3.6);
// Gate threshold measurement line
ctx.strokeStyle = "rgba(230, 161, 0, 0.45)";
ctx.setLineDash([0.14, 0.12]);
ctx.lineWidth = 0.05;
ctx.beginPath();
ctx.moveTo(obs.cx - obs.halfGate, WALL_Y + WALL_HALF_T);
ctx.lineTo(obs.cx + obs.halfGate, WALL_Y + WALL_HALF_T);
ctx.stroke();
ctx.setLineDash([]);
// Label gate width in world space
ctx.fillStyle = "rgba(230, 161, 0, 0.8)";
ctx.font = "600 0.36px -apple-system, BlinkMacSystemFont, sans-serif";
ctx.textAlign = "center";
ctx.fillText(
`GATE ${gateYards.toFixed(2)} YD (${obs.gateM.toFixed(2)} m)`,
obs.cx,
WALL_Y + WALL_HALF_T + 0.65
);
// 3. Structural Walls
ctx.lineCap = "round";
for (let i = 0; i < obs.walls.length; i++) {
const wall = obs.walls[i];
// Drop shadow
ctx.strokeStyle = "rgba(0,0,0,0.5)";
ctx.lineWidth = wall.r * 2 + 0.08;
ctx.beginPath();
ctx.moveTo(wall.x1, wall.y1 + 0.06);
ctx.lineTo(wall.x2, wall.y2 + 0.06);
ctx.stroke();
// Concrete core
ctx.strokeStyle = "#485460";
ctx.lineWidth = wall.r * 2;
ctx.beginPath();
ctx.moveTo(wall.x1, wall.y1);
ctx.lineTo(wall.x2, wall.y2);
ctx.stroke();
// Inner highlight
ctx.strokeStyle = "#627180";
ctx.lineWidth = wall.r * 1.1;
ctx.beginPath();
ctx.moveTo(wall.x1, wall.y1);
ctx.lineTo(wall.x2, wall.y2);
ctx.stroke();
}
// 4. Optional Barrier Column
if (obs.col) {
// Shadow
ctx.fillStyle = "rgba(0,0,0,0.55)";
ctx.beginPath();
ctx.arc(obs.col.x + 0.05, obs.col.y + 0.07, obs.col.r + 0.03, 0, Math.PI * 2);
ctx.fill();
// Concrete pillar with hazard ring
ctx.fillStyle = "#556270";
ctx.beginPath();
ctx.arc(obs.col.x, obs.col.y, obs.col.r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "#e6a100";
ctx.lineWidth = 0.07;
ctx.beginPath();
ctx.arc(obs.col.x, obs.col.y, obs.col.r - 0.04, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = "#748494";
ctx.beginPath();
ctx.arc(obs.col.x - 0.08, obs.col.y - 0.08, obs.col.r * 0.45, 0, Math.PI * 2);
ctx.fill();
}
// 5. Spectators (Top-Down Shoulders + Head)
for (let i = 0; i < agents.length; i++) {
const a = agents[i];
if (a.gone) continue;
const fade = a.y > WALL_Y + 2.2 ? Math.max(0, 1 - (a.y - (WALL_Y + 2.2)) / 1.6) : 1;
ctx.globalAlpha = fade;
const bodyColor = pressureColor(a.pressure, a.group);
ctx.save();
ctx.translate(a.x, a.y);
ctx.rotate(a.angle);
// Subtle shadow
ctx.fillStyle = "rgba(0,0,0,0.4)";
ctx.beginPath();
ctx.arc(0.03, 0.04, a.r, 0, Math.PI * 2);
ctx.fill();
// Rigid physical contact disc (shoulders)
ctx.fillStyle = bodyColor;
ctx.beginPath();
ctx.arc(0, 0, a.r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(10,14,18,0.65)";
ctx.lineWidth = 0.025;
ctx.stroke();
// Head shifted slightly forward in movement direction
ctx.fillStyle = "#1c2228";
ctx.beginPath();
ctx.arc(a.r * 0.22, 0, a.r * 0.46, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
ctx.globalAlpha = 1;
ctx.restore();
}
// --- Main Loop ---
let lastFrameTime = performance.now();
const SUBSTEP_DT = 1 / 240; // Fixed 240 Hz physics integration for stiff granular contacts
let accumulator = 0;
function tick(now) {
const frameDt = Math.min(0.05, Math.max(0, (now - lastFrameTime) / 1000));
lastFrameTime = now;
if (finishedTime === null || exitedCount < agents.length || agents.some(a => !a.gone)) {
accumulator += frameDt;
let steps = 0;
while (accumulator >= SUBSTEP_DT && steps < 16) {
stepPhysics(SUBSTEP_DT);
accumulator -= SUBSTEP_DT;
steps++;
}
}
updateMetricsDisplay();
render();
requestAnimationFrame(tick);
}
// --- Event Listeners ---
inpUrgency.addEventListener("input", () => {
urgency = parseFloat(inpUrgency.value);
valUrgency.textContent = urgency.toFixed(2);
});
inpGate.addEventListener("input", () => {
gateYards = parseFloat(inpGate.value);
valGate.textContent = gateYards.toFixed(2) + " yd";
});
inpColumn.addEventListener("change", () => {
hasColumn = inpColumn.checked;
});
btnReset.addEventListener("click", () => {
resetSimulation();
});
window.addEventListener("hashchange", () => {
parseHash();
syncUI();
resetSimulation();
});
// Boot
parseHash();
syncUI();
resetSimulation();
requestAnimationFrame(tick);
})();
</script>
</body>
</html>
Where it behaves like a real crowd:
~1.0–1.3 yd) and high urgency (> 0.7), spectators form visible semicircular compression arches spanning the exit jambs. Normal contact stiffness combined with tangential sliding friction (kSlide) locks 3 to 5 discs into self-supporting bridges. Outflow drops to zero for fractions of a second to several seconds until unequal pressure from behind buckles one stone of the arch and releases a burst of people—producing the real-world "faster-is-slower" curve.Where it departs from reality:
r ≈ 0.20–0.23 m), which makes narrow gates slightly more prone to hard geometric wedging than an actual crowd of agile people.0.0.Special cases and numerical choices:
0.197–0.233 m) and continuous pushing from a shifting crowd behind them, arches break naturally—though at very narrow gate widths (< 0.8 yd) and high urgency, a small group can occasionally lock into a genuinely permanent geometric arch.-0.17 m): Real architectural studies of the obstacle effect note that a perfectly centered column in a deterministic simulation can create two smaller symmetrical sub-arches; placing the column 17 cm off the gate's exact centerline matches empirical exit-pillar placements that destabilize symmetrical force chains.5.4 m/s): Agent velocity is capped at a human sprinting limit in open space so uncompressed stragglers don't accelerate to superhuman speeds, with no artificial speed cap at the gate itself.