5 minutes
Last lesson drew the handshake. An offer goes to a signaling server. An answer comes back. 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. It hands out seats, and it works as a mailbox for SDP messages. It never sees a card. It has no game logic. It fits in well under a hundred lines. Nothing changes on screen this lesson. You build one backend module and one dev server, and you run that server next to the game.
Every function in roomStore.ts is a pure function. Each one takes one plain value, a RoomState, and returns a new one. There is no Prisma, no HTTP, no globals, and no hidden clock. We did this on purpose. A pure function is easy to unit-test. 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:
SEAT_ORDER does one clever job. Seats fill in the order [0, 2, 1, 3], which puts people across the table first. So when two players sit down, they land opposite each other, not side by side. STALE_MS sets the timeout. A player we haven't heard from in ten minutes counts as gone. pruneStale drops that player so their chair opens up for the next person.
Now the function that hands out seats:
Read it top to bottom and it walks through a few steps. First, prune the players who timed out. If that empties a table that used to have players, treat this join as a fresh room: mint a new seed and clear the old handshakes. Mint the seed only once. A brand-new room gets a seed, and every later joiner is handed that same seed back. If the playerId is one we've seen, this is an idempotent re-join: keep the same seat and set a fresh lastSeen. A new id takes the first open SEAT_ORDER seat. If the table is full, it gets seat: null.
Here is what two callers produce:
The first joiner mints the seed and takes seat 0. The second joiner gets seat 2, not seat 1, because the seat order fills across the table first. The second joiner also gets back the same seed string. That seed is the secret each browser will feed its deck. The server mints it once and never explains what it is for. This is the idea from lesson 1, made real: the only shared randomness is one string, and every player gets the same one.
postSignal and getSignals are the other half. Together they are the SDP mailbox. postSignal files an offer for a {from → to} pair. Or, if an offer is already waiting, it attaches an answer to it. getSignals is the list each peer polls to find handshakes meant for it. Both are pure functions over the same RoomState, just like join.
Now put them behind HTTP. The whole dev server is one file. It uses only built-in modules, and a Map stands in for the database. The routes are the main thing to learn here. Here is src/server/devRoomServer.ts:
Four endpoints, one regex. POST /api/poker/room creates or resets a room and clears its signals. POST …/{code}/join runs join and returns { seat, seed, players }, or a 400 when the table is full. GET and POST …/{code}/signals are the SDP relay: getSignals for GET, postSignal for POST. GET …/{code} returns the room view. Only one part differs from production: the InMemoryRoomStore. In the real deployment, a Prisma row loads where loadOrCreate reads. But the shapes match, so the WebRTC mesh you build later talks to either one without changes.
This server sets up two connections the rest of the course depends on. POST …/join hands a browser its { seat, seed }. The seat decides where DeckDealer places you. The seed is the secret the deck reseeds from each hand. POST/GET …/signals is the mailbox where the offer and answer from last lesson get dropped off and picked up. It is the meeting spot the handshake diagram was missing. There is no EventBus yet. These are plain HTTP routes, and they know nothing about poker on purpose.
Run it and watch a table fill:
Two POSTs give two different seats, 0 then 2. Both responses carry the same seed string. The roster grows by one on each call. A second browser would meet the first right here, at this URL, holding the same secret. And the server still knows nothing about poker.
Next lesson gives the player a way in: a room code you can share, and the joinRoom call. joinRoom turns this raw { seat, seed } into a config the game can boot from.
Continue reading
Unlock the Full Course
Every lesson, the runnable examples, and the finished build — yours to keep.