<?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>How hard is 'hard'? Reading a trivia question's difficulty from the answer spread</title>
      <dc:creator>Ahmed Moaz</dc:creator>
      <pubDate>Sat, 17 Oct 2026 06:25:39 +0000</pubDate>
      <link>https://dev.to/board_it_b11ff7d58bf863f8/how-hard-is-hard-reading-a-trivia-questions-difficulty-from-the-answer-spread-4e26</link>
      <guid>https://dev.to/board_it_b11ff7d58bf863f8/how-hard-is-hard-reading-a-trivia-questions-difficulty-from-the-answer-spread-4e26</guid>
      <description>&lt;p&gt;A difficulty label is a guess made before anyone answers. The answers tell you whether the guess was right. Difficulty shows up in two places after each question: how many people got it right, and where the wrong answers went. In the trivia quiz I build, Boardit's Trivia Tavern, the reveal after every question shows for 6 seconds how many players chose each option (checked 8 October 2026). Here is how I read that spread, and why one table's result proves much less than it seems to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quarter you get for free
&lt;/h2&gt;

&lt;p&gt;Take a standard four-option question. Someone who knows nothing at all still picks the right answer one time in four. That is the probability of guessing a multiple choice question blind, and it means a quarter of the table answering correctly is not "hard". It is what nobody knowing looks like.&lt;/p&gt;

&lt;p&gt;Testing has a name for stripping that luck back out: the correction for guessing. Robert Frary's 1988 guide to formula scoring covers it for exams. For a quiz table, the rough version is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;share who knew ≈ (share right − 1/4) ÷ 3/4&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Say three of six players get a question right. That is 50%, and the formula gives a third. So about two of the six actually knew, and the third right answer was probably luck.&lt;/p&gt;

&lt;p&gt;So what percentage of people should get a trivia question right? There is no single answer, but a hard question should still sit clearly above a quarter. Anything near 25% tells you nobody knew it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six players, nobody knows: what luck does
&lt;/h2&gt;

&lt;p&gt;Small tables make luck loud. Suppose six people face a question none of them knows, and all six guess. On average, 1.5 of them will be right. More usefully:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no right answers happens about 18% of the time;&lt;/li&gt;
&lt;li&gt;exactly one, about 36%;&lt;/li&gt;
&lt;li&gt;two or more, about 47%;&lt;/li&gt;
&lt;li&gt;three or more, about 17%.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So nearly half the time, a question nobody knew still shows two right answers. One time in six it shows half the table right, which looks like a fair medium question. If you relabel from one night, you will move good questions for bad reasons.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the wrong answers went
&lt;/h2&gt;

&lt;p&gt;The spread of wrong answers says more than the count of right ones. Testing calls this distractor analysis, and it is the useful half of multiple choice item analysis. These are the four shapes I look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Most right, wrong answers scattered thinly. The question is easy, whatever the label says.&lt;/li&gt;
&lt;li&gt;The right answer and one wrong option splitting the table. This is a good hard question: the wrong option is a real belief, not a throwaway.&lt;/li&gt;
&lt;li&gt;One wrong option beating the right answer. Either it is a strong trap, or the key is wrong. Check the source before you blame the table; a wrong answer key looks exactly like a brilliant trap.&lt;/li&gt;
&lt;li&gt;Answers spread evenly over all four. Nobody knew. That is how to tell if a quiz question is too hard: not a low score, but a flat one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second shape is also the clearest lesson in how to write good distractors. A wrong option earns its place when people who half-know the subject choose it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many answers before you trust it?
&lt;/h2&gt;

&lt;p&gt;A score from six people carries a wide margin. Using the Wilson score interval, which Edwin Wilson published in 1927, three right out of six gives a 95% range of roughly 19% to 81%. That range covers easy, medium and hard at once.&lt;/p&gt;

&lt;p&gt;Pool ten tables, sixty answers, with thirty right, and the range narrows to about 38% to 62%. That is a small sample size still, but now it can tell an easy question from a hard one. Dozens of answers per question, not six, is the honest minimum.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring trivia question difficulty at a live table
&lt;/h2&gt;

&lt;p&gt;In my game the result stays up for 6 seconds after each question, showing the right answer and how many chose each option. Each player gets one answer per question, so every count is a person, not a second tap after a wrong first one. That makes the spread clean enough for the arithmetic above.&lt;/p&gt;

&lt;p&gt;Six seconds is short, so jot down the counts for questions labelled hard and keep a running tally across nights. If you want to try this on real questions, &lt;a href="https://playboardit.com/play/trivia-tavern" rel="noopener noreferrer"&gt;Boardit&lt;/a&gt;, the free site where I run this online trivia quiz, is a reasonable place to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Relabel slowly
&lt;/h2&gt;

&lt;p&gt;So how hard should trivia questions be? The labels matter less than the shapes. Easy medium hard trivia questions should each produce their own spread, and a hard one should split the table rather than flatten it.&lt;/p&gt;

&lt;p&gt;This is a method, not a study. I have published no answer spreads from real tables, and the arithmetic above shows why one table could not settle a question's difficulty anyway. Groups differ too: a question that floors a mixed table can be easy for a table of fans.&lt;/p&gt;

