logo
By Lawrence

5 minutes

Build the Room Server

Last lesson drew the handshake: an offer goes to a signaling server, an answer comes back, and only then does a DataChannel open between two peers. But the diagram had a gap — the signaling server itself. Two browsers behind two routers can't hand each other an offer directly; something both can reach has to hold it for a moment. And before any of that, someone has to decide who sits in which chair.

That something is the smallest server in the whole course. It has two jobs — hand out seats, and act as a mailbox for SDP messages — and it never sees a card, holds no game logic, and fits in well under a hundred lines. Nothing changes on screen this lesson: you build one backend module and one dev server, and run that server next to the game.

Every function in roomStore.ts is pure: each takes one plain value, a RoomState, and returns a new one, with no Prisma, no HTTP, no globals, and no hidden clock. That is deliberate — if you can't test the seat logic on its own, you end up debugging it in four browser tabs at once.

Here are the constants and one small helper, in src/server/roomStore.ts:

src/server/roomStore.ts
/** Seat hand-out order: take an across-the-table seat first, then fill the gaps. */
export const SEAT_ORDER = [0, 2, 1, 3]

/** A player idle longer than this (ms) is pruned and their seat freed. */
export const STALE_MS = 10 * 60 * 1000

/** Default seed minter — overridable in tests for determinism. */
const defaultMintSeed = (): string => crypto.randomUUID()

/** Drop players whose `lastSeen` is older than STALE_MS relative to `now`. */
export function pruneStale(players: Player[], now: number): Player[] {
  return players.filter((p) => now - (p.lastSeen ?? 0) < STALE_MS)
}

Continue reading

Unlock the Full Course

Every lesson, the runnable examples, and the finished build — yours to keep.

$9one-time

Was this page helpful?

We read every note — tell us what's working and what isn't.

↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search