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.

Trip generation by purpose. src/engine/demand.ts. Attractions are scaled so that the sum of attractions equals the sum of productions for every purpose — an unbalanced matrix silently breaks the balancing step.
PurposeProduction basisRate / dayAttraction basis
Home-based workResident workers1.75Jobs
Home-based otherPopulation1.90Retail & service jobs, population
Non-home-basedPopulation and jobs0.85Jobs and population
SchoolPopulation under 181.60School-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.

Mode choice coefficients. src/engine/constants.ts, LOGIT. Utilities are computed with max-subtraction before exponentiating, so an attractive alternative cannot overflow; infeasible alternatives get share zero without producing NaN; the four shares sum to 1 by construction.
CoefficientValueWhere it comes from
βivt−0.030 /minIn-vehicle time. The reference against which the others are read.
βwait−0.060 /minOut-of-vehicle time weighted about 2× in-vehicle — the standard empirical finding.
βwalk−0.055 /minAs above. 1.83× in-vehicle.
βxfer−0.30Flat 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.85The hook the social and crime events pull on.
λ transit nest0.65Less 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.

Crowding feeds back as a multiplier on perceived in-vehicle time.
Load factorPerceived minute
≤ 0.801.00×
1.001.15×
1.501.70×
≥ 2.002.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.

Economic calibration. Each of these is a single named constant in the file given, next to a comment citing where it came from.
ConstantValueSourceFile
Fare elasticity, short run−0.52Measured, 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.34Flyvbjerg 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.20As above; tunnelled work is worse than surface work because tunnelling is where the unknowns are.construction.ts
Labour content, vehicle operations1.00CRS 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 maintenance0.53
… facility maintenance0.70
… administration0.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:

Nothing was tuned to produce this. It falls out of census population, LODES jobs, observed commute flows and the coefficients above.
Network builtModelled weekday journeysReal system
One metro line, 16 stations27,500METRO Blue Line, ~25–30,000
Three rail lines, 50 stations64,600Metro 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Single region. Trips to and from outside the modelled counties are not represented, which understates commuter rail demand in particular. Still true.
  6. 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.
  7. 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

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.