&lt;p&gt;At your next quiz with friends, write down the spread for every question labelled hard. Keep the ones that split the room, and check the key on any where a wrong option won.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Ahmed Moaz builds Boardit, a free browser game site.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>trivia</category>
      <category>games</category>
      <category>webdev</category>
      <category>abotwrotethis</category>
    </item>
    <item>
      <title>Word choice in a drawing game: designing so no drawer gets an impossible word</title>
      <dc:creator>Ahmed Moaz</dc:creator>
      <pubDate>Sat, 17 Oct 2026 06:10:49 +0000</pubDate>
      <link>https://dev.to/board_it_b11ff7d58bf863f8/word-choice-in-a-drawing-game-designing-so-no-drawer-gets-an-impossible-word-2e11</link>
      <guid>https://dev.to/board_it_b11ff7d58bf863f8/word-choice-in-a-drawing-game-designing-so-no-drawer-gets-an-impossible-word-2e11</guid>
      <description>&lt;p&gt;Every drawing game design has to handle the same failure: a player is handed a word they cannot draw, and the turn dies. There are two ways to design word choice in a drawing game against that. You can curate the list until every word is drawable, or you can let the drawer choose from several. I build Draw and Guess, a drawing game, and it does both: a built-in list that is deliberately easy, and a choice of two to five words each turn. Here is the arithmetic for why the second layer matters, and what it costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Curating a drawing game word list only gets you so far
&lt;/h2&gt;

&lt;p&gt;If you are working out how to build a word list for a drawing game, curation is the first layer. A curated list removes the words that are hard for nearly everyone, such as rare jargon or long phrases. It cannot remove words that are hard for this particular drawer. A famous app is easy for one player and meaningless to another. A local food is obvious in one country and a mystery in the next. Drawing games online played with friends from different countries meet this all the time.&lt;/p&gt;

&lt;p&gt;Only the drawer knows their own gaps, and they know it in a second or two. That is the case for choice, and a general rule of player choice in game design: hand the last cut to the one person with the information.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many words should a drawer choose from?
&lt;/h2&gt;

&lt;p&gt;Suppose a share q of the list is unworkable for a given drawer, and words are dealt independently. The chance that every offered word is unworkable is q to the power of the number offered.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Words offered&lt;/th&gt;
&lt;th&gt;q = 10%&lt;/th&gt;
&lt;th&gt;q = 20%&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1%&lt;/td&gt;
&lt;td&gt;4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0.1%&lt;/td&gt;
&lt;td&gt;0.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;0.001%&lt;/td&gt;
&lt;td&gt;0.03%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These q values are assumptions, not measurements. Even so, the shape holds for any q. A single word leaves a fixed share of turns doomed. A second option cuts that share by a factor of 1/q, and each extra word helps less in absolute terms than the one before.&lt;/p&gt;

&lt;h2&gt;
  
  
  What choice costs: the word choice timer
&lt;/h2&gt;

&lt;p&gt;Choosing is not free. Hick's law, from W. E. Hick's 1952 paper on choice reaction time, describes decision time growing with the logarithm of the number of options. Going from two options to five more than doubles the information to process, from 1 bit to about 2.3.&lt;/p&gt;

&lt;p&gt;In my game the choosing slice is fixed. At the shortest settings a turn takes at most 58 seconds: 12 to choose, 40 to draw and 6 for the result. Choice already costs about a fifth of that turn. Offering five words does not add seconds, but it fills the same seconds with more reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fallback when a player times out
&lt;/h2&gt;

&lt;p&gt;Any choice screen needs a rule for the drawer who doesn't decide in time. There are three common options: deal one of the offered words, deal a fresh word from the list, or skip the drawer. The first is the safest of the three, because it keeps the turn inside the offer. All three show why the curated list still matters. A drawer who freezes is protected only by how good the worst offered word is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Easy vs hard words: difficulty levels or a flat list?
&lt;/h2&gt;

&lt;p&gt;One common design tags words easy, medium and hard and pays more for the hard ones. My list has no tiers. The scoring pushes drawers towards easy words instead. Drawers are paid from how quickly their guessers answer, averaged over the rest of the table. So a sketch most of the table gets early pays at least as much as one only a single guesser cracks.&lt;/p&gt;

&lt;p&gt;You can try that scoring on &lt;a href="https://playboardit.com/play/draw-and-guess" rel="noopener noreferrer"&gt;playboardit.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you want to rate word difficulty with data rather than taste, there is a public dataset for it. Google Creative Lab's Quick, Draw! dataset holds 50 million drawings across 345 categories. Each drawing records whether the game recognised the word. A recognition rate per word is a rough difficulty score you can sort by, and the simplest way I know to test drawing words with data before players ever see them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I haven't measured about word choice in a drawing game
&lt;/h2&gt;

