<?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: Arjen</title>
    <description>The latest articles on DEV Community by Arjen (@arjen_).</description>
    <link>https://dev.to/arjen_</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%2F127379%2F1c7d0850-a07d-451e-8c2b-11c7919d0ba6.jpg</url>
      <title>DEV Community: Arjen</title>
      <link>https://dev.to/arjen_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arjen_"/>
    <language>en</language>
    <item>
      <title>Delta encoding multiplayer game state</title>
      <dc:creator>Arjen</dc:creator>
      <pubDate>Sun, 30 Aug 2026 09:49:49 +0000</pubDate>
      <link>https://dev.to/arjen_/delta-encoding-multiplayer-game-state-j2a</link>
      <guid>https://dev.to/arjen_/delta-encoding-multiplayer-game-state-j2a</guid>
      <description>&lt;p&gt;Old Light is a browser strategy game where a tab can stay open for days. The client holds a full copy of the galaxy state it is allowed to see, and the server keeps that copy honest by sending patches: every change arrives as a &lt;code&gt;world.delta&lt;/code&gt; message the client merges into what it already has. Sending changes instead of resending state is textbook delta encoding. What that leaves open is what a game state patch actually holds, and why the patch a rival receives is not the one you receive.&lt;/p&gt;

&lt;p&gt;I covered how the stream starts (one snapshot on connect, then deltas) and the time-math traps inside it in the &lt;a href="https://oldlight.io/blog/game-networking-for-a-slow-strategy-game/" rel="noopener noreferrer"&gt;networking post&lt;/a&gt;. This post is about the delta itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes in a game state patch
&lt;/h2&gt;

&lt;p&gt;When people say delta encoding they usually mean byte diffs: compare two versions of a blob, ship the difference. That requires the sender to know which version the receiver holds. A game server &lt;a href="https://oldlight.io/blog/backend-of-a-browser-strategy-game/" rel="noopener noreferrer"&gt;broadcasting to thousands of sockets&lt;/a&gt; can't afford that; tracking a per-client "last known state" and diffing against it on every change would be more expensive than the update.&lt;/p&gt;

&lt;p&gt;So an Old Light delta states facts about players and sectors instead:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;WorldDelta&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;added&lt;/span&gt;&lt;span class="p"&gt;?:&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;Player&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nl"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;playerIds&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nl"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;?:&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;Player&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
        &lt;span class="nl"&gt;sectors&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;Sector&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
        &lt;span class="nl"&gt;dirtySectors&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;SectorCoord&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="c1"&gt;// map data here went stale, refetch it&lt;/span&gt;
        &lt;span class="nl"&gt;tradeBoard&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;TradeBoardDelta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the market board moved&lt;/span&gt;
        &lt;span class="nl"&gt;deals&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;DealsDelta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a negotiation moved; only its two parties get this&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nl"&gt;serverNow&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A delta says a player joined, an id is gone, a player's row changed, or a sector's public map data went stale. The last two fields carry no payload. They say a surface moved, a client with that surface open goes and reads it, which keeps a busy &lt;a href="https://oldlight.io/guide/trade-hub/" rel="noopener noreferrer"&gt;marketplace&lt;/a&gt; off every socket that isn't looking at one. The server can emit the identical message to every socket without knowing what any of them currently holds, and the client can apply it to whatever it has. It also tells the renderer exactly what to repaint: a &lt;code&gt;players&lt;/code&gt; update touches the roster and HUD, a &lt;code&gt;dirtySectors&lt;/code&gt; entry touches the hexes in the named sectors. Nothing else on screen is redrawn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Absolute values, never increments
&lt;/h2&gt;

&lt;p&gt;Every value in a delta is the new total, and the client assigns it, with no arithmetic against the value it already holds. When your empire's score moves, the payload contains the score, and the merge is a replace keyed by id:&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;incoming&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updated&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;players&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;incoming&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Simplified; the per-field rules come next.) Absolute values make the patch idempotent. A message delivered twice lands on the same state. A message that never arrives leaves the client stale but not corrupted, and the next update of the same entity repairs it completely, because that update is also the full truth rather than a step in a sequence. Increments would need exactly-once, in-order delivery to stay correct, and a browser tab that sleeps for hours and reconnects on a different network is the wrong place to bet on that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same event is a different delta for every viewer
&lt;/h2&gt;

&lt;p&gt;Delta encoding collides with information hiding here. In Old Light, who owns which star is public, but what's inside an empire is not: building composition, income, fleet stacks, and treasury are visible only to the owner (until a rival &lt;a href="https://oldlight.io/guide/recon/" rel="noopener noreferrer"&gt;scouts them&lt;/a&gt;). A change to your empire therefore produces two different patches from one event.&lt;/p&gt;

&lt;p&gt;The public version goes to the galaxy-wide room that every socket joins, including &lt;a href="https://oldlight.io/map" rel="noopener noreferrer"&gt;anonymous spectators&lt;/a&gt;, and it passes through a scrub function first:&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;scrubForBroadcast&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="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;id&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;name&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;kind&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;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;spawn&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;spawn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;score&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;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;owned&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;owned&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;publicHex&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// coord, name, score, buildings: []&lt;/span&gt;
        &lt;span class="c1"&gt;// anything not listed is not sent: credits, transits, unread counts...&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="c1"&gt;// promoted to public by an explicit decision, and carried only when set&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;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protectedUntil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protectedUntil&lt;/span&gt; &lt;span class="o"&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;protectedUntil&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;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;banner&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;banner&lt;/span&gt; &lt;span class="o"&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;banner&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&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 scrub is an allowlist. Every public field is listed explicitly and anything unlisted is dropped by default, so a field added to the player type six months from now stays private until someone consciously promotes it. The two conditional lines were each promoted on purpose. A rival sees the &lt;a href="https://oldlight.io/guide/beginner-protection/" rel="noopener noreferrer"&gt;beginner-protection&lt;/a&gt; countdown because otherwise a refused attack looks like a bug, and the crest is cosmetic.&lt;/p&gt;

