logo

Babylon.js Market

By Lawrence

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:

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)
}

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:

src/server/roomStore.ts
export function join(
  state: RoomState,
  playerId: string,
  now: number,
  mintSeed: () => string = defaultMintSeed,
): JoinResult {
  const livePlayers = pruneStale(state.players, now)

  // The whole table aged out since the last touch — treat this join as a reset.
  const freshRoom = livePlayers.length === 0 && state.players.length > 0

  // Mint the seed once: on a reset, or the first time the room is given one.
  const seed = freshRoom || !state.seed ? mintSeed() : state.seed

  // A reset wipes signals; otherwise clear only handshakes touching this joiner.
  const signals = freshRoom
    ? []
    : state.signals.filter((s) => s.from !== playerId && s.to !== playerId)

  // Known id → idempotent re-join: keep the seat, bump lastSeen.
  const existing = livePlayers.find((p) => p.id === playerId)
  if (existing) {
    const players = livePlayers.map((p) =>
      p.id === playerId ? { ...p, lastSeen: now } : p,
    )
    return { state: { ...state, seed, players, signals }, seat: existing.seat, seed, players }
  }

  // New id → first free seat in SEAT_ORDER, or null when the table is full.
  const taken = new Set(livePlayers.map((p) => p.seat))
  const seat = SEAT_ORDER.find((s) => !taken.has(s))
  if (seat === undefined) {
    return { state: { ...state, seed, players: livePlayers, signals }, seat: null, seed, players: livePlayers }
  }

  const player: Player = {
    id: playerId,
    seat,
    name: `Player ${livePlayers.length + 1}`,
    lastSeen: now,
  }
  const players = [...livePlayers, player]
  return { state: { ...state, seed, players, signals }, seat, seed, players }
}

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:

const empty = toRoomState({ id: 'ABCDEF' }) // { seed: '', players: [], signals: [] }
const now = Date.now()

const a = join(empty, 'p1', now)
// → { seat: 0, seed: '9f2c…', players: [{ id: 'p1', seat: 0, name: 'Player 1' }] }

const b = join(a.state, 'p2', now)
// → { seat: 2, seed: '9f2c…',  // ← the SAME seed
//     players: [ { id: 'p1', seat: 0, … }, { id: 'p2', seat: 2, … } ] }

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:

src/server/devRoomServer.ts
export function createDevRoomServer(store = new InMemoryRoomStore()) {
  return createServer(async (req: IncomingMessage, res: ServerResponse) => {
    const send = (data: unknown, status = 200) => {
      res.writeHead(status, { 'Content-Type': 'application/json' })
      res.end(JSON.stringify(data))
    }
    const url = new URL(req.url ?? '/', 'http://localhost')
    const m = url.pathname.match(/^\/api\/poker\/room(?:\/([^/]+)(\/join|\/signals)?)?$/)
    if (!m) return send({ error: 'Not found' }, 404)
    const [, code, sub] = m
    const method = req.method ?? 'GET'

    // POST /api/poker/room — create / reset (signals cleared), matches room.ts.
    if (!code) {
      if (method !== 'POST') return send({ error: 'Not found' }, 404)
      const { id } = await readBody(req)
      const existing = store.load(id)
      store.save(existing ? { ...existing, signals: [] } : toRoomState({ id }))
      return send({ id }, 201)
    }

    // POST /api/poker/room/{code}/join
    if (sub === '/join' && method === 'POST') {
      const { playerId } = await readBody(req)
      const result = join(store.loadOrCreate(code), playerId, Date.now())
      if (result.seat === null) return send({ error: 'Room full' }, 400)
      store.save(result.state)
      return send({ seat: result.seat, seed: result.seed, players: result.players })
    }

    // GET / POST /api/poker/room/{code}/signals
    if (sub === '/signals') {
      if (method === 'GET') {
        const room = store.load(code)
        return send({ signals: room ? getSignals(room) : [] })
      }
      if (method === 'POST') {
        const room = store.load(code)
        if (!room) {
          res.writeHead(404)
          return res.end('Not found')
        }
        store.save(postSignal(room, await readBody(req)))
        return send({ ok: true })
      }
    }

    // GET / DELETE /api/poker/room/{code}
    if (!sub) {
      if (method === 'GET') return send(getRoom(store.loadOrCreate(code)))
      if (method === 'DELETE') {
        store.delete(code)
        return send({ ok: true })
      }
    }

    return send({ error: 'Not found' }, 404)
  })
}

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:

node --experimental-strip-types src/server/devRoomServer.ts
# [poker] in-memory dev room server on http://localhost:3001

curl -sX POST localhost:3001/api/poker/room/ABCDEF/join -d '{"playerId":"p1"}'
# → {"seat":0,"seed":"9f2c…","players":[{"id":"p1","seat":0,"name":"Player 1"}]}

curl -sX POST localhost:3001/api/poker/room/ABCDEF/join -d '{"playerId":"p2"}'
# → {"seat":2,"seed":"9f2c…","players":[{…"seat":0…},{…"seat":2…}]}

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.

$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