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 and column=0, increasing urgency from 0.3 to 0.9 increases evacuation time, by about 24 simulated seconds — roughly 70 s → 94 s.urgency=0.9 and gate=1.2, enabling the column (column=1) decreases evacuation time, by about 18 simulated seconds — roughly 94 s → 76 s.<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Stadium gate crowd</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #071414; }
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; color: #eef7f2; }
#view { display: block; width: 100vw; height: 100vh; }
.panel {
position: fixed; top: 14px; left: 14px; width: 330px; max-width: calc(100vw - 28px);
padding: 14px 15px 13px; border: 1px solid rgba(190,255,225,.20); border-radius: 16px;
background: linear-gradient(150deg, rgba(9,28,28,.91), rgba(8,18,21,.86));
box-shadow: 0 16px 40px rgba(0,0,0,.42);
backdrop-filter: blur(8px);
}
h1 { font-size: 14px; letter-spacing: .12em; text-transform: uppercase; margin: 0 0 10px; color: #baf7d7; }
.control { display: grid; grid-template-columns: 1fr auto; gap: 4px 8px; align-items: center; margin: 9px 0; }
.control label { font-size: 12px; color: #cfe9dd; letter-spacing: .02em; }
.control output { font-variant-numeric: tabular-nums; font-size: 12px; color: #fff3b0; }
input[type="range"] { grid-column: 1 / -1; width: 100%; accent-color: #5ee0a0; }
.toggle { display: flex; gap: 9px; align-items: center; font-size: 12px; margin: 10px 0; color: #cfe9dd; }
.toggle input { accent-color: #ffd166; }
button {
width: 100%; border: 0; border-radius: 10px; padding: 9px 12px; cursor: pointer;
color: #061513; background: linear-gradient(180deg,#a9f3c6,#59d898); font-weight: 750;
box-shadow: inset 0 -2px 0 rgba(0,0,0,.17);
}
button:active { transform: translateY(1px); }
.metrics { display: grid; grid-template-columns: repeat(3,1fr); gap: 7px; margin: 12px 0 8px; }
.metric { border: 1px solid rgba(255,255,255,.10); border-radius: 10px; padding: 8px 7px; background: rgba(255,255,255,.035); }
.metric b { display: block; font-size: 18px; line-height: 1.05; font-variant-numeric: tabular-nums; color: #fff; }
.metric span { display: block; font-size: 9px; line-height: 1.25; color: #a9c3b8; text-transform: uppercase; letter-spacing: .06em; margin-top: 4px; }
.foot { display:flex; justify-content:space-between; gap:8px; font-size: 10px; color: #a9c3b8; }
.foot b { color: #eef7f2; font-variant-numeric: tabular-nums; }
.hint { margin-top: 9px; font-size: 10px; line-height: 1.35; color: #8eaa9f; }
code { color: #ffe08a; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
</style>
</head>
<body>
<canvas id="view"></canvas>
<div class="panel">
<h1>Exit gate / section 113</h1>
<div class="control">
<label for="urgency">Urgency</label><output id="urgVal">0.65</output>
<input id="urgency" type="range" min="0" max="1" step="0.01" value="0.65">
</div>
<div class="control">
<label for="gate">Gate width</label><output id="gateVal">1.20 yd</output>
<input id="gate" type="range" min="0.57" max="4.5" step="0.01" value="1.2">
</div>
<label class="toggle"><input id="column" type="checkbox"> place a single column in front of the gate <span id="colVal" style="margin-left:auto;color:#fff3b0">OFF</span></label>
<button id="reset">Reset / refill stands</button>
<div class="metrics">
<div class="metric"><b id="outflow">0.0</b><span>people/min<br>20 s crossings</span></div>
<div class="metric"><b id="timer">0.0s</b><span>empty timer</span></div>
<div class="metric"><b id="remaining">240</b><span>still inside</span></div>
</div>
<div class="foot"><span id="status">running</span><span>left: <b id="exited">0</b>/<b id="total">240</b></span></div>
<div class="hint">Load with <code>#urgency=0.8&gate=1.2&column=1</code>. Values are clamped to a runnable range. Geometry changes are live; reset for clean comparisons.</div>
</div>
<script>
(() => {
"use strict";
const canvas = document.getElementById("view");
const ctx = canvas.getContext("2d");
const ui = {
urg: document.getElementById("urgency"),
urgVal: document.getElementById("urgVal"),
gate: document.getElementById("gate"),
gateVal: document.getElementById("gateVal"),
column: document.getElementById("column"),
colVal: document.getElementById("colVal"),
reset: document.getElementById("reset"),
outflow: document.getElementById("outflow"),
timer: document.getElementById("timer"),
remaining: document.getElementById("remaining"),
exited: document.getElementById("exited"),
total: document.getElementById("total"),
status: document.getElementById("status")
};
const TAU = Math.PI * 2;
const WORLD_W = 20.0, WORLD_H = 12.0;
const WALL_X = 15.0, WALL_T = 0.35;
const GATE_Y = 6.0;
const PERSON_R = 0.225, DIAM = PERSON_R * 2;
const MIN_GATE = DIAM + 0.12, MAX_GATE = 4.5;
const COL_X = WALL_X - 0.65, COL_Y = GATE_Y, COL_R = 0.28;
const EXIT_X = WALL_X + WALL_T + PERSON_R + 0.03;
const TOTAL = 240;
const SOCIAL_D = 0.72;
const DT = 1 / 90;
const MAX_V = 4.0;
const MAX_A = 120;
const PALETTE = ["#ffd166","#ef476f","#06d6a0","#8ecae6","#f78c6b","#bdb2ff","#e9c46a","#72ddf7"];
let params = { urgency: 0.65, gateW: 1.2, column: false };
let gateLo = GATE_Y - params.gateW / 2, gateHi = GATE_Y + params.gateW / 2;
let centerLo = gateLo + PERSON_R, centerHi = gateHi - PERSON_R;
let agents = [], exits = [], flashes = [];
let simTime = 0, emptyTime = null;
let accumulator = 0, lastFrame = performance.now();
let cssW = 1, cssH = 1, dpr = 1, renderScale = 1, offsetX = 0, offsetY = 0;
const clamp = (v,a,b) => v < a ? a : (v > b ? b : v);
const finite = (v, fallback) => { const n = parseFloat(v); return Number.isFinite(n) ? n : fallback; };
const hash01 = n => { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453123; return x - Math.floor(x); };
function parseColumn(v, fallback) {
if (v === null) return fallback;
const s = String(v).trim().toLowerCase();
if (["1","true","yes","on"].includes(s)) return true;
if (["0","false","no","off"].includes(s)) return false;
const n = parseFloat(s);
return Number.isFinite(n) ? n >= 0.5 : fallback;
}
function readParams() {
const raw = location.hash || "";
const p = new URLSearchParams(raw.charAt(0) === "#" ? raw.slice(1) : raw);
return {
urgency: clamp(finite(p.get("urgency"), 0.65), 0, 1),
gateW: clamp(finite(p.get("gate"), 1.2), MIN_GATE, MAX_GATE),
column: parseColumn(p.get("column"), false)
};
}
function updateGeometry() {
params.gateW = clamp(params.gateW, MIN_GATE, MAX_GATE);
gateLo = GATE_Y - params.gateW / 2;
gateHi = GATE_Y + params.gateW / 2;
centerLo = gateLo + PERSON_R;
centerHi = gateHi - PERSON_R;
if (centerHi < centerLo) {
const m = (centerLo + centerHi) / 2;
centerLo = m - 0.01; centerHi = m + 0.01;
}
}
function syncControls() {
ui.urg.value = params.urgency.toFixed(2);
ui.gate.min = String(MIN_GATE);
ui.gate.max = String(MAX_GATE);
ui.gate.value = params.gateW.toFixed(2);
ui.column.checked = params.column;
ui.urgVal.textContent = params.urgency.toFixed(2);
ui.gateVal.textContent = params.gateW.toFixed(2) + " yd";
ui.colVal.textContent = params.column ? "ON" : "OFF";
ui.total.textContent = String(TOTAL);
}
function makePeople() {
const out = [];
let id = 0;
const rows = 12, cols = 24, dx = 0.57, dy = 0.92;
for (let r = 0; r < rows && out.length < TOTAL; r++) {
for (let c = 0; c < cols && out.length < TOTAL; c++) {
const jx = (hash01(r * 89 + c * 13 + 2) - 0.5) * 0.025;
const jy = (hash01(r * 31 + c * 47 + 900) - 0.5) * 0.025;
const x = 0.55 + c * dx + (r % 2) * 0.14 + jx;
const y = 1.00 + r * dy + jy;
if (x > WALL_X - 1.1) continue;
if (params.column && Math.hypot(x - COL_X, y - COL_Y) < PERSON_R + COL_R + 0.07) continue;
let ok = true;
for (const q of out) {
const dd = (x-q.x)*(x-q.x) + (y-q.y)*(y-q.y);
if (dd < (DIAM + 0.03) * (DIAM + 0.03)) { ok = false; break; }
}
if (!ok) continue;
const side = y < GATE_Y - 0.02 ? -1 : (y > GATE_Y + 0.02 ? 1 : ((id & 1) ? 1 : -1));
out.push({
id: id++, x, y, vx: 0, vy: 0, fx: 0, fy: 0,
pressure: 0, nearCount: 0, side,
color: PALETTE[id % PALETTE.length], seed: hash01(id * 4.17)
});
}
}
return out;
}
function wallRects() {
return [
{ x0: WALL_X, x1: WALL_X + WALL_T, y0: -1.5, y1: gateLo },
{ x0: WALL_X, x1: WALL_X + WALL_T, y0: gateHi, y1: WORLD_H + 1.5 }
];
}
function rectInfo(a, r) {
const qx = clamp(a.x, r.x0, r.x1), qy = clamp(a.y, r.y0, r.y1);
const dx = a.x - qx, dy = a.y - qy, d2 = dx*dx + dy*dy;
if (d2 > 1e-10) {
const d = Math.sqrt(d2);
return { nx: dx/d, ny: dy/d, dist: d, inside: false, depth: 0 };
}
let dl = a.x - r.x0, dr = r.x1 - a.x, dt = a.y - r.y0, db = r.y1 - a.y;
let m = dl, nx = -1, ny = 0;
if (dr < m) { m = dr; nx = 1; ny = 0; }
if (dt < m) { m = dt; nx = 0; ny = -1; }
if (db < m) { m = db; nx = 0; ny = 1; }
return { nx, ny, dist: -m, inside: true, depth: m };
}
function applyRectForces(a, r, u, pushScale) {
const inf = rectInfo(a, r);
const range = PERSON_R + 0.30;
const gap = range - inf.dist;
if (gap > 0) {
let f = (13 + 7*u) * gap;
if (inf.dist < PERSON_R + 0.035) {
f += (70 + 120*u) * (PERSON_R + 0.035 - inf.dist) * pushScale;
}
a.fx += inf.nx * f; a.fy += inf.ny * f;
a.pressure += Math.min(f, 80) * 0.45;
}
}
function applyColumnForces(a, u, pushScale) {
if (!params.column) return;
let dx = a.x - COL_X, dy = a.y - COL_Y;
let d = Math.hypot(dx, dy), nx, ny;
if (d < 1e-6) { nx = -1; ny = 0; d = 0.0001; }
else { nx = dx/d; ny = dy/d; }
const minD = PERSON_R + COL_R, range = minD + 0.38, gap = range - d;
if (gap > 0) {
let f = (14 + 8*u) * gap;
if (d < minD + 0.035) f += (80 + 120*u) * (minD + 0.035 - d) * pushScale;
a.fx += nx * f; a.fy += ny * f;
a.pressure += Math.min(f, 80) * 0.40;
}
}
function applyPairForces(u, pushScale) {
const s2 = SOCIAL_D * SOCIAL_D;
for (let i = 0; i < agents.length; i++) {
const a = agents[i];
for (let j = i + 1; j < agents.length; j++) {
const b = agents[j];
let dx = b.x - a.x, dy = b.y - a.y;
let d2 = dx*dx + dy*dy;
if (d2 >= s2) continue;
let d, nx, ny;
if (d2 < 1e-10) {
const ang = ((a.id * 17 + b.id * 31) % 628) / 100;
nx = Math.cos(ang); ny = Math.sin(ang); d = 0.001;
} else {
d = Math.sqrt(d2); nx = dx/d; ny = dy/d;
}
a.nearCount++; b.nearCount++;
const social = (8 + 5*u) * (SOCIAL_D - d);
a.fx -= nx * social; a.fy -= ny * social;
b.fx += nx * social; b.fy += ny * social;
a.pressure += social * 0.55; b.pressure += social * 0.55;
if (d < DIAM + 0.035) {
const overlap = DIAM + 0.035 - d;
const rvx = b.vx - a.vx, rvy = b.vy - a.vy;
const vn = rvx*nx + rvy*ny;
const closing = Math.max(0, -vn);
const fn = ((55 + 120*u) * overlap + (4 + 6*u) * closing) * pushScale;
a.fx -= nx * fn; a.fy -= ny * fn;
b.fx += nx * fn; b.fy += ny * fn;
a.pressure += fn; b.pressure += fn;
const tx = rvx - vn*nx, ty = rvy - vn*ny;
const tl = Math.hypot(tx, ty);
if (tl > 1e-5) {
const mu = 0.10 + 0.55*u;
const ft = Math.min(fn * mu, 20 * tl);
const ux = tx/tl, uy = ty/tl;
b.fx -= ux*ft; b.fy -= uy*ft;
a.fx += ux*ft; a.fy += uy*ft;
}
}
}
}
}
function desiredDirection(a) {
let tLo = centerLo + 0.018, tHi = centerHi - 0.018;
if (tHi < tLo) { const m = (tLo + tHi) / 2; tLo = m; tHi = m; }
const preX = WALL_X - 1.65;
let tx, ty;
if (a.x < preX) {
tx = preX + 0.25; ty = GATE_Y;
if (params.column) {
const splitStart = COL_X - 2.5, splitEnd = COL_X - 0.35;
const w = clamp((a.x - splitStart) / (splitEnd - splitStart), 0, 1);
const laneY = GATE_Y + a.side * Math.min(0.72, Math.max(0.38, COL_R + PERSON_R + 0.15));
ty = GATE_Y * (1 - 0.65*w) + laneY * (0.65*w);
}
} else {
tx = WALL_X + 2.0;
ty = clamp(a.y, tLo, tHi);
if (params.column && a.x < WALL_X - 0.03) {
const maxOff = Math.max(0.05, Math.min(0.45, params.gateW / 2 - PERSON_R - 0.035));
const sideY = clamp(GATE_Y + a.side * maxOff, tLo, tHi);
ty = 0.55 * ty + 0.45 * sideY;
}
if ((a.y < tLo - 0.12 || a.y > tHi + 0.12) && a.x < WALL_X - 0.58) {
tx = WALL_X - 0.60;
ty = clamp(a.y, tLo, tHi);
}
}
let dx = tx - a.x, dy = ty - a.y;
let len = Math.hypot(dx, dy) || 1;
let ux = dx/len, uy = dy/len;
const wob = Math.sin(simTime * 2.1 + a.id * 1.618 + a.seed * 6.0) * 0.022 * (0.4 + params.urgency);
const x0 = ux, y0 = uy;
ux = x0 - y0*wob; uy = y0 + x0*wob;
const l2 = Math.hypot(ux, uy) || 1;
return { x: ux/l2, y: uy/l2 };
}
function resolveBounds(a) {
if (a.y < PERSON_R) { a.y = PERSON_R; if (a.vy < 0) a.vy = 0; }
if (a.y > WORLD_H - PERSON_R) { a.y = WORLD_H - PERSON_R; if (a.vy > 0) a.vy = 0; }
if (a.x < PERSON_R) { a.x = PERSON_R; if (a.vx < 0) a.vx = 0; }
if (a.x > WORLD_W - PERSON_R) { a.x = WORLD_W - PERSON_R; if (a.vx > 0) a.vx = 0; }
}
function resolveRect(a, r) {
const inf = rectInfo(a, r);
const pen = inf.inside ? (PERSON_R + inf.depth) : (PERSON_R - inf.dist);
if (pen > 0) {
const bump = pen + 0.0003;
a.x += inf.nx * bump; a.y += inf.ny * bump;
const vn = a.vx*inf.nx + a.vy*inf.ny;
if (vn < 0) { a.vx -= vn*inf.nx; a.vy -= vn*inf.ny; }
}
}
function resolveColumn(a) {
if (!params.column) return;
let dx = a.x - COL_X, dy = a.y - COL_Y;
let d = Math.hypot(dx, dy);
if (d >= PERSON_R + COL_R + 0.003) return;
let nx, ny;
if (d < 1e-6) { nx = -1; ny = a.side * 0.2; const l = Math.hypot(nx,ny); nx/=l; ny/=l; d = 0; }
else { nx = dx/d; ny = dy/d; }
const minD = PERSON_R + COL_R + 0.003;
const pen = minD - d + 0.0003;
a.x += nx*pen; a.y += ny*pen;
const vn = a.vx*nx + a.vy*ny;
if (vn < 0) { a.vx -= vn*nx; a.vy -= vn*ny; }
}
function resolvePositions(rects, iterations) {
const minSep = DIAM + 0.003;
const min2 = minSep * minSep;
for (let iter = 0; iter < iterations; iter++) {
for (let i = 0; i < agents.length; i++) {
const a = agents[i];
for (let j = i + 1; j < agents.length; j++) {
const b = agents[j];
let dx = b.x - a.x, dy = b.y - a.y;
const d2 = dx*dx + dy*dy;
if (d2 >= min2) continue;
let d, nx, ny;
if (d2 < 1e-10) {
const ang = ((a.id * 23 + b.id * 41 + iter) % 628) / 100;
nx = Math.cos(ang); ny = Math.sin(ang); d = 0;
} else {
d = Math.sqrt(d2); nx = dx/d; ny = dy/d;
}
const push = (minSep - d) * 0.52 + 0.0002;
a.x -= nx*push; a.y -= ny*push;
b.x += nx*push; b.y += ny*push;
const rvx = b.vx - a.vx, rvy = b.vy - a.vy;
const vn = rvx*nx + rvy*ny;
if (vn < 0) {
const imp = -0.5 * vn;
b.vx += imp*nx; b.vy += imp*ny;
a.vx -= imp*nx; a.vy -= imp*ny;
}
}
}
for (const a of agents) {
resolveBounds(a);
for (const r of rects) resolveRect(a, r);
resolveColumn(a);
resolveBounds(a);
}
}
}
function checkExits() {
for (let i = agents.length - 1; i >= 0; i--) {
const a = agents[i];
if (a.x > EXIT_X && a.y > centerLo - 0.012 && a.y < centerHi + 0.012) {
exits.push(simTime);
flashes.push({ x: EXIT_X, y: a.y, born: performance.now() * 0.001 });
agents.splice(i, 1);
}
}
if (agents.length === 0 && emptyTime === null) emptyTime = simTime;
}
function step(dt) {
if (agents.length === 0) return;
simTime += dt;
const u = params.urgency;
const pushScale = 0.65 + 1.25*u;
const driveGain = 0.90 + 0.55*u;
const tau = 0.42;
const K = driveGain / tau;
const desiredSpeed = 0.85 + 1.15*u;
const rects = wallRects();
for (const a of agents) {
a.fx = 0; a.fy = 0; a.pressure = 0; a.nearCount = 0;
}
applyPairForces(u, pushScale);
for (const a of agents) {
for (const r of rects) applyRectForces(a, r, u, pushScale);
applyColumnForces(a, u, pushScale);
}
for (const a of agents) {
const dir = desiredDirection(a);
a.fx += (dir.x * desiredSpeed - a.vx) * K;
a.fy += (dir.y * desiredSpeed - a.vy) * K;
const half = params.gateW * 0.5;
const nearGate = a.x > WALL_X - 2.1 && a.x < WALL_X + WALL_T + 0.95 &&
Math.abs(a.y - GATE_Y) < half + 1.1;
const density = Math.min(1.25, a.nearCount / 5 + Math.min(0.35, a.pressure / 95));
let shadow = 1;
if (params.column && a.x > COL_X - 0.18 && a.x < WALL_X + WALL_T + 0.45 &&
Math.abs(a.y - GATE_Y) < half + 0.85) shadow = 0.68;
const granular = nearGate
? 0.08 + 2.25*u*u*density*shadow
: 0.02 + 0.28*u*u*Math.min(1, density*0.6);
const drag = K * granular;
a.fx -= a.vx * drag; a.fy -= a.vy * drag;
if (a.x > WALL_X - 1.25 && a.x < WALL_X + WALL_T + 0.45) {
const targetY = clamp(a.y, centerLo + 0.005, centerHi - 0.005);
a.fy += (targetY - a.y) * (7 + 9*u);
}
const al = Math.hypot(a.fx, a.fy);
if (al > MAX_A) { a.fx *= MAX_A/al; a.fy *= MAX_A/al; }
}
for (const a of agents) {
a.vx += a.fx * dt; a.vy += a.fy * dt;
const sp = Math.hypot(a.vx, a.vy);
if (sp > MAX_V) { const k = MAX_V/sp; a.vx *= k; a.vy *= k; }
a.x += a.vx * dt; a.y += a.vy * dt;
}
resolvePositions(rects, 8);
checkExits();
}
function currentOutflow() {
const windowSize = 20;
const t = emptyTime !== null ? emptyTime : simTime;
let count = 0;
for (let i = exits.length - 1; i >= 0; i--) {
if (exits[i] > t - windowSize) count++;
else break;
}
const span = Math.min(windowSize, Math.max(1, t));
return count * 60 / span;
}
function updateUI() {
const t = emptyTime !== null ? emptyTime : simTime;
ui.outflow.textContent = currentOutflow().toFixed(1);
ui.timer.textContent = t.toFixed(1) + "s";
ui.remaining.textContent = String(agents.length).padStart(3, "0");
ui.exited.textContent = String(exits.length);
ui.status.textContent = emptyTime !== null
? "EMPTY — timer frozen"
: (agents.length < TOTAL * 0.45 ? "clearing" : "running");
}
function reset() {
updateGeometry();
simTime = 0; emptyTime = null; accumulator = 0;
exits = []; flashes = [];
agents = makePeople();
resolvePositions(wallRects(), 10);
lastFrame = performance.now();
updateUI();
}
function resize() {
dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
cssW = Math.max(1, window.innerWidth); cssH = Math.max(1, window.innerHeight);
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
canvas.style.width = cssW + "px"; canvas.style.height = cssH + "px";
const margin = Math.max(12, Math.min(28, cssW * 0.02));
renderScale = Math.min((cssW - 2*margin) / WORLD_W, (cssH - 2*margin) / WORLD_H);
renderScale = Math.max(9, renderScale);
offsetX = (cssW - WORLD_W * renderScale) / 2;
offsetY = (cssH - WORLD_H * renderScale) / 2;
}
function drawBackground() {
ctx.fillStyle = "#071918"; ctx.fillRect(0, 0, WORLD_W, WORLD_H);
ctx.fillStyle = "#18282a";
ctx.fillRect(WALL_X + WALL_T, 0, WORLD_W - WALL_X - WALL_T, WORLD_H);
ctx.strokeStyle = "rgba(255,255,255,.045)"; ctx.lineWidth = 0.025;
for (let x = WALL_X + WALL_T; x < WORLD_W; x += 0.75) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, WORLD_H); ctx.stroke();
}
for (let y = 0; y < WORLD_H; y += 0.75) {
ctx.beginPath(); ctx.moveTo(WALL_X + WALL_T, y); ctx.lineTo(WORLD_W, y); ctx.stroke();
}
ctx.fillStyle = "#10383a";
ctx.fillRect(0.25, 0.35, WALL_X - 0.70, WORLD_H - 0.70);
for (let r = 0; r < 12; r++) {
const y = 0.72 + r * 0.92;
ctx.fillStyle = r % 2 ? "rgba(255,255,255,.035)" : "rgba(0,0,0,.075)";
ctx.fillRect(0.45, y, WALL_X - 1.10, 0.72);
ctx.fillStyle = "rgba(219,238,226,.16)";
for (let c = 0; c < 24; c++) {
const x = 0.55 + c * 0.57 + (r % 2) * 0.14;
ctx.fillRect(x - 0.17, y + 0.16 + 0.18, 0.34, 0.18);
}
}
ctx.fillStyle = "rgba(220,255,235,.55)";
ctx.font = "700 0.30px system-ui, sans-serif";
ctx.fillText("STANDS / UPPER SECTION", 0.62, 0.68);
ctx.fillText("EXIT CONCOURSE", WALL_X + WALL_T + 0.55, 0.75);
ctx.strokeStyle = "rgba(255,255,255,.18)";
ctx.lineWidth = 0.035; ctx.setLineDash([0.28, 0.22]);
ctx.beginPath(); ctx.moveTo(0.45, GATE_Y); ctx.lineTo(WORLD_W - 0.35, GATE_Y); ctx.stroke();
ctx.setLineDash([]);
ctx.strokeStyle = "rgba(90,255,175,.30)";
ctx.lineWidth = 0.10;
ctx.beginPath(); ctx.moveTo(WALL_X + WALL_T + 0.65, GATE_Y - 0.35);
ctx.lineTo(WORLD_W - 0.65, GATE_Y - 0.35); ctx.stroke();
ctx.beginPath(); ctx.moveTo(WORLD_W - 0.85, GATE_Y - 0.55);
ctx.lineTo(WORLD_W - 0.65, GATE_Y - 0.35);
ctx.lineTo(WORLD_W - 0.85, GATE_Y - 0.15); ctx.stroke();
}
function drawPeople() {
for (const a of agents) {
if (a.pressure > 22 && a.x > WALL_X - 2.2) {
ctx.strokeStyle = "rgba(255,88,72,.25)"; ctx.lineWidth = 0.055;
ctx.beginPath(); ctx.arc(a.x, a.y, PERSON_R + 0.045, 0, TAU); ctx.stroke();
}
ctx.fillStyle = a.color;
ctx.beginPath(); ctx.arc(a.x, a.y, PERSON_R, 0, TAU); ctx.fill();
ctx.strokeStyle = "rgba(4,12,13,.75)"; ctx.lineWidth = 0.025; ctx.st