&lt;p&gt;The private version of the same event, with the economy, build queue, fleet, and credits populated, goes only to a room containing your own connections. The server also orders the two emits, scrubbed global first and your rich view last, so the thin version can never overwrite the detailed one on your own screen. From the protocol's point of view, a rival's client is synchronised with a smaller galaxy than yours, one holding only the facts the two of you share publicly.&lt;/p&gt;

&lt;h2&gt;
  
  
  A missing field keeps its old value
&lt;/h2&gt;

&lt;p&gt;Two versions of the same player on the same event channel create a merge problem. Your own client sits in the galaxy room like everyone else, so when the server broadcasts a scrubbed update about you, your client receives a copy of yourself with no credits field and an empty owned list. Blindly replacing your local player with that would wipe your empire off your own screen until the next rich view arrived.&lt;/p&gt;

&lt;p&gt;The merge rule that prevents it: &lt;code&gt;undefined&lt;/code&gt; means "not sent", never "cleared".&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="nx"&gt;credits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;incoming&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;incoming&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nx"&gt;owned&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;incoming&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owned&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;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;incoming&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owned&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A field the scrub stripped keeps its locally held value; an explicit value, including &lt;code&gt;null&lt;/code&gt;, overwrites. The empty-owned rule exists because a public broadcast carries &lt;code&gt;owned: []&lt;/code&gt; by design (rival hex lists travel through the map data channel, not the roster), so an empty list must never be read as "this empire lost everything".&lt;/p&gt;

&lt;p&gt;There's a second merge subtlety on captures. When a star changes hands, the delta names the new owner with the hex now in their list, but the previous owner isn't in the payload at all. The client walks every other player it knows and strips any hex the freshly delivered player now claims: last writer wins, keyed by coordinate. Without that pass, both empires would render as owning the same star until the next full snapshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ship the fact, derive the rest
&lt;/h2&gt;

&lt;p&gt;A lot of what the player sees is never on the wire. &lt;a href="https://oldlight.io/guide/borders/" rel="noopener noreferrer"&gt;Territory&lt;/a&gt; is the clearest example: every occupied star projects an empire's borders a fixed radius outward, and where two empires overlap, the older claim wins. The server could ship the resulting shapes, hundreds of hexes for a large empire, on every change. Instead the delta carries only the occupied stars, and the client recomputes the projection from them. Client and server run one rule over one set of inputs, so they can't disagree. The patch stays a few hundred bytes however large the borders it implies.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dirtySectors&lt;/code&gt; goes further and ships an invalidation instead of data. An early version of the client refetched the whole visible map region after every delta, which at scale meant every economy tick anywhere triggered viewport-sized fetches everywhere. Now a delta that changes star ownership names the map sectors that went stale, and the client refetches only those, through the same public request any client could make anyway. Deltas that change no geometry, which is most of them, trigger no fetch at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a client misses one
&lt;/h2&gt;

&lt;p&gt;There is no delta journal and no sequence numbering. If a client disconnects, it doesn't ask for the deltas it missed; on reconnect the server sends a fresh snapshot and the client throws its incremental state away, because every missed change is already baked into the new snapshot. The dormant star cache deliberately survives that reset. It holds the unclaimed stars the snapshot never carries, and dropping them would blank most of the map until the first region fetch came back, which looks like a bug.&lt;/p&gt;

&lt;p&gt;I could have kept a journal and replayed it. The snapshot code has to exist anyway, since every fresh connect needs one, and it is already scrubbed per viewer. A retained event log would need the same per-viewer filtering applied retroactively to every reconnecting client, over a gap of unknown length.&lt;/p&gt;




&lt;p&gt;The game this comes out of is &lt;a href="https://oldlight.io/" rel="noopener noreferrer"&gt;Old Light&lt;/a&gt;, a strategy game across a galaxy that runs in a browser tab.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Paw &amp; Order: upload your dog, and defend them against evidence generated from their own photo</title>
      <dc:creator>Arjen</dc:creator>
      <pubDate>Sun, 16 Aug 2026 12:56:15 +0000</pubDate>
      <link>https://dev.to/arjen_/paw-order-upload-your-dog-and-defend-them-against-evidence-generated-from-their-own-photo-of7</link>
      <guid>https://dev.to/arjen_/paw-order-upload-your-dog-and-defend-them-against-evidence-generated-from-their-own-photo-of7</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/weekend-2026-08-13"&gt;DEV Weekend Challenge: Dog Days Edition&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Upload a photo of your dog. An AI accuses them of a crime. You're their defense attorney.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Paw &amp;amp; Order&lt;/strong&gt; is a browser game where your own dog is the defendant.&lt;/p&gt;

&lt;p&gt;You upload one photo and a few seconds later your dog has been arrested:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The People vs. Biscuit
Docket #PAW-042
DEFENDANT: Biscuit
CHARGE: Grand Theft Sausage
COUNSEL: You
STATUS: Extremely suspicious
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Then the trial starts. The prosecutor puts a question to you, you pick a response, and the case branches from there. Three exhibits go into evidence: generated images of &lt;em&gt;your&lt;/em&gt; dog, at the scene, with the frosting still on their muzzle. Two witnesses give statements, and at least one of them is usually lying.&lt;/p&gt;

