<?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: Don Gerard</title>
    <description>The latest articles on DEV Community by Don Gerard (@myeventbingo).</description>
    <link>https://dev.to/myeventbingo</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%2F4217982%2Fddf25d0a-41ec-4d82-8158-761392f3d9a9.png</url>
      <title>DEV Community: Don Gerard</title>
      <link>https://dev.to/myeventbingo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/myeventbingo"/>
    <language>en</language>
    <item>
      <title>Who owns the clock? Lessons from building a live multiplayer game in Blazor and SignalR</title>
      <dc:creator>Don Gerard</dc:creator>
      <pubDate>Fri, 30 Oct 2026 22:12:50 +0000</pubDate>
      <link>https://dev.to/myeventbingo/who-owns-the-clock-lessons-from-building-a-live-multiplayer-game-in-blazor-and-signalr-aef</link>
      <guid>https://dev.to/myeventbingo/who-owns-the-clock-lessons-from-building-a-live-multiplayer-game-in-blazor-and-signalr-aef</guid>
      <description>&lt;p&gt;I build business software on the Microsoft stack for a living. This year I built something different: &lt;a href="https://myeventbingo.com" rel="noopener noreferrer"&gt;My Event Bingo&lt;/a&gt;, a live bingo game for meetings, presentations and watch parties. A host writes 25 squares from the things that will happen at the event ("someone says synergy", "the dog walks into frame"). Everyone joins from a link or QR code and gets their own shuffled 5x5 card. There is no number caller; the event does the calling.&lt;/p&gt;

&lt;p&gt;It runs on .NET 10. One Razor Class Library of about 200 components serves both the website (a Blazor Web App) and a .NET MAUI Blazor Hybrid app for iPhone, Android and Windows. SignalR carries everything live: marks, chat, polls, game start, game over.&lt;/p&gt;

&lt;p&gt;Here is what a live multiplayer game taught me that CRUD apps never did.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The server owns the lifecycle. Clients only mirror it.
&lt;/h2&gt;

&lt;p&gt;Early on, each client worked out for itself whether the game had started, whether someone had won, whether it was over. That is a recipe for two phones showing two different truths.&lt;/p&gt;

&lt;p&gt;Now there is one enum, derived on the server and pushed out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;GameLifecycle&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;NotStarted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// before StartTime; the host can still edit squares&lt;/span&gt;
    &lt;span class="n"&gt;Started&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// StartTime crossed, this player hasn't marked anything yet&lt;/span&gt;
    &lt;span class="n"&gt;InProgress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// at least one square marked&lt;/span&gt;
    &lt;span class="n"&gt;WonByMe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// this player holds a line; the game continues for everyone else&lt;/span&gt;
    &lt;span class="n"&gt;Paused&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// host paused; marking is blocked, chat still works&lt;/span&gt;
    &lt;span class="n"&gt;Ended&lt;/span&gt;        &lt;span class="c1"&gt;// winner limit reached, time limit reached, or host finished it&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every transition has a SignalR event. A client may compute the phase locally only on a cold start, before the first push arrives. After that, the server is the source of truth. When a rule changes, it changes in one place.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Nobody clicks at 7:00 PM. Something on the server has to.
&lt;/h2&gt;

&lt;p&gt;A game has a start time. At that moment the Join button needs to light up on every waiting phone, even if nobody touches anything. My first version only noticed the start time when a request came in. Visitors sat on a welcome card with a dead button.&lt;/p&gt;

&lt;p&gt;The fix is a hosted service, a &lt;code&gt;BackgroundService&lt;/code&gt; with a &lt;code&gt;PeriodicTimer&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GameLifecycleScheduler&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BackgroundService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;TickInterval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;timer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PeriodicTimer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TickInterval&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitForNextTickAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// 1. Only look at games that have someone connected right now.&lt;/span&gt;
            &lt;span class="c1"&gt;// 2. StartTime crossed and not yet announced -&amp;gt; send "game started".&lt;/span&gt;
            &lt;span class="c1"&gt;// 3. StartTime + 24h crossed -&amp;gt; mark the game ended, then send "game over".&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;Three details turned out to matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filter on live sessions, not database rows.&lt;/strong&gt; A brand-new game has zero player rows but plenty of connected visitors, and those visitors are exactly who needs the push.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedupe "started" on delivery, not on attempt.&lt;/strong&gt; If the push reached nobody, retry on the next tick for a short window. A duplicate start is harmless because the client handler is idempotent. A missed start is the bug you are trying to fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedupe "ended" in the database.&lt;/strong&gt; Flip &lt;code&gt;IsEnded = true&lt;/code&gt; before broadcasting. Whichever transaction commits first wins, and the scheduler and a last-second square tap can't both end the game.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fifteen seconds is the worst-case delay a player can feel, and it costs two narrow indexed queries per tick.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A visitor is not a player until they say so
