Getting started
Prerequisites
- Node 24 or newer (the server and the tooling run TypeScript directly through Node's type stripping).
- npm 11 or newer. npm 11 gates install scripts behind
allowScriptsinpackage.json; the entries for Electron and esbuild are already there. - Git.
- To edit levels, the Babylon.js Editor 5.5.2: a desktop app, not an npm package. Download the Windows installer or the build for another platform;
npm installprints this reminder while the editor is missing, andnpm run level:editoropens the project (see Levels).
Docker is only needed for the security scans (npm run sast without a local Semgrep, npm run dast) and the Compose stack (Deploying).
Install and run
npm install
npm run devnpm run dev starts the authoritative game server on port 7777 and the Electron client, which connects to ws://localhost:7777. To run the renderer in a browser instead (faster iteration, DevTools, several windows side by side):
npm run dev:web # the game server on :7777 and Vite on http://localhost:5173then open http://localhost:5173. Both open on the main menu (every text in the game is set in Changa): a rented sedan burns in a car park while you pick the city that ships on Play online (the game server opens a room for it: /game?level=main-riverbend; the desktop app plays it on a platform's server, and adds Play local: host a city on its own server for friends on your network, or join theirs), the community's games, the galleries, the settings (volumes, controls, the HUD; kept in the browser) or the controls. ?play (or ?server=…) goes straight into the game. In the game, Esc opens the pause menu: alone in the room the world waits for you; with others it goes on. The keys are in Controls.
Every window is a player: open a second one to see the second car. The server URL can be overridden with ?server=ws://host:port in the browser (or the legend's Server tab), or FOI_SERVER_URL for Electron.
Only the server:
npm run -w apps/server dev # restarts on change
PORT=7800 npm run -w apps/server start
FOI_LEVEL=test-core-gameplay npm run -w apps/server start # a bundled level by name, or a .json level filehttp://localhost:7777/health reports the level, the tick, the number of clients, the average and maximum step time and the bytes sent. The switches that shape a room (FOI_LEVEL, FOI_TRAFFIC, FOI_PEDESTRIANS, FOI_POLICE, the chasers', FOI_DEBUG, FOI_GAME) and what to try on every level are in Testing by hand. With no FOI_LEVEL the server runs main-riverbend, the flagship city on its river with the four missions of "Smart City Pilot"; a few others worth a drive: FOI_LEVEL=test-compound (a town round Zenful Campus, a walled wellness campus whose guards keep shifts, breaks and rounds by the clock), FOI_LEVEL=test-curves (the first hillside's curves, a roundabout, a tunnel and a serpentine), FOI_LEVEL=test-ai-pathfinding (the AI's road loop) and FOI_LEVEL=test-core-gameplay (the test city). See Levels.
In GitHub Codespaces
Open the repository in a codespace (Code → Codespaces → Create codespace); .devcontainer/devcontainer.json sets up Node 24 and runs npm install. Then, in the codespace terminal:
npm run dev:webPort 5173 opens in a browser tab. Only that port needs forwarding, and it can stay private: the Vite dev server proxies WebSocket connections on /game to the game server on 7777, and the page connects there on its own origin by default. Electron does not run in a codespace, so use dev:web. To let someone else join, set port 5173's visibility to Public in the Ports panel and share its URL. To point the page at another server, add ?server=wss://… to the URL or use the Server field of the legend panel.
Playing a released build
Download the installer for your platform from the latest release and start it: it opens on the main menu, with two ways to play.
- Play online plays the city that ships on a platform's game server, with players anywhere, and its community's games. A release built with the platform's address (the repository variable
FOI_PLATFORM_URL) knows it; otherwise type the platform's address on that screen (a site's,https://play.example.com; its/apiis asked first). The menu asks the platform its version first and greys the city out when the two cannot play together. - Play local plays on this computer: Host starts the app's own game server on the city picked (the bundled
server.mjs, a utility process on port 7777,FOI_PORT) and plays it, and the pause menu says where friends on the same network join (this machine's address and the port; Windows asks once to let the app through its firewall: allow private networks); Join plays on a friend's at the address they read out (192.168.1.23:7777). The app runs no server until you host, and keeps it running until you stop hosting or quit. A server you run elsewhere (the server bundle,node server.mjs,FOI_LEVELpicking its level) is joined the same way.
FOI_SERVER_URL goes straight into a game on that server from the command line. A server of another version says both versions instead of playing. Builds are unsigned for now, so Windows and macOS warn before the first start. How a release is cut is in Releasing; running the whole platform in Deploying.
The camera and the settings
The camera looks down on the player from 30 m, 18° off vertical, a little ahead along the way the car goes, and on foot it leans toward the cursor. Its settings are on the Settings screen of the main menu and of the pause menu (Esc), kept in this browser (apps/client/src/ui/settings.ts):
| Setting | Range | Default | What it does |
|---|---|---|---|
| Look-ahead toward the cursor | ×0–×2 | ×1 | How far the view leans toward the cursor on foot |
| Camera height | ×0.7–×1.5 | ×1 | Scales every height below (30 m at standstill) |
| Zoom out at speed | on, off | on | In a car the camera rises with its speed to show more road ahead; off, it stays at its standstill height whatever the speed |
| Zoom-out strength | +0 %–+100 % | +75 % | How much farther out at full speed (130 km/h and over): the default goes from 30 m to 52.5 m, +100 % twice as high; nothing changes under a crawl (11 km/h) |
| Zoom-out response | ×0.5–×2 | ×1 | How quickly the zoom follows the speed: at ×1 it is half out half a second after a jump in speed, and half back in a second and a half after a dead stop |
The zoom follows the car's speed over the ground (a drift's and a handbrake turn's too), filtered: quicker out than back in, so a brief brake, a bump or a correction of the prediction does not pump the view, eased in and out of its range (a smoothstep, not a straight line), the same at any frame rate. Turned off or on mid-drive it eases there, and getting out of a fast car it eases back in on foot, where the view rises a little with the pace as it always has (2.4 m walking, 6 m sprinting). The sliders under the switch are dimmed while it is off. A camera zoomed out sees more of the city, and draws more: what is drawn is culled to the view the camera has on each frame (apps/client/src/renderer/view-rect.ts), so nothing pops in at the edges, and at the defaults the view at full speed is about the size it always was. The math is apps/client/src/renderer/camera.ts (SpeedZoom), held by camera.test.ts; E2E-560 to E2E-563 check it in the browser.
Next
Controls lists every key and what is on the screen; What the game has is the tour of the game; Architecture explains where the pieces live; Driving model is where the tuning happens; Levels is how the city is built.