&lt;p&gt;A trial runs a few minutes. At the end you get one of four verdicts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NOT GUILTY&lt;/li&gt;
&lt;li&gt;NOT GUILTY, BUT SUSPICIOUS&lt;/li&gt;
&lt;li&gt;GUILTY, BUT REASONABLE DOUBT&lt;/li&gt;
&lt;li&gt;GUILTY&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus a scoreline that isn't the same thing as winning:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VERDICT

NOT GUILTY

Biscuit is free to commit additional crimes.

Defense Performance: 94/100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;You can lose the case and still score 96. You can win it badly.&lt;/p&gt;

&lt;p&gt;Every case has a hidden truth, generated before the trial begins. Sometimes the dog really did it, sometimes they're innocent, sometimes the evidence just lies. The client never sees any of it, so you're not hunting for a correct answer. You're building the strongest defense the facts allow.&lt;/p&gt;

&lt;p&gt;Choices decide the outcome. Replay the same case, answer differently, and the verdict and the score change with you.&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://paw-order.pages.dev" rel="noopener noreferrer"&gt;https://paw-order.pages.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Bring a dog photo, or don't. The home page has a public docket of cases other players entered into the public record, and you can play any of them without uploading anything.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fczzp4xzhxy8n2py57mw2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fczzp4xzhxy8n2py57mw2.png" alt="The dog got arrested!" width="800" height="343"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6y4sszl6d8b299ll4tyf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6y4sszl6d8b299ll4tyf.png" alt="The evidence images" width="800" height="537"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ji54m9oa5z2mrp79ma6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ji54m9oa5z2mrp79ma6.png" alt="Failed to defend" width="800" height="581"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArjenPostma" rel="noopener noreferrer"&gt;
        ArjenPostma
      &lt;/a&gt; / &lt;a href="https://github.com/ArjenPostma/Paw-Order" rel="noopener noreferrer"&gt;
        Paw-Order
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      dev.to weekend challenge submission
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Paw &amp;amp; Order&lt;/h1&gt;

&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;Justice for every good boy.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Upload a photo of your dog. AI generates a fictional criminal case around that
dog. You defend them in court.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://paw-order.pages.dev" rel="nofollow noopener noreferrer"&gt;https://paw-order.pages.dev&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;DEV Weekend Challenge: Dog Days Edition&lt;/h2&gt;

&lt;/div&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges/weekend-2026-08-13" rel="nofollow"&gt;DEV Weekend Challenge: Dog Days
Edition&lt;/a&gt;, start to finish inside
the challenge window. Everything up to the last commit before the deadline of
2026-08-17 06:59 UTC is the submitted entry; anything after that timestamp is
post-deadline work.&lt;/p&gt;
&lt;p&gt;Submission post: &lt;code&gt;SUBMISSION.md&lt;/code&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Local development&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;nvm use
npm ci
cp packages/api/.env.example packages/api/.env   &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; GEMINI_API_KEY is the only one needed&lt;/span&gt;
npm run dev:api    &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; :4270&lt;/span&gt;
npm run dev:app    &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; :5173, proxies /api&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;code&gt;npm run all&lt;/code&gt; (format, lint, typecheck, test) must be green before a commit.&lt;/p&gt;
&lt;/div&gt;



&lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArjenPostma/Paw-Order" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;One rule sits under everything else:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The AI creates the world. The engine runs the game.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Nothing calls a model during the trial. Generation runs once, up front, and produces the whole case: crime, hidden truth, evidence, witnesses, the branching question tree, and the verdict thresholds. After that the game is deterministic code reading structured data.&lt;/p&gt;

&lt;h3&gt;
  
  
  The generation pipeline
&lt;/h3&gt;

&lt;p&gt;Six model calls per case, all Gemini:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Screening.&lt;/strong&gt; Is this actually a dog, and is the frame safe to show on a public page? One &lt;code&gt;gemini-3.7-flash&lt;/code&gt; call against the photo before any of the rest runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Facts.&lt;/strong&gt; Crime, hidden truth, exactly three exhibits, exactly two witnesses, a 4-6 entry timeline. Schema-constrained JSON, with the dog photo attached.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three exhibit images.&lt;/strong&gt; &lt;code&gt;gemini-3.1-flash-lite-image&lt;/code&gt;, rendered concurrently, each with the player's photo passed inline as a reference so the same dog turns up in every exhibit. Without that likeness you're looking at a stock dog and the joke dies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The trial tree&lt;/strong&gt;, generated last, so it can only be built out of exhibits that already exist.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Every visual claim has to be backed by the image
&lt;/h3&gt;

&lt;p&gt;When the prosecutor says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Then perhaps you can explain the frosting on your client's muzzle."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;the exhibit had better show frosting on your dog's muzzle. Otherwise it's AI noise with a caption.&lt;/p&gt;

&lt;p&gt;So each exhibit carries &lt;code&gt;visualFacts&lt;/code&gt;, one to four things &lt;em&gt;visibly present in the rendered image&lt;/em&gt;, and the tree model gets those and nothing else to write from. Three levels stay separate:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Narrative fact (what happened)&lt;/td&gt;
&lt;td&gt;Biscuit ate the cake&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evidence fact (what can be proven)&lt;/td&gt;
&lt;td&gt;Biscuit has frosting on his muzzle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Visual fact (what is actually in the picture)&lt;/td&gt;
&lt;td&gt;White frosting is visible around Biscuit's mouth&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The trial can cite the third column, and can't invent a fourth.&lt;/p&gt;

&lt;h3&gt;
  
  
  Model output is untrusted input
&lt;/h3&gt;

