NEON CITYmaking of
02 / 07

Architecture

One simulation, two places to run it

The same deterministic simulation runs the game on the server and, ahead of time, in the browser. The server has the final word, the client predicts, and in solo mode the server runs locally inside the tab.

2.1 Components

The client is a three.js application: it renders the city, the HUD and the weapon, collects player input and predicts the outcome of the player’s own moves. It does not decide hits, damage or what the horde does next – that is the simulation’s job.

The simulation (the sim package) is deterministic: the same state and the same inputs give a bit-identical result. That lets one codebase play three roles – on the cloud server, in the browser as prediction, and in solo mode as a LocalServer inside the tab, with no network at all.

The backend runs on Cloudflare: the static client on Pages, the API on Workers, co-op rooms and their checkpoints in Durable Objects, tiles and finished arenas in R2. The protocol package makes sure the client and the server speak the same language.

Component diagram: in the browser the three.js client and LocalServer, shared packages sim and protocol, on Cloudflare Pages, Workers, Durable Objects with co-op rooms and R2 with tiles and arenas; the arena generator writes to R2

2.2 A co-op match with prediction

Sequence diagram of a co-op match: the client sends the protocol version and defsHash, the room answers with welcome or a refusal; then every tick the client predicts, sends input, the server runs the tick and returns a snapshot, and the client reconciles

Before a player joins a room, client and server compare the protocol version and the defsHash – a hash of every gameplay definition (weapons, enemies, trams). A mismatched pair is turned away before the welcome message. A clear error beats a match in which the two sides compute different damage.

During play the client doesn’t wait for the server: it predicts its own movement straight away with the same simulation code. When a snapshot arrives, it compares it with the prediction, rewinds to the confirmed state and replays the inputs the server hasn’t acknowledged yet. Other players and bots are interpolated.

With 150 ms latency and 30 ms jitter, the tram tests measured a 0 m gap between prediction and server, and remote entities within 1 mm.

2.3 Arenas from OpenStreetMap

Every arena is a 600 m circle around the market square of a real city. The generator reads tiles of OpenStreetMap data and writes three files: JSON with the arena description, PRE with precomputed data and GEO with geometry and colliders.

The generator has a version, GEN – a hash of the rules that affect its output. Changing a rule changes GEN, and the files go to a new prefix in R2, so old and new versions never mix. Mutation tests check both directions: a rule change must bump the version, and a change that doesn’t affect output must not.

Generation is deterministic. In the latest delivery 20 of 20 arenas had identical SHA-256 sums for all three files across two independent runs. The largest arena takes 853 KB compressed – below the 1 MB limit.

Arena pipeline diagram: OpenStreetMap, vector tiles, the r600 arena generator, JSON, PRE and GEO files, the GEN version and two runs with identical bytes, stored in R2 under the version prefix, loaded by client and server
Trams in r600 arenas – generator output
Arena (seed 1)Qualifying routesTramsStops on chosen routes
Wrocław, Market Square4633
Łódź, Plac Wolności2435
Kraków, Main Square2034
Warsaw, Old Town430
Skierniewice, Market Square000

2.4 Trams as a function of time

In the game a tram is a large moving object that can push a player away and deal damage. Sending its position in every snapshot would be easy – and expensive. Instead, a tram’s position is a pure function of the arena data, its seed and the tick number. Server and client import the same implementation, so both sides know where every tram is without a single byte on the wire.

The timetable doesn’t depend on players, graphics quality or the wall clock. Snapshots and checkpoints hold no tram state – only the player has state (such as the time until the next hit can land), so after a match resumes the tram is exactly where it should be.

Kraków at night, phone – dwell: the tram waits at the platform, doors still closed
01Dwell
Kraków at night, phone – open: doors open on the platform side
02Open
Kraków at night, phone – close: doors closed after the 8 s dwell
03Close
Kraków at night, phone – depart: the tram pulls away
04Depart

The warning corridor (D5) is a strip of light on the road, drawn from the tram’s future positions over the next 3 s – computed from the same function of time. The first version passed the tests but was invisible from above; how that was caught and fixed is told in the TRAM-1 case study, and the before/after comparison is in section 6.

Contact with a tram: always a push, 5 HP of damage only when the tram moves faster than 0.5 m/s, and at most once per pass. Standing at the platform is safe.

Łódź at night from above: an orange warning corridor glows on the road ahead of the tram
D5 at night – the corridor covers the next 3 s. Map: © OpenStreetMap contributors (ODbL).