---
title: One Game Library, Imported by Both the Client and the Server
description: A mobile multiplayer browser game where client and server linked the same TypeScript module. One source of truth, one fewer class of bug.
date: 2026-05-07
period: 'Autumn-Winter 2020'
thumbnail:
  src: ./_assets/decla-red.jpg
  alt: The decla.red browser game interface showing a space scene.
links:
  - label: Source
    url: https://git.schmelczer.dev/andras/decla.red
  - label: BSc thesis
    url: /media/downloads/sdf2d-andras-schmelczer.pdf
    download: true
article:
  tags: ['games', 'web', 'systems']
  stack: ['TypeScript', 'Node.js', 'WebSockets', 'WebGL', 'SDF-2D']
  scale: Game servers ticking at 200 Hz, each serving 16–32 clients at 25 updates a second, browser and mobile
  outcome: A multiplayer browser game that proved SDF-2D survived a real game loop
  media:
    - type: image
      src: ./_assets/decla-red.jpg
      alt: The decla.red browser game interface showing a space scene with team controls and planets.
      caption: Two teams, small planets, real gravity. The renderer underneath is the SDF-2D library from my thesis.
project:
  title: decla.red
  selected: true
  technologies: ['TypeScript', 'Node.js', 'WebSockets', 'WebGL']
  thumbnail:
    src: ./_assets/declared.jpg
---

My BSc thesis was a renderer; decla.red was the proof it could survive contact with a real game. Through autumn 2020 I built a conquest-style space shooter on top of [SDF-2D](/articles/sdf-2d-ray-tracing/): two teams, small planets with actual gravity, ray-traced 2D rendering, playable in a browser on a phone. The decision I still think about, though, came from somewhere less glamorous: needing the client and the server to stop 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 object model lives in a single `shared` package that both `package.json` files pull in with `file:../shared`. Every entity exists in three layers: a `Base` class in the shared package (the wire shape and the rules both sides must agree on), a `Physical` subclass only the server instantiates (forces, collisions, health), and a `View` subclass only the client does (meshes, lights). The server simulates at 200 Hz. Clients hear from it 25 times a second, and each update carries not just values but their rates of change, so the client-side extrapolators can keep planets and projectiles moving believably between packets instead of teleporting them on arrival.

The serialisation format is the part that makes me smile now: an object crosses the socket as `[className, ...fields]`, numbers rounded to three decimals. The class name _is_ the protocol, which means the minifier has to be told, in the webpack config, never to rename game classes. It taught me early that "works in dev, breaks in prod" usually means a build tool was being clever somewhere out of sight.

## Other choices worth a sentence

- **A k-d tree over bounding boxes.** Once the world held more than a few dozen objects, naive collision and proximity checks dominated the server tick. The tree dropped them out of the profile.
- **Message passing with a default handler.** Entities extend a `CommandReceiver` that dispatches commands by type and routes anything unhandled to a default executor: Smalltalk's `doesNotUnderstand:`, reborn in TypeScript. Extending the game meant adding a command, not reorganising an inheritance tree.
- **Bots that perceive at 30 Hz and plan at 1 Hz.** Letting NPCs re-run their spatial queries on every 200 Hz physics tick was the server's dominant cost. A comment in `npc.ts` argues that a few dozen refreshes a second is plenty for dodging a projectile, and it's right.
- **Interest management with hysteresis.** Each player only hears about objects near their view, with the enter threshold (1.2× the viewport) deliberately wider than the exit one (0.9×), so objects sitting on the boundary don't flicker in and out of existence.
- **Server discovery, eventually boring.** The join screen originally asked Firebase Remote Config which servers existed. Today it's a hardcoded list polled over HTTP, which is what it should have been from the start.

## 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. Separate top-level homes from day one next time.
- **Skip multi-server until the maths demands it.** I wired up multiple game servers early because it sounded like the serious thing to do. At 16–32 players per server I was nowhere near needing it, and the complexity wasn't free.