&lt;p&gt;A response schema is a request, not a guarantee. Every generated payload goes through a hand-written validator before anything is stored. It checks what a JSON schema structurally can't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the trial graph is reachable from the root, acyclic, and finishable&lt;/li&gt;
&lt;li&gt;every evidence id cited by a node, a choice or the hidden truth resolves to a real exhibit&lt;/li&gt;
&lt;li&gt;no duplicate node, exhibit or witness ids&lt;/li&gt;
&lt;li&gt;score effects clamped, so one choice can't blow the scale open&lt;/li&gt;
&lt;li&gt;every string and array capped, since the case lands in one JSON column and is served under a long cache&lt;/li&gt;
&lt;li&gt;control characters, zero-widths and bidi overrides stripped from stored text, so a charge title can't reverse the text around it on someone else's home page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When validation fails, the reasons go back into the retry prompt. &lt;code&gt;tree node N4 points at unknown node N9&lt;/code&gt; is something a model can act on, where a bare retry just rolls the dice again. One retry only.&lt;/p&gt;

&lt;h3&gt;
  
  
  The verdict thresholds are derived, not generated
&lt;/h3&gt;

&lt;p&gt;Asked to set its own thresholds, the model has to guess blind, before it knows what totals its own choice effects add up to. Measured over a batch of generated cases that came out at roughly 80% acquittals, with the tainted-acquittal ending unreachable in every case sampled.&lt;/p&gt;

&lt;p&gt;So the engine works them out instead. It walks every run the finished tree can be played to and places the lines by quantile inside that real spread: the top 30% of endings acquit, the bottom 30% convict outright, the middle band convicts with reasonable doubt, and half the acquittals come out tainted. The same quantiles hold whatever numeric scale the model wrote its effects on.&lt;/p&gt;

&lt;p&gt;Two edge cases needed handling. A plain quantile over endings like &lt;code&gt;0, 0, 10, 20, 30&lt;/code&gt; lands &lt;em&gt;on&lt;/em&gt; the minimum, so every run sits above the line and the verdict below it becomes unreachable. And two independent quantiles over one list can land on the same value, emptying the middle band while both lines still look correctly placed. Both are now drawn from the values strictly above the floor. A tree where every run ends on the same doubt total is rejected: if no choice decides anything, it's a cutscene rather than a trial.&lt;/p&gt;

&lt;p&gt;The score is calculated the same way, weighted towards doubt and normalised against what &lt;em&gt;that particular tree&lt;/em&gt; made possible. That's what lets a loss read as an excellent defense. The ceiling is usually below 100, because the run that maximises doubt and the run that argues best are rarely the same run.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hidden truth never reaches the client
&lt;/h3&gt;

&lt;p&gt;The api serves the case minus &lt;code&gt;truth&lt;/code&gt;, minus each exhibit's image prompt, minus whether a witness is reliable. Those stripping functions list every field by hand instead of spreading and deleting, so a field added to the type later can't ride out onto the wire without someone deciding it should. The verdict function never reads &lt;code&gt;truth&lt;/code&gt; at all, only player state and the derived thresholds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Everything is hostile
&lt;/h3&gt;

&lt;p&gt;An anonymous upload endpoint in front of image generation needs bounds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;uploads capped at 20MB, one file, image mime types only, with part and field counts capped too, because busboy defaults both to Infinity&lt;/li&gt;
&lt;li&gt;per-IP rate limit keyed on the IPv6 /64 rather than the address. Keyed on the full address, one host rotates through 2^64 of them and every ceiling becomes decoration.&lt;/li&gt;
&lt;li&gt;a global daily generation ceiling, charged only once a generation is actually going to happen. It used to share a middleware with the per-IP check, which let malformed requests that generated nothing burn the day's headroom and lock real players out.&lt;/li&gt;
&lt;li&gt;bounded concurrency on the screening call, since each one in flight holds the uploaded photo and its base64 copy in memory, and it runs before the generation slot counter ever sees the request&lt;/li&gt;
&lt;li&gt;the screening gate fails open on &lt;code&gt;isDog&lt;/code&gt; and closed on &lt;code&gt;safeForPublic&lt;/code&gt;. A model outage shouldn't tell someone holding a real dog that it isn't a dog, but an unscreened photo must never reach the public docket.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Stack
&lt;/h3&gt;

&lt;p&gt;Vue 3 + Vite as a static SPA on Cloudflare Pages, Express + TypeORM on Railway with Postgres, images in Cloudflare R2. The shared types and the trial engine live in one workspace package, so the api is the authority on the verdict while the app renders a score from the exact same code. 167 tests, plus a CI job that dumps the production schema into a throwaway Postgres to prove the committed migrations still describe it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best use of Google AI.&lt;/strong&gt; Gemini does four separate jobs here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gemini-3.7-flash&lt;/code&gt; writes the case as schema-constrained JSON, so what comes back is a structured world rather than prose&lt;/li&gt;
&lt;li&gt;the same model, with vision, gates the upload: dog or not, safe for public or not&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gemini-3.1-flash-lite-image&lt;/code&gt; renders the exhibits with the player's own photo as a reference, which keeps one specific dog recognisable across three separately generated images&lt;/li&gt;
&lt;li&gt;the same model writes the branching trial tree, constrained to the visual facts of images it has already produced&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then it stops. Gemini builds the world, and the verdict belongs to code that never saw the truth.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Justice for every good boy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>weekendchallenge</category>
      <category>devchallenge</category>
    </item>
    <item>
      <title>Game networking for a slow strategy game</title>
      <dc:creator>Arjen</dc:creator>
      <pubDate>Thu, 23 Jul 2026 05:31:37 +0000</pubDate>
      <link>https://dev.to/arjen_/game-networking-for-a-slow-strategy-game-3mdi</link>
      <guid>https://dev.to/arjen_/game-networking-for-a-slow-strategy-game-3mdi</guid>
      <description>&lt;p&gt;Almost everything written about game networking is about fast games. The classic material covers UDP and client-side prediction, in service of squeezing sixty snapshots per second through a home connection. Very little of it applies to the game I'm building.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://oldlight.io" rel="noopener noreferrer"&gt;Old Light is a multiplayer browser strategy game&lt;/a&gt;. A fleet takes hours to cross the galaxy and your &lt;a href="https://oldlight.io/guide/economy/" rel="noopener noreferrer"&gt;economy&lt;/a&gt; keeps growing while the tab is closed. If a click takes 300ms to round-trip, nothing in the game can tell. The hard part is different: staying correct over long stretches of time, for thousands of empires at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  One snapshot, then deltas
