DEV Community

Superevh
Superevh

Posted on

A multiplayer party game with no game server: one log, one reducer, and a lot of trust

Disclosure: this was written by Claude, an AI agent, working with the maker of RetroLift. Every snippet comes from the source, lightly trimmed. RetroLift is the product being described, so read the praise accordingly.

RetroLift has ten browser games for teams: Draw & Guess, Higher or Lower, Mind Meld and so on. Up to 40 people join from a link, with no sign-up. There is no game server. The backend is Supabase: two Postgres tables, six SQL functions and a Realtime channel. The rules of each game run in every player's browser.

Here is how it works, and what it gives up.

One log, one reducer

A game is a JSON snapshot plus an ordered list of actions. Boards, planning poker rooms and games all share the same tables:

create table public.board_events (
  board_id text not null references public.boards (id) on delete cascade,
  seq bigint not null,
  action jsonb not null,
  primary key (board_id, seq)
);
Enter fullscreen mode Exit fullscreen mode

Each game is a pure function, reduce(state, action). Every browser folds the same log through the same function, so every browser reaches the same state. A comment in the sync code gives the one rule that makes this work:

// `reduce(doc, action)` must be pure and take time only from
// action.at, so every browser folding the same log reaches the same state.
Enter fullscreen mode Exit fullscreen mode

The database does one job for the game: it decides the order. Appending takes a row lock on the board, so each board's sequence has no gaps:

update boards set head = head + 1, updated_at = now()
  where id = p_board returning head into s;
if s is null then raise exception 'board not found'; end if;
insert into board_events (board_id, seq, action) values (p_board, s, p_action);
return s;
Enter fullscreen mode Exit fullscreen mode

Randomness without a referee

Shuffling looks like it breaks purity, since each browser would shuffle differently. The fix is to seed the shuffle from the action itself. Every action gets a random nonce when it is created, so the START action carries the seed:

case 'START': {
  if (r.phase === 'play') return r
  const deck = spec.deck(r, rng(a.nonce)).slice(0, r.settings.count)
  ...
}
Enter fullscreen mode Exit fullscreen mode

The randomness happens once, in the browser that clicked Start. After that it is just data in the log.

Timers without a clock owner

Timed rounds look like they need a server. They don't. When the countdown ends, the host's browser posts a REVEAL action marked as timed, and everyone else's browser does the same 3.5 seconds later in case the host has gone. The reducer decides what counts:

case 'REVEAL': {
  if (r.phase !== 'play' || !c || c.revealed || c.id !== a.qid) return r
  if (a.timed && !(secs && a.at >= c.at + secs * 1000 - SKEW)) return r
  return reveal(r, a.at)
}
Enter fullscreen mode Exit fullscreen mode

Several browsers may post it. The first one in the log reveals the round, and the rest hit c.revealed and do nothing. If the host's laptop goes to sleep, the game still moves on. SKEW is 2 seconds, to allow for clocks that disagree a little.

Fast path and true path

Waiting for a database round trip before showing each tap would feel slow, so there are three layers:

  1. Optimistic: the action goes into a local pending list straight away, and the screen shows the state with pending actions applied on top.
  2. Broadcast: after the append returns a seq, the browser sends { seq, action } over a Supabase Realtime channel to everyone else.
  3. Catch-up: the log is the source of truth. Browsers ask for missed events every 12 seconds, when the tab becomes visible and when the network returns.

Out-of-order arrivals sit in a buffer until the gap is filled:

for (const e of events) if (e.seq > d.seq) d.buffer.set(e.seq, e.action)
while (d.buffer.has(d.seq + 1)) {
  const a = d.buffer.get(d.seq + 1)
  d.buffer.delete(d.seq + 1)
  d.doc = reduceRef.current(d.doc, a)
  d.seq += 1
  d.pending = d.pending.filter(p => p.nonce !== a.nonce)
}
if (d.buffer.size) catchUp()
Enter fullscreen mode Exit fullscreen mode

A lost broadcast costs a small delay and nothing else. The nonce removes your own action from pending when it comes back through the log, so it never applies twice.

Every 40 events, a browser saves a new snapshot, so a late joiner does not replay the whole game. save_snapshot ignores anything older than the current snapshot, so two browsers saving at once does no harm.

What this gives up

This design only works because of what it is used for. These are the costs.

Secret answers are not secret. "Everyone answers in secret" holds only on screen. Answers are in the log, and any player with dev tools can read them before the reveal. Moving that check to a server would mean writing every game's rules twice. For a team warm-up that nobody is paid to win, that is an acceptable trade. For anything with prizes, it isn't.

There is no server validation. The SQL functions check size limits and that the board exists. They know nothing about the rules. A client can post an action claiming to be someone else, or with a false timestamp. The reducer guards against nonsense such as answering after the deadline, but it believes a.pid and a.at. The only access control is an unguessable link.

Client clocks decide scoring. Faster answers score more, and speed is measured from timestamps set in each player's browser. A browser whose clock runs behind looks faster than it is, and nothing corrects for that.

Changing a reducer is risky. If two browsers run different versions of the code during a deploy, they could fold the same log into different states. Nothing here detects that. A refresh should fix it, and games last a few minutes, so it has been left alone.

It hasn't been tested under load. The site has very little traffic so far. The 40-player limit is a product choice, not the result of a load test. Every client posts through one row lock, so a busy turn is the first place to look for trouble.

When to copy this

Copy it if you have short sessions, players who know each other, and rules that are cheap to write once. It is about 130 lines of sync code, and adding a new game means writing one reducer and its UI. Don't copy it if anyone gains from cheating, or if the state outlives the code that wrote it.

You can see it working in the team games. They are free and need no sign-up. If you spot a hole that isn't listed here, please say so in the comments.

Top comments (0)