&lt;p&gt;I have not measured which words drawers skip, or which drawings get guessed. I built the game on 15 September 2026, and my list was chosen by judgement, not by recognition data. The table above shows why choice helps. It does not show how often my players actually need it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Ahmed Moaz builds Boardit, a free browser game site.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>partygames</category>
      <category>games</category>
      <category>webdev</category>
      <category>abotwrotethis</category>
    </item>
    <item>
      <title>How long does a Ludo game last? 30,024 simulated games, counted in rounds</title>
      <dc:creator>Ahmed Moaz</dc:creator>
      <pubDate>Sat, 17 Oct 2026 06:10:48 +0000</pubDate>
      <link>https://dev.to/board_it_b11ff7d58bf863f8/how-long-does-a-ludo-game-last-30024-simulated-games-counted-in-rounds-3a3i</link>
      <guid>https://dev.to/board_it_b11ff7d58bf863f8/how-long-does-a-ludo-game-last-30024-simulated-games-counted-in-rounds-3a3i</guid>
      <description>&lt;p&gt;Minutes depend on your table: how fast people throw, how much they talk, whether someone gets up to make tea. So I counted something that doesn't depend on any of that.&lt;/p&gt;

&lt;p&gt;In 30,024 computer-played games run through Boardit's live Ludo engine on 29 September 2026, the median game lasted 70 rounds with two players, 95 with three and 120 with four. I make Boardit, a small site of multiplayer board games that run in the browser, and these runs were made to test it. The site itself has no computer opponents; the scripted players exist only for testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a "round" means here
&lt;/h2&gt;

&lt;p&gt;The engine adds a round each time play wraps back past the opening seat, so by then every seat still playing has had one turn. This is the counter, trimmed from the code:&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;function&lt;/span&gt; &lt;span class="nf"&gt;nextTurn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&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="k"&gt;void&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;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;players&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;step&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;step&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;step&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="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;turn&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;count&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="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;stillPlaying&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;players&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// Wrapping past the opening seat is one round.&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;index&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;round&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;turn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;index&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="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;Two details matter. A finished player is skipped, so late rounds have fewer turns in them. And a turn can hold several throws, because a six, a capture or a piece reaching home earns another one.&lt;/p&gt;

&lt;p&gt;The stopping rule matters too. A game ends only when one player is left unfinished, because the others play on for second and third. A two-player game stops when one player has all four pieces home. A four-player game runs until three have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious guess
&lt;/h2&gt;

&lt;p&gt;A natural first guess is that the rounds stay roughly flat as players are added. Each player still has four pieces to walk round the same board, so each should need about the same number of their own turns. More players would mean more turns per round, not more rounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the simulation measured
&lt;/h2&gt;

&lt;p&gt;That guess is wrong. Here are the simulated results, 10,008 games at each table size:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Seats&lt;/th&gt;
&lt;th&gt;Median rounds&lt;/th&gt;
&lt;th&gt;Middle 80%&lt;/th&gt;
&lt;th&gt;Mean rounds&lt;/th&gt;
&lt;th&gt;Mean captures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;56–88&lt;/td&gt;
&lt;td&gt;71.6&lt;/td&gt;
&lt;td&gt;4.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;95&lt;/td&gt;
&lt;td&gt;76–117&lt;/td&gt;
&lt;td&gt;95.9&lt;/td&gt;
&lt;td&gt;14.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;td&gt;99–147&lt;/td&gt;
&lt;td&gt;121.8&lt;/td&gt;
&lt;td&gt;30.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each added seat adds about 25 rounds to the median. The spread widens too: the middle 80% of four-player games covers 48 rounds, against 32 at two seats. In every case the mean sits a round or two above the median, so the distribution has a modest tail of long games.&lt;/p&gt;

&lt;p&gt;A worked reading of the four-seat row: in ten simulated four-player games, expect about one to be over by round 99 and about one to run to round 147 or later. Half end by round 120. That long tail comes from the dice and the captures alone, because computer players are never slow to move. A human table adds its own pace on top.&lt;/p&gt;

&lt;p&gt;Method, in one breath: every seat was a scripted player moving through the real rules engine with a seeded die. Most seats took a capture or a finish when one was available, otherwise brought a piece out, otherwise moved their furthest piece. One seat per game used a different scoring rule for a separate test that isn't about length. No game was cut short and no move was rejected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the rounds grow
&lt;/h2&gt;

&lt;p&gt;Two things push the count up.&lt;/p&gt;

&lt;p&gt;The first is captures. The simulated games averaged 4.4 captures with two players and 30.0 with four. Per seat, that is about 2.2 captures a game at two players and 7.5 at four. Every capture sends a piece back to base, where it waits for a six and then walks the lap again. At two seats there is about one capture every 16 rounds; at four, one every 4.&lt;/p&gt;

&lt;p&gt;The second is the stopping rule above. At four seats the count runs until three players are home, not one.&lt;/p&gt;

&lt;p&gt;The data can't separate these two effects. The run recorded each game's total rounds, not the round when the first player finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  The minutes, if you must
&lt;/h2&gt;

&lt;p&gt;Rounds times seats gives an upper bound on turns: 140, 285 and 480 for the median game. Multiply by your own seconds per turn. An earlier, different test in August, with random moves on an older version of the rules, is why the Ludo card on my site changed from 20 minutes to 30–60. I have no measured human pace, so I won't print one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check it at your own table
&lt;/h2&gt;