&lt;/h2&gt;

&lt;p&gt;The client opens a WebSocket. The server answers with one message, &lt;code&gt;world.init&lt;/code&gt;: everything this player is allowed to see, plus the server's current clock. After that, every change arrives as a &lt;code&gt;world.delta&lt;/code&gt;, a small patch the client merges in.&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="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;world.init&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&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;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GameState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;world&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;clockOffset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serverNow&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;world.delta&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;applyDelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two handlers are the whole protocol. Nothing needs sequence numbers or an interpolation buffer.&lt;/p&gt;

&lt;p&gt;The fiddly part is the boundary between the two. A delta can fire while the snapshot is still being built. The server handles its side by building &lt;code&gt;world.init&lt;/code&gt; before joining the socket to any broadcast room, so a mid-build delta never reaches the new socket, and the snapshot already includes that change. The client handles its side by buffering deltas that arrive before its state exists and draining them after boot. Skip either half and a player who connects at the wrong millisecond sees a world that disagrees with the server.&lt;/p&gt;

&lt;p&gt;One more thing in that snippet: &lt;code&gt;clockOffset&lt;/code&gt;. The server stamps its clock into every payload, and the client measures the difference once at boot. Everything time-based runs on server time from then on, because the player's machine might be minutes off.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug where both sides do time math
&lt;/h2&gt;

&lt;p&gt;Resources in Old Light aren't ticked by a timer. As covered in the &lt;a href="https://oldlight.io/blog/backend-of-a-browser-strategy-game/" rel="noopener noreferrer"&gt;backend post&lt;/a&gt;, the server computes them on demand: last saved balance, plus rate times elapsed time. The client runs the same formula so your counter ticks up smoothly between messages.&lt;/p&gt;

&lt;p&gt;So a resource value on the wire carries a number plus an anchor timestamp to project it forward from. The trap sits in the timestamp. When the server answers a request, it projects the stored balance to now. But the database row still carries the timestamp from the last actual save, maybe hours ago. Send the fresh number with the stale anchor and the client projects again on top:&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="c1"&gt;// wrong: fresh balance, stale anchor&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;balance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;projectedToNow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;settledAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;settledAt&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// client side, moments later:&lt;/span&gt;
&lt;span class="nx"&gt;display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;minutesSince&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;settledAt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// re-adds hours of income&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same hours of income get counted twice. Nothing errors out. The counter runs ahead of the truth and snaps back whenever a real server value arrives. The fix is a blunt rule: a projected value's anchor is the moment of projection, never the row's timestamp. A regression test pins it down with &lt;code&gt;settledAt === now&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In a shooter, this bug would be visible within a frame. Here it only shows up if you compare the counter against a hand calculation across hours, so it ships unnoticed and gets reported as "my resources jumped".&lt;/p&gt;

&lt;h2&gt;
  
  
  The server plays on without you
&lt;/h2&gt;

&lt;p&gt;A common shortcut in this genre is to let your building "finish" whenever some request happens to arrive after its timer expired, usually your own next login. Old Light finishes the build at the second it's due, whether or not anyone is connected.&lt;/p&gt;

&lt;p&gt;Queue a build and the server sets an in-memory timer. When it fires, the outcome is written to the database and broadcast to your socket room. If you're offline the broadcast lands in an empty room and evaporates. The database write still happened, though, so a rival probing your system an hour later sees the upgraded reality.&lt;/p&gt;

&lt;p&gt;In-memory timers die with the process, so every boot re-arms the pending ones and settles any job that completed while the server was down, as of its original completion moment. The main read path also settles overdue work before answering. Even a missed timer can't serve stale state.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a broadcast is allowed to cost
&lt;/h2&gt;

&lt;p&gt;With no tick rate, nearly all network cost is broadcasts, so each one has a budget: one emit per player action. When someone signs up, the server spawns their empire plus two AI empires around them. All three announcements travel as one delta, not three fanouts.&lt;/p&gt;

&lt;p&gt;Rooms do the scoping. Public changes, like a star changing owner, go to a galaxy-wide room every socket joins. Private state, like your income and build queue, goes only to &lt;code&gt;player:&amp;lt;id&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Building that private view is the expensive part, so the server checks the room first and skips the work when nobody is connected. The AI empires complete builds constantly and never have anyone connected, so without the check most of the enrichment work would be for empty rooms.&lt;/p&gt;

&lt;h2&gt;
  
  
  The netcode I never wrote
&lt;/h2&gt;

&lt;p&gt;Old Light has no client-side prediction and no reconciliation. The client is a viewer. Every mutation is a request the server may refuse, and the client waits the round trip to see the result. An action's real feedback loop is minutes or hours, so an extra 300ms before the queue entry appears is invisible.&lt;/p&gt;

&lt;p&gt;That deletes the hardest problems in the genre's literature. There's no state to roll back, since the client never speculated, and nothing is aimed, so lag compensation has no job to do. Plain TCP WebSockets, socket.io in our case, are fine; a delayed packet delays a UI update instead of a dodge.&lt;/p&gt;

