Skip to content

AGENTS.md ​

Five On It is a top-down open-city driving game (GTA2 pace, real 3D city, low-poly cars and people), organized as an npm-workspaces monorepo. The design is in documents/GAME_DESIGN.md (what), the engineering plan in documents/TECHNICAL_SPECS.md (how); when they disagree the GDD wins on what and the specs on how. See @README.md for the layout and commands.

The following is a guide for working with this project.

Project structure ​

  • Plain npm workspaces (packages/*, apps/*). Always npm, never pnpm/yarn. Workspace scripts: npm run -w apps/<name> <script>.
  • packages/shared, world, sim, protocol, net-client, ai, missions, catalogue are isomorphic: no DOM, no Node APIs, no Babylon or Electron imports. ESLint (no-restricted-imports) and the empty types list in tsconfig.base.json enforce it. Apps may depend on packages; packages never depend on apps.
  • Node runs TypeScript directly (type stripping): imports use explicit .ts extensions; no enums, namespaces or parameter properties (erasableSyntaxOnly). The workspace packages resolve straight to src/index.ts; there is no build step for them.
  • The client UI has two layers. What is part of the game (the prompt and every label that follows something in the world, the phone, the mission panel and a race's readout, the inventory strip and the panels built on slots: containers, shops, garages) is drawn in the scene with Babylon's GUI (apps/client/src/gui), owned by the game loop and updated in the render loop: a world-attached label is projected on every frame, never moved by a React render. React with i18next (apps/client/src/ui) is only for static overlays: the helper UI (the main menu over its burning car and the pause menu with their Settings screen, 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). The game loop never imports React; it publishes into the overlay store (ui/store.ts) and the overlay never reads the simulation. Audio is a sound bank of events on Howler (apps/client/src/audio/: apps/client/audio/sounds.json declares every sound and how it is mixed, the code plays it by its id, a file dropped under apps/client/audio/sfx/ by the id replaces its synthesized placeholder; renderer/audio.ts feeds the game's, ui-sounds.ts the menus' cues) and the radio (audio/radio.ts: stations of files, addresses or a stream in apps/client/audio/radio/, live on their own clock, the player's alone), at the player's settings (ui/settings.ts, kept in the browser); see docs/guide/audio.md. Every text is set in Changa (apps/client/src/fonts/), awaited before anything draws text. A bare address opens the main menu (ui/boot.ts); ?play or ?server= the game. The menu's Play online plays on the page's server or the one a platform names (ui/online.ts, asked its /info and its protocol first: ui/versions.ts); the desktop app's Play local starts its own server only to host (electron/main.ts, the bridge electron/bridge.ts).
  • TypeScript is pinned to 6.0 (not 7): the typescript@7 npm package is the native compiler only, and typescript-eslint and typedoc need the JS API. Revisit when they support 7.

Branching and releases (git flow) ​

  • develop is the integration branch and the default branch; main holds released code only. Work on feature/* branches off develop and merge by pull request; never push to main directly.
  • Releases are semantic versions shared by every workspace, cut with npm run release -- start <version> (a release/<version> branch from develop) and npm run release -- finish (merge into main, back-merge, push). Hotfixes: npm run release -- hotfix <version> from main. Tags are never made by hand: the push of main runs .github/workflows/release.yml, which tags v<version> and publishes the installers and the server bundle, so a credential that only updates branches (a Claude Code cloud session's) can release too. See docs/guide/releasing.md.

The live platform ​

  • https://play.fiveonit.online runs on Hetzner Cloud from deploy/fleet: stack-server.mjs makes the platform's server or brings it to the checkout's release and to .env.fleet, add-server.mjs and remove-server.mjs add and drain game hosts, fleet-status.mjs reports them, teardown.mjs takes everything away after a backup. Their settings are in the gitignored deploy/fleet/.env.fleet (the domain is FOI_DOMAIN, never written in code); the runbook is docs/guide/operations.md, the scripts' design deploy/fleet/README.md.
  • A release does not update the live platform: an admin runs npm run fleet:update (deploy/fleet/update.mjs, or the Update the platform workflow), which backs it up, runs the release's own stack-server from its tag and adds each game host again. The repository variable FOI_PLATFORM_URL bakes its address into the installers.
  • Hetzner retires server types and API endpoints without warning (GET /datacenters answers 410, the cpx31 is gone from nbg1): after touching deploy/fleet/hcloud.mjs, run stack-server --dry-run, add-server --dry-run and teardown without --yes, which read the real API and change nothing. Never create, change or delete servers without the user's go-ahead: they bill by the hour.

The simulation contract ​

  • packages/sim is the only place physics happens, and it runs identically on the server and inside the client for prediction. No Math.random, no Date.now, no wall clock inside systems; randomness comes from createPrng in packages/shared.
  • One pinned Rapier version (@dimforge/rapier3d-compat, exact, with a root overrides entry). All integration parameters are set in world.ts (createSimWorld, and setPhysicsSubsteps for the steps a tick is cut into, which substeps.ts decides) and nowhere else.
  • The determinism test (packages/sim/src/determinism.integration.test.ts) runs two worlds through the same input log and compares hashes. It must stay green; if it fails, the change introduced non-determinism, not a test bug.
  • Vehicle tuning lives in packages/sim/src/vehicle-classes.ts. vehicle.test.ts pins the envelope (acceleration, top speed, turning direction, braking, drift, ramps); update the numbers in both places together.
  • Clients send inputs only (packages/protocol). The server never accepts a position from a client.
  • Missions are data: a game document (packages/missions: a level's places and zones, its rules, its missions with their actors, objectives and when/if/do rules) is checked by validateGame and run by the deterministic MissionRuntime, which the server's MissionHost (apps/server/src/mission-host.ts) steps every sixth tick with facts from the simulation; it never touches the simulation itself, the host carries out its commands. A bundled level's game lives in packages/missions/content/<level>.json; FOI_GAME runs another file. A mission's play makes it co-op: it never starts in a level's room (the hub) and is played in a run's room of its own for its roster (apps/server/src/run-host.ts, the API's runs.ts), held by the stand-in holder 0. The language is in docs/guide/missions.md.
  • The platform (docs/guide/platform.md): a level spec is a grid-town (built by Blender), a region (land from tiles, built by @foi/catalogue's solver without Blender, in the worker or, without one, in the API's own process; PUT /drafts/:id/spec solves it at once and answers with its map) or a city (a city drawn freely: @foi/catalogue's city solver, packages/catalogue/src/city/, resolves it at once into a plan, or its issues at their paths, and Blender builds the plan, assets/blender/levels/city.py; docs/guide/city-spec.md); the API (apps/api) keeps drafts, builds and published games behind adapters (memory or file locally, Postgres and S3 with the Compose stack in deploy/, npm run stack:up and stack:smoke, docs/guide/deploying.md; accounts dev or Nakama; moderation pluggable, off locally), serves the catalogue (packages/catalogue: the level spec's schema, a union on template whose grid-town mirrors assets/blender/levels/generate.py field for field, and everything a game may name, the region's tiles included), builds a draft on a bundled level or on a level spec, whose level the generator worker (apps/worker: headless Blender, then @foi/tools' exportRecipe in memory; FOI_WORKER_URL, else in the API's process when Blender is found) generates and whose places join the game's, and opens rooms on game servers that have the shared FOI_ROOM_SECRET (apps/server/src/rooms.ts, http.ts); players join a room with a join token the API signs (@foi/protocol). The server never trusts a client with anything else. Published games have pages in the web client (?browse, ?game=<id>) with ratings, reports (decided by the FOI_MODERATORS, every decision in an audit log), remixes and a preview picture: the worker renders a generated level's, and npm run level:previews renders the bundled levels' into apps/worker/previews/ (run it after changing or adding a level). Players play together through the social layer (docs/guide/social.md): friends, presence, parties and the party's and direct messages in the API (social*.ts, the friend graph and mutes in memory or a file with dev accounts, Nakama's with Nakama, whose runtime module apps/nakama moderates a public room's channel and keeps the messages), a room's roster, chat and votes to kick in the game server (room-social.ts, a public room's lines checked at the API's POST /moderation/chat with the room secret), a party's shared missions in the runtime, safe zones and the ghost after respawning in the simulation (griefing.ts). Creators reach the API through the MCP server (apps/mcp, docs/guide/creating.md), a thin adapter with no rights of its own: every tool calls the API as the signed-in creator (stdio with FOI_EMAIL/FOI_PASSWORD or FOI_API_TOKEN, HTTP forwarding the caller's bearer token), so a new capability is an API route first and a tool second.
  • NPCs are inputs too: packages/ai (run by the server alone, before the step) perceives, decides and plans within a budget, then writes an ordinary PlayerInput; it never moves a body and packages/sim never imports it. Its randomness is its own createPrng stream; hashAiState and packages/ai/src/determinism.integration.test.ts are its gate. Tuning lives in packages/ai/src/profiles.ts; the model is in docs/guide/ai.md.

Levels (Babylon.js Editor) ​

  • Levels are authored in apps/level-editor, a Babylon.js Editor 5.5 project; the editor owns assets/** (never edit those files by hand while a scene is open). A level is placed pieces: instances of the ten unit blocks under _blocks (each kind defined once in packages/world/src/pieces.ts) or of kit pieces modelled in Blender (apps/level-editor/assets/kits/<kit>.glb + <kit>.foi.json, written by the scripts under assets/blender/ through Blender's MCP server, see assets/README.md) and installed under _kits with metadata.foi by npm run level:recipe -- <file.recipe.json> --scene <name> (a recipe places kit pieces and blocks in the game's frame; the recipes live beside the kits). The export turns them into LevelData (packages/world/src/level.ts): meshes (triangle lists with their colours) and placements (where each stands, its kind terrain | structure | building | prop and its collider trimesh | hull | none), polyline roads (lanes and a speed, no collider; a road block is a two-point road, a kit road piece a real curve), joined into the road graph by buildRoadGraph; terrain pieces are ground (surfaceHeight, isOpenGround and the navigation grid read the triangles in packages/world/src/geometry.ts, so a cliff blocks and a slope is walked), structures are decks and walls (not ground: a bridge has a street under it), buildings and props are obstacles. The same triangles are the server's colliders, the client's picture and the AI's ground. npm run level:export (apps/tools) reads every scene folder (no editor CLI), writes packages/world/levels/<name>.json and its declaration <name>.d.json.ts (what TypeScript reads in place of the JSON: a level is unknown until loadLevel has checked it, never megabytes of inferred types), removes what a gone scene left behind and rewrites the registry packages/world/levels/index.ts; commit them together. That directory is the pipeline's: never edit or delete there by hand (an ad-hoc .json level for FOI_LEVEL=<path> lives elsewhere). npm run level:check (in validate and CI, no editor needed) fails when anything drifts or a level fails checkLevel. Every level passes the level audit (packages/world/src/audit/: no prop in another or in a building, every piece resting on what is under it, nothing in a carriageway but a cone, no z-fighting, no paint over paint; packages/catalogue/src/levels-audit.test.ts holds the bundled ones to it as they are): a generator that breaks a rule is fixed in the generator, never in the scene; a generated level is repaired instead (apps/worker/src/audit.ts), and npm run level:fuzz builds random specs through the whole pipeline (twelve regions in every pull request's tests, grid towns nightly in the worker's image, apps/worker/fuzz-corpus/ on every release).
  • Scenes are named test-<words> (a test level: every one has an e2e folder tests/e2e/specs/levels/<name>/ that the core tier runs) or main-<words> (a level that ships; e2e optional); the export refuses other names. The server loads the level FOI_LEVEL names (by default main-riverbend by name, else the first main-* in name order, else the first test-*) through the registry entry @foi/world/levels, the only module that carries level files, and sends it in the welcome message; the client builds its prediction world, scene and minimap from the welcome and bundles no level. test-core-gameplay is the fixture of every unit test of the city and is frozen: a change to it must keep the fixture contract test (packages/world/src/levels.test.ts) green; a new scenario is a new test-* scene with its own e2e folder. test-ai-pathfinding is the AI's level (roads, junctions, an obstacle course; its contract is in the same test): both test levels are the POC's block levels dressed with the city kit, built from their recipes (npm run level:recipe -- apps/level-editor/assets/kits/<name>.recipe.json --scene <name>), never edited in the editor, so a change is a changed recipe followed by level:recipe and npm run level:export. test-mission-basics (a straight road with a side street, a parked rental compact) is the missions' level, built the same way, with its bundled game in packages/missions/content/; test-mission-logic (a straight road with three side streets) is the mission language's (waves of enemies, counters, while rules, branches), built and bundled the same way; test-mission-verbs (an avenue with a street per mission, a hydrant at the end of the diner's road) is the verbs past v1's (give, escort, chase, photograph, wait, escape, destroy, parallel groups), built and bundled the same way, its server with police. test-breakables (two roads ending at hydrants) is the light pieces' level, built the same way: a kit piece with a breakable mass (assets/blender/kits) is planted until a vehicle with the kinetic energy of a mid-size sedan at 60 km/h (a truck at a crawl, a compact at 68 km/h) knocks it loose (packages/sim/src/breakables.ts). test-collisions (an open road, a road along a wall, one between two walls) is the collisions' level, built the same way: cars ramming the player's, drawn where the prediction meets them, and getting out where it is free. test-new-vehicles (a straight street and a row of four spawns) is the city's vehicles' level (the taxi, the bus, the ambulance, the fire truck, the garbage truck), built the same way. test-police (a road loop around a block, range dummies by the south road) is the police's level, built the same way; the core tier runs it with police, and test-locks-garages (a street with three locked cars of the level's vehicle spawners, the keys of two lying by them, a range dummy watching the third, a garage with a driveway into its bay; the locks' and garages' level, built the same way with its bundled game), and every other level server without (FOI_POLICE=0). test-stealth (a walled compound behind a gate locked by a keycard, a security camera, four guards from NPC spawner blocks) is stealth's level (docs/guide/stealth.md), built the same way, with its bundled game (Clean Exit) in packages/missions/content/. test-vertical (a shed and a tower with their ladders, a lift up to a deck, an escalator onto a landing) is the vertical blocks' level (packages/sim/src/ladders.ts, lifts.ts; falls in character.ts), built the same way from its recipe. test-storeys (an office of two storeys, its ground floor sealed, up 4 m wide stairs; an overpass carrying a road over the street) is the layered navigation's level (packages/world/src/nav-layers.ts: the floors over the ground the AI walks and the reach checks count), built the same way from its recipe. test-safe-zones (a plaza with both spawns and a street out of it) is the griefing controls' level, built the same way, its bundled game making the plaza a safe zone and keeping a player a ghost for four seconds after coming back. test-versus (an avenue between the two teams' spawns, a bank with the loot on its verge, a hideout at its north end, a loop of road round it all) is versus' level (robbers and chasers, a race between the players), built out of blocks from its recipe as test-vertical is, with its bundled game (Versus basics: robbers and chasers, a variant on an eight-second clock and one where a caught robber watches, a race of every player on their own) in packages/missions/content/. test-inventory (a first-aid kit, a crate, the player's car's trunk, a mission of parcels) and test-interaction (a room with a door locked by the key lying nearby, an unlocked door, a locker) are the items' levels (see docs/guide/items.md), built the same way out of blocks (a door's and a container's texts in the recipe's params), each with its bundled game in packages/missions/content/. test-drift-pad (an asphalt skidpad with a painted circle, a dirt patch, a run up to a small ramp, a shipping container) is the level of the tyres' effects and the seats, generated like test-curves: assets/blender/levels/drift_pad.py writes its own kit and its recipe, then level:recipe and the export. test-on-foot (a yard with ledges of 1, 2.5 and 5 m to fall from, a low and a high wall, a door, a crate, items, a coach and a kiosk, guards at a post, on a patrol and to put to sleep, dummies, a bicycle, a motorbike and the bus) is the people's animations' level, built the same way, with its bundled game in packages/missions/content/; the clips are made by assets/blender/models/animations.py (see docs/guide/animation.md). test-traffic-junction (a loop of road, an avenue and a street crossing under four traffic lights, T junctions without) is the ambient traffic's level, built the same way; its e2e server runs sixteen cars (FOI_TRAFFIC, tests/e2e/support/levels.ts). test-city-actions (a high street with a bench, a cash machine, a kiosk, a market stall and a bus stop on one side, a petrol station on the other) is the level of the city's furniture (sitting, the bank, food, filling up) and of the crowd's routines, built the same way with its bundled game (places for Go to); its shared e2e server has no pedestrians, its routines' spec a crowd of its own. test-race (a closed circuit of eight roads) is the races' level and test-city-life (a ring road and a signalled cross, spawn points of every role, a gate locked by a keycard, a car park's barrier locked by a ticket, a shop and a garage, a clerk on a schedule, a taxi driver, a target) the level of the city's blocks (docs/guide/city.md), both built the same way with their bundled games; the city's e2e server runs with police and eight cars. test-city (a 320 m town on a river's south bank: a turned grid, a roundabout, the river's one bridge, a quay, a crescent, a close, a knoll with a lane up it, three districts) is the city template's level: its spec apps/level-editor/assets/kits/test-city.spec.json (the catalogue's example of the template too) is solved by the city solver and built by city.py with npm run level:city -- <spec> (--check: the solver alone), then level:recipe and the export; change the spec, the solver or city.py, never the scene, and keep its contract packages/world/src/levels-city.test.ts green; riverbend.py builds Riverbend through city.py too, and Riverbend's spec (packages/catalogue/src/city/fixtures/main-riverbend.spec.json) solves to its roads within a few millimetres (riverbend.test.ts), so it can take the script's place. test-generated-town (a 3 × 2 grid town with an avenue, two districts and a park) is the generator's level: its level spec apps/level-editor/assets/kits/test-generated-town.spec.json is built by assets/blender/levels/generate.py (the grid-town template, town.py), which writes the level's kit, its recipe and its named places (<name>.places.json) into the kits folder, then level:recipe and the export as for any recipe; change the spec or the template, never the scene, and keep its contract in levels.test.ts green. A town's dressing (a grid-town spec's or a region town's) is none by default (lamps, trees, hydrants and traffic lights only, so the flat towns stay byte-identical) or lived-in (assets/blender/levels/dressing.py, on its own random stream, mostly the street kit: kerbside furniture by district theme, bus stops on the avenues, street signs, billboards, back yards, car parks with parked cars that a hard hit shoves loose, a construction site, a playground in a park or an old town's market). test-osm-buildings (a 3 × 2 grid town of six districts: a high street, a farm village, works, homes, a street of shops of every kind, a leisure quarter) is the level of the buildings made from OpenStreetMap's tags, built the same way from its spec: npm run osm:tags writes the tag snapshot (packages/catalogue/src/osm/osm-tags.json, committed, never asked for at runtime), packages/catalogue/src/osm/recipes.json says what each tag becomes, and assets/blender/kits/osm.py builds the six osm-* kits and buildings.generated.json, which the catalogue and cityparts.py read; a district may be given a landuse and its mix may weigh wildcards (shop=*, family:<f>, landuse:<l>); regenerate the kits after changing a recipe, and the towns that name none of it stay byte-identical. test-buildings (a 3 × 3 grid town of six districts, one per satire theme, every building of the downtown and civic kits standing as a district's landmark, their anchors as places, a garage in the body shop, locked cars on the car rental's lot and a bank one walks into, its upper floors a part of the civic kit that fades over a player inside, its vault behind a gate the generator puts there as a level door) is the buildings' level, built the same way from its spec apps/level-editor/assets/kits/test-buildings.spec.json; the themes are packages/catalogue/src/themes.json, which the generator reads, and a building's tags, look and anchors come from its kit's sidecar (assets/blender/kits/civic.py, downtown.py). test-compound (a 3 × 2 grid town of 110 m blocks round Zenful Campus, a walled compound: gates locked by keycards, a gap in the west wall, an annex with fire stairs over the back wall, a lab one walks into, four cameras, guards on shifts, breaks and rounds by the room clock) is the walled compounds' level, the proof that a creator's spec can ask for a compound: it is generated from its level spec apps/level-editor/assets/kits/test-compound.spec.json by the grid-town template's compounds (assets/blender/levels/compounds.py, the compound kit), and its bundled game is a test game, "Zenful Campus" (packages/missions/content/test-compound.json: the night's clock, 21:40 at half pace, a loadout of the tranquilizer and the pistol, a few places, no missions), so the lab's sliding door stays locked by a keycard, which the lab's guard carries; the server's test of the guards' shifts is apps/server/src/compound.integration.test.ts; change the spec, the template or the kits, never the scene, and keep its contract packages/world/src/levels-compound.test.ts green. test-landmarks (a 2 × 2 town of 120 m blocks, the five walk-in landmarks of the landmarks kit as its districts' landmarks: a mega-mall, a general hospital, a mensa, a castle, a ski lodge) is the landmarks' level, built the same way from its spec apps/level-editor/assets/kits/test-landmarks.spec.json, its bundled game (Landmarks) in packages/missions/content/ held to the generated places by content.test.ts; a landmark (assets/blender/kits/landmarks.py on the interiors library interiors.py) is a building and one part per storey above its ground floor (each holding its floor, its walls and the lintels under it, never a ceiling), its stairs, escalators, lifts and ladders blocks its anchors carry, and every place of every landmark stays reachable on its floor (its contract in levels.test.ts). test-tiles-terrain (a 10 × 10 region of 64 m tiles: a hill, a steep little mountain, a plateau, its rim at level 0) is the terrain tiles' level: its region spec apps/level-editor/assets/kits/test-tiles-terrain.spec.json is built without Blender by npm run level:region (@foi/catalogue's solver in packages/catalogue/src/region/, the tiles in tiles.json, written out by apps/tools/src/region-level.ts), which writes the level's kit, its recipe and its places into the kits folder, then level:recipe and the export as for any recipe; change the spec, the solver or tiles.json, never the scene, and keep its contract in levels.test.ts green (see Tiles). test-tiles-roads (a 12 × 10 region: a highway across it on a bridge over a gorge, a country road north from the spawns across the highway to a hill, a dirt track from the highway up to a peak) is the road tiles' level, built the same way from its spec apps/level-editor/assets/kits/test-tiles-roads.spec.json; its roads carry their class (RoadDef.kind). test-tiles-tunnel (a 16 × 16 alpine region: a highway under a mountain in one long tunnel, a country road through two hills in two short ones, "tunnels": "auto" in its spec) is the tunnels' level (packages/catalogue/src/region/tunnels.ts: the cutting, the roof that rebuilds the hill and fades over the player under it, the portals and the lights), built the same way from its spec apps/level-editor/assets/kits/test-tiles-tunnel.spec.json, its contract packages/world/src/levels-tunnel.test.ts; tunnels are opt-in, so a region that names none is built byte for byte as before. test-tiles-town (a 12 × 12 region: a 4 × 4 town over a steep hill and into a valley, lived-in, a terraced district, a country road from the spawns coming in) is the towns' level and the lived-in dressing's (1,026 pieces, 251 of them from the street kit): a region's towns are grid towns built by the town generator in Blender on the region's land (generate.py --relief, assets/blender/levels/relief.py: streets on the hills between level junctions, buildings on pads, the ground meeting the region's vertex for vertex), so level:region needs Blender for it; its contract is packages/world/src/levels-town.test.ts, and the five flat grid towns (test-generated-town, test-osm-buildings, test-buildings, test-landmarks and test-compound) must stay byte-identical when the town generator changes. A region's lakes and volcanoes come out as block:water placements (the level's water: a sheet of water or lava over a rectangle) and a ski run as painted snow; a volcano's flow breaches its rim on one side and paints lava down that flank. Every lake is dressed with a beach, a jetty with boats and reeds, every volcano with boulders, fumaroles, a lookout on its southern rim and a seismograph's hut (packages/catalogue/src/region/dressing.ts), and a region's start puts the players on a town's first east-west avenue instead of the spawn apron. No bundled level has a lake, a volcano, a ski run or a lighthouse (a hill's lighthouse): a creator's region can; the solver's own tests (packages/catalogue/src/region/*.test.ts) cover the lakes, the volcanoes and the ski runs, and nothing tests a lighthouse yet. test-water (a yard 2.2 m high round a pond 2 m deep, a shelf under its south end with steps up to the yard, a pit of lava) is the water's level (packages/sim/src/water.ts, sinking.ts: wading, swimming, a car flooding, lava), built out of blocks from its recipe as test-vertical is. test-fire (a yard with a row of four sedans side by side, a box truck, a bus and a lone sedan parked by the level's vehicle spawners, a low wall behind them) is the fire's level (packages/sim/src/fire.ts: cars catching fire, burning and blowing up, a blast lighting the cars beside it), built out of blocks from its recipe the same way; its e2e server runs without pedestrians. test-weather (an avenue to brake on, a lit street of five street lamps and a dark one at a crossing, a guard looking down each, a bundled game that skips three hours and brings the rain) is the level of the hour and the weather (docs/guide/weather.md: @foi/shared's weather.ts, the grip in packages/sim/src/weather.ts, the sight in @foi/ai's visibility.ts, the picture in the client's weather-view.ts and night-view.ts), built out of blocks and downtown kit pieces from its recipe; every e2e server runs with the weather off (FOI_WEATHER=off: always clear, the dark hiding nobody), its specs setting it from the View tab. A level spec's climate and clock (every template) become the exported level's sky (apps/tools/src/level-sky.ts), never its geometry. test-plots (a 10 × 10 meadow region a level up, details: rich, every landuse kind, a point of interest of each kind) is the rich ground's level (packages/catalogue/src/region/ground.ts, plots.ts, landuse.ts; the nature kit, assets/blender/kits/nature.py), built like test-tiles-terrain from its spec, its bundled game naming its places; a region's details: rich paints its ground in the biome's tones and dresses every tile's plot, its points of interest (a pond dug into land a level up, a ruin, a camp, a viewpoint, a wreck) named, and its landuse areas (fields, an orchard, a vineyard, a wood, a cemetery, a campsite…) are painted and dressed in any region; what is over a level's budget is trimmed when the region is written, the smallest details first; plain regions stay byte-identical. main-riverbend is the level that ships and the server's default, the flagship city, the level of "Smart City Pilot": a city drawn freely round a river's loop, nothing of it on a grid of the map's axes (an Old Town of lanes round a market square on the peninsula, a downtown grid turned 17° and cut by a boulevard, three roundabouts, a harbour on a basin, a garden suburb of crescents and closes, the Heights' streets either side of a hill with a serpentine up to a lookout, a parkway round it all, seven bridges; thirteen districts; 8,007 placements, 943,898 placed triangles and 86 roads, its server about 20 s to boot), generated in Blender by assets/blender/levels/riverbend.py on network.py (streets drawn as polylines and cut onto the roads they meet: loops, roundabouts, culs-de-sac, its own checks of what would break the ground) and citygen.py (one conforming ground with the river's channel in it), built by cityparts.frontage in walks (the landmarks first, the big before the small, an infill, the outbuildings behind, no kind twice too often in a district) and dressed by citylife.py (the pavements' furniture by district, bus stops, car parks, building sites, container yards, cars on drives, yards, jetties, boats and reeds, roadworks, billboards, the roads' paint, and the urban kit's pieces: what the roofs carry on the spots the kit library says are free, barges, tugs, fishing boats and buoys on the harbour's reach, cranes on its quay, a statue, a carousel and carts on the squares), which writes the level's kit and main-riverbend.recipe.json into the kits folder (then level:recipe and the export, about three minutes); the whole city is lifted 4 m as its recipe is written (a level's ground is never under 0), so its river is water under the whole level, its bed its bottom; its bundled game (four solo missions: a rental across town, a race once round the parkway, a consultant escorted to the stadium, the market's fountain photographed; and two played in runs of their own: a co-op heist of the neobank's vault, and robbers and chasers between that vault and The Docks) in packages/missions/content/, its places held to the generated ones by content.test.ts; change the generator, never the scene, and keep its contract packages/world/src/levels-riverbend.test.ts green. test-curves is the first hillside's curves, kept for driving feel on what a region's roads cannot make: a city on a grid of kerbed blocks (avenues crossing at a roundabout, a crescent around a garden, a park with a pond and a sinuous drive, a plaza, a car park with a deck, a ring road with sweeping corners and a wavy boulevard along the hills, buildings of the downtown kit along every street) under a range of hills (the main avenue runs under the central hill through a tunnel whose roof fades, a serpentine of hairpins climbs the east hill to a lookout, a bumpy dirt rally track crosses the western hills, the north road bridges a gully), generated in Blender by assets/blender/levels/curves.py on top of citygen.py (roads with height profiles, rounded junction corners, one conforming ground mesh in which streets, kerbs, pavements, cuttings and abutments never overlap, footprints that never touch), which writes the level's kit and test-curves.recipe.json into apps/level-editor/assets/kits/ (then level:recipe and the export); it has no game; change the generator, not the scene, and keep its contract in levels.test.ts green.
  • The editor scene is left-handed, the game right-handed, both in metres: the mirror (x and the yaw change sign) lives in apps/tools/src/frame.ts and nowhere else.
  • Directions read like a map of the screen: north is +z (up), east −x (the screen's right), south −z, west +x (the left), and degrees shown to people are compass bearings (0 north, 90 east: compassBearing in @foi/shared); the code keeps the yaw atan2(dx, dz) (+π/2 faces +x, west). Every name, text, test and guide says east for −x (the Directions section of docs/guide/levels.md).
  • apps/level-editor/src is type-checked (experimentalDecorators) but never run by Node; the editor runs those scripts.
  • A new kind of block means: a block script, both directions in pieces.ts (with their BlockError cases and tests, and its unit mesh in BLOCK_MESHES), the editor mesh in block-library.ts, a row in the skill file (.claude/skills/five-on-it-levels/SKILL.md, copied to apps/level-editor/.claude/skills/), the guide, and a row in the e2e plan when it changes what the player sees. A new kit piece means: the piece in its kit's script (assets/blender/kits/<kit>.py, a Builder of flat-coloured faces with its collider and kind), the kit regenerated through Blender and committed with its sidecar and npm run kits:export's kit library (packages/catalogue/src/kits.generated.json, the pieces' footprints, tags and anchors the catalogue shows, and for a building the free spots on its flat roof, apps/tools/src/roof-spots.ts; level:check fails when it drifts), and the pieces placed by recipe.

Documentation and tests (keep them in sync with the code) ​

  • Every exported symbol carries a TSDoc comment and every source file starts with a @packageDocumentation header; npm run docs:build (typedoc with notDocumented validation) fails otherwise. Update the comment when you change behavior.
  • Each folder with non-trivial logic has a README.md next to the code. VitePress serves the whole repo (srcDir: '.', config in .vitepress/config.ts); hand-written guides live in docs/guide, the generated API reference in docs/api. Add new READMEs to the sidebar in .vitepress/config.ts.
  • Tests live next to their sources: *.test.ts are unit tests (one world at a time, no services), *.integration.test.ts wire several components in one process (server + bots over the memory transport, the determinism gate). Browser end-to-end tests are Playwright specs in tests/e2e. See tests/README.md.

Quality gates are mandatory: run npm run validate before declaring work done. It runs Prettier, ESLint, tsc, knip, cspell, the LOC counter (build/loc.mjs: lines of product code and of tests, per folder, with no budget; it rewrites the README badges in badges/. State every estimate in these lines, as the milestones in documents/TECHNICAL_SPECS.md §11 do, so scope creep shows), the e2e test-plan gate, the env gate (env:check: a new environment variable goes into its example file, .env.example, deploy/.env.example or deploy/fleet/.env.fleet.example, with a usage comment in the same change; the example files are checked against the code by npm run env:check), the level gate (level:check), the model gate (models:check: the chassis hulls in packages/sim/src/vehicle-hulls.json are what the vehicle models give), vitest with coverage thresholds (90% on sim and protocol, 70% elsewhere), the builds and the docs. Add new words to cspell.json rather than disabling the check; do not add dependencies before they are used (knip fails otherwise).

End-to-end test plan (update it on every product iteration) ​

tests/e2e/TEST-PLAN.md is the source of truth for feature coverage: one row per meaningful user-facing behavior, linked by an @E2E-nn tag to the Playwright test that proves it. npm run e2e-plan (in validate) fails when the plan and tests/e2e/specs drift, so treat the plan as part of the product:

  • Add a user-facing behavior (a key, a HUD element, a netcode behavior, a level feature) → add a row with the next free id and a tagged test in the same change.
  • Change a behavior → update the row's wording and the test.
  • Remove a behavior → delete its row and its test.
  • Cannot test it yet → add the row with status planned or blocked: <why>; move it to implemented when the test lands.
  • A new mechanic (a block, GDD §5) → its own mini test level test-<mechanic> that exercises it and nothing else (small, fixed spawns, fast to boot and to draw), its e2e folder and tagged tests. The mini levels are the QA and regression baseline; the planned list is in documents/TECHNICAL_SPECS.md §10.2.

Tiers and tags: every test carries exactly one tier (@smoke: the Electron app boots; @core: the gate, each mechanic's fast tests in the browser renderer against a real server, run on every pull request, 20 seconds at most each on the self-hosted runner, every test-* level keeping at least one; @long: the rest in the browser, the missions played through, the galleries, the tours of generated levels, run every night and before a release is published), at least one area tag (@app, @drive, @net, @ai, @platform) and its plan id. Shared steps go in tests/e2e/support, never in a spec. The specs observe the game through window.__poc(); extend that hook rather than scraping the canvas.

Layout: one folder per level, tests/e2e/specs/levels/<level>/<area>/*.spec.ts, plus specs/smoke/ (the Electron app) and specs/levels.spec.ts (every exported level boots). playwright.config.ts starts one game server per level folder (ports 7900 and up, see tests/e2e/support/levels.ts) and openGame connects a spec to the server of its folder; a spec that needs another level starts its own with startGameServer on a port from 7800 to 7899. The plan's Spec column names the file that holds the tag; the gate checks that, that every folder is an exported level and that every test-* level has a folder with a tagged test.

Frame rate: without a GPU, Playwright's headless Chromium draws in software, at 14 to 45 fps on four cores by level, so a spec that waits a fixed time for a frame fails there and not on a fast machine. CI runs a tier in lanes (.github/workflows/e2e.yml): the route plans the machines (build/e2e-route.mjs: the self-hosted runner E2E_RUNNER names, rtx-4060, five lanes, and two of GitHub's GPU runners gpu-t4, three lanes each, both drawing on their GPU; GitHub's ubuntu-4-core in software only when neither is there), and every job packs the run the same way by the tests' recorded seconds (tests/e2e/support/lanes.ts, tests/e2e/timings.json, rewritten from a run's reports by npm run e2e:timings) and runs its machine's lanes side by side (npm run test:e2e:lanes, each lane on ports of its own, against a build of the web client); the core tier must pack into 12 minutes in the self-hosted runner's lanes alone, the plan gate checks (see docs/guide/quality-gates.md). Locally, E2E_ONLY=<folder or file under specs> npm run test:e2e runs part of the suite with its levels' servers only. Before calling a CI failure a logic bug, check __poc().render and the frame rate.

Working on driving feel ​

The current milestone is in docs/guide/roadmap.md; driving feel stays the heart of the game (GDD pillar 3). The loop: change a number in vehicle-classes.ts (or the model in vehicle.ts), run npm run test:unit -- packages/sim to see whether the envelope still holds, then npm run dev:web and drive. V cycles the twenty-three classes (six cars, three trucks including the semi with its trailer, three two-wheelers, the police car, then the taxi, the bus, the ambulance, the fire truck and the garbage truck, then the pickup, the limo, the delivery scooter, the ice-cream van and the tractor, whose liveries, light bars, sirens (G), roof sign and ladder are a class's livery and features), which differ in drivetrain, engine placement and axles; the model behind them is in docs/guide/driving-model.md. Use L to add latency and P to switch prediction off to judge the netcode separately from the physics. The legend's Go to (View tab, on a server whose debug commands are on, as under npm run dev) puts the car on any road of the level; on FOI_LEVEL=test-curves, the tunnel, the serpentine, the rally track. The HUD's grip, slip and SPIN readouts show what the drift and wheelspin models are doing.

Working on AI ​

The chasers are tested on test-ai-pathfinding (FOI_LEVEL=test-ai-pathfinding; npm run dev runs main-riverbend with the debug channel on): change a number in packages/ai/src/profiles.ts (or the model in chaser.ts, steer-foot.ts, steer-vehicle.ts), run npm run test:unit -- packages/ai and npm run test:integration -- packages/ai (the chases on the AI level, the determinism and budget gates), then npm run dev:web, get out with E, pick a style by clicking its chip on the legend's Chasers panel (its pages: the style with an explainer, the car tactics with the crew and the car's class, the player's infinite health; untick armed for one that never shoots) and spawn one behind you, on foot or in a car on the road behind; enemies are drawn orange, the HUD's chasers line and the minimap's coloured dots show what they do. The ambient traffic (traffic.ts, traffic-drive.ts, traffic-route.ts; its numbers in TRAFFIC, DRIVE and ROUTE) is drivers of the same kind on the road graph's lanes, obeying the traffic lights (@foi/world's signals.ts: a fixed plan by tick, no state on the wire) and giving way where there are none; FOI_TRAFFIC=<n> sets how many (the game's rules.traffic by default, 40 on main-riverbend), and it is watched on FOI_LEVEL=test-traffic-junction. Cars hurt people on foot in packages/sim (car-hits.ts, after the physics step: a step aside from a slow car, a hit by the closing speed that throws the person off the front, death above 35 km/h for a pedestrian, a body a car catches sent over it, one lying in the road driven over): the AI only sets the tumble time. FOI_CHASERS_FOOT=<n> pre-spawns chasers, FOI_CHASER_CREW fills their cars; FOI_DEBUG=1 turns the debug channel on elsewhere than a test-* level or npm run dev. The AI fixture is frozen like the core one: a new scenario is new geometry on the AI level only if the contract test (packages/world/src/levels.test.ts) and the AI integration tests stay green, else a new test-* scene.