A simulation game about engineering, not about trains.
Each playthrough generates a city, a set of independent transport companies, and — crucially — their information systems. Every company publishes its own data, in its own schema, with its own idea of what a "stop" is, its own timezone handling, and its own way of being wrong. None of them talk to each other.
Your job is to build the layer that unites them: fetch from every operator, reconcile their models, match stops that nobody has ever declared to be the same place, plan journeys across the whole network, notice when the world changes, and tell people in time.
There is no reference solution, and no single right answer. You are scored on what happens to the passengers.
Phases 0 and 1 complete. All gates pass — on a generated world as well as the committed one.
The loop runs end to end: a hand-built 34-quay city, three operators whose data genuinely disagrees, live operator APIs, a reference player in its own process, and a capture-scored scorecard — reproducibly, byte for byte.
Central Square is published by all three: as two stands (NL-S0001/NL-S0002), as 7 "Central Sq" 150 m north of where it is, and as 1 "Tsentralna" — one stop covering two platforms. Their timestamps are ISO-with-offset, epoch seconds, and local time with no offset at all. A coordinate-threshold matcher now forfeits 38 % of the available headroom and scores negative: worse than not integrating at all.
npm ci
npm run world:build # Python: city -> SQLite world bundle
npm run demo # build -> simulate -> call a player -> score
npm run calibrate # the three-gap difficulty calibration
npm run audit # every declared conflict must actually be present
npm run check # lint, typecheck, contract drift, tests
The world is now live: services run late and get cancelled, each operator's feed lags and lies in its own way, and a player that never looks at a realtime feed scores exactly 0 on Information while one that polls sensibly scores 0.658.
Runs now produce a full scorecard — three families, a named profile, tier clearance, and a report of where the capture went. A player that plans with information no feed published is caught by the information-set audit even though its score looks perfect.
Four solutions of different quality separate cleanly, and a player planning with information no feed published is caught by the information-set audit even though its score looks perfect.
The third gate — that the declared conflicts are what makes the world hard — has the most instructive history in the project. It was recorded as passing at 61 %, and P1M0 found the instrument wrong at both ends: the baseline had been handed the true disruption set and never read a published feed, while the reference it was divided by was granted foresight of disruptions before they were announced, which no player can have. Against a matched reference it read 3 % and the gate failed. Phase 0 was reopened for four milestones to fix the instruments, the world and the references; it now reads 36 % on the committed world and 31 % on a generated one, against a ratified 20 % bar. The original numbers are kept in docs/PHASES.md, because a result that is quietly rewritten cannot be challenged.
npm run gates # the three Phase 0 proof gates
npm run conformance -- URL # check a player speaks the contract
Want to build a solution? docs/PLAYING.md.
Phase 1 generated the worlds: a whole city, its operators' disagreements, its names and its scored journeys, calibrated so that two worlds of one tier ask the same amount of a solver without asking it the same way. Next is Phase 2 — the living world, scoped in ROADMAP.md.
Most "integration" exercises hand you static mock responses. Here the operator APIs are live interfaces to a running simulation — a world of vehicles, delays, breakdowns and travellers that keeps moving whether or not your solution is any good.
The difficulty is deliberately semantic, not cosmetic. Renaming a field is busywork; a player writes one adapter and it is solved forever. The interesting problems are the ones where two systems disagree about what is true:
- the same physical stop, published by two operators under different names, 40 m apart, one modelling the station and the other its five platforms;
- a feed that is silently 90 seconds stale, next to one that is fast but frequently wrong;
- trips that vanish from a feed instead of being marked cancelled;
- delays in seconds here and minutes there, one signed, one not.
Your score is how much of the available headroom you captured: 1.0 means you matched a planner with perfect information, 0.0 means you did no better than a city with no integration layer at all, and negative means you made things worse.
Read in this order. Each is a draft; each marks its own open questions with OPEN.
docs/CORECONCEPT.md |
Start here. What the project is, the catalogue of semantic conflicts, the difficulty ladder, and the 44 questions everything else answers |
docs/TECHNICAL-RESEARCH.md |
Prior art, technology choices, and the measurements behind them |
docs/PLAYER-CONTRACT.md |
The player↔simulator interface. The actual product |
docs/TIME-MODEL.md |
Two clocks, three modes, and why a paused clock is safe |
docs/DATA-MODEL.md |
Three layers: canonical world, live state, operator projections |
docs/REFERENCE-POLICY.md |
How travellers decide without a player — the baseline you compete against |
docs/SCORING.md |
Headroom capture, the three metric families, explaining a score |
docs/OBSERVABILITY.md |
Per-traveller causal tracing, and forensics on impossible scores |
docs/PLAYING.md |
Building a solution. Start here if you want to play |
docs/PHASES.md |
The long arc: Phase 0 (MVP) through Phase 5, and Phase 0's result |
ROADMAP.md |
Work still to do — the current phase, broken into milestones |
docs/BUILD-LOG.md |
What has been built, and what each milestone taught us |
docs/KNOWN-ISSUES.md |
Defects and gaps we know about and have not fixed |
docs/GLOSSARY.md |
Shared vocabulary. Useful early, and useful when a term looks familiar but is being used precisely |
docs/LICENSING-NOTES.md |
Why MIT, and why OpenStreetMap data stays out of the repository |
Two words are easy to confuse, and they mean different things:
- Tier — how hard a world is for the player. Tiers 0–5, defined in
CORECONCEPT.md§7. Appears in the run brief. - Phase — how far the project has been built. Phases 0–5, defined in
docs/PHASES.md.
Phase 0 delivered a Tier-2 world. Milestones are numbered P<phase>M<milestone>, so P1M2 is the third milestone of Phase 1.
The specs describe the finished system. Phase 0 builds a deliberately small subset. Without stating which is which, the first week tries to build all of it.
In scope for Phase 0
| Spec | What of it |
|---|---|
PLAYER-CONTRACT.md |
all of it — the contract is the product |
TIME-MODEL.md |
virtual mode, latency: none, both deadlines, ticks, snapshot rule |
DATA-MODEL.md |
all three layers, one hand-authored world, manifest-driven projections |
REFERENCE-POLICY.md |
P0, P1 at timetable, P2 |
SCORING.md |
capture, three families, validity / clearance / score |
OBSERVABILITY.md |
trace level, information-set audit |
Deferred
| Deferred | Until |
|---|---|
| World generation of any kind — city, names, schemas | the core loop is proven interesting |
| Closed loop, app-user fraction, ghost-rider feedback | Phase 2 |
realtime / scaled modes, latency: sim / wall |
Phase 2–3 |
| Monitoring UI, map replay, visualisation | Phase 2 |
Tiers 3–5, single_operator_rt, habitual |
Phase 3 |
| Counterfactual ablation | P0M5 (attribution stage 1 before that) |
| Hosted anything, sandboxing, leaderboards | Phase 4 |
The target is one hand-built Tier-2 world: three operators, semantically divergent hand-written schemas, static timetables plus simple delays, open loop, a fixed query set, a scorecard.
Maps one-to-one onto the specifications, so the code for any section is findable.
/
├── README.md this file
├── ROADMAP.md milestones for the current phase
├── CLAUDE.md conventions and hard rules for contributors
├── LICENSE MIT
├── docs/ specifications
├── benchmarks/ measurements that back decisions
├── contract/ generated OpenAPI — committed, CI-checked, stable URLs
├── src/ TypeScript — runtime (npm workspaces)
│ ├── schema/ source of truth: Zod → types, JSON Schema, OpenAPI
│ ├── core/ DES engine, L2 state, virtual clock, seeded RNG
│ ├── router/ RAPTOR; serves the P0 oracle and the P1 baseline
│ ├── projections/ L3 operator projections + defect library
│ ├── server/ operator APIs, control API, obligation issuing
│ ├── scoring/ run log → scorecard
│ ├── refplayer/ deliberately mediocre reference player
│ └── conformance/ suite any candidate player runs against itself
├── tools/ Python — offline
│ ├── worldbuild/ city → L1 → SQLite bundle
│ ├── validate/ the five gates (DATA-MODEL §7)
│ └── analysis/ calibration, scoring analysis
└── worlds/ committed world bundles + OSM extract
The boundary that matters: src/core may depend on src/schema, never the reverse, and schema stays free of runtime concerns so the Python side can consume its output.
TypeScript at runtime, Python offline, split along the world-build seam — which is clean, because world building already emits a data artefact.
TypeScript was chosen for the type system, not for speed: this project's content is schemas and their mutations, and one schema source feeding types, validators, OpenAPI documents and generated operator documentation is the whole game. A benchmark (benchmarks/) confirmed speed is not the constraint either way — a TypeScript discrete-event core runs ~17× a Python one and processes a million-passenger day in about five seconds, but the API layer will dominate long before the event loop does.
Python keeps the offline pipeline: OSM extraction, world building, validation, and scoring analysis, where its numeric and geospatial ecosystem is far stronger.
TypeScript — Node 22, npm workspaces, node:test and node:sqlite (both built in, so the runtime side starts with close to zero required dependencies). tsconfig sets erasableSyntaxOnly, so every script and tool stays runnable by node file.ts with no build step.
Python — uv, ruff, pytest. Consumes JSON Schema emitted by src/schema and validates on write.
CI — lint, typecheck, tests, and the golden-trajectory hash from P0M4 onward.
Four lint rules are load-bearing rather than stylistic, and are enforced in src/core and src/router. They exist because determinism is a hard requirement; see CLAUDE.md.
MIT — see LICENSE.
World bundles under worlds/ are data rather than software and may carry their own terms. None currently contain third-party data: Phase 0's city is hand-authored, and OpenStreetMap extracts are deliberately kept out of the repository so that nothing here is encumbered by ODbL share-alike. The reasoning is in docs/LICENSING-NOTES.md.
Nothing to build against yet. When there is, CLAUDE.md carries the conventions and the rules that must not be broken.
The specifications are the source of truth. If code and spec disagree, that is a bug in one of them — say which.