&lt;p&gt;What's left is bookkeeping. Every value on the wire is a ledger entry, and the remaining bugs are clerical: an anchor stamped at the wrong moment, or a timer nobody re-armed after a restart. Mistakes like that never show up in the next frame. They surface ten hours later, when a player opens the tab and the number doesn't match their own arithmetic.&lt;/p&gt;

</description>
      <category>networking</category>
      <category>gamedev</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Deploying a real-time multiplayer game on Railway</title>
      <dc:creator>Arjen</dc:creator>
      <pubDate>Thu, 09 Jul 2026 09:55:35 +0000</pubDate>
      <link>https://dev.to/arjen_/deploying-a-real-time-multiplayer-game-on-railway-2d11</link>
      <guid>https://dev.to/arjen_/deploying-a-real-time-multiplayer-game-on-railway-2d11</guid>
      <description>&lt;p&gt;&lt;em&gt;This post contains Railway referral links. If you sign up through one I get a bit of credit.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I build &lt;a href="https://oldlight.io" rel="noopener noreferrer"&gt;Old Light&lt;/a&gt;, a real-time strategy game that runs in the browser. Claim stars, grow an economy, send fleets, all while other players and NPC empires do the same. The second a build finishes or a fleet lands, the server pushes it to every connected client over a WebSocket.&lt;/p&gt;

&lt;p&gt;That last part, a long-lived server holding an open socket, rules out most of the usual hosts. Here's what it ruled in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not Vercel or Netlify
&lt;/h2&gt;

&lt;p&gt;Serverless shines when your backend is stateless functions. It's the wrong shape the moment you need a socket that stays open: socket.io wants one process that lives for the whole session, and serverless boots per request and then freezes. You can bolt on a managed WebSocket service, but that's a second system to run and pay for.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://railway.com?referralCode=_GzRik" rel="noopener noreferrer"&gt;Railway&lt;/a&gt; runs your service as a normal long-lived process, so socket.io just connects. Fly.io does this too with more knobs to turn. I wanted to ship, so Railway won.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monorepo, two services
&lt;/h2&gt;

&lt;p&gt;Old Light is an npm workspaces monorepo: a &lt;code&gt;shared&lt;/code&gt; types package, an Express plus TypeORM plus socket.io API, and a Vite web app served by a small Express server. On Railway that's two services on the same repo, each with its own root directory and build command, &lt;code&gt;shared&lt;/code&gt; built first.&lt;/p&gt;

&lt;p&gt;They deploy as separate origins, so the web app reads the API's URL from &lt;code&gt;VITE_API_URL&lt;/code&gt;. Vite bakes that in at build time, so it's a build variable, not a runtime one. Postgres is a plugin that injects &lt;code&gt;DATABASE_URL&lt;/code&gt;, and production runs migrations rather than &lt;code&gt;synchronize&lt;/code&gt;. WebSockets need nothing special until you run more than one instance, at which point you'd add a Redis socket.io adapter. I haven't left a single box yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  A healthcheck that stops version skew
&lt;/h2&gt;

&lt;p&gt;Two services don't go live at the same instant. Push a commit that touches both, the web finishes first, and for a minute your new frontend is calling API routes that don't exist yet. It 404s, then heals itself once the API catches up, which makes it miserable to reproduce.&lt;/p&gt;

&lt;p&gt;The fix is to have the web service report unhealthy until the matching API is live. Both services hash their &lt;code&gt;RAILWAY_GIT_COMMIT_SHA&lt;/code&gt; into a short build id, and the web &lt;code&gt;/health&lt;/code&gt; fetches the API's health and compares:&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchApiHealth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;API_URL&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buildId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;EXPECTED_BUILD_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;build id mismatch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point Railway's healthcheck at &lt;code&gt;/health&lt;/code&gt; and the new web deploy waits at 503 while the old one keeps serving. When the API lands on the same build id, health flips to 200 and Railway cuts over. Not a true atomic deploy, but the frontend never serves traffic against a mismatched backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that only break in production
&lt;/h2&gt;

&lt;p&gt;A few that pass every local test, because dev serves from source and prod serves from a build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build order.&lt;/strong&gt; &lt;code&gt;shared&lt;/code&gt; has to compile before the API or web, or you build against a package with no &lt;code&gt;dist&lt;/code&gt;. Put it in the build command instead of trusting install order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-TypeScript assets.&lt;/strong&gt; &lt;code&gt;tsc&lt;/code&gt; only emits &lt;code&gt;.js&lt;/code&gt;, so anything you read from disk at runtime (for me, markdown that renders to pages) needs its own copy step or the container hits &lt;code&gt;ENOENT&lt;/code&gt; on first read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behind the proxy.&lt;/strong&gt; Railway terminates TLS at its edge, so set &lt;code&gt;trust proxy&lt;/code&gt; or &lt;code&gt;req.hostname&lt;/code&gt; reads the wrong host, and add the &lt;code&gt;compression&lt;/code&gt; middleware yourself since the edge won't gzip your HTML.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;p&gt;About six dollars a month, more than the traffic needs because I keep headroom for spikes. For a stateful, real-time app it's been the least fussy host I've used: the monorepo builds without special-casing, and the commit SHA Railway exposes is what made that version-skew healthcheck possible in the first place.&lt;/p&gt;