&lt;p&gt;Keep a tally: one mark each time the turn comes back to whoever went first. If your two-player games land far outside 56–88 rounds, look at your house rules first. Two dice, a one that also releases a piece, or a bounce-back finish could each move the count, and so could human choices. If you'd rather play the ruleset these numbers come from, &lt;a href="https://playboardit.com/play/ludo" rel="noopener noreferrer"&gt;Ludo on Boardit&lt;/a&gt; is the version I build, and you can keep the same tally there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the simulation cannot say
&lt;/h2&gt;

&lt;p&gt;If you play Ludo online, the Ludo rules are the same as on a cardboard board, so these lengths are a fair guide when you plan an evening of Ludo with friends.&lt;/p&gt;

&lt;p&gt;These are computer players, not people. The opponents favoured captures, so the capture counts run higher than random play would give. The engine is the one live on 29 September 2026. The run doesn't speak to seat order, to strategy, or to how long your evening will feel.&lt;/p&gt;

</description>
      <category>boardgames</category>
      <category>games</category>
      <category>webdev</category>
      <category>abotwrotethis</category>
    </item>
    <item>
      <title>Hosting Discord trivia for your server without writing a bot</title>
      <dc:creator>Ahmed Moaz</dc:creator>
      <pubDate>Fri, 16 Oct 2026 09:45:52 +0000</pubDate>
      <link>https://dev.to/board_it_b11ff7d58bf863f8/hosting-discord-trivia-for-your-server-without-writing-a-bot-3ecb</link>
      <guid>https://dev.to/board_it_b11ff7d58bf863f8/hosting-discord-trivia-for-your-server-without-writing-a-bot-3ecb</guid>
      <description>&lt;p&gt;The usual route to Discord trivia is a bot. Someone finds one, or writes one, adds it to the server, and trivia questions start appearing in a text channel. It works, but it asks more of a server than most event hosts expect. This is how to host trivia on Discord by the other route: a voice channel, a link to a browser quiz, and three people with clear jobs.&lt;/p&gt;

&lt;p&gt;I build Boardit, whose trivia quiz is the one I use as the example, so weigh that as you read.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Discord trivia bot asks of a server
&lt;/h2&gt;

&lt;p&gt;Start with permissions. Discord's help pages say that to add an app to a server, you must own the server or have the Manage Server permission. So how to add a bot to a Discord server is really a question about who holds that permission. On a community server, that is usually a short list of admins, and the event host may not be on it.&lt;/p&gt;

&lt;p&gt;The bot also keeps whatever access it was granted until someone removes it. A bot you write yourself is a program that has to run somewhere, all the time, and someone has to keep it running.&lt;/p&gt;

&lt;p&gt;Then there is the format. Many quiz bots post questions in a text channel, where answers are typed in the open. When the first correct reply takes the point, everyone else sees it the moment it lands, which rewards fast typists and watchers more than knowers.&lt;/p&gt;

&lt;h2&gt;
  
  
  A browser quiz in a voice channel
&lt;/h2&gt;

&lt;p&gt;The alternative keeps Discord for what it does well: voice, faces and banter, the parts that make Discord voice channel games fun. The quiz runs in a browser tab, so you can play with friends in the call while each player answers privately on their own phone or computer. Nobody sees anyone else's answer until the reveal.&lt;/p&gt;

&lt;p&gt;The setup is a link. Anyone who types the room code, or opens the invite link, takes a seat. That makes the link the door key. Post it in the voice channel's own text chat, not in an announcements channel the whole server can read.&lt;/p&gt;

&lt;p&gt;Set the room to Private before people arrive, and check it once. Hosts often keep two tabs open, so one fix matters here: since 3 October 2026, on Boardit's quiz, an older lobby tab can no longer undo settings saved in the newer one.&lt;/p&gt;

&lt;p&gt;The quiz's two scoring modes, and how a question ends once everyone has answered, are on its page at &lt;a href="https://playboardit.com/play/trivia-tavern" rel="noopener noreferrer"&gt;Boardit&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three roles for a trivia night on Discord
&lt;/h2&gt;

&lt;p&gt;A Discord game night built around a quiz runs better with three people than with one.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The host opens the room, sets it to private, picks the topics and posts the link in the voice channel's chat.&lt;/li&gt;
&lt;li&gt;The announcer reads the scores aloud. The standings stay up for seven seconds between rounds, which is a fixed beat to call out the leaders and tease whoever is last.&lt;/li&gt;
&lt;li&gt;The moderator watches the text chat, deletes any answers posted there, and handles anyone who arrives once the seats are full.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last job matters. A room has a seat limit, shown on the game page, and a busy voice channel can hold more people than one quiz seats. Discord itself caps a voice channel at 25 once anyone turns a camera on. Plan a second host for overflow, or run the quiz again an hour later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules to post before the first question
&lt;/h2&gt;

&lt;p&gt;Pin these in the event channel so nobody argues later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No answers in any channel while a question is open.&lt;/li&gt;
&lt;li&gt;No searching. Phones are for answering only.&lt;/li&gt;
&lt;li&gt;Ties stand as the game settles them; the host does not overrule the result.&lt;/li&gt;
&lt;li&gt;Late arrivals wait for the next quiz rather than taking a seat mid-game.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Posted early, they keep an online trivia night fair to the quiet members as well as the loud ones. A quiz night for an online community lives or dies on that.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a Discord trivia bot is still the right tool
&lt;/h2&gt;

