Authoritative multiplayer game server in Node.js and
ws: room management, a fixed tick loop, snapshot broadcast, input validation, and token-bucket rate limiting.
Working code for the article "The Tower Speaks: An Authoritative Multiplayer Server from Scratch in Node". The client sends what it wants to do, not where it is; the server computes the position. Rooms, fixed tick loop (20 Hz), snapshot broadcast, input validation, token bucket rate limiting and disconnect/rejoin — all in TypeScript.
The critical design decision: the game logic knows nothing about the network.
Room, RoomRegistry, TickAccumulator, TokenBucket and parseClientMessage
are pure and synchronous; WebSocket only appears in the thin shell inside
src/server.ts. That is why every test runs without listening on a single port
or opening a single socket.
npm installTwo separate terminals are required:
npm run server # terminal 1 — ws server: ws://localhost:8080 · 20 Hz
npm run dev # terminal 2 — Vite: browser client (http://localhost:5173)The server is a separate Node process; Vite only serves the browser client. With the server down the page still opens but says "connection: disconnected", and the client retries with exponential backoff (0.5s → 1s → 2s … 8s).
If you open it with
file://you get a blank screen; always open it withnpm run dev.
- Two tabs: open
http://localhost:5173/in two tabs. You will see two circles (your own character blue, the others gray). Press the arrow keys / WASD in one and the same circle moves in the other tab (latency ~1 tick = 50 ms). - Separate room:
http://localhost:5173/?room=second— this tab does not see the others, just one circle. Rooms do not hear each other. - Rejoining: close a tab and reopen it within 10 s in the same tab (same
sessionStorage): the character is where it left off. If you wait 30 s it spawns at the center ({x:320, y:200}) —RESUME_GRACE_MS = 15_000. - Cheat attempt: send
{"type":"input","input":{"seq":1,"x":9999}}over the socket from the console. Nothing happens;parseInputsilently drops the message because it carries no movement information, the server logs nothing and the connection does not drop.
Note: this client has no prediction (deliberately). Your character waits for
the server snapshot; you will see input lag. For the prediction + reconciliation
layer, the GameClient in the client-prediction-server-reconciliation project
plugs straight in here — the protocol (Input.seq, lastProcessedSeq) is identical.
npm test16 deterministic tests, 4 files, ~400 ms. None of them listens on a port, opens a
socket or imports ws:
test/room.test.ts(5) — join/leave, applying input on a tick,lastProcessedSeqonly reflects what was simulated, rejecting repeated/going-backwards seq, queue cap + unknown player.test/registry.test.ts(4) — empty room cleanup, room isolation, resume within the grace period, expired/unknown token.test/tick.test.ts(3) — 100 ms = 2 ticks, drift compensation (61 ms → next wait 39 ms), death spiral protection.test/guards.test.ts(4) — token bucket, valid input, rejecting garbage, ignoring a position claim.
npm run benchSingle room, 2000 ticks, 3 inputs per player per tick; no Math.random. Measures
tick cost and snapshot size against player count. Sample output (Node 22):
tick budget: 50 ms (20 Hz) · 3 inputs per player per tick (cap 5)
players | ms/tick | budget % | snapshot (bytes) | 20 Hz broadcast (KB/s)
-------|---------|---------|-----------------|-------------------
1 | 0.0016 | 0.003 | 130 | 2.5
2 | 0.0016 | 0.003 | 196 | 7.7
8 | 0.0039 | 0.008 | 573 | 89.5
32 | 0.0122 | 0.024 | 2101 | 1313.0
128 | 0.0489 | 0.098 | 8250 | 20624.1
512 | 0.1876 | 0.375 | 33117 | 331169.0
The simulation is free (even at 512 players it is four thousandths of the tick budget); what blows up is the broadcast traffic, because the cost of a full snapshot grows with the square of the player count. Delta compression and area of interest exist for exactly this.
npm run typecheck # tsc --noEmit
npm run build # tsc && vite build
npm run preview # serve the production build
npm run scratch # the minimal 3-event ws example from the article (8080)src/
sim.ts # PlayerState, Input, SIM_DT, SPEED, WORLD, pure step() + clamp
room.ts # Room: input queue, tick, Snapshot, lastProcessedSeq
registry.ts # RoomRegistry: join/leave/resume/sweep/tickAll, empty room cleanup
tick.ts # TickAccumulator (drift compensation) + startTickLoop (injected clock)
validate.ts # parseInput / parseClientMessage — every incoming byte is hostile
ratelimit.ts # TokenBucket
server.ts # ws layer: connection/message/close + snapshot broadcast
bench-cli.ts # measuring tick cost and snapshot size
client/net.ts # reconnect with exponential backoff + Connection
client/main.ts # browser client: input generation (60 Hz), drawing, status
scratch/minimal-ws.ts# the smallest ws example from the article
test/
room.test.ts registry.test.ts tick.test.ts guards.test.ts
index.html # Vite entry point (canvas 640x400)
- TypeScript
- Node.js +
ws(WebSocket server) - Vite / vite-node (browser client + CLI scripts)
- Vitest
MIT