Browser 3-D city twins on a CARTO basemap, populated by an agentic synthetic population that has to get out — choosing between real evacuation routes traced over each city's actual OpenStreetMap road network.
| City | Hazard | Routes | Buildings | Cohort figures |
|---|---|---|---|---|
| Mariupol | Siege, March 2022 | 4 | 45,544 | Published ETC severity-model cohorts, five surveyed zones |
| Lower Manhattan | Coastal storm surge (Zone 1) | 5 road + 7 transit | 17,689 | ACS 2024, New York County |
| Las Vegas Strip | Mass-gathering egress | 4 | 2,607 | ACS 2024, Clark County — but see below |
| Downtown Miami & Brickell | Hurricane (Zone A) | 4 | 14,054 | ACS 2024, Miami-Dade County |
Every city now carries real cohort figures, not a generic average. The three US cities come from the Census Bureau's American Community Survey 2024 1-year estimates (tables B01001 and B18101, via the Census Reporter API); Mariupol keeps its published ETC severity-model cohorts.
The four cohorts are made mutually exclusive so they partition the
population, which is the only way they can be counted without double-counting
anyone: child is under 18, elderly is 65 and over, disabled is ages 18–64
with a disability, and adult is everyone else.
| Child | Elderly | Disabled (18–64) | Adult | |
|---|---|---|---|---|
| Mariupol | 16.1% | 21.7% | 4.9% | 57.2% |
| Lower Manhattan | 13.3% | 18.7% | 5.9% | 62.1% |
| Miami-Dade | 19.7% | 17.2% | 3.9% | 59.2% |
| Clark County | 21.5% | 16.5% | 7.4% | 54.6% |
| Strip visitors | 6% | 11% | 4% | 79% |
Two caveats, both stated in the app. These are county figures applied to a district — Lower Manhattan is not demographically identical to all of New York County, and sub-county geographies were not reachable through the open API. Zone populations remain modelled; only the cohort proportions are sourced.
And the Las Vegas Strip is a genuine problem. At any given hour the Strip is mostly visitors, not Clark County residents, and their age structure is nothing like it. A separate Strip visitors population mix is provided: its child share is derived from LVCVA visitor-profile data (~13–14% of visitors bring anyone under 21; mean visitor age 43.6 in 2024), but its elderly and disabled shares are modelled, not sourced, and the app says so. Residents against visitors is a 45.4% vulnerable share against 21.0%, and it moves median clearance from 3.03 h to 2.68 h.
Switch cities in the console, or with ?city=mariupol|nyc|vegas|miami.
Live: https://ethical-tech-colab.github.io/3d-agentic-evacuation-simulation/
Open-data, non-operational. A planning and teaching artifact: one retrospective case that ended in 2022, and three illustrative ones built on real geography with modelled populations. It is not a tracking tool, it is not keyed to any live individual location, no agent corresponds to a real person, and none of it should be read as operational guidance or as an evacuation plan for any of these cities.
Two existing pieces of work, joined:
| From | What it contributes |
|---|---|
ETC mariupol-evacuation-model |
The real city and the real demography — 45,544 OSM building centroids, 783 UNITAR/UNOSAT verified damage points, and the five published emergency-zone cohorts (37,663 exposed, 16,102 vulnerable) |
Race Condition (via the ETC fork race-condition-mod) |
The agent model — thousands of autonomous agents on a shared spline, each with its own pace, the field stretching out behind the leaders |
Race Condition puts a marathon field on a route through a 3-D city. An evacuation is the same shape of problem: many people, imperfect information, shared routes, bottlenecks, and a clock. This repo keeps that loop and changes two things.
What slows an agent down. A runner is slowed by fatigue; an evacuee is slowed by congestion where the crowd funnels onto a route, by hazard on the leg out of the district, and by not knowing whether the way out is open.
How many courses there are. A marathon has one. Every city here has four or more, each a Dijkstra path over its real road graph, and every agent has to choose. That choice — made on imperfect information, with a pull toward whatever the crowd is doing — is most of what the model is about. A route is only fast until everyone picks it.
The rendering is a deliberate departure from the fork: instead of a photogrammetry GLB and a bespoke Three.js scene, this is deck.gl over a CARTO vector basemap — keyless, free, ~40 KB of application code, and every layer is georeferenced rather than model-space.
Three basemaps, because they answer different questions:
| What it is | Good for | |
|---|---|---|
| Dark | CARTO dark matter | The city recedes; the agents carry all the colour |
| Colour | CARTO Voyager | Street names, parks, land use — sharp at every zoom |
| Satellite | Sentinel-2 cloudless (EOX, CC BY 4.0, Copernicus) | Checking a route goes where the ground says it does |
The imagery is Sentinel-2 rather than Esri. Esri's World Imagery is sharper in cities, but it is display-only under Esri's terms; this repo's own terrain plan sets the rule of preferring Copernicus so an open, attributable, redistributable dataset stays open. The honest cost is resolution: the mosaic is 10 m, so it softens as you zoom into a street. Attribution follows the active basemap.
The camera turns a full 360°. Drag with the right mouse button, press Q and E, or use the buttons: rotate, face north, tilt to street level, and a slow orbit. Any manual input stops the orbit rather than fighting it.
By default every household has its own colour, built from four of its own variables at once, so a crowd looks like a crowd of individuals rather than four flat categories:
| Channel | Variable |
|---|---|
| Hue | behaviour, nudged by cohort — an elderly seeker is visibly not an adult seeker |
| Saturation | information quality — washed out means badly informed |
| Lightness | walking speed — dark means slow |
| Size | household headcount |
A big, dark, washed-out dot is a large, slow, badly-informed household, and you can find it on the map without reading a number. The other four modes colour by a single axis when you want to answer "where are the elderly?" instead.
Every agent is classified on three independent axes. It matters that they are independent: an elderly person travelling alone and an elderly person inside a family that is waiting for a son to come home are the same row in a census and completely different evacuation outcomes.
One agent is one household, not one person, and it carries a weight: how
many real people it stands for. Everything the model counts is counted in
people, by summing weights. This is what makes the Agents slider a resolution
control rather than a population size — the weights always sum to the exposed
population, and the answer does not move when you change it. npm test asserts
exactly that.
1 · Who they are — adult, child, elderly, disabled. Sets base walking speed. Weights are post-stratified so the person-weighted cohort shares match the published split exactly, correcting for the fact that household size is not independent of who is in the household.
2 · Who they travel with — the decision unit, which is what actually moves, at the pace of its slowest member:
| Unit | Share | Size | Why it matters |
|---|---|---|---|
| Alone | 28% | 1 | Nobody to wait for. First out. |
| Family | 44% | 2–5 | Will not leave until it is whole — the largest single source of delay |
| Ad-hoc group | 20% | 3–10 | Forms out of people already moving; the strongest transmitter of crowd route choice |
| Institutional | 8% | 12–40 | A ward, a care home, a coach. Cannot self-evacuate; waits for transport that may not come |
3 · How they behave — the axis no census records, and the one that decides who is still inside when a route closes:
| Behaviour | Share | What it does |
|---|---|---|
| Prompt | 24% | Acts on the first warning it believes |
| Information-seeker | 30% | Will not move until it confirms the warning elsewhere |
| Wait-and-see | 26% | Watches the neighbours; a district sits still, then empties at once |
| Reluctant to leave | 13% | Will not go while staying seems survivable. Only rising hazard moves them |
| Returner | 7% | Leaves, then goes back — for a person, a document, an animal |
Each agent then carries an information quality (how soon the warning reaches it, how well it can judge a route), a risk tolerance, and an origin jittered inside its zone.
UNAWARE → SEEKING → MILLING → EVACUATING → (RETURNING) → DONE — the same
vocabulary the CoLab's Evacuation Behavior Simulator
uses, deliberately. Milling burns down faster the more of the district is
visibly already moving, so the whole population can stall and then go at once.
Every transition rule, the full route-choice and closure logic, and what each
marker on the map represents are written out in
docs/AGENT_DECISIONS.md.
The shares are the weakest numbers in the model, so they are the ones you can move. Every share is a slider; editing one rebalances the rest of its axis to keep the total at 100%, marks the axis custom, and lands in the URL — so a configured population is a link you can send someone.
By default the cohort split is the city's own per-zone figures, which no single set of city-wide sliders can represent without losing that resolution. Touch a cohort slider and the app says plainly that you have replaced them.
There are also four variability controls — how much individuals differ from their group's mean in speed, information, risk and timing. At zero, everyone in a cohort is identical: a useful control run and a terrible model of a city.
Presets — each one a claim about a population, with the claim written down: As sourced, Ageing district, Family district, Visitors & crowd, Drilled population, Warning not believed, Uniform (control).
The same population, on the same roads, evacuates very differently in freezing rain at three in the morning than on a clear afternoon. Eight weather states and three times of day compose into multipliers on things the model already has — walking pace, route capacity, added hazard, how well anyone can judge a route, how long the warning takes to land, and how long a household takes to gather. The console prints the resulting effect in plain terms, so a run is never quietly shaped by numbers nobody looked at.
Conditions apply live, without rebuilding the population — you can watch fog roll in over a run already under way.
Lower Manhattan, same population and seed, 3,000 agents:
| Conditions | 50th | 90th | Elderly | Disabled |
|---|---|---|---|---|
| Clear · evening | 1.3 h | 2.3 h | 1.7 h | 2.0 h |
| Clear · day | 1.5 h | 2.5 h | 1.8 h | 2.1 h |
| Heavy rain · day | 1.8 h | 3.4 h | 2.4 h | 3.2 h |
| Clear · night | 1.8 h | 3.2 h | 2.3 h | 2.8 h |
| Ice · day | 2.1 h | 4.1 h | 3.5 h | 4.7 h |
| Ice · night | 2.9 h | 6.6 h | 5.6 h | 7.4 h |
The headline number roughly doubles. The number for disabled evacuees more than triples. That gap — not the median — is what the weather actually does.
The console foregrounds 50th and 90th percentile clearance time, measured on when an agent actually reached the far end of its route — not when it left the district, which flatters anyone who turned back. Some results that fall out of the model rather than being put into it:
- Districts mobilise at different times, not in lockstep. Social proof is measured per zone — milling is watching your own neighbours, not reading a city-wide statistic — and the warning reaches districts unevenly. Zones diverge by up to 42 percentage points: one district can be 15% mobilised while the one beside it is at 58%.
- People re-route around jams, if they can see them. An agent still in its own district reconsiders every five minutes and will switch if another route is clearly better. In fog, far fewer do — you cannot be re-routed by a queue you cannot see.
- Institutional units clear in ~5.9 h against ~3.3 h for people travelling alone — nearly double, on the same routes.
- A returner costs itself ~2.6 hours.
- At low hazard, a persistent minority refuses to leave; raise the hazard slider and they finally move — and the median clearance time gets worse, because they are now leaving late and into a crowd.
- Reaching an exit is not reaching safety. Mariupol's eastward route led to filtration, and the households that take it are counted separately, never as evacuated.
Lower Manhattan does not empty over its bridges. It empties through Fulton Street, the PATH, and the Staten Island Ferry — until the operator stops them. The city pack now carries every subway complex, PATH station and ferry landing inside the zone (66 of them, from the MTA's open station list and the NYC Ferry and PATH GTFS feeds), and seven of them are transit exits: routed over the road graph exactly like a bridge, with their own boarding capacity.
A transit exit differs from a bridge in two ways, and both are the point.
- The weather shuts it, mode by mode. Each weather state and time of day
carries a multiplier for ferries, subway and rail. A gale ties the boats up
(
ferry0) before it slows anyone walking; heavy rain halves the subway through flooded tunnels; a severe storm or tornado stops surface rail and boats while the subway, underground, keeps running until the water arrives; at night every mode runs on long headways. Zero means suspended: the station is drawn hollow and grey, and anyone who can see the news stops choosing it. - The operator shuts it on a clock. In a forecast hazard transit stops before the hazard. The MTA suspended the subway at 19:00 the evening before Sandy, seven and a half hours after the evacuation order. The Transit runs until slider sets that hour; at 24 h only the weather decides.
A household that walks to a shut station does not sleep on the steps. It picks the best route still running, from where it is standing, and walks there along a precomputed road leg from that station — so nobody crosses the East River to reach the Brooklyn Bridge. Those households are counted: found the station shut is a number on the console, because it is the number a shutdown clock exists to produce.
Lower Manhattan, clear day, 3,000 households, road capacity 900/min:
| Transit runs until | 50th | 90th | Left by transit | Found the station shut |
|---|---|---|---|---|
| never (no transit) | 2.61 h | 3.91 h | 0% | 7%* |
| T+1 h | 2.69 h | 4.05 h | 1% | 15% |
| T+2 h | 2.71 h | 4.79 h | 11% | 30% |
| T+3 h | 2.35 h | 3.58 h | 33% | 10% |
| T+8 h (Sandy) | 2.35 h | 3.23 h | 43% | 0% |
| weather only | 2.35 h | 3.23 h | 43% | 0% |
* The badly informed head for a station they cannot know is shut.
The result that matters is the T+2 row: a shutdown two hours in is worse than having no transit at all. The 90th-percentile clearance is 53 minutes longer than with the stations closed from the start, because nearly a third of the population walked to a station, found it shut, and only then started for a bridge. It is an emergent result — nothing in the inputs says it — and it is exactly the trade-off an operator faces: run the trains as long as the water allows, or stop early and send everyone to the bridges from the first minute.
The same population in a gale: ferries carry nobody, the median rises from 2.35 h to 2.98 h, and 1% of households still walk to Whitehall to find the boats tied up. Station locations and line service are real; the boarding capacities and every weather multiplier are modelled and are listed in docs/METHOD.md.
npm testNo dependencies; the tests are the point. They encode the invariants that a model like this has to hold — most importantly that the answer must not depend on how many households are simulated, which is the defect that a full review of this repo turned up first. Others assert that weights sum to the exposed population in every city, that a filtration exit is never counted as safety, that capacity actually constrains a route, that no cohort can deadlock, that no agent in Lower Manhattan starts in or crosses the Hudson, that nobody boards a train after the shutdown clock and nobody is stranded by it, and that a gale carries nobody by ferry.
The hashed building heights and the schematic corridor are the two places where the picture is illustrative rather than surveyed. Both are labelled in the app.
No build step, no keys, no backend.
git clone https://github.com/Ethical-Tech-CoLab/3d-agentic-evacuation-simulation
cd 3d-agentic-evacuation-simulation
python3 -m http.server 8000
# open http://localhost:8000index.html console + scene shell
src/population.js aggregates -> individuals, on three classification axes
src/engine.js lifecycle, route choice, pace, congestion, hazard
src/cities.js the city registry
src/map.js CARTO basemap + deck.gl layer definitions
src/app.js wiring, clock, statistics
tools/fetch_city.py builds a city pack from OSM: buildings, roads, routes
data/cities/<id>/ one pack per city
docs/METHOD.md what is real, what is modelled, what is illustrative
docs/AGENT_DECISIONS.md every agent decision rule, route closures, what each dot means
docs/LOCAL_DECISION_MODEL.md running a decision model in-browser or on our own GPU
Basemap © CARTO, data ©
OpenStreetMap contributors (ODbL).
Damage points UNITAR/UNOSAT (CE20220223UKR). Zone cohorts from the Ethical Tech
CoLab mariupol-evacuation-model. Agent model after Race Condition
(Apache-2.0, Google Cloud Platform).
An Ethical Tech CoLab project.
The test workflow is not committed: the token used to push this repo lacks the
GitHub workflow scope. To turn it on, either run gh auth refresh -s workflow
and commit .github/workflows/ci.yml, or add it through the web UI — it needs
nothing but actions/checkout, actions/setup-node, and npm test.
