WARBIRDS.IO Dev Log

← All posts

Building a multiplayer flight sim where the server and browser run the same physics, byte for byte

How Warbirds.io keeps an authoritative Go server and an untrusted JavaScript client in lockstep — and why that decision made the whole game shippable by one person.

The problem with multiplayer flight

Real-time multiplayer is hard for a well-known reason: the server has to be the authority (or players cheat), but the network has latency (or the game feels like mud). The standard answer is client-side prediction with server reconciliation — the client simulates the local player immediately and quietly corrects itself when the authoritative state arrives.

That works beautifully if the client and server agree on what the simulation does. For a game with a simple movement model — capsule plus velocity — they agree easily. But Warbirds.io flies a JSBSim-style six-degree-of-freedom flight model: a coefficient buildup over nondimensional stability and control derivatives (CLα, Cmq, Clp, Cnβ…), evaluated against a standard-atmosphere density model, integrated with quaternion attitude. Planes genuinely stall, sideslip, weathervane, and bleed energy in hard turns.

You cannot approximate that on the client and hope it lines up. If the two models disagree by even a little, the disagreement compounds every frame, and the plane visibly rubber-bands. So the design constraint became blunt:

The client and the server must run the same flight model — not a similar one, the same one.

The server is Go. The browser is JavaScript. So "the same model" means a line-for-line port, and a way to prove it stays a line-for-line port forever.

Authoritative server, untrusted client

The Go server simulates everything — flight, bullets, damage, scoring — at 60 Hz, and broadcasts 20 Hz snapshots over WebSocket. Clients are pure input devices: they send stick positions and a sequence number, nothing else. A client literally cannot tell the server "I'm at position X"; it can only say "I'm holding the stick here."

This kills an entire category of cheating and keeps the mental model clean: there is exactly one real game, and it lives on the server. Everything the browser shows is either authoritative state it received or a prediction it will reconcile.

Prediction without the pop

The naïve version of prediction has an ugly failure mode: when the correction arrives, the plane jumps. The fix here has two halves.

1. A deterministic control timeline. Inputs are sampled on the physics-tick cadence and run through a small server-side jitter buffer, so every input steers for exactly tickRate / inputRate ticks. Each snapshot echoes the active input's sequence number and how many ticks of it have already run. The client therefore knows precisely where the authoritative sim is in the input stream, and can replay its unacknowledged inputs on top of the authoritative state tick-for-tick to rebuild "now."

2. Bit-identical models. Because the client's flight model is the same math as the server's, replaying the same inputs from the same state produces the same result. The reconciliation residual isn't "close enough" — it's wire quantization, on the order of millimeters. The client has been verified to track the Go model to ~1e-13 m over 6 seconds of free flight.

Whatever tiny residual remains is bled off as decaying position and attitude visual offsets, so corrections smear out over a few frames instead of snapping. The result is that a plane you fly feels instant, and a plane someone else flies arrives smooth.

The terrain you never download

Here's the trick I'm most fond of. The world is a voxel archipelago — a quantized heightfield rendered as Minecraft-style columns. The server never sends the terrain. Instead, the heightfield is generated from a single seed using only operations that are bit-identical between Go and JavaScript: uint32 wrap-around hashing and IEEE-754 float64 arithmetic, carefully avoiding anything where the two languages might round differently.

The server puts the seed in the config the client fetches on join, and the browser regenerates the exact same world — the same columns you see are the same columns the server runs collision against. No terrain streaming, no "loading the map," no desync between what you see and what you hit. It's the determinism dividend: get the math bit-identical and a whole networking problem disappears.

Keeping two languages honest: parity tests as CI gates

A dual-language simulation sounds like a maintenance nightmare, and it would be — except the drift is caught mechanically. Every CI run executes parity fixtures:

go run ./cmd/terrainfixture | node tools/check_terrain_parity.mjs   # Go == JS terrain (bit-exact)
go run ./cmd/fdmfixture     | node tools/check_fdm_parity.mjs       # Go ~= JS flight model

The Go side emits a fixture; the JS side regenerates it and asserts equality. If I touch the physics or the terrain hashing on one side and forget the other, the build fails and calls me a liar. This single discipline is what makes a server/client mirror maintainable by one person. The wire format gets the same treatment — the binary snapshot layout is pinned by a Go round-trip test, so the JS decoder can't silently fall out of step with the encoder.

The flight-model tests pull double duty as a tuning harness: they log top speed, roll rate, max G, and stall behavior, and fail if the numbers leave the fun zone. Physics regressions and balance regressions are the same red X.

The wire: small, culled, self-correcting

Snapshots are binary, ~41 bytes per plane, with interest management: players beyond 3 km and bullets/bombs beyond 2.5 km are omitted from your stream, with a full-roster echo every 10th frame so the minimap stays honest. Control messages (join, round flow, kills) ride a separate JSON channel — JSON where human-readability and flexibility matter, packed binary where bandwidth does.

No asset pipeline, no build step

Two more solo-dev decisions that punch above their weight:

What it all buys you

On top of this foundation sits a genuine game: capture-the-ring rounds that flip into carrier-sinking fleet actions, player-crewed AA guns and battleship turrets that lob plotted ten-second ballistic arcs, torpedo bombers, a day/night cycle with sweeping searchlights, weather that drags fog into knife-fight range, killcams, and persistent pilot medals. Bots fill empty servers by flying the same flight model through the same inputs as humans — they can't do anything a player physically couldn't.

But none of that gameplay would have been reachable for one developer without the boring foundation: one source of truth for the physics, mirrored exactly, proven equal on every commit. Get the determinism right and prediction stops popping, terrain stops needing to be sent, and AI stops being able to cheat — three hard problems collapse into one solved one.