&lt;/h2&gt;

&lt;p&gt;My original page load did two things at once: it fetched the game and generated the visitor's card. A GET that writes. Anyone who opened the link, including a link-preview bot, became a player with a locked card.&lt;/p&gt;

&lt;p&gt;Now loading the game is read-only, and joining is an explicit &lt;code&gt;POST /api/bingo/joingame&lt;/code&gt;. It is idempotent: call it twice and you get the same card back, never a second card and never an error. The client also disables the button while the request is in flight, but the server does not rely on that.&lt;/p&gt;

&lt;p&gt;Rule of thumb I now apply everywhere: if a GET changes state, it will eventually be called by something you didn't expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Two tabs, one player: last in wins
&lt;/h2&gt;

&lt;p&gt;People open the game link in a second tab or on a second device. If both sessions can mark squares, you get double events and confused boards.&lt;/p&gt;

&lt;p&gt;A singleton &lt;code&gt;GameplaySessionRegistry&lt;/code&gt; keys sessions by &lt;code&gt;UserId|GameId&lt;/code&gt;, gives each registration a monotonic version, and treats the newest one as authoritative. The older session is told it has been superseded, and gameplay commands from it are ignored. It is in-memory and &lt;code&gt;ConcurrentDictionary&lt;/code&gt;-based. The few read-then-write paths that &lt;code&gt;ConcurrentDictionary&lt;/code&gt; can't make atomic on its own take a short lock.&lt;/p&gt;

&lt;p&gt;The same registry answers a question SignalR groups would normally answer: who should get this event? This app routes per connection through the registry instead of using groups, which made it easy to include people who are on the welcome card but haven't joined yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. InteractiveAuto means your component boots more than once
&lt;/h2&gt;

&lt;p&gt;Gameplay uses the &lt;code&gt;InteractiveAuto&lt;/code&gt; render mode. A first visit runs on a Server circuit; a returning visit runs on WebAssembly. That is great for load time. It also means a component can prerender, boot on the server, and later boot again in the browser.&lt;/p&gt;

&lt;p&gt;What helped was sorting state into three buckets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Server-authoritative&lt;/strong&gt; (the game, players, card, lifecycle, winners): re-fetched on every interactive boot, then kept current by SignalR. Never trusted from a client-side cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UI preferences&lt;/strong&gt; (selected tab, mark color): nice to keep, fine to rebuild.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UI-ephemeral&lt;/strong&gt; (animations, unread counts, scroll position): allowed to reset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once the rule is "server state is always re-fetched, everything else is best-effort", the handoff stops being scary. The other half is guarding JavaScript interop: create the &lt;code&gt;DotNetObjectReference&lt;/code&gt; once per component instance, dispose it when the component goes away, and make every setup path safe to run twice. Treating "boot" as something that can happen again removed a whole class of ghost-handler bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Derive winners from the event log, and decide what "first" means
&lt;/h2&gt;

&lt;p&gt;Several players can win. Who finished first? I don't store a winners table. Every bingo, and every lost bingo, is already logged as a &lt;code&gt;GameEvent&lt;/code&gt;, so winners are projected from that log when the game ends.&lt;/p&gt;

&lt;p&gt;The interesting decision was the timestamp. A player's finish time is the &lt;em&gt;latest&lt;/em&gt; moment they went from "no line" to "holding a line". If you make a bingo, un-mark a square, and remake it later, you are timed at the remake. That keeps the ordering fair without recording which squares formed the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone starting a real-time Blazor app
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Decide early which side owns time, and put a background service behind it.&lt;/li&gt;
&lt;li&gt;Make every state-changing call a POST and make it idempotent.&lt;/li&gt;
&lt;li&gt;Assume a component will boot twice, and write setup code that is safe to rerun.&lt;/li&gt;
&lt;li&gt;Keep the UI library thin. I've been retiring a third-party component suite in favor of small in-house primitives, and every one I replace makes the render-mode handoff easier to reason about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see it running, start a free game at &lt;a href="https://myeventbingo.com" rel="noopener noreferrer"&gt;myeventbingo.com&lt;/a&gt; and open it on two phones. Happy to answer questions about the SignalR or MAUI side in the comments.&lt;/p&gt;

</description>
      <category>blazor</category>
      <category>dotnet</category>
      <category>signalr</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