&lt;p&gt;A bot has real strengths, and a community game night is not always the right shape. A bot runs while people come and go, keeps a leaderboard across weeks, and can ask questions your members wrote. A browser room needs everyone online at once and holds fewer people than a big channel.&lt;/p&gt;

&lt;p&gt;So pick by the event. For a weekly scheduled slot among your Discord server events, with a crowd in voice at the same time, the browser room is simpler. For an all-week, drop-in quiz in text, keep the bot. Either way, games to play in Discord voice chat work best when the talking is the fun and the scoring stays out of sight.&lt;/p&gt;

&lt;p&gt;The Discord limits above come from its help pages as read on 8 October 2026, and they can change. Discord is a trademark of Discord Inc. This post is not affiliated with it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Ahmed Moaz builds Boardit, a free browser game site.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>trivia</category>
      <category>games</category>
      <category>webdev</category>
      <category>abotwrotethis</category>
    </item>
    <item>
      <title>Structuring a multiple-choice quiz bank: eight fields per question, and why each exists</title>
      <dc:creator>Ahmed Moaz</dc:creator>
      <pubDate>Fri, 16 Oct 2026 06:11:26 +0000</pubDate>
      <link>https://dev.to/board_it_b11ff7d58bf863f8/structuring-a-multiple-choice-quiz-bank-eight-fields-per-question-and-why-each-exists-50f8</link>
      <guid>https://dev.to/board_it_b11ff7d58bf863f8/structuring-a-multiple-choice-quiz-bank-eight-fields-per-question-and-why-each-exists-50f8</guid>
      <description>&lt;p&gt;I build Boardit, a free browser game site, and one of its six games is a live quiz called Trivia Tavern. As of 4 October 2026, its public pages describe a bank of nearly 4,000 checked questions across 38 topics, and the site's trivia hub prints 663 of them as samples on 17 topic pages. The bank was rebuilt on 26 and 27 September 2026.&lt;/p&gt;

&lt;p&gt;This post is not a copy of our files. It is the structure I would hand anyone starting a question bank of their own, and where something Trivia Tavern does in public explains a field, I say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep one question per line
&lt;/h2&gt;

&lt;p&gt;Store each question as one JSON object on its own line. The format is called JSON Lines, and its whole specification fits on one page at &lt;a href="https://jsonlines.org/" rel="noopener noreferrer"&gt;jsonlines.org&lt;/a&gt;. A broken record then breaks one line, not the whole file. A diff shows exactly which question changed. And counting the bank is a line count, not a guess.&lt;/p&gt;

&lt;p&gt;Here is one record, written for this post. It is not from our bank:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"rivers-0001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"difficulty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"easy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"question"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Which river runs through Cairo?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"answer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Nile"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"wrong"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"Danube"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"Thames"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"Volga"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="nl"&gt;"explain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Cairo sits where the Nile Valley meets the Nile Delta."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"sources"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"https://en.wikipedia.org/wiki/Cairo"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="nl"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Nile"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is eight fields. The rest of this post gives the reason for each.&lt;/p&gt;

&lt;h2&gt;
  
  
  The id outlives every edit
&lt;/h2&gt;

&lt;p&gt;An &lt;code&gt;id&lt;/code&gt; is the only part of a question that survives being fixed. Correct a typo or relabel the difficulty and the id stays, so any answer history still points at the right question. Never give an old id to a new question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Difficulty is a field because it changes the score
&lt;/h2&gt;

&lt;p&gt;In Trivia Tavern an easy question is worth 100 points, a medium one 200 and a hard one 300, and that weighting is the same in every room. So a wrong label is a scoring bug, not a matter of taste: a "hard" question that everyone knows pays three times what it should.&lt;/p&gt;

&lt;p&gt;Rounds also build. On the default Mixed setting, each round starts with questions nearly everyone knows and ends on a couple of hard ones. A dealer can only do that if &lt;code&gt;difficulty&lt;/code&gt; is a field it can read.&lt;/p&gt;

&lt;p&gt;I counted the labels on one public sample page on 4 October 2026, the landmarks topic: 39 questions, of which 16 are easy, 15 medium and 8 hard. That is about 41%, 38% and 21%.&lt;/p&gt;

&lt;p&gt;Settle one thing before anyone labels anything. With four options, a blind guess is right one time in four. If difficulty means the share of a casual table who get it right, lucky guesses included, no label can honestly sit below 25%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong answers get their own list
&lt;/h2&gt;

&lt;p&gt;Store the right answer and the three wrong ones apart, and do not store a letter. Every Trivia Tavern question has four options, with no lifelines and no passing, and options in any quiz like this should be shuffled each time a question is shown. A stored "C" is wrong the moment you shuffle.&lt;/p&gt;

&lt;p&gt;A separate &lt;code&gt;wrong&lt;/code&gt; list also lets you review the wrong options on their own, which is where weak questions hide. For what makes a good wrong option, start with Haladyna, Downing and Rodriguez's review of 31 item-writing guidelines (&lt;em&gt;Applied Measurement in Education&lt;/em&gt;, 2002). Two of its plainest rules: keep the options alike in kind and length, and avoid "all of the above".&lt;/p&gt;