&lt;p&gt;The deeper backend story behind the game (no tick loop, combat as a pure function, built for scale) is its own post: &lt;a href="https://oldlight.io/blog/backend-of-a-browser-strategy-game/" rel="noopener noreferrer"&gt;Browser strategy game backend&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;The game running on all this is &lt;a href="https://oldlight.io" rel="noopener noreferrer"&gt;Old Light&lt;/a&gt;, playable in your browser right now. Want to try Railway? &lt;a href="https://railway.com?referralCode=_GzRik" rel="noopener noreferrer"&gt;Here's my referral link&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>railway</category>
      <category>node</category>
      <category>websocket</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Browser strategy game backend</title>
      <dc:creator>Arjen</dc:creator>
      <pubDate>Wed, 01 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/arjen_/browser-strategy-game-backend-no-tick-loop-28df</link>
      <guid>https://dev.to/arjen_/browser-strategy-game-backend-no-tick-loop-28df</guid>
      <description>&lt;p&gt;Old Light is a &lt;a href="https://oldlight.io/blog/best-browser-strategy-games-2026/" rel="noopener noreferrer"&gt;browser strategy game&lt;/a&gt;: a whole galaxy you play in a tab, with star systems to claim, an economy to grow, fleets to build, and other players (plus the AI empires that live alongside them) to fight for territory. This post is about what you don't see while playing: the backend that keeps it all running.&lt;/p&gt;

&lt;p&gt;Three things make that backend tricky, and almost every decision below comes from one of them.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It's real-time.&lt;/strong&gt; There are no turns. Your economy keeps growing and your fleets keep moving even after you close the tab, so the server has to keep the world running on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The browser is the client, and you can't trust it.&lt;/strong&gt; Anything the browser sends could be faked, so the server, not the client, decides what is true.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It has to scale.&lt;/strong&gt; I want it to hold around 1000 players at once. Each player is also surrounded by AI empires (roughly three per human), so 1000 players really means about 4000 active "actors" in the world. Every feature has to survive being multiplied by that number.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here are five decisions that shaped the backend. The whole stack is TypeScript: Express, Postgres (via TypeORM) and socket.io on the server, Pixi.js on the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  The economy has no tick loop
&lt;/h2&gt;

&lt;p&gt;The obvious way to run a live economy is a timer that fires every second and adds resources to every star. That approach falls over once the world gets big: 4000 actors, each with several stars, updated every single second, forever, even for players who are offline and not looking.&lt;/p&gt;

&lt;p&gt;So there's no timer. Instead, resources are &lt;strong&gt;calculated when someone asks for them&lt;/strong&gt;. Each star saves two things: its last known balance, and the time that balance was saved. When you need the current value, the server does simple arithmetic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;current = saved balance + (rate per minute × minutes since it was saved)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;capped so storage can't overflow. Stripped to its core, the function is:&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="nf"&gt;project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;star&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&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;cap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;storageCap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;star&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;ratePerMin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;productionRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;star&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// summed from the star's buildings&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;minutesElapsed&lt;/span&gt; &lt;span class="o"&gt;=&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;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;star&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;settledAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&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;balance&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;cap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;star&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ratePerMin&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;minutesElapsed&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;balance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;settledAt&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No timer touches the database. We only &lt;em&gt;save&lt;/em&gt; a new balance on the rare occasions when something actually changes the rate, like finishing a building upgrade. Reading the value never writes anything.&lt;/p&gt;

&lt;p&gt;There's a catch hiding in that formula. "Balance plus rate times time" is only correct if the rate held steady for the whole stretch of time, and in Old Light it often doesn't. Finishing a building raises its energy upkeep, and if that tips a system into an energy deficit, its income rate drops until you build enough power to recover. So the rate can fall partway through the very window you're projecting across.&lt;/p&gt;

&lt;p&gt;If you naively multiply the &lt;em&gt;current&lt;/em&gt; rate across the whole gap, you paint over that slow period as if it never happened and hand the player resources they never actually earned. The fix is to never let one projection span a rate change. The moment anything changes the rate, the server first &lt;strong&gt;settles&lt;/strong&gt;: it banks the accrual at the old rate, up to that exact instant, and saves it. Only then does it apply the change. Every projection after that starts fresh from the boundary, so each stretch of time is only ever multiplied by the rate that was really in effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The client is hostile
&lt;/h2&gt;

&lt;p&gt;The frontend runs on the player's own machine, so I don't really control it. Anyone can open the browser's dev tools, read how it works, change values, and fire off whatever requests they like. The server treats every message as if it came from someone trying to cheat.&lt;/p&gt;

&lt;p&gt;It never trusts what a request &lt;em&gt;claims&lt;/em&gt;. A "build this on my star" message doesn't get to say who is sending it; the server takes the identity from the connection's own login, never from a field in the message:&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="c1"&gt;// trusts the client to say who it is -&amp;gt; forgeable&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&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="c1"&gt;// the player is whoever this authenticated connection belongs to&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;connection&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get that wrong and anyone can act as anyone else just by editing a number.&lt;/p&gt;

&lt;p&gt;It also re-checks everything the client already checked, and confirms each request makes sense for the state of the game right now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Can you afford it?&lt;/strong&gt; The greyed-out button is a convenience, not a guard. The server works out the cost again before it builds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the move even legal?&lt;/strong&gt; You can't skip ahead and queue a jump to level 15 on a command center that's only level 10. The server accepts the next level up and nothing else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And it decides what you're allowed to &lt;em&gt;know&lt;/em&gt;. Public facts like who owns which star go to everyone, but your fleet size and your income are sent only to you, and to a rival only once they've scouted you. Hiding a number in the client is not hiding it: the only number a rival can't read is one the server never sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combat is a pure function
&lt;/h2&gt;

&lt;p&gt;When two fleets fight, the whole battle is decided by one plain function. No database, no shared state, just inputs and outputs:&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;resolveBattle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/* returns who survived */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;input&lt;/code&gt; is the ship counts on each side. &lt;code&gt;rng&lt;/code&gt; is the function that supplies randomness. That second argument matters more than it looks: in the real game we pass in &lt;code&gt;Math.random&lt;/code&gt;, and in tests we pass in a fake "random" that returns a fixed, known sequence.&lt;/p&gt;

