I ship BingWow — a free, no-signup multiplayer bingo platform. The core feature is the part I want to write about: up to 20 people opening one link, every player landing on their own independently-shuffled board, every tap resolving against an atomic Postgres RPC, and the server detecting bingo so nobody has to adjudicate by hand.
Most of the interesting decisions sit in three trade-off pairs. None of them are obvious from a brief, and each one cost us a regression before it stabilised.
1. One unified tap event, not separate claim/unclaim
The first version had claim and unclaim events on the Ably channel. Race conditions appeared the moment two players tapped the same cell within a second of each other — the broadcast order didn't match the database write order, so a fast double-tap could end up with the cell rendered as "unclaimed" on one player's screen and "claimed" on the host's.
Replacing the pair with a single tap event fixed it. The server's RPC (tap_claim) is the single source of truth: it reads the current state, toggles the claim, and returns the new state. The Ably broadcast carries the post-toggle state explicitly — no inference required.
If you're designing a real-time game protocol: prefer events that carry the resolved state rather than the intended action. It eliminates the entire class of "client A and client B intend opposite things at the same time" failures.
2. Per-player boards, not a shared board
Every player has their own players.board jsonb array. A 5×5 board is 25 entries; a 3×3 is 9. The clue set is shared (all players are watching for the same prompts), but the positions are independently shuffled per player.
This means there's no such thing as "the room's board." Bingo detection runs per-player in TypeScript (lib/bingo-checker.ts) using the per-player grid size, derived at runtime from SQRT(jsonb_array_length(players.board)). We had a dedicated players.grid_size column for a while — dropped it in a 2026-05 refactor because it was the third source of truth (clues, board length, grid_size column) and the three drifted under concurrency.
Mixed grids in the same room are deliberate. If a mobile player joins a 5×5 desktop room, the mobile player gets a 3×3 board and wins on 3-in-a-row — the desktop players still need 5-in-a-row. This is the product spec, not a bug, because BingWow is a casual party / classroom / workplace game, not a competitive ladder. Forcing every player onto the same grid would either make a phone screen unreadable or waste the desktop players' real estate.
3. Server-authoritative round transitions
The host doesn't decide when to start the next round. The server does, in the same RPC that processed the winning tap. The flow:
- Player taps a cell →
POST /api/game/tap→tap_claimRPC - RPC writes the claim, checks every player's board for a completed row/column/diagonal
- If anyone wins, the same RPC inserts the round's outcome row AND generates the next round's boards for every player atomically
- The full new state is returned in the response
The host's browser just renders what the server returns. Latecomers can join mid-round and pick up the current state from the same endpoint. Round transitions happen in a single SQL transaction, so there's no race where Player A sees Round 5 and Player B is still on Round 4.
The catch: Vercel freezes the serverless function before Ably has finished publishing the celebration event. We solved that by publishing the bingo event from the winner's browser — the only Ably event published from the client. Every other event is server-published.
4. player-joined over Ably presence
Ably presence is convenient — every connected client appears in a members array — but it leaked ghost members on refresh. A player closing their browser tab didn't disappear from members until Ably's heartbeat timed out, which made the "more than one human in this room" check unreliable.
Replaced with a player-joined Ably event published from the server when players.length increments. No presence dependency, no ghost rows.
While I was in there, I also added a round_number filter to the Ably subscribe handler. Without it, an old round's "claim" event could replay onto the new round's board after a network reconnect — a Cell appearing claimed for a clue the player had never seen.
5. The SP/MP boundary
/play is the multiplayer route. /cards/{slug} is the single-player route. There is no in-between — a "lobby" UX, a "waiting for 2nd player" screen, none of it. The instant a second player joins, both clients redirect from /cards/{slug} to /play. The instant a player walks into /play and finds only themselves, they're redirected back.
This is enforced on both sides: useRoomLifecycle handles the upward redirect when a reconnect lands ≥2 players; PlayPageClient handles the downward redirect when first state-applied finds <2 players. It survives localStorage wipes because the room id + player id are also in the URL (?p=&r=).
The whole boundary is ~80 lines, but it pre-empts an entire family of "I'm stuck on a lobby screen forever" bugs.
Stack — minimal pieces, opinionated wiring
- Next.js 16 (App Router) for the React app and server actions
- Supabase Postgres for state + auth (RLS-enabled, mostly bypassed in API routes via the service-role client)
- Ably for the real-time channel — chat is client-to-client (server publishes from-client would duplicate); claims are server-published
- Tailwind v4 for styling, semantic tokens, dark mode via class strategy
If you're building something similar and the stack helps, the actual product is at bingwow.com — free, no signup, runs in any browser. The two surfaces that exercise the architecture most are the free bingo caller (75/90/30-ball, voice + flashboard, projector-ready) and the Real-Time Multiplayer Bingo guide, which is the human-readable version of this post with screenshots and a step-by-step setup.
If you're trying to drop something like this into a Slack or Microsoft Teams workflow without an admin install, the Slack-friendly link share pattern and the Microsoft Teams pattern are both just a normal web link — that turned out to be the underrated UX win.
The under-rated win
The biggest lesson from a year of running this in production: the protocol shape decides the bug class.
Pick a resolved state event payload (not an intended-action payload) and races stop being a category. Pick a per-player board model (not a shared-board model) and you can ship a mobile-3×3 / desktop-5×5 mixed game on the same room without writing special code. Pick a server-authoritative round transition (not client-coordinated) and "Player A is on Round 5, Player B is on Round 4" stops being possible. None of these are obvious until you've shipped the wrong shape and watched the bug reports come in.
If you want to play with the result: BingWow is free, no signup, and a card builds from any topic in about 60 seconds.
Top comments (0)