&lt;h2&gt;
  
  
  An explanation, and where the fact came from
&lt;/h2&gt;

&lt;p&gt;When Trivia Tavern reveals an answer, the result stays up for six seconds and shows how many people chose each option. A table that has just got something wrong wants to know why, and one &lt;code&gt;explain&lt;/code&gt; sentence gives a reveal something to say. Make it a new fact, not the answer said again.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sources&lt;/code&gt; is a list because one link is not enough for anything a player could argue with. Our changelog calls the rebuilt questions "checked", and a check needs something to check against, kept next to the question rather than in someone's head.&lt;/p&gt;

&lt;h2&gt;
  
  
  Topic and subject are not the same field
&lt;/h2&gt;

&lt;p&gt;A topic is what a host picks: football, flags, the ancient world. In Trivia Tavern a match left on Mixed gives every category an equal share, and most topics hold about a hundred questions, with more for football and for food and drink. Keep the topic in the file name, so moving a file moves its questions.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;subject&lt;/code&gt; is narrower: the one thing a question is about, such as a river or a person. With it, a dealer can avoid asking two questions about the same thing in one match, where the first tends to give the second away. Spell subjects one fixed way, such as an encyclopedia article title.&lt;/p&gt;

&lt;h2&gt;
  
  
  What no format can check
&lt;/h2&gt;

&lt;p&gt;A schema checks shape. It can refuse a record with two wrong options or no source. It cannot tell you that a fact is true, that a subject is famous enough for your table, or that "easy" really is easy. A second reader can catch the first two. Only real answers, collected over time, settle the third.&lt;/p&gt;

&lt;p&gt;The same fields work for trivia questions written for a pub or a classroom, and for a trivia quiz you run as trivia online with friends.&lt;/p&gt;

&lt;p&gt;Trivia Tavern does not take questions written by players, so all of this is for people building a quiz of their own. If you want to see a finished bank from the player's side, the quiz is one of the games on &lt;a href="https://playboardit.com/" rel="noopener noreferrer"&gt;Boardit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Ahmed Moaz builds Boardit, a free browser game site, from Egypt.&lt;/p&gt;

</description>
      <category>trivia</category>
      <category>games</category>
      <category>webdev</category>
      <category>abotwrotethis</category>
    </item>
    <item>
      <title>Scoring a drawing game fairly: speed points, a shrinking share, and a paid drawer</title>
      <dc:creator>Ahmed Moaz</dc:creator>
      <pubDate>Sat, 03 Oct 2026 06:58:37 +0000</pubDate>
      <link>https://dev.to/board_it_b11ff7d58bf863f8/scoring-a-drawing-game-fairly-speed-points-a-shrinking-share-and-a-paid-drawer-23el</link>
      <guid>https://dev.to/board_it_b11ff7d58bf863f8/scoring-a-drawing-game-fairly-speed-points-a-shrinking-share-and-a-paid-drawer-23el</guid>
      <description>&lt;p&gt;When I built the scoring for a draw and guess game, the first version was the obvious one: the faster you guess, the more you get. It took one test table to show why that is not enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step one: speed points
&lt;/h2&gt;

&lt;p&gt;A correct guess earns points on a straight line from the clock. A guess as the timer hits zero is worth 100, and one at the very start is worth 500, so the range is 100 plus up to 400 across the whole drawing time. The values are rounded to the nearest 5.&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;GUESS_FLOOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&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;GUESS_RANGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;speedPoints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secondsLeft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;drawSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&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;drawSeconds&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;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secondsLeft&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;round5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;GUESS_FLOOR&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;GUESS_RANGE&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;total&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 clamp matters. A clock read a hair outside its bounds must not pay more than 500 or less than 100.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step two: a shrinking share
&lt;/h2&gt;

&lt;p&gt;If three people read a good drawing in the same second, they should not all take the full amount. Each correct guess is its speed points multiplied by an order share of 1 / (1 + 0.8 x earlier correct guesses). The first guesser keeps 100%, the second about 56%, the third about 38%, and no one ever drops below 20%. At 450 speed points that pays 450, 250, 175, 130, 105 and 90.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step three: pay the drawer on what the table actually scored
&lt;/h2&gt;

&lt;p&gt;The drawer takes the points the other players scored this turn, as shown on the result screen after the order cut, divided by everyone at the table: the drawer and any player who did not answer count in the divisor. Rounded to a multiple of five. In a two-player room the drawer gets half of the guess. With four players and three guesses worth 450, 250 and 175, the drawer gets 875 / 4, about 220. If nobody guesses, the drawer gets nothing.&lt;/p&gt;

&lt;p&gt;I changed this after the first version. An earlier rule divided by the number of guessers, so a drawing only one person read could pay the drawer as much as one the whole table read. Dividing by the whole table means every extra right answer adds to what the drawer earns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hints without giving it away
&lt;/h2&gt;

&lt;p&gt;The dashes show how long each word is, and letters fill in one at a time as the clock runs down. A guess that is one letter off tells only that player they are close, and a right guess is hidden from the table so nobody copies it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;When you design scoring for a multiplayer game, test it with a table that behaves badly: simultaneous guesses, no guesses, and one very good drawing. Most of the fairness bugs show up there.&lt;/p&gt;

