One Game Library, Imported by Both the Client and the Server
A mobile multiplayer browser game where client and server linked the same TypeScript module. One source of truth, one fewer class of bug.
2026 update: I recently revived the game: modernised the toolchain, made it more fun, rebranded it to doppler, and rewrote the netcode. It’s playable at doppler.schmelczer.dev1. The section near the end covers what changed; everything before it describes the 2020 original.
My BSc thesis was a renderer; decla.red was the proof it could survive contact with a real game. In autumn 2020, I built a conquest-style space shooter on top of SDF-2D2: two teams fight over tiny planets with actual gravity, everything is ray-traced, and it all runs in a phone’s browser. The teams are called decla and red, which is how the game got its domain. The decision I still think about, though, came from a less glamorous need: stopping the client and server from disagreeing about what the game even was.
One package, both sides of the wire
Real-time multiplayer hands you an awkward two-machine problem. The server has to be authoritative or the game is cheatable; the client has to feel immediate or the game is unplayable. Write the rules twice, once per side, and they drift: slowly at first, then visibly, with a player’s screen saying one thing while the server believes another.
So the whole object model lives in a single shared package that both sides’ package.json files pull in with file:../shared. Every entity exists in three layers. A PlanetBase in the shared package holds the wire shape and the rules both sides must agree on. The server subclasses it into a PlanetPhysical, adding the collision outline, gravity, and ownership; the client subclasses it into a PlanetView, adding the drawable and the ownership ring. Characters, projectiles, and lamps follow the same pattern. The server simulates at 200 Hz, each of its instances hosting 16 players by default, with bots filling the seats humans haven’t taken, and sends every client a snapshot 25 times a second.
The class name is the protocol
The snapshots go through a serialiser that is ten lines long, and I’m still fond of it:
export const serialize = (object: any): string => {
return JSON.stringify(object, (_, value) => {
if (value && value[mangledTypeKey]) {
const props = value.toArray() as Array<any>;
props.unshift(value[mangledTypeKey]);
return props;
}
return value?.toFixed ? Number(value.toFixed(3)) : value;
});
};
An object crosses the socket as [className, ...fields], with every number rounded to three decimals on the way out. Deserialisation is the mirror image: a JSON.parse reviver looks up the first element in a registry and calls the matching constructor with the rest. And since JSON.parse revives from the inside out, nested objects are already live instances by the time the outer constructor runs.
The registry is what lets the three class layers cooperate, and decorators wire it up:
// on the server
@serializesTo(PlanetBase)
export class PlanetPhysical extends PlanetBase { ... }
// on the client
overrideDeserialization(PlanetBase, PlanetView);
A PlanetPhysical leaves the server labelled PlanetBase and materialises on the client as a PlanetView. Neither program ever learns the other’s class names; the only contract is the base class’s constructor signature.
Keying a protocol on class names has one famous enemy: the minifier. The frontend and the backend are two separately minified bundles, so PlanetBase gets mangled into two different single letters, the registry lookup misses, and every packet quietly deserialises into a plain array. The fix took two commits in October 2020: the first switched the minifier off altogether, and the next day’s3 turned it back on with a webpack option telling it never to rename classes. It taught me early that “works in dev, breaks in prod” usually means a build tool was being clever somewhere out of sight.
messageNotUnderstood
Both directions of the protocol, and most calls inside each program, are batches of Command objects. A command’s type is its class name (the minifier constraint, again), and every actor extends a CommandReceiver: a lookup table from command type to handler, plus a defaultCommandExecutor for everything else. I borrowed the idea from Smalltalk4’s message passing, doesNotUnderstand: included.
The default handler is where it gets fun. Containers default to broadcasting: the server’s physics container forwards any command it doesn’t recognise to every object it holds, which is how one StepCommand becomes the whole world stepping. The client’s socket wrapper defaults to queueing the command for the server. Nothing on the client handles a MoveActionCommand, so when the keyboard listener emits one, it falls through the default handlers and lands on the wire, addressed to the one machine that does understand it. Extending the game meant adding a command and a handler, not reorganising an inheritance tree.
Sphere tracing on the server
SDF-2D never sees a triangle: every shape is a signed distance function. The server has no pixels, but it kept the fields. A moving circle is collision-checked by sphere tracing, the same algorithm the renderer uses to march light rays: advance by the field’s value, which is by definition a safe step, until the distance is covered or the field dips below the circle’s radius. On a hit, the surface normal comes from sampling the field 0.01 units either side of the hit along each axis, and both parties receive a ReactToCollisionCommand: the projectile learns it should bounce, the character learns it should bleed. Rendering, lighting, and collision speak the same geometric language, so each shape in the game is defined exactly once.
The shapes themselves are modest: planets are seven-sided polygons with jittered vertices, and characters are three circles, a head and two feet, which the character shader melts into a single creature. Gravity is per-planet and hand-shaped rather than physical: the pull is 5000 * ((800 / d) ** 1.5 - 1), clamped to 50,000, where d is the distance beyond the planet’s mean radius. It’s exactly zero beyond 800 units, so most of space stays calm, and it saturates up close, so orbiting near a surface feels sticky instead of chaotic.
A k-d tree with four dimensions
Once the world held more than a few dozen objects, the question “what’s near this circle?” dominated the server tick. Static objects therefore live in a k-d tree5 with a trick I remain pleased with: each axis-aligned bounding box is treated as a point in four-dimensional space (its two x-bounds and two y-bounds), and the tree cycles through those four coordinates level by level, discarding provably non-overlapping subtrees along the way. Dynamic objects, the dozens of characters and projectiles, stay in a plain array that gets filtered linearly; at that count, a tree would cost more to maintain than it saves. The static tree is built once at world generation and never rebalanced, which is fine, because planets don’t move.
Bandwidth on a diet
Each player only hears about objects near their view. The view box is computed from a constant area budget, four 1080p screens’ worth of world, shaped by the client’s reported aspect ratio and oversized by 20%: a portrait phone and an ultrawide monitor see the same amount of world, cut differently. Every 40 ms, the server diffs the box’s contents against the previous cycle. Newcomers arrive as fully serialised objects, leavers as a list of ids to delete. Players beyond a slightly smaller box get special treatment: the client receives only a normalised direction vector for each, enough to draw an arrow at the edge of the screen, not enough to reveal where anyone actually is.
Within the box, every property update carries not just a value but also its rate of change. Between snapshots, the client advances each property along its streamed derivative, and when the next snapshot disagrees with the guess, the error is not corrected with a jump: it’s folded in as an extra velocity spread over the following 33 ms, bending the object back onto the true path. Only a disagreement of more than 200 units, a respawn or a teleport, snaps. In 2020, this was the whole story. Your own character was extrapolated like everything else, so every input took a round trip before it moved you. The shared package made real prediction possible; cashing that cheque took another six years.
Other choices worth a sentence
- Bots with 1 Hz brains. NPCs re-plan once a second, consider shooting twice a second with a coin flip and up to 200 units of deliberate aim error, and re-roll their wanderlust every three seconds. A bot that strays outside the world turns back and doesn’t resume hunting until it’s well inside again, so it never dithers at the boundary. The roster of 48 names includes Sisyphus, who keeps trying.
- Reinforcements, not respawns. A player’s new character spawns next to a living teammate when one exists (bots take their chances at a random spot), which quietly turns every respawn into help arriving.
- A slow-motion ending. When a team reaches the score limit, the server divides the timestep by an exponentially growing factor, so simulated time halves every real second and the final shots sail in slow motion. Then it regenerates the world and everyone rejoins.
- Server discovery via Firebase Remote Config. The list of live servers was a JSON string in Firebase Remote Config6, so adding a server was an edit in a web console, no redeploy. The join screen fetched the list, then asked each origin’s
/stateendpoint for its name and player count, retrying every eight seconds and silently dropping any that didn’t answer; once a server was listed, its live count arrived over the socket. Today it’s a hardcoded list, which is what it should have been from the start.
The 2026 revival
Six years later, I dusted the game off, and the branch (“Modernise & make fun”, says the merge commit) became a study in what changed about how I write multiplayer code.
The extrapolators are gone. The character-movement code moved from the server into the shared package, so the client now runs the exact stepCharacterMovement the server does. Every input is stamped with an integer millisecond timestamp (integers survive the serialiser’s rounding; there’s a test for that now). The server acknowledges how much of your input each snapshot reflects, and the client replays the unacknowledged remainder on top of the authoritative pose, at the server’s exact 200 Hz. Corrections ease in over 60 ms; anything beyond 250 units is treated not as prediction error but as a respawn or teleport, and snaps. Everything that isn’t you renders 100 ms in the past, interpolating between buffered snapshots and coasting on the streamed rates of change only when the buffer runs dry.
Sharing the simulation raised a requirement 2020-me never faced: determinism. Floating-point addition isn’t associative, so the character’s centre is computed as ((head + leftFoot) + rightFoot) / 3 on both sides, with a comment forbidding reassociation, and a test steps the shared simulation through 300 ticks of scripted input and compares the result against a pinned reference pose. The serialiser got its tests first, on the grounds that it’s the mechanism with the highest blast radius. And the wire’s remote method calls now pass through an allowlist, because dispatching function calls by a raw string off the network is the kind of idea 2020-me found elegant and 2026-me finds alarming.
What I’d change
- Observability for desync. Multiplayer systems live or die by visibility into divergence. I had logs; I needed to see the rate, the shape, and the triggering interaction for every extrapolation miss. Without that, debugging was guessing.
- Untangle rendering from networking. Both were interesting, both pushed on the architecture in different directions, and their directories slowly grew into each other. Give them separate top-level homes from day one next time.
- Skip multi-server until the maths demands it. I wrote Helm charts in the project’s first week because it sounded like the serious thing to do. There wasn’t even a game server yet, and the one that arrived three months later held 16 players; I was nowhere near needing them, and the complexity wasn’t free.
- https://doppler.schmelczer.dev/↩
- SDF-2D: https://schmelczer.dev/articles/sdf-2d-ray-tracing/↩
- the next day’s: https://home.schmelczer.dev/git/andras/decla-red/commit/d34f25295c2b73c28fc44e9223ef00b8c78d508e↩
- Smalltalk: https://en.wikipedia.org/wiki/Smalltalk↩
- k-d tree: https://en.wikipedia.org/wiki/K-d_tree↩
- Firebase Remote Config: https://firebase.google.com/docs/remote-config↩