Realtime as a Doorbell

When I started Long Distance Games I assumed the hard part would be the games. It was not. The hard part was the boring question underneath all of them: when two browsers are looking at the same board, who is right?

The answer I landed on has one sentence in it. The realtime channel is a doorbell, not a delivery. It tells a client that the room changed. It never says what changed.

The version everybody writes first

The intuitive design is to broadcast the move. Alice plays a piece, her client sends { column: 3 } down a channel, Bob's client receives it and applies it to his copy of the board. It is fast, it is simple, and it is wrong in three separate ways.

It is wrong about trust, because Bob's client is applying a move that Alice's client decided was legal. Anyone who can open devtools can play twice.

It is wrong about convergence, because Alice and Bob are each running their own reducer over their own copy of state. In a turn-based game you can mostly get away with it. In Word Duel, where both players move at once, or Dots & Boxes, where closing a box grants you another turn, "mostly" starts producing two people staring at different boards and neither of them being obviously wrong.

And it is wrong about secrets, because a broadcast goes to everyone in the room. Word Duel needs Bob to see that Alice's third guess had two greens without ever learning which letters they were. You cannot do that with a message that contains the guess.

What it does instead

Every move in the app goes to one endpoint: POST /api/rooms/[code]/move. That handler runs under the service role and is the only thing in the system that can advance a game. Clients hold no write access to game state at all.

Inside, it does the obvious sequence and one non-obvious thing:

const verdict = game.validateMove(room.state, body?.move, seat)
if (!verdict.ok) {
  return NextResponse.json({ error: verdict.reason }, { status: 422 })
}

const played = game.applyMove(room.state, body.move, seat)

Those are the same pure TypeScript rules the browser imports to grey out illegal squares. The client copy is a courtesy, so the UI feels honest. The server copy is the one that counts. Having them be literally the same function is the payoff from keeping every game behind one interface.

Then the write:

const { data: updated } = await admin
  .from('rooms')
  .update({ state: nextState, version: room.version + turns.length, ... })
  .eq('id', room.id)
  .eq('version', room.version) // lost race => zero rows, and we say so
  .select('version')
  .maybeSingle()

if (!updated) {
  return NextResponse.json({ error: 'Someone moved first.' }, { status: 409 })
}

That .eq('version', room.version) is optimistic concurrency in one line. Two moves arriving in the same instant both read version 7; the first update matches and bumps it to 8, the second matches zero rows and gets a 409. The loser is told to catch up rather than being silently applied on top. In a simultaneous game like Word Duel this is not a theoretical race, it happens.

Then the doorbell rings

The client subscribes to Postgres changes on its own room and does not read the payload:

channel.on(
  'postgres_changes',
  { event: '*', schema: 'public', table: 'rooms', filter: `id=eq.${roomId}` },
  () => void refresh(),
)

refresh() calls GET /api/rooms/[code]/state, which reads the room back and runs it through redactFor for the seat asking. That is what makes hidden information work: the answer to "what can I see" is computed per request, per seat, on the server, right before the bytes leave. There is no version of the state in transit that contains something the recipient should not have.

It costs a round trip. The move response already carries the mover's own new state, so the person who moved sees it immediately; the notification-then-fetch path is the opponent's, and over a real connection it is not something you notice. What you get for that round trip is that the two boards cannot disagree, because there is only one board and both clients are reading it.

The parts that are deliberately not durable

Presence and emoji reactions do the opposite, and it is worth saying why they are not an inconsistency.

They ride the same channel as ephemeral traffic and never touch a table. Presence is the little dot that tells you the other person still has the tab open. Reactions are the emoji that float up the screen. Neither is game state. Nobody needs to reconstruct which hearts were sent at 11pm. If a reaction is dropped, the correct behaviour is for it to be dropped.

The rule I ended up with: if losing it would change the outcome of a game, it goes through the server and into a table. If losing it would only mean you missed a moment, it can be a broadcast. That line has been stable through every game I have added since.

What I would tell myself at the start

Pick where truth lives before you write the first game, not after the third. Every awkward question I ran into later, solo bots, rematch, hidden information, someone refreshing at exactly the wrong moment, turned out to have an easy answer once there was exactly one place a game could change. And every one of them would have been a nightmare in a design where two clients each held an opinion.

If you want the layer above this one, that is One Folder Per Game. If you want the reason the site exists at all, it is here, and it has nothing to do with concurrency control.

The site itself is at longdistancegames.com.

Realtime as a Doorbell, Shagun Mistry