&lt;p&gt;If you want to see it running, the game is a free draw and guess game for two to twelve players on &lt;a href="https://playboardit.com/play/draw-and-guess" rel="noopener noreferrer"&gt;Boardit&lt;/a&gt;: a drawing game with friends in the browser, with no download. It is not just for friends, because Quick match seats you at an open table with other players.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>algorithms</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Keeping multiplayer board game rules honest: pure reducers on the server</title>
      <dc:creator>Ahmed Moaz</dc:creator>
      <pubDate>Sat, 03 Oct 2026 06:51:37 +0000</pubDate>
      <link>https://dev.to/board_it_b11ff7d58bf863f8/keeping-multiplayer-board-game-rules-honest-pure-reducers-on-the-server-5065</link>
      <guid>https://dev.to/board_it_b11ff7d58bf863f8/keeping-multiplayer-board-game-rules-honest-pure-reducers-on-the-server-5065</guid>
      <description>&lt;p&gt;In a multiplayer board game, the dangerous bug is not a crash. It's two players seeing different boards, or one player doing something the rules don't allow. The fix that has worked best for me is boring: put the rules in pure functions, and run them only on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  One shape for every game
&lt;/h2&gt;

&lt;p&gt;Each game's rules live in a reducer: given the current state and an action ("roll", "buy", "vote"), it returns the next state or an error. It doesn't read the clock, call the network or touch the database. A thin layer around it loads the state, calls the reducer, and saves the result.&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;function&lt;/span&gt; &lt;span class="nf"&gt;reduce&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;State&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;player&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="nx"&gt;State&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// validate that it's this player's turn, the action is legal,&lt;/span&gt;
  &lt;span class="c1"&gt;// then return a new state. No side effects.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why that pays off
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Testing is cheap.&lt;/strong&gt; A reducer is a function, so a test is "start here, do this, expect that". The project now runs close to a thousand such tests in seconds, and many of them are rules a player would argue about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The client can't cheat.&lt;/strong&gt; The browser sends an intent ("I roll"), never a result. The server rolls the dice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret information stays secret.&lt;/strong&gt; In a hidden-role game, the secret word never reaches anyone it shouldn't, because the part of the code that picks it only runs on the server and isn't imported by any browser code. A test fails if that ever changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it still gets hard
&lt;/h2&gt;

&lt;p&gt;Timers are the awkward part. A turn clock isn't a pure input, so I pass the current time in as part of the action and let the server decide when an action like "time out" is sent. Reconnection is the other: a player who drops and returns should get the authoritative state, not replay missed events.&lt;/p&gt;

&lt;p&gt;If you build a turn-based multiplayer board game, start with the reducer before the UI. The UI is the part you'll rewrite anyway. My own project is &lt;a href="https://playboardit.com/" rel="noopener noreferrer"&gt;Boardit&lt;/a&gt;, a set of free multiplayer board games online that run in the browser. You can play with friends by sharing a room code, and it isn't only for friends: open tables and Quick match seat you with other players.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>gamedev</category>
      <category>testing</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Why a five-character room code beats accounts for a party game</title>
      <dc:creator>Ahmed Moaz</dc:creator>
      <pubDate>Fri, 02 Oct 2026 21:51:24 +0000</pubDate>
      <link>https://dev.to/board_it_b11ff7d58bf863f8/why-a-five-character-room-code-beats-accounts-for-a-party-game-21mo</link>
      <guid>https://dev.to/board_it_b11ff7d58bf863f8/why-a-five-character-room-code-beats-accounts-for-a-party-game-21mo</guid>
      <description>&lt;p&gt;When I started building a site for playing board games with friends, the obvious design was accounts: sign up, add friends, send invites. I dropped almost all of it for a five-character room code. Here is why, and what it cost me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with accounts for a one-evening game
&lt;/h2&gt;

&lt;p&gt;A party game is a short-lived thing. Four people want to play for forty minutes. Every extra step before the first move loses someone: an email form, a verification link, a username that's already taken. The people most likely to quit are the ones you invited, not the host.&lt;/p&gt;

&lt;p&gt;A room code is the opposite deal. One person opens a table and reads out five characters. Everyone else types them in, picks a name and sits down. Guests don't need an account at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the code has to be
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Short enough to read aloud&lt;/strong&gt; across a video call. Five characters is about the limit before people start mishearing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unambiguous.&lt;/strong&gt; No characters that look alike. If you read "O" and "0" to someone, you've already lost a minute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard to guess.&lt;/strong&gt; It is effectively a password to the table, so it can't be sequential and can't be listed anywhere public.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The cost: the code is now a credential
&lt;/h2&gt;

&lt;p&gt;That last point changed more than I expected. Because the code is the only thing keeping strangers out of a table:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Room pages are excluded from search indexing and from the sitemap.&lt;/li&gt;
&lt;li&gt;The code never goes into analytics, URLs sent to third parties, or logs.&lt;/li&gt;
&lt;li&gt;Any script I didn't write, like an ads script, simply doesn't load on room pages, because I can't redact what it reads from the address bar.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd tell someone considering the same design
&lt;/h2&gt;

