When I started putting multiplayer games into one browser app, I expected the hard part to be implementing six sets of rules. It turned out that the rules were often the cleanest part. The harder questions were about which copy of the board to trust, what happens when a socket goes quiet, how to keep a private word private when a whole database row is broadcast, and whether a perfectly good app would fit in the hosting plan I wanted.
I’m Ahmed Moaz, a solo developer in Egypt, and I build Boardit, a browser table for six multiplayer games. The stack is Next.js App Router, Supabase Auth/Postgres/Realtime, and Cloudflare Workers. Here are a few design decisions that came from actual failures rather than a diagram that looked tidy.
Make the rules boring: pure reducers
Each game has its own engine under lib/games/<game>/engine/. An action goes in with the current state and an explicit context; the reducer returns a result. The context supplies things that would otherwise make the function unpredictable, especially time and randomness. That means a test can say exactly what time it is and exactly what the die rolled.
In Ludo, for example, the reducer checks the phase before acting. It won’t accept a move before a roll, a second roll during the same turn, or a pawn destination that the die did not permit. It clones and hydrates state, applies the action, and returns the next state and events. No network request or database write belongs inside that rule function.
export function applyAction(state: LudoState, action: Action, ctx: Ctx): ApplyResult {
const next = structuredClone(state);
hydrate(next);
const events: GameEvent[] = [];
const log: Log = (kind, text, playerId) => {
next.seq += 1;
events.push({ seq: next.seq, kind, text, playerId });
};
const error = route(next, action, ctx, log);
if (error) return { error };
return { state: next, events };
}
The same separation exists for Property Rush, Trivia Tavern, Snakes & Ladders, Who’s the Spy, and Draw and Guess. Each engine has reducer tests beside it. That makes the tests fast and lets them cover edge cases that are awkward to reproduce through a live room: a tied vote, a timeout, a simultaneous correct guess, a rematch, or a malformed older state. The tests do not prove that the browser and database agree; they prove the rules do what the rules say when given a specific state and action.
The boundary matters. A pure reducer is not a security boundary by itself. The server action still has to decide who may send an action, supply trusted randomness and time, and persist the result safely. But it gives me one small place to reason about game rules without also debugging Supabase or React.
A room code is a credential
Joining a private room with a short code is convenient, but a code that seats someone is a bearer credential. Anyone who has it can try to enter. I treat it more like a temporary password than a harmless label.
The application checks room membership through the session-bound Supabase client before a game action proceeds. Row Level Security is part of that check: the membership-scoped room lookup must return a room for the caller. Server-side actions then use the privileged database path only where the game needs it. Random choices come from server-side cryptographic randomness rather than from a browser that a player can inspect or modify.
The same assumption changes what I log and broadcast. Room codes do not belong in analytics, screenshots, public URLs, or debugging output. The route can contain one because it is how a player gets to the room; that does not make it safe to send to a third-party analytics service. Boardit’s analytics code redacts the room path and strips code-like properties, and room identity in events uses an internal UUID instead.
It is also why a database row is not automatically safe just because it is in a private game. Supabase Realtime can deliver an updated row to subscribers who are allowed to read it. For Draw and Guess, the shared board state must not contain the word: the secret word and choices live in a separate, restricted table, and only the drawer gets them from their own server action. In Who’s the Spy, roles and words receive the same scrutiny. A useful review question is: “If every authorized player receives this whole object, is every field in it meant for every player?”
Realtime is a hint, not the source of truth
The first version of “multiplayer” in my head was simple: write a row, subscribe to changes, update the screen. The production behavior was less simple. On this Supabase project, some postgres_changes updates took around five seconds to reach a subscriber. That is a long pause after a die roll, and a silent WebSocket failure is worse: the UI can look connected while the board stops moving.
For the shared board hook, I kept Postgres Changes for the quick update and added a two-second fetch of the authoritative state as a reconciliation path. The hook orders deliveries by a monotonically increasing version so an older payload cannot rewind the board. It also has a special case for version zero: a rematch starts a new board at zero, but a late duplicate of the previous game’s opening state can also arrive late. In that ambiguous case, the client asks the database for the current row instead of guessing.
const POLL_MS = 2000;
if (next.version !== 0 && next.version < versionRef.current) return;
versionRef.current = next.version;
setState(next);
Polling is a trade-off. It creates a small, regular read load and means “disconnected” may take a moment to become visible. In exchange, one dropped socket does not leave a turn-based game frozen indefinitely. The poll is not the game engine and does not invent a move; it simply asks the database what state is current.
For Draw and Guess, I added a different fast path after seeing the delay. A database trigger sends a Supabase Broadcast “nudge” when relevant state, event, or chat rows change. The nudge says only what changed at a high level; it carries no game secret or board contents. The client then fetches the authorized data. Broadcast avoids the slower write-ahead-log path in this setup: the migration notes measured delivery in tens of milliseconds for the drawing channel. Trivia later adopted the same idea; its migration records roughly 300 ms versus about five seconds for the old row-change path. Those are observations from this project, not latency guarantees from Supabase.
The distinction is deliberate: the database row is authoritative, Realtime tells the client to look sooner, and polling repairs the view if the message never arrives. For some games the subscribed row itself is still the fast path; for Draw and Guess and Trivia, the nudge shortens the wait without broadcasting the sensitive payload.
A timeout should preserve the game
“Skip the absent player” sounds reasonable until the game’s rules make skipping destructive. In Ludo, a player may need to roll a six to get a pawn out of base. A skipped turn can mean that pawn never enters the game, while the rest of the table spends every round waiting for a clock that keeps expiring.
Ludo and Snakes & Ladders use 30-second turn clocks. When Ludo expires, the server checks the deadline and rolls and chooses an obvious legal move on the player’s behalf. It favors a capture, then a pawn getting home, then getting a pawn out of base, then progress with the pawn furthest along. Nobody is removed just for being slow. Any client can request the expiration action, but the server checks the deadline against its own clock, so the player’s browser does not get to declare a turn over early.
Property Rush needed a different rule. A fixed turn timer penalized a player who had already made a move and was waiting for others during a long auction. Its clock measures inactivity since that player’s last move, gives a 30-second grace period, then shows a warning before the 150-second deadline. The auction pauses the deadline; on expiry, the quiet player leaves the table. That is more state to carry and test, but it measures the behavior the table is trying to discourage: leaving everyone waiting without taking an action.
Timeout policy is game design, not just a setTimeout. The reducer needs an explicit expiry action, the server must validate it, and the automatic choice should keep the game playable rather than punish someone by making a valid strategy impossible.
Guests make the first click easier—and identity harder
People can enter a display name and play as a guest. Under the hood, that is still a Supabase anonymous auth session, not an untracked browser pretending to be a user. The session gives the server a stable user ID for room membership and RLS while avoiding an email, password, or account form before the first game.
That is a good onboarding trade: fewer steps before the table, more work around guest cleanup, account boundaries, and what happens when someone returns on another device. Anonymous does not mean authorization can be skipped. The server still checks that session’s membership for each protected action, and the app has to distinguish a guest session from a full account in its navigation and account flows.
The hosting limit that was 12 KiB away
I tried the Cloudflare Workers free tier and measured the generated Worker at 3,084 KiB gzipped against a 3,072 KiB limit. Twelve kibibytes over meant a deploy rejection. The surprising part was how much of the budget was supporting things the app did not need at runtime.
The social share image was rendered dynamically with Next’s image tooling, which pulled roughly 1.5 MB of WebAssembly into the Worker to make one image that never changes. Replacing it with a pre-rendered 1200×630 public/og.png removed about 279 KiB from the bundle. I also replaced a server analytics SDK used for one JSON request with fetch, removing another 94 KiB, and removed an unused icon package. These cuts were worthwhile even before they made the size fit.
The app now runs on Workers Paid, whose documented limit for this setup is 10 MiB, so the free-tier overage is historical context rather than the current deployment ceiling. The exercise still changed how I think about server bundles: inspect what the adapter actually includes, and ask whether a thing that never changes needs to execute in the request runtime at all. Static assets are often the right place for static work.
There is another useful boundary here: Supabase Realtime is a browser-to-Supabase WebSocket. Gameplay subscriptions do not pass through the Worker, so they do not use its WebSocket connection budget. The Worker handles server rendering and trusted actions; Supabase handles the live database channel.
What I would keep
The architecture has costs. A server action and database write add a round trip to a move. Polling adds reads. A single JSON state document makes versioned updates simple, but it demands care when a whole row is broadcast. Anonymous sessions are easy to start and harder to reconcile across devices. A Worker can be a tight fit when a framework adapter bundles more runtime than the app itself appears to use.
I would keep the pure reducers, the server membership check, and the separation between authoritative state and delivery hints. They make failures easier to isolate. A slow board update can be traced to transport or reconciliation without rewriting the rules. A leaked secret is less likely when private information never enters the broadcast row. A weird timeout can be reproduced with a fixed context and a reducer test.
The best multiplayer feeling is not a clever subscription. It is that a player can take an action, see it settle, and trust that everyone else will converge on the same board—even when a socket is slow, a player disappears, or a deployment has a budget measured in kibibytes.
If you want to see the finished table and how the six games fit together, visit Boardit.
Top comments (2)
Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support
Do not follow any external links! DEV.to uses Sloan for automated messages, this is likely phishing.