<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ahmed Moaz</title>
    <description>The latest articles on DEV Community by Ahmed Moaz (@board_it_b11ff7d58bf863f8).</description>
    <link>https://dev.to/board_it_b11ff7d58bf863f8</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4145513%2F7e19347a-4c98-415a-b08d-2b5a40655070.png</url>
      <title>DEV Community: Ahmed Moaz</title>
      <link>https://dev.to/board_it_b11ff7d58bf863f8</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/board_it_b11ff7d58bf863f8"/>
    <language>en</language>
    <item>
      <title>Keeping a secret word secret in a realtime drawing game</title>
      <dc:creator>Ahmed Moaz</dc:creator>
      <pubDate>Sun, 27 Sep 2026 17:35:06 +0000</pubDate>
      <link>https://dev.to/board_it_b11ff7d58bf863f8/keeping-a-secret-word-secret-in-a-realtime-drawing-game-4ioc</link>
      <guid>https://dev.to/board_it_b11ff7d58bf863f8/keeping-a-secret-word-secret-in-a-realtime-drawing-game-4ioc</guid>
      <description>&lt;p&gt;A code-grounded look at server-held answers, narrow responses, and why a realtime room should not receive every private value.&lt;/p&gt;

&lt;p&gt;A realtime game has a deceptively simple privacy problem: every player needs the shared board, but not every player should receive the answer. If the server broadcasts one complete state object to the whole room, a player can inspect the network response or browser state and read information the interface tried to hide.&lt;/p&gt;

&lt;p&gt;In Boardit’s Draw and Guess implementation, the answer is treated as server-held game data. The public game state tells the room what phase and turn it is in; a guesser’s response does not contain the answer. The server checks a submitted guess and returns only the result appropriate for that player. The drawer has a different view because the drawer is the one drawing the answer. The key idea is not “hide it with CSS.” It is “do not send the private value to a client that has no reason to know it.”&lt;/p&gt;

&lt;p&gt;This is a description of the repository implementation, not a general claim that every realtime product uses the same design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model visibility before designing the screen
&lt;/h2&gt;

&lt;p&gt;Start by writing down what each actor is allowed to know. In a drawing round, that may look like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Actor&lt;/th&gt;
&lt;th&gt;Needs to know&lt;/th&gt;
&lt;th&gt;Should not receive&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Drawer&lt;/td&gt;
&lt;td&gt;The answer for the active turn&lt;/td&gt;
&lt;td&gt;Other turns’ private answers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guesser&lt;/td&gt;
&lt;td&gt;The drawing, timing, and whether their own guess is correct or close&lt;/td&gt;
&lt;td&gt;The answer before the round reveals it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Room observer&lt;/td&gt;
&lt;td&gt;The shared board and round progress&lt;/td&gt;
&lt;td&gt;Any private answer payload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server&lt;/td&gt;
&lt;td&gt;The answer and enough state to validate actions&lt;/td&gt;
&lt;td&gt;Nothing beyond the game’s required data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The precise table varies by game, but making it explicit reveals the common mistake: storing a private answer in an object that is serialized to every connected browser, then merely hiding the text in the UI. If a browser receives a value, it is generally available to the person using that browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the secret out of the broadcast state
&lt;/h2&gt;

&lt;p&gt;Boardit stores Draw and Guess secrets separately from the shared room state. The repository’s build notes describe the word bank as server-only and the active turn’s selected word as data held outside the guest-facing HTML. A client component does not import the server word bank. That separation limits accidental exposure through a client bundle.&lt;/p&gt;

&lt;p&gt;The distinction matters because a module imported by browser code can be included in the delivered JavaScript even when a component never visibly renders the full data. Hiding a list behind a conditional is not access control. Keeping the list on the server and exposing only a narrow action boundary is much easier to reason about.&lt;/p&gt;

&lt;p&gt;In implementation terms, this means the browser asks to act; it does not submit a replacement authoritative board. The server loads the room state, checks the requested action against the rules, and decides what result to return. That also avoids trusting a modified client to mark its own guess correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Return the smallest useful answer
&lt;/h2&gt;

&lt;p&gt;A guesser needs feedback, not the answer itself. A response can say “right,” “close,” or “not yet,” while the server compares the submitted text against the stored answer. In Boardit’s implementation, the guess action returns a role and a nullable word field; the guesser receives the word only when the guess was already correct. That is a small interface, but it expresses a strong boundary: the response shape is designed around what the caller is allowed to see.&lt;/p&gt;

&lt;p&gt;This is also why “the UI does not display it” is not an adequate privacy test. Inspect the action response and the state received by another room member. Check the serialized payload, not just the pixels. A private value can leak in a hidden DOM node, a debug object, a log row, a source map, or an analytics event even if the main game screen looks correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep authorization beside the data fetch
&lt;/h2&gt;

&lt;p&gt;The secret-handling pattern only works if the server checks who is asking. Boardit’s separate Who’s the Spy implementation makes that boundary especially visible: a multi-device player requests their own card, while the pass-one-device mode allows the host to request only the card for the slot currently being revealed. The shared game state is not used as a channel for everybody’s private role.&lt;/p&gt;

