Architecture
The repository implements M0, the foundation of the Technical specs (§11): the shared simulation, the netcode over a WebSocket with full snapshots, twelve vehicle classes, characters on foot, the NPC chasers, and levels built from Blender kits. The creation platform (the block catalogue, the generator on headless Blender workers, the mission runtime, the platform API, accounts on Nakama and the MCP server for Claude Code) is planned; see the Roadmap.
Workspaces
The six packages are isomorphic: the same files run under Node (server, tests) and in Chromium (client); packages/ai runs on the server alone (the client imports its profile names for the legend) and writes inputs like a client would, see the AI guide. ESLint forbids platform imports there and their tsconfig has no types, so a stray process or window is a type error.
One tick, end to end
- Inputs are quantized to 8 bits before being applied locally (
quantizeInput), so the client and the server simulate exactly the same numbers. - Inputs are numbered by the client's clock: input
nis the client's tickn. A frame that cannot catch up a stall drops its oldest ticks (frameTicks): their inputs are never sent, and the prediction runs them on the server's guess, the last input held (Predictor.guess), as the server does, so the numbering never falls behind the time that passed and both worlds run the same ticks. - The server runs exactly one of a client's ticks with each of its own, from a short queue. When the input for the tick due has not arrived (a stall, a lag spike), it runs the tick on a guess, the last input held (minus one-shot presses such as reset, and the trigger), and that tick is spent: the input meant for it, arriving later, is too late to steer and only its presses are kept, for the next tick. So the server never runs more of a client's ticks than time has passed, and the state it acknowledges as
lastSeqholds exactly the client's ticks up to it, which is what the client's replay counts on. Behind its own schedule, the server catches up one tick per callback, reading the sockets in between (loop.ts). The snapshot carries the queue length so the client can nudge its tick rate (ClientClock.tickScale) and keep the queue short but never empty. - Reconciliation is a snap-and-replay; the difference between the predicted and the corrected pose is rendered as a decaying visual offset (
SmoothingOffset) so small corrections are invisible. Large ones (a reset) are shown as the teleports they are.
The simulation
packages/sim owns a bitECS world with structure-of-arrays components and a Rapier world. Components are created per world (not module globals) so a process can hold several worlds: the client's prediction world, the two worlds of the determinism test, an in-process server in integration tests.
Systems run in a fixed order inside stepSim: the vehicle system (input → controller forces, drift model) → physics.step() → copy poses and derived state back into components. Nothing in there reads a clock.
Rapier's DynamicRayCastVehicleController drives a box chassis with four raycast wheels. Conventions, verified empirically against Rapier 0.21: local +z forward, +y up, axle -x; positive steering yaws toward +x. The renderer uses a right-handed Babylon scene so no axis is flipped anywhere.
The level
A level is LevelData (packages/world): the ground, the meshes and their placements (each with a kind for the navigation and a collider shape), spawns, strips, dummies and roads (polylines with lanes, joined into the road graph the AI drives on). The server loads one (FOI_LEVEL) and sends it in the welcome message; the client builds its prediction world, its scene and its minimap from that, so both sides build the same colliders from the same data in the same order, which is part of the determinism contract. Levels are authored in the Babylon.js Editor project (apps/level-editor) as placed pieces (unit blocks, and kit pieces modelled in Blender) and exported by apps/tools (npm run level:export) to a level file and the generated registry (@foi/world/levels). Scenes are named test-<words> (test levels, each with its e2e folder) or main-<words> (levels that ship); test-core-gameplay (a 5 × 5 grid of city blocks with the central row and column left open, two ramps, a bridge, a tunnel, a garage, a terrace, a shooting range) is the fixture of every test. See Levels.
The client
apps/client is Electron (main + preload, built by vite-plugin-electron) around a Vite renderer: a Babylon scene (scene.ts), the game loop (game.ts, on the netcode's mirror of the server world) and small pure modules for the camera, the key map, the HUD text, the latency simulator and the synthesized placeholder sounds, each with unit tests. The UI has two layers. What is part of the game is drawn in the scene with Babylon's GUI (src/gui: the prompt and the labels that follow something in the world, placed on every frame; the phone; the mission panel and a race's readout; the inventory strip and the panels built on slots), owned by the game loop. React (with i18next, in src/ui) is kept for static overlays: the helper UI (the legend and its tabs, the toolbar, the account and server forms, the game pages, the chat) and the stats and the game's events (the HUD and its hit log, the Wanted stars and the Heat, the stealth badge, the clock, the minimap), fed by the game loop through a small store, never the other way round. Audio is a sound bank of events on Howler, and the radio (Sound and the radio): audio/bank.ts, audio/sound-player.ts, audio/radio.ts, fed by renderer/audio.ts. With FOI_WEB=1 the same renderer runs in a plain browser; the Playwright core tier uses that.
Decisions worth knowing
- TypeScript 6.0, not 7. The
typescript@7npm package ships only the native compiler; typescript-eslint and typedoc need the JS API and cap their peer range at 6.0. Everything else in the specs' stack is at its current version. - Full snapshots, no delta compression yet. At this entity count a snapshot is a few hundred bytes; delta and quantization come with M7.
- The client only predicts its own car. Remote cars are not in the prediction world, so collisions between players are corrected by the server. Good enough to tune driving; revisit when traffic exists.
- No
apps/api,packages/missionsyet. They are specified but would be empty today; knip and typedoc would complain about empty packages. They are created when their milestone starts.apps/toolsexists for the level pipeline only. - The level travels with the welcome. Its JSON, validated by zod on arrival; nothing on the wire names a level any other way, so a server can run any level file without the client knowing it beforehand. A big level (64 kB or more: a region is megabytes) is named by its hash to a page that asks (
?levels=hash, protocol 30): the game server serves it at/levels/<hash>.json, immutable (apps/server/src/level-files.ts), and the page keeps it in the browser by that hash (apps/client/src/renderer/level-cache.ts: the Cache API, else IndexedDB), so the second game on a level loads it from there, holding the frames that come meanwhile for the game. Bots, older clients and the stack's smoke do not ask and get it inline.