&lt;p&gt;Use a code when the session is short and everyone is in a hurry. Use accounts when people return across weeks. And whichever you pick, write down early where the identifier can leak, because a "harmless" ID in a URL ends up in places you didn't plan.&lt;/p&gt;

&lt;p&gt;This is how it works in &lt;a href="https://playboardit.com/" rel="noopener noreferrer"&gt;Boardit&lt;/a&gt;, a free browser site with six multiplayer board and party games. I'd like to hear where you'd draw the line between codes and accounts.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ux</category>
      <category>showdev</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>Three bugs I hit building a multiplayer board game site on Supabase and Cloudflare Workers</title>
      <dc:creator>Ahmed Moaz</dc:creator>
      <pubDate>Fri, 02 Oct 2026 19:15:52 +0000</pubDate>
      <link>https://dev.to/board_it_b11ff7d58bf863f8/three-bugs-i-hit-building-a-multiplayer-board-game-site-on-supabase-and-cloudflare-workers-36e</link>
      <guid>https://dev.to/board_it_b11ff7d58bf863f8/three-bugs-i-hit-building-a-multiplayer-board-game-site-on-supabase-and-cloudflare-workers-36e</guid>
      <description>&lt;p&gt;I've been building &lt;a href="https://playboardit.com/" rel="noopener noreferrer"&gt;Boardit&lt;/a&gt;, a free site where friends play board and party games in the browser. One person opens a table, shares a five-character room code, and everyone else joins from their own phone or laptop. It runs on Next.js (App Router), Supabase and Cloudflare Workers, and it has six games so far: Property Rush, Trivia Tavern, Ludo, Who's the Spy, Snakes &amp;amp; Ladders and Draw and Guess.&lt;/p&gt;

&lt;p&gt;Most of the game logic was the easy part. These three bugs cost me the most time, and none of them showed an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;void query&lt;/code&gt; never sends anything
&lt;/h2&gt;

&lt;p&gt;I had a heartbeat that updates a row so the room list knows a room is still alive. It looked like this:&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;void&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rooms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;last_seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It did nothing. No error in the console, no failed request, no row in the network tab. The reason: a Supabase (PostgREST) query builder is a lazy thenable. It only sends the HTTP request when something calls &lt;code&gt;.then()&lt;/code&gt; on it. &lt;code&gt;void&lt;/code&gt; doesn't, and neither does just building it.&lt;/p&gt;

&lt;p&gt;The fix is to actually run it:&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;void &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rooms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;last_seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;roomId&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 lesson I took: when a write "isn't happening" and nothing is failing, patch &lt;code&gt;window.fetch&lt;/code&gt; and look for the request before you read any more code.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Realtime row changes were slow, so I nudged over broadcast
&lt;/h2&gt;

&lt;p&gt;Every live feature (game state, chat, the drawing board) subscribed to &lt;code&gt;postgres_changes&lt;/code&gt; and also polled. On my project I measured it on 16 September 2026: a row written to a chat table reached a subscribed browser through &lt;code&gt;postgres_changes&lt;/code&gt; after about &lt;strong&gt;5 seconds&lt;/strong&gt;. A broadcast message on the same connection arrived in about &lt;strong&gt;310 ms&lt;/strong&gt;. Mine isn't a general benchmark, just my setup, but it explained why the games felt like they were catching up: the polls were doing all the real work.&lt;/p&gt;

&lt;p&gt;What worked: after a write, the database sends a contentless "look again" message on a private broadcast topic (&lt;code&gt;realtime.send()&lt;/code&gt; from a trigger), and each client refetches through its own row-level security policy. Because the message carries no data, nothing secret rides the broadcast, and Postgres still decides who can see which rows. I kept a slow poll as a backstop for a dropped socket.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The room code is a credential, so analytics had to be locked down
&lt;/h2&gt;

&lt;p&gt;In Boardit, anyone with the code can sit at the table, so the code is effectively a password. That made normal analytics settings dangerous:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Autocapture&lt;/strong&gt; sends the text of whatever was clicked. The lobby shows the code as a "copy" button, so the code would have gone to the analytics vendor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URLs&lt;/strong&gt; such as &lt;code&gt;/room/AB3XK/table&lt;/code&gt; contain it, including inside redirect parameters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heatmaps&lt;/strong&gt; key their data by URL, which turns the code into a property name that a "clean the values" filter walks straight past.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I ended up with: autocapture, session replay, heatmaps and dead-click capture are off in code (not just in the dashboard, because the dashboard setting can switch itself back on from remote config), room paths are rewritten to &lt;code&gt;/room/[code]&lt;/code&gt; before anything is sent, and every event property goes through a scrubber. Third-party scripts like ads simply don't load on room pages, because you can't redact a script you didn't write.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Write the "no code in analytics" test before the first analytics event, not after the first leak. And treat any Supabase write that isn't awaited as a bug until proven otherwise.&lt;/p&gt;

&lt;p&gt;If you want to see how it plays, it's here: &lt;a href="https://playboardit.com/" rel="noopener noreferrer"&gt;playboardit.com&lt;/a&gt;. I'd be glad to hear what you'd build differently, especially around realtime on Supabase.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>nextjs</category>
      <category>supabase</category>
    </item>
    <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>
