Vallum — Deterministic Multiplayer Game Server on Cloudflare Durable Objects
Summary
Vallum is a browser-based two-player siege game reimplementing the rules of Rampart (Atari, 1990). It runs a server-authoritative simulation at 30 ticks per second inside a Cloudflare Durable Object, streams state to both browsers over a binary WebSocket protocol, and survives losing the object mid-match by replaying the game from a snapshot and an intent log.
Playable at https://vallum.games.
The game is the excuse. The engineering interest is a real-time multiplayer system with a hard correctness requirement — two machines must agree, tick for tick — built on edge compute where the object holding the match can be evicted at any moment.
Context and problem
Real-time multiplayer has one property that most web work does not: being nearly right is indistinguishable from being wrong, and it fails silently. A simulation that consumes a random number in a different order on two machines does not throw — it drifts, and the first symptom is two players seeing different boards several minutes later.
Cloudflare Durable Objects are a good fit for a match (single-threaded, addressable by name, co-located with their storage) and add a second constraint: the object holding a live game can be evicted, so the match has to be reconstructible from what was written down.
The problem, then, was to make "the same match" a property the system enforces rather than one it hopes for.
My role and ownership
Personal project, designed and built end-to-end: the simulation, the network protocol, the server, the client and its renderer, the opponent AI, and the deployment.
Technical implementation
A simulation that is a pure function
The simulation is a separate package with zero dependencies and no access to wall-clock time, Math.random, I/O, or any knowledge that a network exists. Time is counted in ticks; randomness comes from a seed held in state. A match is therefore a pure function of (seed, map, intent log) — storing those three stores the match, and re-running them anywhere reproduces it exactly.
Concretely, that meant:
- A hand-written
sfc32PRNG in pure 32-bit integer arithmetic (|0,>>>0,Math.imulare exactly specified, so every conforming JavaScript engine produces the same stream). Bounded draws are rejection-sampled rather than taken modulo — not for statistical purity, but because modulo bias would be baked into every stored replay, and the algorithm is part of the persistence format. - Integer-only state, which makes serialization byte-exact for free: two states representing the same match compare equal as bytes.
- A 64-bit state hash taken over the serialized snapshot rather than over a hand-written walk of the state's fields. A field-walking hash drifts from the serializer the first time a field is added to one and not the other, and the drift is invisible — a hash that ignores a field still matches itself, forever, on every machine. Hashing the bytes makes "the hash changed" and "the snapshot changed" the same statement by construction.
- Golden vectors: full matches replayed tick by tick against committed hash chains, so any change to the simulation that alters an outcome fails the build with the tick number where the two diverged.
- Property-based tests over the determinism claim itself, alongside the golden vectors.
Purity is enforced rather than documented. A lint rule forbids bare imports inside the simulation, a CI script fails the build if its manifest declares a single dependency, and the package is compiled with no ambient type definitions at all — so Date, setTimeout and fetch are not merely discouraged, they do not typecheck.
Crash recovery is replay, not repair
The server writes two things: a snapshot at every phase boundary, and the intents that arrived, one entry per tick. Recovering a match is a fold — restore the snapshot, replay the log forward — with nothing in the recovery path that inspects a state, patches one, or decides what a recovered match should look like. The answer is right because the step function is.
Two decisions carry the weight:
- The intent log is written every tick, not batched. Batching is cheaper, but it makes the correctness claim conditional on when the object died: a match recovered mid-batch is identical to one that never stopped, except for the ticks in the batch. Per-tick writes make it unconditional. They are affordable because the overwhelming majority of entries are the empty array — the expensive thing, a snapshot per tick, is exactly what the phase-boundary snapshot exists to avoid.
- The write is issued before the tick it produced is broadcast, and is not awaited. Durable Objects hold outgoing messages behind the output gate until pending writes are confirmed, so a tick a client has seen is a tick that is durable — and a tick lost to a crash is one nobody saw either. Awaiting inside the tick interval would buy nothing and would put a storage round trip between the two halves of a tick.
One interface, two implementations, and a test that they agree
The client talks to a single MatchClient interface. One implementation runs the simulation locally in the browser; the other is a WebSocket to the Durable Object. Nothing above that interface — not the renderer, not the HUD, not the audio — knows which one it has.
A parity test asserts the two produce identical client state at every tick of a full match. That test is what makes the interface real rather than aspirational: the server and the local loop are the same phase machine with the clock and the drain point moved, and it is the only thing that can catch one drifting from the other.
The wire protocol
- msgpack over a two-element
[id, payload]envelope, with message ids as small integers rather than strings — the envelope is sent thirty times a second, so the discriminant is one byte instead of nine. - Custom extension types for
Uint16ArrayandUint32Array. msgpack has one binary type and it is a byte string: wide typed arrays encode as their bytes and decode as aUint8Arrayof twice or four times the length, without throwing. Three fields on the wire are wide typed arrays, so without the extension a snapshot decodes into a state whose every structure lookup and territory test reads the wrong half of a number, with no error anywhere. It would present as a rendering bug in a client that had been told the truth. The round-trip test asserts the constructor is preserved, not just the contents — an assertion on contents alone passes for the broken case. - Snapshot on join, then deltas. Sending the whole state each tick would put the entire board on the wire thirty times a second to report that a countdown decremented.
- Delta reassembly preserves object identity, not merely value. The client's dirty checks, its store subscriptions and its terrain cache all compare by reference, so reassembly returns the previous references for everything a delta did not mention and allocates only along the path that actually changed. A correct-by-value implementation that rebuilt every object would pass every equality test in the suite and repaint the entire board thirty times a second. This is sound because the simulation is immutable at its boundary: a field that did not change is the same object on the next tick.
Server topology
- One Durable Object per match, holding the authoritative simulation and terminating both players' sockets. No gateway, no registry, no shared session store: a match has no untrusted user code to isolate, and a Durable Object is singular by construction, so there is no state to reconcile across processes.
- One singleton object for matchmaking. Two workers reading a shared queue can pair the same player into two matches, which is a bug with no clean repair — both matches have claimed a seat and neither will give it back. A Durable Object is single-threaded, so that race does not exist rather than being defended against.
- Hibernation-aware sockets from the first line. The hibernation API is a different interface from ordinary socket accept plus event listeners, and the in-memory closure the listener style encourages does not survive eviction — so adopting it later means rewriting the layer, not adding a call. Nothing lives in a field that has to outlast a hibernation: the roster lives in storage, a socket's seat lives in its own attachment, and the match travels as
(snapshot, intent log). - The matchmaking object runs no clock at all, so waiting players cost nothing until the next one arrives. A keepalive ping, a wait timeout or a periodic "still looking" broadcast is one line each, and each would reintroduce continuous billing on the one object every player in the game touches.
Identity without accounts
Two tokens, deliberately distinct: a seat token says which chair in this match, and a player token says who keeps coming back. The player token is minted by the server and signed with HMAC-SHA256 via WebCrypto, verified with a constant-time comparison rather than by comparing two strings, and carries two keys through a rotation so a rollover does not invalidate every outstanding token at once — which would read to a player as being logged out of an account they never made.
It is signed rather than stored, so identity needs no database: verifying one is a signature check, not a lookup. It is server-issued rather than client-minted because a client-minted token is a name you assert and a server-issued one is a claim you cannot fabricate — for an unrated game the two are indistinguishable, but a rating computed over self-reported identities can never be repaired after the fact, so it is paid for before there is a rating rather than after.
Stack
- Language: TypeScript (strict), across simulation, server and client
- Runtime: Cloudflare Workers and Durable Objects (SQLite-backed), WebSockets with hibernation
- Client: canvas 2D renderer, React for overlays only, no game framework
- Protocol: msgpack with custom extension types over a binary WebSocket
- Tooling: pnpm workspaces, Turborepo, Vitest (including the Workers test environment), oxlint/oxfmt
- Delivery: GitHub Actions to Cloudflare Workers, static assets prerendered at build time
Outcome and evidence
Live and playable at https://vallum.games — two browsers, one link, a full match over the network, with reconnection and crash recovery.
What is verified rather than claimed:
- 1,000 full matches (~10 million ticks) replay deterministically from seed, as a committed benchmark baseline
- Golden hash-chain vectors covering full matches tick by tick
- A parity test asserting the local and networked implementations produce identical state at every tick
- Build-time assertions on bundle contents, package dependencies and published figures
- ~67k lines of TypeScript, 88 test files, built over roughly three weeks
It has no user base and no load data, and no claim is made about either.
Links
- Play: https://vallum.games