&lt;p&gt;That repository example is useful because it shows how access rules change with the device model. In a shared-phone game, one browser acts for different local players, so the server checks the host and current reveal index. In a multi-device game, it checks the authenticated player. The correct permission check depends on the interaction, but it belongs on the server next to the data access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat realtime and persistence as separate questions
&lt;/h2&gt;

&lt;p&gt;A value can be absent from the room’s broadcast and still leak through a database table, event log, analytics property, or error response. Ask four separate questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Where is the secret stored?&lt;/li&gt;
&lt;li&gt;Which table or channel is broadcast to all room members?&lt;/li&gt;
&lt;li&gt;What exact fields does each server action return?&lt;/li&gt;
&lt;li&gt;Could logs, telemetry, or error messages include the secret or private URL data?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a game-state update, write shared state to the channel that every player needs, and keep private data in storage with tighter access. When an action creates both a public state change and a private write, consider ordering and concurrency. The Spy action path in this repository updates the shared game state under a version check, then writes associated secret changes after that compare-and-swap succeeds. That reduces the chance of attaching secret data to a state transition that lost a race.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the boundary, not just the happy path
&lt;/h2&gt;

&lt;p&gt;Useful checks should fail if the secret crosses a boundary. Boardit’s Spy word-bank tests scan application, component, and library source to ensure the full word-bank binding is not imported outside the server-side game directory and never appears in a client component. The implementation also has reducer tests for state transitions. Those checks complement one another: one guards module reachability, another guards game behavior.&lt;/p&gt;

&lt;p&gt;For a drawing game, the equivalent tests should inspect action results and serialized state. As a guesser, submit a wrong guess and assert the response does not contain the answer. As another room member, inspect the broadcast payload. Build the production client and search for server-only data. Test errors and retry paths as well as the normal turn.&lt;/p&gt;

&lt;p&gt;The guiding rule is simple: define who may know each value, keep private values on the server until needed, and make each response contain only the caller’s authorized view. Realtime speed does not require broadcasting every piece of state to every player.&lt;/p&gt;

&lt;p&gt;The game is available at &lt;a href="https://playboardit.com/" rel="noopener noreferrer"&gt;Boardit&lt;/a&gt; and its &lt;a href="https://playboardit.com/play/draw-and-guess/" rel="noopener noreferrer"&gt;Draw and Guess page&lt;/a&gt;. Repository-specific claims above come from the Draw and Guess and Spy action files, docs/draw-and-guess-build.md, and lib/games/spy/words.test.ts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; I’m Ahmed Moaz, the solo developer of Boardit. This article describes implementation details from my own project.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>supabase</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Six Multiplayer Browser Games on Cloudflare Workers and Supabase: What Broke</title>
      <dc:creator>Ahmed Moaz</dc:creator>
      <pubDate>Sun, 27 Sep 2026 12:54:00 +0000</pubDate>
      <link>https://dev.to/board_it_b11ff7d58bf863f8/six-multiplayer-browser-games-on-cloudflare-workers-and-supabase-what-broke-5244</link>
      <guid>https://dev.to/board_it_b11ff7d58bf863f8/six-multiplayer-browser-games-on-cloudflare-workers-and-supabase-what-broke-5244</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I’m Ahmed Moaz, a solo developer in Egypt, and I build &lt;a href="https://playboardit.com" rel="noopener noreferrer"&gt;Boardit&lt;/a&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the rules boring: pure reducers
&lt;/h2&gt;

&lt;p&gt;Each game has its own engine under &lt;code&gt;lib/games/&amp;lt;game&amp;gt;/engine/&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LudoState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Ctx&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ApplyResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;structuredClone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;hydrate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GameEvent&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;playerId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seq&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;playerId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same separation exists for Property Rush, Trivia Tavern, Snakes &amp;amp; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A room code is a credential
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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?”&lt;/p&gt;

&lt;h2&gt;
  
  
  Realtime is a hint, not the source of truth
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;postgres_changes&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;POLL_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;versionRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;versionRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A timeout should preserve the game
&lt;/h2&gt;

&lt;p&gt;“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.&lt;/p&gt;

&lt;p&gt;Ludo and Snakes &amp;amp; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Timeout policy is game design, not just a &lt;code&gt;setTimeout&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guests make the first click easier—and identity harder
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hosting limit that was 12 KiB away
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;public/og.png&lt;/code&gt; removed about 279 KiB from the bundle. I also replaced a server analytics SDK used for one JSON request with &lt;code&gt;fetch&lt;/code&gt;, removing another 94 KiB, and removed an unused icon package. These cuts were worthwhile even before they made the size fit.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would keep
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you want to see the finished table and how the six games fit together, visit &lt;a href="https://playboardit.com" rel="noopener noreferrer"&gt;Boardit&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>webdev</category>
      <category>gamedev</category>
      <category>nextjs</category>
    </item>
  </channel>
</rss>
