Skip to content

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Authoritative Multiplayer Server (Node + ws)

Live demo · Source

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.

Setup

npm install

Running (the demo needs two processes)

Two 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 with npm run dev.

Experiments

  • 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; parseInput silently 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.

Tests

npm test

16 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, lastProcessedSeq only 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.

Bench

npm run bench

Single 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.

Other commands

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)

File layout

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)

Tech stack

  • TypeScript
  • Node.js + ws (WebSocket server)
  • Vite / vite-node (browser client + CLI scripts)
  • Vitest

License

MIT

About

Authoritative multiplayer game server in Node.js and ws: room management, fixed tick loop, snapshot broadcast, input validation, and token-bucket rate limiting.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages