Interurban / The model
The travel demand model
This page specifies what the game actually computes. It exists because
“realistic” is a marketing word unless somebody can check it, and
the whole position of this project is that the model should be arguable rather
than trusted. Everything below is implemented in src/engine/, and
every coefficient named here lives in src/engine/constants.ts, where
it can be changed in one place.
1 — What is observed and what is modelled
The most important thing to be clear about is which numbers come from the world and which come from us. Getting this backwards is how a simulation game starts lying to its players. The full provenance table is on the home page; the summary is that tract geometry, population, population 18 and over, occupied households, median household income, jobs by sector, resident workers, home-to-work flows, water, roads, traffic counts, transit lines and metro population by decade are all observed from published federal or agency sources, and that the income index, distance to centre, non-work trip patterns, mode choice, road topology and historical population distribution are derived or modelled and labelled as such.
Where the Census suppresses a tract's median income — two of 668 tracts
in Minneapolis — the pack records it as unknown and mode choice treats the
tract as metro-typical. It is never given an invented figure. Each city pack
carries a source string stating all of this, and the game shows it
on the setup screen.
The largest abstraction, stated first rather than last: Interurban does not simulate streets. It connects zone centroids into a sparse graph with capacities scaled by the activity at each end, and applies a volume-delay function to it. This is a strategic-level representation. It will tell you correctly that a new line relieves a corridor; it will not tell you what happens at a particular intersection.
2 — The four steps
The structure is the standard four-step model used by metropolitan planning organisations, with the feedback loop that distinguishes a modern implementation from a 1960s one: trip generation, distribution, mode choice and assignment, with congested times and crowding fed back into generation and iterated to equilibrium.
Step 1 — Trip generation
Each zone produces and attracts trips by purpose. Four purposes are modelled, which is itself a departure — commuting is roughly a fifth of urban travel, and a network optimised only for it is optimised for the wrong city.
| Purpose | Production basis | Rate / day | Attraction basis |
|---|---|---|---|
| Home-based work | Resident workers | 1.75 | Jobs |
| Home-based other | Population | 1.90 | Retail & service jobs, population |
| Non-home-based | Population and jobs | 0.85 | Jobs and population |
| School | Population under 18 | 1.60 | School-age population |
The total lands at 3.94 trips per person per day on the real Minneapolis pack, against the 3.5–4.5 that US household travel surveys report. This is checked in the test suite rather than asserted here.
Step 2 — Trip distribution
This is where the real commute data earns its place. Work trips use a pivot-point (incremental) gravity model, which is what practitioners do when they have observed flows:
Tij = seedij · exp(−β · (cij − crefij))
where seed is the observed LODES commute flow
between tracts i and j. The real spatial structure of the city
— the product of a century of geography, zoning and history that no
gravity model reproduces — is preserved, and the model predicts only the
change the network causes. Where the seed is zero but both ends have
activity, a small gravity term lets genuinely new pairs appear; without it a
tract pair with no 2022 commuters could never gain any, however good the service
became.
Non-work trips use a conventional doubly-constrained gravity model with
deterrence exp(−βc). Every purpose is then balanced with
Furness / iterative proportional fitting — 12 iterations, tolerance 0.004.
Intrazonal trips are real and numerous, so each zone gets a nonzero intrazonal
cost derived from its area.
Step 3 — Mode choice
A nested logit over auto, transit, walk and bike, with transit nested at λ = 0.65.
| Coefficient | Value | Where it comes from |
|---|---|---|
| βivt | −0.030 /min | In-vehicle time. The reference against which the others are read. |
| βwait | −0.060 /min | Out-of-vehicle time weighted about 2× in-vehicle — the standard empirical finding. |
| βwalk | −0.055 /min | As above. 1.83× in-vehicle. |
| βxfer | −0.30 | Flat transfer penalty in addition to the walk and wait a transfer costs. Worth ten in-vehicle minutes. |
| βcost | −0.120 /$ | Implies a value of time of (0.030/0.120)×60 = $15/hour, in the normal range for US regional models. |
| βunsafe | −0.85 | The hook the social and crime events pull on. |
| λ transit nest | 0.65 | Less than 1, so transit alternatives are substitutes for each other. |
If you think the value of time is wrong, it is one number in one file.
Step 4 — Assignment
Transit assignment is frequency-based, which is the correct formulation for a game where the player sets headways rather than writing a timetable. The network is a graph of stop nodes, route-at-stop nodes, board edges costed at the expected wait, ride edges costed at run time plus a crowding penalty, alight edges at zero, and transfer-walk edges between stations within 450 m.
Expected wait is not simply half the headway, because riders time their arrival for infrequent service:
wait = h/2 for h ≤ 12 min
wait = 6 + (h/2 − 6) · 0.35 above that
Without the kink, hourly service looks like a thirty-minute wait instead of the roughly ten minutes riders actually experience, and the model systematically under-predicts suburban and off-peak ridership. It is also the reason twelve minutes is the number a player should chase first.
The key performance decision: skims are computed station-to-station, not zone-to-zone. A metro has ~900 zones but a network has 30–300 stations, so Dijkstra runs from each stop node and zones attach by walking to their nearest four stops within a kilometre. That is what makes a six-period, six-iteration equilibrium solve in about a second rather than about a minute.
| Load factor | Perceived minute |
|---|---|
| ≤ 0.80 | 1.00× |
| 1.00 | 1.15× |
| 1.50 | 1.70× |
| ≥ 2.00 | 2.60× (capped) |
Feedback and convergence
The four steps are iterated by method of successive averages,
step size 1/(k+1), up to six iterations, stopping early when the relative gap
falls below 0.012. Both congested auto times and transit crowding penalties are
averaged across iterations rather than replaced, which is what stops the solution
oscillating between “everyone drives” and “everyone
rides”. Road congestion uses the standard BPR volume-delay function,
t = t_free · (1 + 0.15(v/c)⁴) — which is what makes
transit investment actually relieve traffic, and what produces induced demand when
it does.
The in-game Analysis panel reports the iteration count, the convergence gap and the solve time, so a player can see whether the model actually converged for the network they built rather than hitting the iteration cap.
3 — The money
Three constants here do most of the work, and each carries its source in the code rather than on a slide.
| Constant | Value | Source | File |
|---|---|---|---|
| Fare elasticity, short run | −0.52 | Measured, not assumed. Nothing in the model multiplies by an elasticity — the fare reaches demand through the logit, so the response is emergent. This figure is what the model produces when the base fare is swept over the Minneapolis reference line, and a test re-measures it and fails if the constant and the model drift apart. It is steeper than Simpson-Curtin’s −0.33 because that rule was fitted to cities where a large share of riders had no car; these markets carry 1–3% of trips and nearly every rider has one. Used only to narrate an observed change. | economy.ts |
| Cost overrun, tunnel | ×1.42, σlog 0.34 | Flyvbjerg on large infrastructure: across 258 projects in 20 countries, rail came in at a mean overrun near 45% against the estimate at the decision to build, with roughly nine in ten projects over and an asymmetric right tail. | construction.ts |
| Cost overrun, surface | ×1.15, σlog 0.20 | As above; tunnelled work is worse than surface work because tunnelling is where the unknowns are. | construction.ts |
| Labour content, vehicle operations | 1.00 | CRS R47900, drawing on APTA's 2022 Public Transportation Fact Book (2020 data): compensation is 62% of all US transit operating expense — 34% salaries and wages plus 28% fringe — which across the ~85% that is directly operated works out at about 73%. Applied per line these reproduce about 63% labour on a rail network and about 75% on a bus network. | policy.ts |
| … vehicle maintenance | 0.53 | ||
| … facility maintenance | 0.70 | ||
| … administration | 0.60 |
Two honesty constraints shape the overrun model. Risk is drawn once, when the project is committed, from the simulation's own seeded stream — not re-rolled on load and not re-rolled each tick, so a player cannot reload to escape a bad draw and a replay of the same seed gives the same project. And it is revealed rather than hidden: the player is told at commit time that the estimate is an estimate, told the moment a problem is found, and told what it cost. A cost overrun that arrives as an unexplained drop in the cash balance is not a mechanic, it is a bug with a story attached.
4 — Validating it
The claim “realistic” is only worth anything if it can fail. On the real Minneapolis–Saint Paul pack, with lines placed on the corridors the real system uses and realistic 1.2 km station spacing:
| Network built | Modelled weekday journeys | Real system |
|---|---|---|
| One metro line, 16 stations | 27,500 | METRO Blue Line, ~25–30,000 |
| Three rail lines, 50 stations | 64,600 | Metro Transit rail, ~60–70,000 |
Two caveats on reading that. Station spacing matters enormously — the same corridor at 3.4 km spacing carries a tenth of the riders, because the one-kilometre walk catchment leaves gaps. And regional transit mode share comes out around 0.7% for a 134-station test network against roughly 2% for the real Twin Cities system, because the real agency runs over a hundred bus routes covering the whole metro and a sparse test network should carry less.
You can run the checks yourself: npm test covers model
invariants — balance, no NaN, shares summing to one, better service
carrying more people, and determinism — and
npm run build:city <id> rebuilds a pack from source and prints
the observed/derived split as it goes.
5 — What it cannot do
Stated plainly, because a model whose limits are hidden is a model that will eventually embarrass whoever trusted it. Seven limitations were named in the specification. Two of them have since been closed and the page says which, rather than quietly deleting them.
- No street network. Road congestion is on an abstract zone graph. Corridor-level conclusions are meaningful; intersection-level ones are not. Still true, and it is the big one.
- Distribution uses peak skims. Destination choice is solved once per equilibrium iteration against AM peak level of service, not separately per period. Standard practice, but it does understate off-peak destination shifting. Still true.
- Commute data is 2022. LODES reflects a post-pandemic labour market for present-day eras. The work-from-home shock is baked into the observed data rather than modelled on top of it. Still true, and arguably the right way round.
- Fare elasticity is applied as a multiplier rather than through the logit, so very large fare changes are less reliable than small ones. Still true.
- Single region. Trips to and from outside the modelled counties are not represented, which understates commuter rail demand in particular. Still true.
- Income was a proxy. It was derived from job mix and jobs-per-worker because ACS income needed an API key. Closed — packs now carry real ACS 2018–2022 B19013 median household income per tract, and record suppressed tracts as unknown.
- No dynamic land use. Building a line did not move jobs or housing. Closed — the land-use model now grows population and jobs zone by zone, weighted by accessibility, tracked as a delta against the real census baseline with a counterfactual alongside so the change can be attributed. That is a new model with its own new uncertainty, and the elasticity it rests on (0.9) is one number that should be argued with.
Where Interurban is wrong, it should be wrong in a
way you can trace to a number in constants.ts — and change.
The full specification, with the source, lives in docs/MODEL.md in
the repository.
Sources
- US Census 2020 PL94-171 redistricting summary files — population, population 18+, occupied households by tract
- LEHD Origin-Destination Employment Statistics (LODES8) — jobs by workplace, workers by residence, observed home-to-work flows
- American Community Survey — 2018–2022 5-year, table B19013, median household income
- FHWA Highway Performance Monitoring System — traffic and lane counts
- Census TIGER/Line and TIGERweb — tract boundaries, hydrography, roads
- National Transit Database and APTA Public Transportation Fact Book — operating cost structure
- Agency GTFS feeds — transit lines, stations, official line colours, opening years
Now go and check it.
The free edition is Minneapolis–Saint Paul in 2025, complete: the same model, the same data, every system. If the numbers above are wrong, that is where you would find out.