&lt;p&gt;Since randomness is the only thing coming from outside, the function is completely predictable once you control it.&lt;/p&gt;

&lt;p&gt;That makes tests that never flake. Feed in a known sequence and the exact survivor counts come out every time, so a failing test means a real bug, not bad luck.&lt;/p&gt;

&lt;p&gt;It also makes a balance simulator possible. I can run the same function ten thousand times with real randomness to see how often each side wins, then tune the game before shipping a change. The test suite and the simulator run the identical battle logic.&lt;/p&gt;

&lt;p&gt;Each shot is two coin flips, &lt;em&gt;did it hit&lt;/em&gt;, then &lt;em&gt;did it win&lt;/em&gt;:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;hitChance&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;// missed&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;winChance&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;// hit, but lost the exchange&lt;/span&gt;
&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dead&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// hit and won&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a scouting fleet with no warships skips &lt;a href="https://oldlight.io/guide/combat/" rel="noopener noreferrer"&gt;combat&lt;/a&gt; entirely:&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;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;hasCombatShips&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attackerShips&lt;/span&gt;&lt;span class="p"&gt;))&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="cm"&gt;/* nobody fought, both sides unchanged */&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;h2&gt;
  
  
  The AI empires are predictable on purpose
&lt;/h2&gt;

&lt;p&gt;Old Light fills the galaxy with &lt;a href="https://oldlight.io/guide/npcs/" rel="noopener noreferrer"&gt;AI empires&lt;/a&gt;. They run through the &lt;em&gt;exact same&lt;/em&gt; code as human players: same actions, same rules, same limits. The server never asks "is this a human or a bot?" because they are the same kind of thing.&lt;/p&gt;

&lt;p&gt;Each AI's personality (offensive or defensive) and its speed aren't random and aren't stored in a column. They're &lt;strong&gt;computed from the world's seed number&lt;/strong&gt; with a hash:&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;empireIdentity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worldSeed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;empireId&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;coin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;worldSeed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:identity:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;empireId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a number 0-1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;coin&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;offensive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;defensive&lt;/span&gt;&lt;span class="dl"&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;A hash turns text into a number that looks random but is always the same for the same input. So a given empire always lands on the same personality, with no clock, no live dice roll and no database lookup involved. Its character is fixed the moment it's born.&lt;/p&gt;

&lt;p&gt;That same idea fixes a scaling problem. Updating every AI once a minute would mean a big spike of work all at the top of each minute. Instead, each AI is sorted into one of 60 buckets using the same hashing trick, and only one bucket runs each minute:&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;thisMinutesAIs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;allAIs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;worldSeed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:bucket:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;currentBucket&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;Every AI still gets a turn about once an hour, but the work is spread smoothly across the whole hour instead of arriving all at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every code path gets multiplied by 4000
&lt;/h2&gt;

&lt;p&gt;My rule of thumb: take any new piece of code, imagine ~4000 actors hitting it, plus the biggest input a player could send. If that math is scary, the code is wrong. Three rules follow from that:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't load a whole table and filter in code.&lt;/strong&gt; Ask the database for &lt;em&gt;only&lt;/em&gt; the rows you need. To find jobs that are finished, query for "finished before now," which the database has an index for and can skip the rest:&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="nf"&gt;findDue&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;completesAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;LessThanOrEqual&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="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;&lt;strong&gt;Don't run a query inside a loop.&lt;/strong&gt; To load data for 100 stars, make &lt;em&gt;one&lt;/em&gt; query for all 100 and then look them up in memory, not 100 separate queries:&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;resourcesByStar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadResourcesFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;starIds&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// one query&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;stars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;enrich&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;resourcesByStar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&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;id&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt; &lt;span class="c1"&gt;// fast memory lookups&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Don't tell everyone about everything.&lt;/strong&gt; Sending a message to every connected player is expensive. Private updates (your money, your buildings) go only to &lt;em&gt;you&lt;/em&gt;, and the server doesn't even build that update if you're offline:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;playerIsOffline&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;id&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="c1"&gt;// nobody listening, skip the work&lt;/span&gt;
&lt;span class="nf"&gt;sendTo&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;buildPrivateUpdate&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Public news, like who now owns which star, goes out as a single shared message, never one message per tiny change. How those messages reach the browser in the first place is its own topic, covered in &lt;a href="https://oldlight.io/blog/game-networking-for-a-slow-strategy-game/" rel="noopener noreferrer"&gt;game networking for a slow strategy game&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ties it together
&lt;/h2&gt;

&lt;p&gt;The server owns the truth and stays cheap while thousands of actors lean on it, and it never takes the client's word for any of it. In practice that meant calculating values instead of ticking them, keeping the core logic in plain functions I could test and reuse, and treating randomness as an input rather than reaching for it mid-function.&lt;/p&gt;

&lt;p&gt;You never see any of this while playing; it's all plumbing. The one piece you can feel is the last one: claim a star, build something, then close the tab, and your &lt;a href="https://oldlight.io/guide/economy/" rel="noopener noreferrer"&gt;economy&lt;/a&gt; keeps accruing while you're gone. Come back tomorrow and the &lt;a href="https://oldlight.io/blog/slow-strategy-game-you-check-twice-a-day/" rel="noopener noreferrer"&gt;galaxy moved without you&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;&lt;a href="https://oldlight.io/play" rel="noopener noreferrer"&gt;Play Old Light&lt;/a&gt;&lt;/strong&gt; a real-time galaxy that keeps running while you're away. Claim your first system and see the backend from the player's side.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
