<?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: Sharang Parnerkar</title>
    <description>The latest articles on DEV Community by Sharang Parnerkar (@mighty840).</description>
    <link>https://dev.to/mighty840</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%2F3787721%2Fb0f05659-7097-4c46-a737-50bac1a6e824.jpg</url>
      <title>DEV Community: Sharang Parnerkar</title>
      <link>https://dev.to/mighty840</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mighty840"/>
    <language>en</language>
    <item>
      <title>What does "balanced" mean for a board game?</title>
      <dc:creator>Sharang Parnerkar</dc:creator>
      <pubDate>Tue, 10 Nov 2026 20:20:33 +0000</pubDate>
      <link>https://dev.to/mighty840/what-does-balanced-mean-for-a-board-game-3d8m</link>
      <guid>https://dev.to/mighty840/what-does-balanced-mean-for-a-board-game-3d8m</guid>
      <description>&lt;p&gt;Let's start with a dice game called Pig. Two players race to 100 points. On your turn you roll a die as often as you like and add up the faces. If you roll a 1, you lose everything you rolled this turn and the turn passes. If you stop first (you "hold"), the points are banked and safe. The first player to bank 100 wins.&lt;/p&gt;

&lt;p&gt;Here's a question a game designer would ask about it: does going first help? And if it does, by how much is too much?&lt;/p&gt;

&lt;p&gt;People call a game "balanced" or "unbalanced", but that word doesn't tell you what to measure. Here we'll turn it into something we can measure, measure it with a short Rust program, and then find out how much the answer depends on who is playing. That last part is the surprise, and it shapes everything else in this series.&lt;/p&gt;

&lt;p&gt;I'm building a harness called gameforge that does this for my own board games (L.U.C.A., Samudra Manthana and Kölnisch Wasser) at a larger scale. Pig is small enough that we can do the whole thing by hand first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning "balanced" into a target
&lt;/h2&gt;

&lt;p&gt;"Is going first fair?" has no yes-or-no answer until we say what fair means. Let's write it down as a number we're aiming for, plus how far off we'll accept:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The first player wins 50% of games, give or take 2 points.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's a target (50%) and a tolerance (2 points). Why a tolerance at all? Because no game is exactly even, and a designer has to decide how much edge is too little to matter. The 2 points is my choice for this example; a tournament game might want 1, a party game might accept 5. gameforge calls such a written-down target a declared intent, and every balance claim it makes is about intents like this one.&lt;/p&gt;

&lt;p&gt;Now we need a win rate to compare against it. Where does that come from?&lt;/p&gt;

&lt;h2&gt;
  
  
  Who plays the 10,000 games?
&lt;/h2&gt;

&lt;p&gt;A win rate means many games. People can't play ten thousand games of Pig, so we'll let the computer play both sides, following a fixed rule. I'll call such a rule a simulated player.&lt;/p&gt;

&lt;p&gt;A sensible rule for Pig is "hold at 20": keep rolling until this turn's points reach 20, then hold (and hold early if that wins the game). Why 20? Say you have &lt;code&gt;t&lt;/code&gt; points this turn and roll once more. Five times in six you gain a face from 2 to 6, on average 4; once in six you lose the &lt;code&gt;t&lt;/code&gt;. So one more roll is worth &lt;code&gt;(5 * 4 - t) / 6 = (20 - t) / 6&lt;/code&gt; points on average, positive below 20 and negative above. "Hold at 20" stops exactly when another roll stops paying, on average.&lt;/p&gt;

&lt;p&gt;That's a reasonable player. Keep the word "reasonable" in mind, because we'll come back to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules, as a program
&lt;/h2&gt;

&lt;p&gt;The program has two parts. The first is the game: a small random number generator, and &lt;code&gt;play&lt;/code&gt;, which runs one game where both players hold at the same &lt;code&gt;limit&lt;/code&gt; and returns who won. Look for two things: the &lt;code&gt;while&lt;/code&gt; loop is one turn, and a roll of 1 empties &lt;code&gt;turn&lt;/code&gt; before it's banked.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// A small random number generator that repeats exactly from its seed (SplitMix64).&lt;/span&gt;
&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;Rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="nc"&gt;Rng&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;die&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="kd"&gt;mut&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;u64&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wrapping_add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x9E37_79B9_7F4A_7C15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="kd"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&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="nf"&gt;wrapping_mul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0xBF58_476D_1CE4_E5B9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;27&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="nf"&gt;wrapping_mul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x94D0_49BB_1331_11EB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="p"&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;6&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;/// One game where both players "hold at `limit`": keep rolling until this&lt;/span&gt;
&lt;span class="c1"&gt;/// turn's points reach `limit`, or holding would win. Returns the winner (0 or 1).&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="kd"&gt;mut&lt;/span&gt; &lt;span class="nc"&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="kt"&gt;usize&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="kd"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="kd"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;loop&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="kd"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;turn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;turn&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;turn&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;die&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="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;turn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="n"&gt;face&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;turn&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;face&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="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;current&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;Player 0 always moves first, so "player 0 won" is our event. The generator takes a seed, and the same seed gives the same dice every time. That will matter in a moment.&lt;/p&gt;

&lt;p&gt;The second part plays many games and prints how often the first player won:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// Plays `games` games from `seed` and prints the first player's win rate.&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;games&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="kd"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;wins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;games&lt;/span&gt;&lt;span class="p"&gt;)&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="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="kd"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&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="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wins&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;f64&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;games&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;margin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.96&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;games&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&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="nf"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"hold at {limit}, seed {seed}: first player wins {:.1}% ({:.1} to {:.1})"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="mf"&gt;100.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;100.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;100.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;margin&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;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&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="nf"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="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;Those two blocks together are the whole &lt;code&gt;src/main.rs&lt;/code&gt;, with no dependencies. The &lt;code&gt;margin&lt;/code&gt; line computes a range around the measured rate, which I'll explain with the output in front of us.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the first player get?
&lt;/h2&gt;

&lt;p&gt;I ran it in a new project (&lt;code&gt;cargo new pig&lt;/code&gt;, edition 2024) with &lt;code&gt;cargo run --release&lt;/code&gt; on Rust 1.97, x86-64 Linux. The first line it prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hold at 20, seed 1: first player wins 53.2% (52.2 to 54.2)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So with two "hold at 20" players, the first player wins 53.2% of 10,000 games. What are the two numbers in brackets? Any 10,000 games are a sample; another 10,000 with different dice would give a slightly different rate. The bracket is a 95% interval: roughly, the range in which the true rate for this game and these players is likely to lie, given how many games we played. (Post 4 of this series is about exactly that, and about how many games are enough.)&lt;/p&gt;

&lt;p&gt;We can check the "different dice" part directly. The second line uses seed 2, a fresh set of 10,000 games:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hold at 20, seed 2: first player wins 53.4% (52.4 to 54.4)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Close to the first, inside its interval, and nowhere near 50. Now compare with our intent, 50% give or take 2. Even the low end of the interval, 52.2%, is above 52. For these players, the first player's edge is too big: the intent is not met.&lt;/p&gt;

&lt;p&gt;Is Pig unbalanced, then? Before you answer, look at the third line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same game, different players
&lt;/h2&gt;

&lt;p&gt;The third line changes only the players. Both now hold at 60, pushing their luck three times as far:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hold at 60, seed 1: first player wins 50.8% (49.8 to 51.8)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rules and the number of games are unchanged, and now the intent is met: the whole interval sits inside 48 to 52. Nothing about Pig changed. Only the players did.&lt;/p&gt;

&lt;p&gt;That's not a quirk of two hand-picked numbers. The figure runs &lt;code&gt;measure&lt;/code&gt; for holding limits from 5 to 60, 10,000 games each:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh5xvltp120p9jnsm9olw.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh5xvltp120p9jnsm9olw.gif" alt="Animation that ends on the full figure. For hold at 20, seed 1, the first player's running win rate is measured game by game, and its 95% interval narrows to 52.2 to 54.2, settling at 53.2%, above the band from 48 to 52 that the intent allows: the intent is not met. Then hold at 60 settles at 50.8% (49.8 to 51.8), inside the band: the intent is met. Then the other limits appear: from 5 to 60, 10,000 games each, about 54% at limits 5 and 10 and down to 50.8% at 60; from limit 30 on, every point but one (52.2% at 40) lies inside the band." width="759" height="388"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The points wobble by about a point from one limit to the next, which is the noise the intervals describe, but the trend is clear: the more the players push their luck, the smaller the first player's edge. So "is Pig balanced?" has no answer until we also say who is playing. A balance verdict is about a game and its players together.&lt;/p&gt;

&lt;p&gt;Remember "reasonable"? "Hold at 20" is a good rule, and real people don't play exactly like it either. A simulation can't tell us how humans will play, or whether a 53% edge feels unfair at the table. Those questions belong to playtesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a balance claim can promise
&lt;/h2&gt;

&lt;p&gt;Here's what we can honestly say, and it's the shape every result in this series will take:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Balanced against declared intents, for simulated players of stated strength, with a reproducible log.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Each part maps onto something we did above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Declared intents.&lt;/strong&gt; "First player wins 50%, give or take 2 points." Without it, "balanced" has nothing to be measured against.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simulated players of stated strength.&lt;/strong&gt; Both seats hold at 20. Change that and the verdict changed with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reproducible log.&lt;/strong&gt; Seed 1, 10,000 games, and the program above. Anyone who runs it gets 53.2% again, to the decimal, because the seed fixes every die.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it never says is "Pig is balanced", full stop. gameforge's own run of Pig, with its own random numbers and 9,604 games, gives the first player 53.1% (52.1 to 54.1) for two "hold at 20" players: the same verdict, from a much bigger program.&lt;/p&gt;

&lt;p&gt;That bigger program has to handle games far longer than Pig, with many more rules. To make every game replayable from its seed the way ours was, the rules need a particular shape. In the next post, we'll write Pig again in that shape: a game as four functions.&lt;/p&gt;

&lt;p&gt;The program in this post is complete as shown: paste both blocks into &lt;code&gt;src/main.rs&lt;/code&gt; of a new Cargo project.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>boardgames</category>
      <category>simulation</category>
      <category>beginners</category>
    </item>
    <item>
      <title>A neural network for a &gt; 4, written by hand</title>
      <dc:creator>Sharang Parnerkar</dc:creator>
      <pubDate>Tue, 03 Nov 2026 19:59:41 +0000</pubDate>
      <link>https://dev.to/mighty840/a-neural-network-for-a-4-written-by-hand-562e</link>
      <guid>https://dev.to/mighty840/a-neural-network-for-a-4-written-by-hand-562e</guid>
      <description>&lt;p&gt;Let's start with a small program, where &lt;code&gt;a&lt;/code&gt; is a whole number from 0 through 100:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We're going to build a neural network that gives the same result for those 101 inputs. Neural networks are usually trained, which means their numbers are learned from examples. We won't train anything. We'll construct the numbers directly from the program.&lt;/p&gt;

&lt;p&gt;The construction comes from Cheng Lou's &lt;a href="https://github.com/chenglou/handwired" rel="noopener noreferrer"&gt;handwired&lt;/a&gt;, a JavaScript project that turns ordinary code into neural-network operations, and this same comparison is the first example in its README. I ported handwired to Rust as &lt;a href="https://github.com/mighty840/handwired-rs" rel="noopener noreferrer"&gt;handwired-rs&lt;/a&gt;, which this series uses.&lt;/p&gt;

&lt;p&gt;Why would anyone want this? Not for speed: three arithmetic steps in place of one &lt;code&gt;if&lt;/code&gt; is not a better way to write an &lt;code&gt;if&lt;/code&gt;, and this post only demonstrates the mechanism. What it gives us is a program that is a fixed list of arithmetic steps, the same for every input, and such a list can be reasoned about and checked on every input we declare. By the end, we'll have done both for all 101.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two building blocks: weighted sums and ReLU
&lt;/h2&gt;

&lt;p&gt;The first building block is a weighted sum: multiply the input by a constant and add a constant offset. &lt;code&gt;5 - a&lt;/code&gt; is one, with &lt;code&gt;a&lt;/code&gt; multiplied by -1 and an offset of 5.&lt;/p&gt;

&lt;p&gt;The second is ReLU, short for rectified linear unit: &lt;code&gt;relu(x) = max(0, x)&lt;/code&gt;. It keeps a positive number and turns a negative one into 0.&lt;/p&gt;

&lt;p&gt;Why would we need the second one at all? Because weighted sums alone can't make a decision. A weighted sum of a weighted sum is again a weighted sum: &lt;code&gt;c * (d * a + e) + f&lt;/code&gt; is &lt;code&gt;(c * d) * a + (c * e + f)&lt;/code&gt;, a constant times &lt;code&gt;a&lt;/code&gt; plus an offset. So any number of them in a row is a single straight line in &lt;code&gt;a&lt;/code&gt;, and no straight line is 12 for every &lt;code&gt;a&lt;/code&gt; up to 4 and 10 for every &lt;code&gt;a&lt;/code&gt; from 5 on.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do we turn &lt;code&gt;a &amp;gt; 4&lt;/code&gt; into 0 or 1?
&lt;/h2&gt;

&lt;p&gt;Our first goal is a number, let's call it &lt;code&gt;flag&lt;/code&gt;, that is 1 when &lt;code&gt;a &amp;gt; 4&lt;/code&gt; and 0 otherwise. handwired-rs builds it as two ReLUs in a chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;inner = relu(5 - a)
flag  = relu(1 - inner)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Does that really behave like &lt;code&gt;a &amp;gt; 4&lt;/code&gt;? Let's try the two numbers right at the threshold. For &lt;code&gt;a = 4&lt;/code&gt;, &lt;code&gt;inner = relu(1) = 1&lt;/code&gt;, and then &lt;code&gt;flag = relu(0) = 0&lt;/code&gt;. For &lt;code&gt;a = 5&lt;/code&gt;, &lt;code&gt;inner = relu(0) = 0&lt;/code&gt;, and then &lt;code&gt;flag = relu(1) = 1&lt;/code&gt;. So far so good.&lt;/p&gt;

&lt;p&gt;What about the other 99 whole numbers? We don't have to try them one by one. From 0 to 4, &lt;code&gt;a&lt;/code&gt; is at most 4, so &lt;code&gt;5 - a&lt;/code&gt; is at least 1, which makes &lt;code&gt;inner&lt;/code&gt; at least 1 and &lt;code&gt;1 - inner&lt;/code&gt; 0 or negative, and ReLU turns that into a &lt;code&gt;flag&lt;/code&gt; of 0. From 5 to 100, &lt;code&gt;5 - a&lt;/code&gt; is 0 or negative, so &lt;code&gt;inner&lt;/code&gt; is 0 and &lt;code&gt;flag = relu(1) = 1&lt;/code&gt;. That's an algebraic argument: it covers every whole number in the range without trying any of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning the flag into 10 or 12
&lt;/h2&gt;

&lt;p&gt;Now we have a number where the program had a decision, so the choice can become arithmetic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;result = 12 - 2 * flag
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;code&gt;flag = 0&lt;/code&gt; this is 12, and for &lt;code&gt;flag = 1&lt;/code&gt; it is 10, the two values of the original &lt;code&gt;if&lt;/code&gt;. The flag selects the output by multiplication instead of by a jump.&lt;/p&gt;

&lt;h2&gt;
  
  
  Naming the parts: weights, biases and layers
&lt;/h2&gt;

&lt;p&gt;Each of our three steps is a weighted sum, and the first two are followed by a ReLU. In neural-network terms, the multipliers (-1, -1 and -2) are weights, the offsets (5, 1 and 12) are biases, and each step is a layer. So we've built a network of three layers with one step in each, and it runs the same three operations in the same order for every input. handwired calls such a fixed list of operations a circuit, and that's the name you'll see in the code below.&lt;/p&gt;

&lt;p&gt;In the figure, the chain is on the right, and &lt;code&gt;flag&lt;/code&gt; for &lt;code&gt;a&lt;/code&gt; from 0 to 10 is on the left: dots for the whole numbers, a dashed line for what happens in between.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwntsoqqehclqnaopyw5o.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwntsoqqehclqnaopyw5o.gif" alt="Animation that ends on the full figure. Left: flag = relu(1 - relu(5 - a)) for a from 0 to 10. As a steps through the whole numbers, each value passes through relu(5 - a) as inner, relu(1 - inner) as flag and 12 - 2 * flag as result, and a dot lands at 0 for a up to 4 and at 1 from 5. Then a slides from 4 to 5: at 4.5, inner and flag are 0.5 and the result is 11, a blend strictly between 4 and 5." width="800" height="296"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That dashed slope between 4 and 5 should make you a little suspicious. What is it doing there?&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens between whole numbers?
&lt;/h2&gt;

&lt;p&gt;Everything we argued so far relied on &lt;code&gt;a&lt;/code&gt; being a whole number. So let's break that on purpose and feed in &lt;code&gt;a = 4.5&lt;/code&gt;. Before reading on, what do you expect: 10, 12, or something else?&lt;/p&gt;

&lt;p&gt;Here is the walk through the chain: &lt;code&gt;inner = relu(0.5) = 0.5&lt;/code&gt;, &lt;code&gt;flag = relu(0.5) = 0.5&lt;/code&gt;, and &lt;code&gt;result = 12 - 1 = 11&lt;/code&gt;. Neither branch. Inputs strictly between 4 and 5 produce a blend of 10 and 12, and that's the dashed slope in the figure.&lt;/p&gt;

&lt;p&gt;So the network's correctness is guaranteed for the declared inputs, the whole numbers from 0 to 100. How do we tell the code what those inputs are?&lt;/p&gt;

&lt;h2&gt;
  
  
  Declaring the inputs in Rust, and testing them
&lt;/h2&gt;

&lt;p&gt;Two facts describe our inputs. &lt;code&gt;a&lt;/code&gt; lies between 0 and 100, which handwired-rs calls its range. The allowed values are spaced one apart, which it calls its grid. The comparison needs the grid: it works because no allowed input falls strictly between 4 and 5.&lt;/p&gt;

&lt;p&gt;In the program below, look for &lt;code&gt;assume_range&lt;/code&gt; and &lt;code&gt;assume_grid&lt;/code&gt;, which declare those two facts, and &lt;code&gt;emit_compare&lt;/code&gt;, which writes the two ReLUs for us. &lt;code&gt;TensorExecutor&lt;/code&gt; runs the compiled circuit, and the loop counts the inputs where it differs from the &lt;code&gt;if&lt;/code&gt;. Before you run it, what should it print? If our algebra is right, zero wrong, and 11 for 4.5.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;handwired_rs&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Circuit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Relation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TensorExecutor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;emit_compare&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;circuit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Circuit&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;circuit&lt;/span&gt;&lt;span class="nf"&gt;.source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;circuit&lt;/span&gt;&lt;span class="nf"&gt;.assume_range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;100.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// a is between 0 and 100&lt;/span&gt;
    &lt;span class="n"&gt;circuit&lt;/span&gt;&lt;span class="nf"&gt;.assume_grid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// in steps of 1&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;four&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;circuit&lt;/span&gt;&lt;span class="nf"&gt;.constant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;4.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;flag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;emit_compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;circuit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;Relation&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Greater&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;four&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;circuit&lt;/span&gt;&lt;span class="nf"&gt;.affine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flag&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;12.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;program&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;circuit&lt;/span&gt;&lt;span class="nf"&gt;.compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;TensorExecutor&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;program&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;wrong&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mf"&gt;12.0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="nf"&gt;.run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;wrong&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{} operations, {wrong} of 101 inputs wrong"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;program&lt;/span&gt;&lt;span class="nf"&gt;.live_operation_count&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"a = 4.5 gives {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="nf"&gt;.run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;4.5&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran it in a new binary crate after &lt;code&gt;cargo add handwired-rs@0.8.0&lt;/code&gt;, with Rust 1.97 on x86-64 Linux, and it prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3 operations, 0 of 101 inputs wrong
a = 4.5 gives 11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both expectations hold. The three operations are our three steps: for a "greater than" comparison on a grid of 1, &lt;code&gt;emit_compare&lt;/code&gt; builds &lt;code&gt;relu(1 - relu(5 - a))&lt;/code&gt;, which I checked in &lt;a href="https://github.com/mighty840/handwired-rs/blob/v0.8.0/src/emit.rs#L312-L331" rel="noopener noreferrer"&gt;its source at v0.8.0&lt;/a&gt;. "0 of 101 wrong" is a test result, and it checks this implementation on exactly the declared inputs. The last line shows that the executor doesn't refuse 4.5. It computes the blend, just as we did by hand.&lt;/p&gt;

&lt;p&gt;There is one thing we quietly assumed all along, though. Our algebra used exact arithmetic. Does the computer's arithmetic agree?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the floating-point arithmetic agree?
&lt;/h2&gt;

&lt;p&gt;The program runs on &lt;code&gt;f32&lt;/code&gt;, 32-bit floating-point numbers, which round some results. So why wasn't anything rounded here? Two facts. Floating-point addition and multiplication round only when the exact answer can't be stored. And an &lt;code&gt;f32&lt;/code&gt; can store every whole number up to a limit far above 100. Every value here, from the input through each intermediate product and sum (such as the &lt;code&gt;-1 * a&lt;/code&gt; inside &lt;code&gt;5 - a&lt;/code&gt;) to the result, is a whole number between -100 and 100. So every exact answer can be stored, and nothing is rounded. That's the numerical argument, and the test agrees with it.&lt;/p&gt;

&lt;p&gt;Let's take stock. A one-line &lt;code&gt;if&lt;/code&gt; became a circuit of three numerical operations, with weights written down from the program, an argument that covers every declared input, and a test that ran all of them. I ported handwired to Rust because I wanted to write the logic as ordinary Rust functions and have a compiler turn them into circuits like this one, so the original function could stay around as the test. Later posts get to that compiler.&lt;/p&gt;

&lt;p&gt;Aside: handwired's README writes the flag as &lt;code&gt;relu(a - 4) - relu(a - 5)&lt;/code&gt;, two ramps subtracted, which gives the same 0 or 1 on whole numbers. We used the chained form because handwired-rs builds it.&lt;/p&gt;

&lt;p&gt;Our numbers here never went past 100. What happens when they grow? In the next post we'll find out where that limit on whole numbers is, and what happens to the arithmetic beyond it.&lt;/p&gt;

&lt;p&gt;Code: &lt;a href="https://github.com/mighty840/handwired-rs" rel="noopener noreferrer"&gt;github.com/mighty840/handwired-rs&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>machinelearning</category>
      <category>neuralnetworks</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Supermarket fridges at 50 degrees: could you still build the firmware patch?</title>
      <dc:creator>Sharang Parnerkar</dc:creator>
      <pubDate>Tue, 03 Nov 2026 16:46:11 +0000</pubDate>
      <link>https://dev.to/breakpilot/supermarket-fridges-at-50-degrees-could-you-still-build-the-firmware-patch-17pp</link>
      <guid>https://dev.to/breakpilot/supermarket-fridges-at-50-degrees-could-you-still-build-the-firmware-patch-17pp</guid>
      <description>&lt;p&gt;Attackers switched supermarket refrigeration controllers across Europe into defrost mode. Fixes for known flaws had been out since 2025. What that means for firmware teams, and how to keep every release rebuildable.&lt;/p&gt;

&lt;p&gt;In the night from 1 to 2 November, fridges and freezers in supermarkets across Europe started warming up. Stores in Belgium, Luxembourg, the Netherlands, Sweden, Finland, Estonia, Ireland and Italy reported warming cabinets or spoiled food. A store in Sweden reported cabinet temperatures of up to 50 °C. A refrigeration service company in Belgium said the systems had been switched remotely into defrost mode.&lt;/p&gt;

&lt;p&gt;It was the second wave in a month. On 12 October, hundreds of supermarkets in France had been hit the same way. The EU Commission has since set up a task force with ENISA and Europol.&lt;/p&gt;

&lt;p&gt;If you write firmware for connected equipment, this one is worth reading closely. Not because of how the attackers got in, which is still being investigated, but because of what the reporting says about updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is known, and what is not
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Remote controllers.&lt;/strong&gt; CERT-FR &lt;a href="https://cert.cyber.gouv.fr/alerte/CERTFR-2026-ALE-012/" rel="noopener noreferrer"&gt;reported numerous compromises&lt;/a&gt; of remote controllers for refrigeration installations and listed Copeland xweb, Danfoss AK-SC, Danfoss AK-SM and Dixell xweb. Its first advice: take them off the public internet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Known flaws, fixed in 2025.&lt;/strong&gt; For one of those controller families, the AK-SM 8xxA series, &lt;a href="https://www.cisa.gov/news-events/ics-advisories/icsa-25-140-03" rel="noopener noreferrer"&gt;CISA advisory ICSA-25-140-03&lt;/a&gt; describes an authentication bypass based on passwords generated from the date and time (CVE-2025-41450), fixed in R4.2, and two further flaws fixed in 4.3.1. &lt;a href="https://claroty.com/team82/research/freeze-the-controller-defrost-the-food-uncovering-vulnerabilities-in-danfoss-refrigeration-controllers" rel="noopener noreferrer"&gt;Claroty Team82&lt;/a&gt; described the "code of the day" mechanism and counted 2,765 of these systems reachable from the internet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old firmware.&lt;/strong&gt; Danfoss said, as reported by &lt;a href="https://www.theregister.com/security/2026/10/13/cyberattacks-crank-french-supermarket-refrigeration-systems-up-to-50c/5302784" rel="noopener noreferrer"&gt;The Register&lt;/a&gt;, that the affected French stores "were running outdated firmware". A security adviser quoted by &lt;a href="https://www.cnbc.com/2026/11/03/supermarket-fridges-hack-cyberattack-europe.html" rel="noopener noreferrer"&gt;CNBC&lt;/a&gt; said that thousands of units never received the update and that the oldest models cannot be updated at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not confirmed.&lt;/strong&gt; Nobody has claimed the attacks. Authorities have not named the entry point or confirmed that the known flaws were used.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CERT-FR's alert includes a warning every firmware team should have on the wall: an end-of-life device without security patches that stays exposed to the internet carries a very high risk of compromise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two reasons a fix never reaches a device
&lt;/h2&gt;

&lt;p&gt;The first is on the operator side. The controller sits on a public IP so a contractor can reach it, nobody in the store owns its updates, and the patch that exists is never installed. That is what CERT-FR's advice addresses, and it is not what this post is about.&lt;/p&gt;

&lt;p&gt;The second is on the manufacturer side, and it is the part firmware teams control. A controller sold eight years ago only gets a security update if someone can still build its firmware: with the toolchain it was built with, the SDK it was built against and the build script that lived on one engineer's laptop. Then you want to show that the new image differs from the one in the field only by the fix.&lt;/p&gt;

&lt;p&gt;Can you rebuild the firmware you shipped years ago and get the same bytes? Not "something that boots". The same file, with the same hash as the image on the devices in the field. For most embedded teams the honest answer is "probably not". That is rarely carelessness. An embedded build depends on far more than the source tree, and almost none of it is written down.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the CRA asks for, in plain terms
&lt;/h2&gt;

&lt;p&gt;The main obligations of the EU Cyber Resilience Act apply from 11 December 2027. For products with digital elements, it expects manufacturers to handle vulnerabilities and provide security updates over a support period that typically runs for years, and to document the software that goes into each product.&lt;/p&gt;

&lt;p&gt;The regulation does not use the word "reproducible". But if you have to patch a version that is five years old, you need to be able to build it again, and you want to know that the only difference in the new image is your patch. That is a reproducibility problem.&lt;/p&gt;

&lt;p&gt;Whether and how the CRA applies to a specific product is for the manufacturer to decide. This post is about the engineering side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the same source gives different binaries
&lt;/h2&gt;

&lt;p&gt;A typical bare-metal Makefile calls &lt;code&gt;arm-none-eabi-gcc&lt;/code&gt; and trusts whatever is on &lt;code&gt;PATH&lt;/code&gt;. Three things leak into the output:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Toolchain drift.&lt;/strong&gt; One engineer has GCC 15.2 from the distro, CI has 13.x from a container image, another engineer uses the vendor's bundled toolchain. Different code generation, different binaries, same commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timestamps.&lt;/strong&gt; &lt;code&gt;__DATE__&lt;/code&gt; and &lt;code&gt;__TIME__&lt;/code&gt;, archive member times, random build IDs. Every build looks new.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paths.&lt;/strong&gt; Debug info (&lt;code&gt;-g3&lt;/code&gt;), &lt;code&gt;__FILE__&lt;/code&gt; and some linker outputs carry absolute paths. Build in &lt;code&gt;/home/anna/fw&lt;/code&gt; and in &lt;code&gt;/builds/ci/fw&lt;/code&gt; and you get two images.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9mlh7zgg4ideh7p68p5v.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9mlh7zgg4ideh7p68p5v.gif" alt="Same commit, three machines, three different firmware hashes. With a sealed toolchain, one hash." width="760" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of this matters on the day you ship. It matters on the day you have to ship a fix for an old version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways a rebuild fails
&lt;/h2&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%2Fu7l2te9xhakiu7gbutvf.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%2Fu7l2te9xhakiu7gbutvf.png" alt="Three ways a rebuild fails: the toolchain is gone, the build is not deterministic, the build knowledge is gone." width="800" height="371"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The toolchain is gone.&lt;/strong&gt; The compiler version the image was built with is not installed anywhere anymore, nobody wrote it down, and the vendor download page has moved twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The build is not deterministic.&lt;/strong&gt; You have the toolchain and the source, but timestamps and paths change the output, so you cannot show that the rebuild matches what shipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The build knowledge is gone.&lt;/strong&gt; The Makefile lived on a laptop, the SDK headers sat in a directory nobody committed, and what is left is a folder of &lt;code&gt;.c&lt;/code&gt; and &lt;code&gt;.h&lt;/code&gt; files.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each needs a different fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sealing a toolchain: detect, seal, build, verify
&lt;/h2&gt;

&lt;p&gt;The well-known answer to the first two is Nix: pin every input by content hash, build in a sandbox, set &lt;code&gt;SOURCE_DATE_EPOCH&lt;/code&gt;, use a fixed build prefix. It works very well. It also means writing Nix expressions, which most firmware teams have no reason to learn.&lt;/p&gt;

&lt;p&gt;So we put Nix underneath and kept it out of sight. The flow has four steps:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcw8i70e0m9eaaycq0iz4.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcw8i70e0m9eaaycq0iz4.gif" alt="detect, seal, build with a pinned toolchain, verify byte for byte" width="760" height="321"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here it is on a public project, step 1 of &lt;a href="https://github.com/cpq/bare-metal-programming-guide" rel="noopener noreferrer"&gt;cpq/bare-metal-programming-guide&lt;/a&gt; at commit &lt;code&gt;594fe3c&lt;/code&gt;: an STM32 Cortex-M4 blinky with &lt;code&gt;main.c&lt;/code&gt;, &lt;code&gt;link.ld&lt;/code&gt; and a Makefile that calls &lt;code&gt;arm-none-eabi-gcc&lt;/code&gt;. Nothing in the repo was changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detect&lt;/strong&gt; reads the build system, not the source, because that is where the signal is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ firmwerk detect .
Selected: make (medium confidence)

Build plan
  build system : make
  toolchain    : arm-none-eabi-gcc
  target arch  : cortex-m4
  build        : make
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Seal and build&lt;/strong&gt; copies the sources into a sandbox, supplies a toolchain pinned by nixpkgs commit and content hash, runs the project's own &lt;code&gt;make&lt;/code&gt;, and writes &lt;code&gt;firmwerk.lock&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ firmwerk lock --backend nix .
Sealed make (make build) with the `nix` backend
  toolchain : gcc-arm-embedded [79bf431783c2]
  artifact  : firmware sha256:sha256-y37o+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"make"&lt;/span&gt;
&lt;span class="n"&gt;build_system&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"make"&lt;/span&gt;
&lt;span class="n"&gt;build_commands&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"make"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;[&lt;/span&gt;&lt;span class="n"&gt;toolchain&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"gcc-arm-embedded"&lt;/span&gt;
&lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"b134951a4c9f3c995fd7be05f3243f8ecd65d798"&lt;/span&gt;   &lt;span class="c"&gt;# nixpkgs commit&lt;/span&gt;
&lt;span class="n"&gt;nixpkgs_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8="&lt;/span&gt;
&lt;span class="n"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"79bf431783c2667c326344d3e13a347bc354cbde29a0490cf1178016501e3282"&lt;/span&gt;

&lt;span class="k"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;artifacts&lt;/span&gt;&lt;span class="k"&gt;]]&lt;/span&gt;
&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"firmware"&lt;/span&gt;
&lt;span class="n"&gt;sha256&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"sha256-y37o+KexPFWmv/mTn2XE7YaSU/fpXq122hTSTBP/Jck="&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Excerpt of the lock file.)&lt;/p&gt;

&lt;p&gt;The lock is the file you commit next to the firmware. It records the exact toolchain, the build commands and the expected hash of every artifact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify&lt;/strong&gt; builds again and compares:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ firmwerk verify --backend nix .
Reproducible: all 1 artifact(s) rebuilt byte-identical.

firmware  [sha256-y37o+KexP]  (make build)
|- toolchain  gcc-arm-embedded [pinned b134951a4c9f3c995fd7be05f3243f8ecd65d798]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That worked despite &lt;code&gt;-g3&lt;/code&gt; debug info, because the sandbox uses a fixed build prefix and &lt;code&gt;SOURCE_DATE_EPOCH&lt;/code&gt;. With the toolchain cached, the lock took about 13 seconds and the verify under two. If a build leaks a timestamp or a path into the image, &lt;code&gt;verify&lt;/code&gt; fails and names the artifact, which is exactly what you want to know.&lt;/p&gt;

&lt;p&gt;The test we like most: step 2 of the same guide. Built natively on a laptop with Fedora's GCC 15.2.0, &lt;code&gt;firmware.bin&lt;/code&gt; hashes to &lt;code&gt;39c2f7c815d0…&lt;/code&gt;; sealed, it is &lt;code&gt;sha256-FUE/MaOsdUw9…&lt;/code&gt;. Then we put a deliberately broken &lt;code&gt;arm-none-eabi-gcc&lt;/code&gt; first on &lt;code&gt;PATH&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'#!/bin/sh\necho "shim arm-none-eabi-gcc: refusing to run" &amp;gt;&amp;amp;2\nexit 1\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; shim/arm-none-eabi-gcc

&lt;span class="nv"&gt;$ PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"shim:&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; make
shim arm-none-eabi-gcc: refusing to run
make: &lt;span class="k"&gt;***&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;Makefile:17: firmware.elf] Error 1

&lt;span class="nv"&gt;$ PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"shim:&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; firmwerk verify &lt;span class="nt"&gt;--backend&lt;/span&gt; nix &lt;span class="nb"&gt;.&lt;/span&gt;
Reproducible: all 1 artifact&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt; rebuilt byte-identical.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The native build breaks. The sealed build does not notice, because it never touches the host toolchain. Two machines with different compilers produce different images natively and the same image this way. That is the property you want eight years later, when the laptop is gone and the patch is due.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping instead of replacing
&lt;/h2&gt;

&lt;p&gt;Firmwerk does not translate your build into something else. It handles Make, CMake, Zephyr's west, ESP-IDF, PlatformIO, Arduino and Rust firmware (embassy, RTIC, bare metal), and runs your existing build inside the sealed environment. For Make it follows &lt;code&gt;include&lt;/code&gt; directives and &lt;code&gt;CROSS_COMPILE&lt;/code&gt; prefixes. When a project's Makefile reaches into &lt;code&gt;../../libs&lt;/code&gt;, it seals the whole repository and builds in the subdirectory.&lt;/p&gt;

&lt;p&gt;Developers keep building the way they do. CI runs &lt;code&gt;verify&lt;/code&gt; on top. Nobody has to agree on a new build system before the builds become reproducible.&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%2F2hdx7yuzocfsqf6cte4o.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%2F2hdx7yuzocfsqf6cte4o.png" alt="Project types and architectures Firmwerk handles" width="800" height="342"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Recovering a lost build from bare .c/.h files
&lt;/h2&gt;

&lt;p&gt;The third failure is the hard one, and it is the usual state of firmware for a model that left the catalogue years ago. Here the approach is closer to archaeology:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run &lt;code&gt;clang -fsyntax-only&lt;/code&gt; in a loop. Each missing header is stubbed and the pass runs again, so you walk the real include graph, &lt;code&gt;#if&lt;/code&gt; branches included.&lt;/li&gt;
&lt;li&gt;Map well-known headers to libraries: &lt;code&gt;stm32f4xx.h&lt;/code&gt; and &lt;code&gt;core_cm4.h&lt;/code&gt; to STM32 HAL/CMSIS, &lt;code&gt;FreeRTOS.h&lt;/code&gt; to FreeRTOS, &lt;code&gt;lwip/*&lt;/code&gt;, &lt;code&gt;mbedtls/*&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Take the architecture from CMSIS core headers or the vendor part family.&lt;/li&gt;
&lt;li&gt;Collect macros that are used in &lt;code&gt;#if&lt;/code&gt; but never defined (&lt;code&gt;-Wundef&lt;/code&gt;) as candidate &lt;code&gt;-D&lt;/code&gt; flags. The holes in the source show you the old command line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu1v5azg7t4h54kurx0sd.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu1v5azg7t4h54kurx0sd.gif" alt="The include closure grows pass by pass. Missing headers are named, not hidden." width="720" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We tried it on step 5 of the same guide, keeping only &lt;code&gt;main.c&lt;/code&gt;, &lt;code&gt;hal.h&lt;/code&gt; and &lt;code&gt;syscalls.c&lt;/code&gt;. &lt;code&gt;hal.h&lt;/code&gt; includes &lt;code&gt;stm32f429xx.h&lt;/code&gt;, which is not there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ firmwerk detect .
Selected: source-archaeology (low confidence)

Evidence
  - 3 source/header files, no build system &amp;lt;- repo scan
  - include closure via clang (3 passes) &amp;lt;- clang -fsyntax-only
  - library = STM32 HAL/CMSIS      &amp;lt;- include closure

Gaps
  ! MCU/architecture not identified from source; confirm target
  ! build not synthesized (architecture unknown); confirm the target to enable compilation
  ! libraries identified; set FIRMWERK_CACHE and run `firmwerk fetch` to pin: STM32 HAL/CMSIS
  ! library versions not recoverable from source; reproducibility applies from now on
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does not guess the MCU, because the header that would tell it is the missing one. It names the library instead and asks you to confirm the target. &lt;code&gt;firmwerk fetch&lt;/code&gt; pulls a pinned SDK into a local cache, the closure runs again, and the next layer shows up. For FreeRTOS that is the project's own &lt;code&gt;FreeRTOSConfig.h&lt;/code&gt; and &lt;code&gt;portmacro.h&lt;/code&gt;. Once the target is confirmed, Firmwerk synthesises the compile, and with startup code and a known MCU it generates a linker script and links a full ELF.&lt;/p&gt;

&lt;p&gt;Note the last gap. Reconstruction gets you a reproducible build from today. It cannot tell you which SDK version was used in 2019.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take from the fridge attacks
&lt;/h2&gt;

&lt;p&gt;A reproducible build does not stop anyone from logging into a controller. It decides whether you can still ship the fix when they do. Four things are worth checking on the next connected product, before it leaves the building:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No service logins derived from something an attacker can compute.&lt;/strong&gt; A password generated from the date is a password everyone has.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Management interfaces are not meant for the public internet.&lt;/strong&gt; Make the safe setup the default one, and say so in the installation guide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An update path that still works on the oldest hardware you support.&lt;/strong&gt; Whether a device can still be updated at all is decided years earlier, at design time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every release rebuildable.&lt;/strong&gt; Seal the toolchain at release time and keep the lock next to the image. "Reproducible from now on" and "identical to the image we shipped in 2019" are different claims. The first one becomes routine. The second needs the original toolchain version, which is exactly the information that tends to get lost. So the best time to seal a build is the release you are about to ship.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;We built this as Firmwerk, part of the BreakPilot platform. Docs: &lt;a href="https://docs.breakpilot.com/en/firmwerk/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=d1" rel="noopener noreferrer"&gt;Firmwerk Docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you build connected controllers and want to find out whether your oldest supported release still builds, write to &lt;a href="mailto:kontakt@breakpilot.com"&gt;kontakt@breakpilot.com&lt;/a&gt;. We are also at SPS in Nuremberg from 24 to 26 November, hall 6, stand 6-253.3.&lt;/p&gt;

&lt;p&gt;Have you had to rebuild an old firmware release, or patch a device that was never meant to be patched? We would like to hear how it went in the comments.&lt;/p&gt;

</description>
      <category>security</category>
      <category>iot</category>
      <category>embedded</category>
      <category>firmware</category>
    </item>
    <item>
      <title>A pure, seeded game engine gives you multiplayer almost for free</title>
      <dc:creator>Sharang Parnerkar</dc:creator>
      <pubDate>Fri, 11 Sep 2026 16:57:04 +0000</pubDate>
      <link>https://dev.to/mighty840/a-pure-seeded-game-engine-gives-you-multiplayer-almost-for-free-4412</link>
      <guid>https://dev.to/mighty840/a-pure-seeded-game-engine-gives-you-multiplayer-almost-for-free-4412</guid>
      <description>&lt;p&gt;I built a board game as a browser app (&lt;a href="https://mighty840.itch.io/samudra-manthan" rel="noopener noreferrer"&gt;Samudra Manthana&lt;/a&gt;), and adding online multiplayer turned out to be almost anticlimactic - not because netcode is easy, but because one early design constraint did most of the work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The constraint: the engine is a pure, deterministic, serialisable function.&lt;/strong&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GameState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;GameState&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;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GameState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;structuredClone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// never mutate the input&lt;/span&gt;
  &lt;span class="c1"&gt;// ... validate + apply, entirely from s and action ...&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&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 &lt;code&gt;Date.now()&lt;/code&gt;, no &lt;code&gt;Math.random()&lt;/code&gt;, no I/O, no reaching outside. Same input, same output, forever. Every bit of randomness comes from a seeded PRNG whose state lives &lt;em&gt;inside&lt;/em&gt; &lt;code&gt;GameState&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The RNG is a single uint32
&lt;/h2&gt;

&lt;p&gt;It is &lt;a href="https://github.com/bryc/code/blob/master/jshash/PRNGs.md" rel="noopener noreferrer"&gt;mulberry32&lt;/a&gt; - a whole PRNG in one 32-bit integer of state, seeded from a string 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="c1"&gt;// string seed -&amp;gt; uint32 starting state&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hashSeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&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="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1779033703&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&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;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&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;h&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;imul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;3432918353&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;19&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="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;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="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Rng&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;state&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="nf"&gt;next&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&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;state&lt;/span&gt; &lt;span class="o"&gt;=&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;state&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mh"&gt;0x6d2b79f5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;t&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;imul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;^=&lt;/span&gt; &lt;span class="nx"&gt;t&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;imul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;61&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;t&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;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="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;4294967296&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxExclusive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&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;floor&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="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;maxExclusive&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;Because the RNG state serialises with everything else, a game is fully described by its seed plus the list of actions taken. That single property buys you a lot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multiplayer falls out of it
&lt;/h2&gt;

&lt;p&gt;The server does not reimplement any rules. It holds the authoritative &lt;code&gt;GameState&lt;/code&gt; and runs the &lt;em&gt;exact same&lt;/em&gt; &lt;code&gt;applyAction&lt;/code&gt;. Clients send actions; the server validates and applies through a thin adapter, then broadcasts the new state:&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;GameAdapter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;currentSeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;isTerminal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Validate + apply an action from a seat; return the new state, or null if illegal.&lt;/span&gt;
  &lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;botAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;persona&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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 client-side prediction to reconcile, no desync class of bugs, and replays are free (seed + actions). The adapter is even game-agnostic, so one server process serves two different games.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one bug worth warning you about
&lt;/h2&gt;

&lt;p&gt;The interesting failure was in &lt;strong&gt;action canonicalisation&lt;/strong&gt;. Clients often omit optional fields ("spawn a unit" without saying &lt;em&gt;where&lt;/em&gt;, defaulting to home). The server matches an incoming action against the legal set with a &lt;code&gt;key()&lt;/code&gt; function - and if &lt;code&gt;key()&lt;/code&gt; does not resolve the same defaults the engine does, a perfectly legal move gets rejected as illegal. Non-mercenary clans could not spawn online until the key resolved &lt;code&gt;at ?? homeHex&lt;/code&gt; exactly the way the engine did.&lt;/p&gt;

&lt;p&gt;Lesson: &lt;strong&gt;the client and server must canonicalise actions identically.&lt;/strong&gt; The determinism gives you correctness for free everywhere &lt;em&gt;except&lt;/em&gt; the boundary where an under-specified action becomes a concrete one. Pin that down and the rest really is almost free.&lt;/p&gt;

&lt;p&gt;Play it in the browser (hotseat, vs bots, or online): &lt;a href="https://mighty840.itch.io/samudra-manthan" rel="noopener noreferrer"&gt;https://mighty840.itch.io/samudra-manthan&lt;/a&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Every sound in my browser game is a few lines of Web Audio (no files)</title>
      <dc:creator>Sharang Parnerkar</dc:creator>
      <pubDate>Tue, 08 Sep 2026 17:08:12 +0000</pubDate>
      <link>https://dev.to/mighty840/every-sound-in-my-browser-game-is-a-few-lines-of-web-audio-no-files-44an</link>
      <guid>https://dev.to/mighty840/every-sound-in-my-browser-game-is-a-few-lines-of-web-audio-no-files-44an</guid>
      <description>&lt;p&gt;I have been building a browser board game (&lt;a href="https://mighty840.itch.io/samudra-manthan" rel="noopener noreferrer"&gt;Samudra Manthana&lt;/a&gt;, an asymmetric strategy game), and when it came time to add sound, the usual route was to go shopping: find effects, check each licence, download a pile of &lt;code&gt;.wav&lt;/code&gt; files, and watch the bundle get heavier.&lt;/p&gt;

&lt;p&gt;I did not do that. &lt;strong&gt;Every sound effect in the game is synthesised at runtime with the Web Audio API.&lt;/strong&gt; No files, no licences, almost no bytes. Here is the whole approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two primitives
&lt;/h2&gt;

&lt;p&gt;It turns out you can build a surprisingly expressive palette from just two functions.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;tone&lt;/strong&gt;: one note with a soft attack and an exponential decay.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;freq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dur&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sine&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;delay&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;osc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createOscillator&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;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createGain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                    &lt;span class="c1"&gt;// sine | triangle | square | sawtooth&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frequency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;freq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;linearRampToValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.012&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// soft attack&lt;/span&gt;
  &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exponentialRampToValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;dur&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// decay&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;master&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;dur&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.03&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;And a &lt;strong&gt;thud&lt;/strong&gt;: a short burst of filtered noise, for impacts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;thud&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dur&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lowpass&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;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sampleRate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dur&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sampleRate&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getChannelData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&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;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// white noise, fading out&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createBufferSource&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&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;filt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createBiquadFilter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nx"&gt;filt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lowpass&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;filt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frequency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lowpass&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;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createGain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exponentialRampToValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;dur&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filt&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;master&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&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;That is the entire kit. Layer a couple of tones, add a thud, pick the pitch and length, and you have a sound.&lt;/p&gt;

&lt;h2&gt;
  
  
  A voice per event
&lt;/h2&gt;

&lt;p&gt;The game narrates itself through a log ("chronicle"), so the sound layer just maps each kind of event to a little synthesised voice, tuned to be quiet rather than arcade-loud:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SFX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;move&lt;/span&gt;&lt;span class="p"&gt;:&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;tone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;520&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.06&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;triangle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.09&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;combat&lt;/span&gt;&lt;span class="p"&gt;:&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="nf"&gt;thud&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sawtooth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.09&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;tribute&lt;/span&gt;&lt;span class="p"&gt;:&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="nf"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;784&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1047&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sine&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.09&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;glory&lt;/span&gt;&lt;span class="p"&gt;:&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="nf"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;659&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sine&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.14&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;880&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sine&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                    &lt;span class="nf"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1175&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sine&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;// a rising chime&lt;/span&gt;
  &lt;span class="na"&gt;churning&lt;/span&gt;&lt;span class="p"&gt;:&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="nf"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&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="s1"&gt;sawtooth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;,&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="s1"&gt;sine&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.06&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.03&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// deep rumble&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// win flourish: a C-E-G-C major arpeggio&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;playWin&lt;/span&gt; &lt;span class="o"&gt;=&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="mi"&gt;523&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;659&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;784&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1047&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&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;tone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sine&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it is driven off the log, I get sound for free in every mode. A tiny effect watches the log and plays one cue per new entry - hotseat or online, no extra wiring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;playSfx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HUD makes sounds too, deliberately different from the game (lighter and drier): a committing action gets a firm two-note cue, a small toggle gets a quiet tick. Your fingers learn the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one real recording, and the autoplay gotcha
&lt;/h2&gt;

&lt;p&gt;There is exactly one audio file in the whole thing: the background music (dropped low, looped, and started from a random point each load so no two sessions open on the same bar). Everything is gated behind the first user gesture, because browsers will not let you make noise until the user has interacted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pointerdown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unlockAudio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&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;h2&gt;
  
  
  Why bother
&lt;/h2&gt;

&lt;p&gt;The entire sound design adds almost nothing to the download, carries zero licensing strings, and is trivial to tweak - a sound is just numbers, so making the churn deeper or a move softer is a one-line change.&lt;/p&gt;

&lt;p&gt;If you want to hear it, the game is playable in the browser: &lt;a href="https://mighty840.itch.io/samudra-manthan" rel="noopener noreferrer"&gt;https://mighty840.itch.io/samudra-manthan&lt;/a&gt;. Turn sound on and have a churn.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>gamedev</category>
      <category>audio</category>
    </item>
    <item>
      <title>WebAssembly as a first-class workload - next to your containers</title>
      <dc:creator>Sharang Parnerkar</dc:creator>
      <pubDate>Sat, 05 Sep 2026 15:51:53 +0000</pubDate>
      <link>https://dev.to/mighty840/webassembly-as-a-first-class-workload-next-to-your-containers-bli</link>
      <guid>https://dev.to/mighty840/webassembly-as-a-first-class-workload-next-to-your-containers-bli</guid>
      <description>&lt;p&gt;&lt;em&gt;This is part of a series on building &lt;a href="https://github.com/mighty840/orca" rel="noopener noreferrer"&gt;Orca&lt;/a&gt;, a single-binary orchestrator for the gap between Coolify and Kubernetes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most orchestrators run containers. Orca runs containers &lt;em&gt;and&lt;/em&gt; WebAssembly modules, as equal citizens. That's not a checkbox feature - it's a bet about where a lot of small workloads are heading, and it shaped the internals.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same machine, two runtimes
&lt;/h2&gt;

&lt;p&gt;Here's a Wasm service in the same config file as your containers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[[service]]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"edge-fn"&lt;/span&gt;
&lt;span class="py"&gt;runtime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"wasm"&lt;/span&gt;
&lt;span class="py"&gt;module&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"./modules/api.wasm"&lt;/span&gt;     &lt;span class="c"&gt;# local path or OCI reference&lt;/span&gt;
&lt;span class="py"&gt;triggers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"http:/api/edge/*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c"&gt;# route these requests to the module&lt;/span&gt;
&lt;span class="py"&gt;replicas&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"auto"&lt;/span&gt;

&lt;span class="nn"&gt;[service.env]&lt;/span&gt;
&lt;span class="py"&gt;API_KEY&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"${secrets.edge_key}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only thing marking it as different from a container is &lt;code&gt;runtime = "wasm"&lt;/code&gt; and a module instead of an image. Everything else - placement, secrets, env, domains, the reconciler that keeps it running - is identical.&lt;/p&gt;

&lt;p&gt;That's deliberate. Internally, Orca has a single &lt;code&gt;Runtime&lt;/code&gt; trait. There's a &lt;code&gt;ContainerRuntime&lt;/code&gt; backed by Docker and a &lt;code&gt;WasmRuntime&lt;/code&gt; backed by &lt;a href="https://wasmtime.dev/" rel="noopener noreferrer"&gt;wasmtime&lt;/a&gt;, and the reconciler dispatches to whichever one a service declares. The scheduler doesn't know or care which it's placing. Adding Wasm as a first-class workload wasn't a special case bolted onto the container path; it was implementing one more &lt;code&gt;Runtime&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you've ever tried to graft a second execution model onto a system that assumed one, you know how much that abstraction is worth. The trait was the whole game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother, when containers exist
&lt;/h2&gt;

&lt;p&gt;Containers are great. But a container is a heavy way to run a 200-line function that fires on an HTTP path. You're carrying a whole userland, a cold start measured in seconds, and a memory floor that makes "run a hundred tiny endpoints" expensive.&lt;/p&gt;

&lt;p&gt;Wasm inverts those costs. A module is a few hundred kilobytes. It starts in milliseconds. The sandbox is the execution model, not a kernel feature you're trusting. For the class of workload that's really &lt;em&gt;"a function behind a URL"&lt;/em&gt; - webhooks, edge transforms, glue endpoints, per-tenant customization - Wasm is structurally cheaper than a container, and it's not close.&lt;/p&gt;

&lt;p&gt;Orca routes HTTP triggers straight to a module invocation. A request to &lt;code&gt;/api/edge/*&lt;/code&gt; doesn't hit a long-lived container; it invokes the Wasm module. That's the seed of something I think matters for the kind of person this whole series is for: the self-hoster, the small team, the person running real things on modest hardware.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this goes: scale-to-zero
&lt;/h2&gt;

&lt;p&gt;Here's the direction, stated honestly as direction and not as a shipped feature.&lt;/p&gt;

&lt;p&gt;A container that serves an endpoint used twice a day still sits there, resident, costing you memory 24/7. A Wasm module that starts in milliseconds doesn't have to. It can genuinely &lt;strong&gt;scale to zero&lt;/strong&gt; - nothing resident until a request arrives, spun up to serve it, gone again after. For a box hosting a long tail of rarely-hit endpoints, that's the difference between "I can afford to run all of these" and "I have to pick which ones survive."&lt;/p&gt;

&lt;p&gt;That's the payoff I'm building toward: a small cluster where your handful of always-on containers coexist with an arbitrary number of scale-to-zero Wasm functions, all in the same &lt;code&gt;service.toml&lt;/code&gt;, all reconciled by the same loop, all behind the same proxy and TLS. The container-only PaaS can't offer the second half of that. The abstraction that made Wasm a first-class runtime is what makes it reachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The through-line
&lt;/h2&gt;

&lt;p&gt;If you've followed this series, you've seen the same instinct three times now: delete the dependency (secrets in git, no Vault), fix the disease not the symptom (the zombie-session liveness rewrite), refuse to guess (exact placement matching). Wasm-as-a-first-class-runtime is the same instinct pointed forward - build the one clean abstraction (&lt;code&gt;Runtime&lt;/code&gt;) so that the &lt;em&gt;next&lt;/em&gt; execution model is an implementation, not a rewrite.&lt;/p&gt;

&lt;p&gt;That's what building an orchestrator for the gap between Coolify and Kubernetes actually feels like: not a smaller Kubernetes, but a different set of bets about what a small operator actually needs - and the discipline to keep the config on one screen while you make them.&lt;/p&gt;

&lt;p&gt;Orca is open source (AGPL-3.0) on &lt;a href="https://github.com/mighty840/orca" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If this series resonated, that's the best place to follow where it goes next.&lt;/p&gt;

</description>
      <category>webassembly</category>
      <category>rust</category>
      <category>devops</category>
    </item>
    <item>
      <title>WebAssembly as a first-class workload - next to your containers</title>
      <dc:creator>Sharang Parnerkar</dc:creator>
      <pubDate>Fri, 04 Sep 2026 16:47:08 +0000</pubDate>
      <link>https://dev.to/mighty840/webassembly-as-a-first-class-workload-next-to-your-containers-41fn</link>
      <guid>https://dev.to/mighty840/webassembly-as-a-first-class-workload-next-to-your-containers-41fn</guid>
      <description>&lt;p&gt;&lt;em&gt;This is part of a series on building &lt;a href="https://github.com/mighty840/orca" rel="noopener noreferrer"&gt;Orca&lt;/a&gt;, a single-binary orchestrator for the gap between Coolify and Kubernetes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most orchestrators run containers. Orca runs containers &lt;em&gt;and&lt;/em&gt; WebAssembly modules, as equal citizens. That's not a checkbox feature - it's a bet about where a lot of small workloads are heading, and it shaped the internals.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same machine, two runtimes
&lt;/h2&gt;

&lt;p&gt;Here's a Wasm service in the same config file as your containers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[[service]]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"edge-fn"&lt;/span&gt;
&lt;span class="py"&gt;runtime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"wasm"&lt;/span&gt;
&lt;span class="py"&gt;module&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"./modules/api.wasm"&lt;/span&gt;     &lt;span class="c"&gt;# local path or OCI reference&lt;/span&gt;
&lt;span class="py"&gt;triggers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"http:/api/edge/*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c"&gt;# route these requests to the module&lt;/span&gt;
&lt;span class="py"&gt;replicas&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"auto"&lt;/span&gt;

&lt;span class="nn"&gt;[service.env]&lt;/span&gt;
&lt;span class="py"&gt;API_KEY&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"${secrets.edge_key}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only thing marking it as different from a container is &lt;code&gt;runtime = "wasm"&lt;/code&gt; and a module instead of an image. Everything else - placement, secrets, env, domains, the reconciler that keeps it running - is identical.&lt;/p&gt;

&lt;p&gt;That's deliberate. Internally, Orca has a single &lt;code&gt;Runtime&lt;/code&gt; trait. There's a &lt;code&gt;ContainerRuntime&lt;/code&gt; backed by Docker and a &lt;code&gt;WasmRuntime&lt;/code&gt; backed by &lt;a href="https://wasmtime.dev/" rel="noopener noreferrer"&gt;wasmtime&lt;/a&gt;, and the reconciler dispatches to whichever one a service declares. The scheduler doesn't know or care which it's placing. Adding Wasm as a first-class workload wasn't a special case bolted onto the container path; it was implementing one more &lt;code&gt;Runtime&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you've ever tried to graft a second execution model onto a system that assumed one, you know how much that abstraction is worth. The trait was the whole game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother, when containers exist
&lt;/h2&gt;

&lt;p&gt;Containers are great. But a container is a heavy way to run a 200-line function that fires on an HTTP path. You're carrying a whole userland, a cold start measured in seconds, and a memory floor that makes "run a hundred tiny endpoints" expensive.&lt;/p&gt;

&lt;p&gt;Wasm inverts those costs. A module is a few hundred kilobytes. It starts in milliseconds. The sandbox is the execution model, not a kernel feature you're trusting. For the class of workload that's really &lt;em&gt;"a function behind a URL"&lt;/em&gt; - webhooks, edge transforms, glue endpoints, per-tenant customization - Wasm is structurally cheaper than a container, and it's not close.&lt;/p&gt;

&lt;p&gt;Orca routes HTTP triggers straight to a module invocation. A request to &lt;code&gt;/api/edge/*&lt;/code&gt; doesn't hit a long-lived container; it invokes the Wasm module. That's the seed of something I think matters for the kind of person this whole series is for: the self-hoster, the small team, the person running real things on modest hardware.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this goes: scale-to-zero
&lt;/h2&gt;

&lt;p&gt;Here's the direction, stated honestly as direction and not as a shipped feature.&lt;/p&gt;

&lt;p&gt;A container that serves an endpoint used twice a day still sits there, resident, costing you memory 24/7. A Wasm module that starts in milliseconds doesn't have to. It can genuinely &lt;strong&gt;scale to zero&lt;/strong&gt; - nothing resident until a request arrives, spun up to serve it, gone again after. For a box hosting a long tail of rarely-hit endpoints, that's the difference between "I can afford to run all of these" and "I have to pick which ones survive."&lt;/p&gt;

&lt;p&gt;That's the payoff I'm building toward: a small cluster where your handful of always-on containers coexist with an arbitrary number of scale-to-zero Wasm functions, all in the same &lt;code&gt;service.toml&lt;/code&gt;, all reconciled by the same loop, all behind the same proxy and TLS. The container-only PaaS can't offer the second half of that. The abstraction that made Wasm a first-class runtime is what makes it reachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The through-line
&lt;/h2&gt;

&lt;p&gt;If you've followed this series, you've seen the same instinct three times now: delete the dependency (secrets in git, no Vault), fix the disease not the symptom (the zombie-session liveness rewrite), refuse to guess (exact placement matching). Wasm-as-a-first-class-runtime is the same instinct pointed forward - build the one clean abstraction (&lt;code&gt;Runtime&lt;/code&gt;) so that the &lt;em&gt;next&lt;/em&gt; execution model is an implementation, not a rewrite.&lt;/p&gt;

&lt;p&gt;That's what building an orchestrator for the gap between Coolify and Kubernetes actually feels like: not a smaller Kubernetes, but a different set of bets about what a small operator actually needs - and the discipline to keep the config on one screen while you make them.&lt;/p&gt;

&lt;p&gt;Orca is open source (AGPL-3.0) on &lt;a href="https://github.com/mighty840/orca" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If this series resonated, that's the best place to follow where it goes next.&lt;/p&gt;

</description>
      <category>webassembly</category>
      <category>rust</category>
      <category>devops</category>
    </item>
    <item>
      <title>One upgrade, three outages: glibc, cgroups, and a placeholder bug</title>
      <dc:creator>Sharang Parnerkar</dc:creator>
      <pubDate>Fri, 28 Aug 2026 22:56:58 +0000</pubDate>
      <link>https://dev.to/mighty840/one-upgrade-three-outages-glibc-cgroups-and-a-placeholder-bug-m31</link>
      <guid>https://dev.to/mighty840/one-upgrade-three-outages-glibc-cgroups-and-a-placeholder-bug-m31</guid>
      <description>&lt;p&gt;This is the last of three parts. In part one I split my personal services onto their own two-node cluster. In part two I read the orchestrator's source and fixed two bugs, which shipped in the next release. This part is what happened when I installed that release.&lt;/p&gt;

&lt;p&gt;It broke the cluster three separate ways. None of them shared a root cause. Here they are in the order I hit them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure one: glibc floor
&lt;/h2&gt;

&lt;p&gt;I copied the official release binary to the second node and the service went into a crash loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/usr/local/bin/orca: /lib/x86_64-linux-gnu/libc.so.6:
  version `GLIBC_2.32' not found (required by orca)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The node runs Ubuntu 20.04, which ships glibc 2.31. The official builds are linked against something newer, so the binary simply cannot load there. The controller node is newer and took the release fine. The old node could not.&lt;/p&gt;

&lt;p&gt;The fix was to stop using the official binary on that node and build one linked against its glibc. Compiling inside a matching container does it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:/src &lt;span class="nt"&gt;-w&lt;/span&gt; /src rust:1-bullseye sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'
  apt-get update &amp;amp;&amp;amp; apt-get install -y protobuf-compiler &amp;amp;&amp;amp;
  cargo build --release'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rust:1-bullseye&lt;/code&gt; is Debian 11, glibc 2.31, so the resulting binary runs on the 2.31 node.&lt;/p&gt;

&lt;p&gt;The lesson: &lt;strong&gt;your oldest host sets your glibc floor for every prebuilt binary.&lt;/strong&gt; Either build for that floor or retire the host. I now had a standing rule that any orchestrator upgrade on that node needs the container build. I also had a fresh argument for replacing the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure two: status=219/CGROUP
&lt;/h2&gt;

&lt;p&gt;With a working binary in place, the unit still refused to start, now failing differently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Main PID: ... (code=exited, status=219/CGROUP)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit 219 from systemd means it could not set up the service's cgroup. Not a binary problem at all. The node is a Virtuozzo container, and in that environment the systemd cgroup hierarchy has a hard cap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/systemd/cgroup.subgroups_limit
100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;find /sys/fs/cgroup/systemd &lt;span class="nt"&gt;-type&lt;/span&gt; d | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
101
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the cap. systemd could not create even one more service cgroup, so any new unit failed with 219, working binary or not.&lt;/p&gt;

&lt;p&gt;What filled it was the interesting part. Two contributors. First, hours of crash-looping from failures one and two churned units. Second, and larger, &lt;strong&gt;leaked SSH session scopes&lt;/strong&gt;. Every SSH login created a &lt;code&gt;session-*.scope&lt;/code&gt; cgroup that this systemd version never cleaned up on logout. With zero users actually logged in, 96 of the 101 cgroup directories held no processes at all. My own repeated logins to debug the box had been quietly eating the budget.&lt;/p&gt;

&lt;p&gt;The recovery was to remove the process-less session scopes, clear the failed state, and start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;d &lt;span class="k"&gt;in&lt;/span&gt; /sys/fs/cgroup/systemd/user.slice/user-&lt;span class="k"&gt;*&lt;/span&gt;.slice/session-&lt;span class="k"&gt;*&lt;/span&gt;.scope&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;/cgroup.procs"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;sudo rmdir&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reset-failed orca-agent
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start orca-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson: &lt;strong&gt;on a constrained container, the cgroup budget is a real, exhaustible resource.&lt;/strong&gt; Automation or debugging that opens a lot of SSH sessions can silently consume it, and the failure it produces (&lt;code&gt;219/CGROUP&lt;/code&gt;) points at systemd, not at whatever you were actually trying to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure three: the placeholder regression
&lt;/h2&gt;

&lt;p&gt;The binary loaded, the unit started, and the cluster was still broken. Every domain on the controller node returned 404 while the containers underneath ran perfectly fine and served correct responses on localhost.&lt;/p&gt;

&lt;p&gt;The orchestrator status showed those services as &lt;code&gt;0/1 stopped&lt;/code&gt;. But &lt;code&gt;docker ps&lt;/code&gt; showed the containers up for days. The controller thought the services were not running; the containers disagreed.&lt;/p&gt;

&lt;p&gt;The release I had just installed added a startup path that registered each service, and it treated services pinned to the controller's own hostname as &lt;strong&gt;remote placeholders&lt;/strong&gt;: entries that mean "this belongs on some other node, wait for that node to report in". The controller then waited forever for a node that was itself, never adopted the already-running local containers, and never registered their routes. Hence 404 at the edge while the containers ran.&lt;/p&gt;

&lt;p&gt;If that sounds familiar, it is the same family as the redeploy bug I fixed in part two: the controller's self-registration matching a placement lookup it should be excluded from. My fix covered the redeploy path. This was a different code path, added later, with the same blind spot.&lt;/p&gt;

&lt;p&gt;Two things made it stickier than expected. The placeholder registration also persisted a stop-mark, so simply removing the pins from config was not enough to bring the services back; the reconciler still saw them as stopped. And the new release had dropped the CLI &lt;code&gt;start&lt;/code&gt; verb, so clearing the mark meant calling the REST endpoint per service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;s &lt;span class="k"&gt;in &lt;/span&gt;app-a app-b app-c&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"http://127.0.0.1:PORT/api/v1/services/&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="s2"&gt;/start"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The durable fix was in configuration, and it is the through-line of this whole series: &lt;strong&gt;do not pin controller-hosted services at all.&lt;/strong&gt; Unpinned services default to the controller anyway. Only services that genuinely live on a remote node get an explicit pin. I removed every controller pin, kept the one real remote pin, and filed the regression as &lt;a href="https://github.com/mighty840/orca/issues/151" rel="noopener noreferrer"&gt;orca#151&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The through-line
&lt;/h2&gt;

&lt;p&gt;Across all three parts, one trap kept resurfacing: &lt;strong&gt;pinning a service to "the controller's own node" is a landmine in this orchestrator.&lt;/strong&gt; It caused the redeploy 503 in part two and the placeholder 404 here, through two different code paths. The reliable rule is to never name the controller in a placement; let it be the default and reserve pins for remote nodes.&lt;/p&gt;

&lt;p&gt;The other lesson is blunter. An end-of-life host turned a routine upgrade into a multi-hour, three-failure incident: glibc too old for the binary, a Virtuozzo cgroup cap I did not know existed, all of it made worse by the debugging itself. The single best fix for two of these three outages is to retire that box. That is now at the top of the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would keep, and what I would change
&lt;/h2&gt;

&lt;p&gt;Keep:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Declarative reconcile with git-push deploys. It is fast and legible, and the same property that makes it dangerous makes recovery a &lt;code&gt;git revert&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Reading the source. Twice it beat guessing outright.&lt;/li&gt;
&lt;li&gt;Verifying in production against the real path, not in my head.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never pin a service to the controller's own node.&lt;/li&gt;
&lt;li&gt;Retire end-of-life hosts before they set your glibc floor and hand you exotic kernel limits.&lt;/li&gt;
&lt;li&gt;Watch the cgroup budget on constrained containers, and do not let your own debugging sessions leak scopes into it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the split, the bug hunt, and the upgrade. Three posts, one small orchestrator, and a much cleaner separation between the things I run for money and the things I run for fun.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>linux</category>
      <category>sysadmin</category>
      <category>docker</category>
    </item>
    <item>
      <title>Reading the orchestrator's Rust source to fix my own outage</title>
      <dc:creator>Sharang Parnerkar</dc:creator>
      <pubDate>Tue, 25 Aug 2026 13:53:28 +0000</pubDate>
      <link>https://dev.to/mighty840/reading-the-orchestrators-rust-source-to-fix-my-own-outage-5ann</link>
      <guid>https://dev.to/mighty840/reading-the-orchestrators-rust-source-to-fix-my-own-outage-5ann</guid>
      <description>&lt;p&gt;This is part two of three. In part one I split my personal services onto their own cluster. The cutover went fine. Then two things broke that had nothing to do with the migration itself and everything to do with the orchestrator's code, and because the orchestrator is a small open-source tool I run myself, I could go read it.&lt;/p&gt;

&lt;p&gt;That turned out to be the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom one: a shared domain that 404s at random
&lt;/h2&gt;

&lt;p&gt;Two demo apps share a single hostname. A storefront answers &lt;code&gt;/*&lt;/code&gt;, an admin panel answers &lt;code&gt;/admin/*&lt;/code&gt;. After the migration, whichever one you hit would sometimes 404, and which one "won" changed after every deploy or health-check flap.&lt;/p&gt;

&lt;p&gt;The orchestrator, &lt;a href="https://github.com/mighty840/orca" rel="noopener noreferrer"&gt;orca&lt;/a&gt;, keeps a route table mapping each domain to a list of upstream targets. The request path already did the right thing: it matched the longest path prefix across all targets for a host. So the read side supported several services per domain. The write side did not.&lt;/p&gt;

&lt;p&gt;The registration function replaced the whole entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// on each service's route update&lt;/span&gt;
&lt;span class="n"&gt;route_table&lt;/span&gt;&lt;span class="nf"&gt;.insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;targets&lt;/span&gt;&lt;span class="nf"&gt;.clone&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;insert&lt;/code&gt; overwrites. So when the storefront registered its targets, it wiped the admin panel's, and vice versa. Every deploy or health transition re-ran registration for one service and clobbered its sibling. Last writer wins, and the loser's paths 404 until it happens to register next.&lt;/p&gt;

&lt;p&gt;The removal paths already used the right idea, &lt;code&gt;retain&lt;/code&gt; by service name, so only the registration path was wrong. The fix was to merge instead of replace: drop only this service's stale targets, then extend.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;route_table&lt;/span&gt;&lt;span class="nf"&gt;.entry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="nf"&gt;.or_default&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="nf"&gt;.retain&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="py"&gt;.service_name&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="py"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="nf"&gt;.extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;targets&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.cloned&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;route_table&lt;/span&gt;&lt;span class="nf"&gt;.remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;domain&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;Twelve lines. Multi-service domains became stable across deploys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom two: webhook redeploys returning 503
&lt;/h2&gt;

&lt;p&gt;The second problem showed up as soon as the automated deploy pipeline fired for the first time against controller-hosted services. Push, build, push image, the webhook calls the controller, and the controller answers 503 with a message about an agent not being connected.&lt;/p&gt;

&lt;p&gt;The clue was "controller-hosted". Services that ran on a remote agent redeployed fine. Only services pinned to the controller's own node failed.&lt;/p&gt;

&lt;p&gt;Reading the redeploy path, the cause was clean. The controller &lt;strong&gt;self-registers as a node&lt;/strong&gt; in the cluster so it shows up in the node list. A service pinned to the controller's own hostname resolved to that self-registered node id. Redeploy then treated it like any remote node and tried to send the operation over a control WebSocket. But the controller does not hold a WebSocket to itself. No channel, so the operation failed with an "agent offline" error, surfaced as a 503.&lt;/p&gt;

&lt;p&gt;The fix was to exclude the controller's own node id from the remote-placement match, so a service pinned to the controller falls through to the local path it should have taken:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="nf"&gt;.filter&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nf"&gt;master_node_id&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both fixes went into one pull request: &lt;a href="https://github.com/mighty840/orca/pull/138" rel="noopener noreferrer"&gt;orca#138&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying in production, not in my head
&lt;/h2&gt;

&lt;p&gt;I did not want to trust a reread. The route fix I checked by hammering both paths of the shared domain and forcing redeploys to confirm the routes stayed put. The redeploy fix I checked with the real pipeline: an empty commit to one service repo, which ran CI, pushed the image, called the signed webhook, and this time the controller logged a successful redeploy and returned 200 instead of 503.&lt;/p&gt;

&lt;p&gt;Building the patched binary had its own wrinkle. One of my nodes is old enough that I had to compile inside a glibc-2.31 container to get a binary it would run. That is a story for part three, and it is where this migration stopped being tidy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual lesson
&lt;/h2&gt;

&lt;p&gt;Two things generalize.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;when you self-host a small tool, its source is part of your operational surface.&lt;/strong&gt; I have spent hours poking at closed systems from the outside, inferring behavior from logs. Here the behavior was one function call away. Reading &lt;code&gt;insert&lt;/code&gt; versus &lt;code&gt;retain&lt;/code&gt; was faster and more certain than any amount of black-box probing.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;the "it worked before" detail localizes the bug.&lt;/strong&gt; The route bug only appeared with two services on one domain, which is a shape the old single-node layout never had. The redeploy bug only appeared for controller-hosted services once an automated pipeline started calling redeploy. In both cases the exact conditions under which it broke pointed straight at the code path responsible. When something regresses, the first useful question is not "what is wrong" but "what is different about the case that fails".&lt;/p&gt;

&lt;p&gt;Next time you hit a wall with a tool you run yourself, check whether you can read the wall. Often you can.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;The fixes were real and they shipped in the next release. Installing that release is what broke the cluster three different ways in one afternoon. Part three: glibc, cgroups, and a bug I had just helped create.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>debugging</category>
      <category>devops</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>Splitting one self-hosted cluster into two, without losing a byte</title>
      <dc:creator>Sharang Parnerkar</dc:creator>
      <pubDate>Fri, 21 Aug 2026 13:49:13 +0000</pubDate>
      <link>https://dev.to/mighty840/splitting-one-self-hosted-cluster-into-two-without-losing-a-byte-177g</link>
      <guid>https://dev.to/mighty840/splitting-one-self-hosted-cluster-into-two-without-losing-a-byte-177g</guid>
      <description>&lt;p&gt;For a while I ran everything on one small orchestrator: my company's tools and my personal side projects, mixed together on shared nodes, one git repo, one object storage account. It worked, but it was the wrong shape. Personal experiments could compete with production for memory, one repo held two very different blast radii, and the bill was a single lump.&lt;/p&gt;

&lt;p&gt;So I split it. Personal services moved to their own git repo, their own cluster, their own nodes, and their own object storage project. This is part one of three: the plan and the cutover. Part two is the bug hunt that followed, and part three is the upgrade that broke everything three separate ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The orchestrator is &lt;a href="https://github.com/mighty840/orca" rel="noopener noreferrer"&gt;orca&lt;/a&gt;, a small single-binary container orchestrator. Two facts about it shape everything below:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It is &lt;strong&gt;declarative and it prunes&lt;/strong&gt;. A controller loop reads a directory of service definitions from git and converges the cluster to match. If you delete a service block and push, the container is removed. A &lt;code&gt;git push&lt;/code&gt; is a deploy.&lt;/li&gt;
&lt;li&gt;Ingress, TLS, and routing are built in. Each node runs a reverse proxy, and certificates are issued per domain over HTTP-01.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The new personal cluster would be two nodes: one box as the controller, and an older second box joining later as an agent. The business cluster stayed exactly where it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanic that matters: reconcile prunes
&lt;/h2&gt;

&lt;p&gt;Because the controller continuously converges the cluster to git, every edit is live the moment it lands on the branch the controller watches. That is convenient and it is dangerous. The dangerous case here was moving a node that already ran personal containers from the old cluster into the new one. If I just deleted those services from the old cluster's git, the controller would happily remove the containers, and depending on how volumes were declared, the data could go with them.&lt;/p&gt;

&lt;p&gt;The trick is ordering. To hand a node from cluster A to cluster B without losing data:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stop the agent on that node first.&lt;/strong&gt; A controller cannot act on a node it cannot reach. The containers keep running, the volumes stay put.&lt;/li&gt;
&lt;li&gt;Only then remove those services from cluster A's git. The controller drops them from desired state but physically cannot prune the now-unreachable node.&lt;/li&gt;
&lt;li&gt;Remove the old containers locally, keeping the volumes.&lt;/li&gt;
&lt;li&gt;Install the new controller, re-set the secrets, deploy. The new services reattach to the surviving volumes by name.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Downtime for the whole cutover was about half an hour, most of it TLS reissue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets do not travel as files
&lt;/h2&gt;

&lt;p&gt;The per-service secret store is encrypted at rest with a per-node key. Copying the encrypted file to the new controller would have produced garbage. The path that worked was to export the plaintext on the old controller, stage it, and re-import on the new one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# on the old controller, per service&lt;/span&gt;
orca secrets get MY_KEY   &lt;span class="c"&gt;# prints the decrypted value&lt;/span&gt;

&lt;span class="c"&gt;# stage the values, then on the new controller&lt;/span&gt;
orca secrets import &lt;span class="nt"&gt;-f&lt;/span&gt; staged.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The values never touched a terminal I did not control, and the encrypted blobs never left their node.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving object storage without a server-side copy
&lt;/h2&gt;

&lt;p&gt;The buckets lived in one object storage project and needed to move to a separate one. There is no server-side cross-project copy on this provider, so the move was a streamed &lt;code&gt;rclone copy&lt;/code&gt;, run on a machine in the same data center as the storage so the bytes never left the region:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rclone copy business:media personal:media &lt;span class="nt"&gt;--transfers&lt;/span&gt; 16 &lt;span class="nt"&gt;-P&lt;/span&gt;
rclone check business:media personal:media &lt;span class="nt"&gt;--one-way&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rclone check&lt;/code&gt; reporting zero differences over several thousand objects was the green light. From there the app config pointed at the new bucket, and the old one was retired at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The footgun: a mount that silently vanished
&lt;/h2&gt;

&lt;p&gt;One service, a &lt;a href="https://www.navidrome.org/" rel="noopener noreferrer"&gt;Navidrome&lt;/a&gt; music server, kept losing its library on every deploy. A roughly 26 GB corpus would reappear as empty. The cause was a single misplaced line in the TOML config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[[service]]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"navidrome"&lt;/span&gt;
&lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;span class="nn"&gt;[service.volume]&lt;/span&gt;
&lt;span class="py"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/data"&lt;/span&gt;
&lt;span class="py"&gt;mounts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"/host/music:/music"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c"&gt;# WRONG: this is service.volume.mounts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In TOML, a key that appears after a &lt;code&gt;[section]&lt;/code&gt; header belongs to that section. So &lt;code&gt;mounts&lt;/code&gt; here parsed as &lt;code&gt;service.volume.mounts&lt;/code&gt;, a field the orchestrator does not read. It never saw the bind, and the image's own &lt;code&gt;VOLUME&lt;/code&gt; declaration created a fresh anonymous volume every deploy. The fix was to move &lt;code&gt;mounts&lt;/code&gt; above the first sub-table so it belongs to the top-level service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[[service]]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"navidrome"&lt;/span&gt;
&lt;span class="py"&gt;mounts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"/host/music:/music"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c"&gt;# top-level, seen by the orchestrator&lt;/span&gt;
&lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;span class="nn"&gt;[service.volume]&lt;/span&gt;
&lt;span class="py"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/data"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson is boring and worth internalizing: in TOML, section headers are sticky. A key is owned by whatever &lt;code&gt;[table]&lt;/code&gt; most recently opened above it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What made this easy
&lt;/h2&gt;

&lt;p&gt;Most of the personal subdomains already pointed at the node that was becoming the new controller, because those services already ran there. So the big cutover needed almost no DNS change. The proxy came up, HTTP-01 reissued certificates for each domain, and the sites were back. The only real DNS work was two records for services that migrated off the business box later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;The checks I actually ran after deploy, in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Both databases present and listing their expected tables.&lt;/li&gt;
&lt;li&gt;The music bind resolving to the host path, not a fresh volume.&lt;/li&gt;
&lt;li&gt;Each domain returning a valid certificate and a 200.&lt;/li&gt;
&lt;li&gt;A trivial push producing an automatic redeploy through the webhook.&lt;/li&gt;
&lt;li&gt;The first scheduled backup landing in the new bucket.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one matters. A manual backup command only proved the local leg. Only the scheduled run proved the object storage upload path end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;The cutover was the calm part. Right after it, two demo apps that share a domain started returning 404s at random, and webhook redeploys began failing with 503s. That sent me into the orchestrator's own Rust source, which is part two.&lt;/p&gt;

</description>
      <category>selfhosting</category>
      <category>devops</category>
      <category>docker</category>
      <category>homelab</category>
    </item>
    <item>
      <title>`node = "ubuntu"` matched two machines: a placement split-brain</title>
      <dc:creator>Sharang Parnerkar</dc:creator>
      <pubDate>Tue, 18 Aug 2026 13:45:35 +0000</pubDate>
      <link>https://dev.to/mighty840/node-ubuntu-matched-two-machines-a-placement-split-brain-954</link>
      <guid>https://dev.to/mighty840/node-ubuntu-matched-two-machines-a-placement-split-brain-954</guid>
      <description>&lt;p&gt;&lt;em&gt;This is part of a series on building &lt;a href="https://github.com/mighty840/orca" rel="noopener noreferrer"&gt;Orca&lt;/a&gt;, a single-binary orchestrator for the gap between Coolify and Kubernetes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In Orca you can pin a service to a specific machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[service.placement]&lt;/span&gt;
&lt;span class="py"&gt;node&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ubuntu"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple, obvious, and it worked fine - right up until the cluster had two nodes whose names shared a prefix: &lt;code&gt;ubuntu&lt;/code&gt; and &lt;code&gt;ubuntu-16gb-fsn1-1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then one push recreated &lt;em&gt;every&lt;/em&gt; pinned service on &lt;em&gt;both&lt;/em&gt; machines. Including a database. Two &lt;code&gt;Postgres&lt;/code&gt; instances, on two different hosts, both believing they were the one true copy of the same service. In production. A split-brain, born from a single line of code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one line
&lt;/h2&gt;

&lt;p&gt;The placement matcher did this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="py"&gt;.address&lt;/span&gt;&lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* schedule here */&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;contains&lt;/code&gt;. A substring test. &lt;code&gt;"ubuntu-16gb-fsn1-1".contains("ubuntu")&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;. So a pin of &lt;code&gt;"ubuntu"&lt;/code&gt; matched both nodes, and every service pinned to &lt;code&gt;ubuntu&lt;/code&gt; got scheduled onto both.&lt;/p&gt;

&lt;p&gt;Worse: &lt;em&gt;which&lt;/em&gt; node "won" a plain deploy was decided by &lt;code&gt;HashMap&lt;/code&gt; iteration order - non-deterministic across restarts. So the behavior wasn't even consistently wrong. It was randomly wrong, which is the most expensive kind.&lt;/p&gt;

&lt;p&gt;Substring matching on an identity is one of those bugs that looks completely reasonable in the diff and is a landmine in the field. It works in every test you write, because your test nodes have distinct names. It only detonates when someone, months later, names a second node with an overlapping prefix - which is exactly the kind of thing people do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: exact identity, and refuse to guess
&lt;/h2&gt;

&lt;p&gt;Two changes, and the second matters more than the first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One - match exact identity, never a substring.&lt;/strong&gt; A pin now matches a node only if it equals the node's id, its full &lt;code&gt;ip:port&lt;/code&gt; address, the host portion of that address, or its hostname label. &lt;code&gt;"ubuntu"&lt;/code&gt; matches the node named &lt;code&gt;ubuntu&lt;/code&gt; and &lt;em&gt;only&lt;/em&gt; that node.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two - when a pin is ambiguous, refuse.&lt;/strong&gt; This is the real lesson. If a pin somehow matches more than one node, the old code picked one (arbitrarily). The new code &lt;strong&gt;fails the deploy loudly&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;placement pin "ubuntu" matches multiple nodes: 1 (10.0.0.1), 2 (10.0.0.2)
- refusing to schedule; make the pin unique
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if a pin matches &lt;em&gt;nothing&lt;/em&gt; - a decommissioned node, a typo - it also refuses, instead of silently falling through to "just run it on the master." That silent fallback was its own latent bug: a mistyped pin would quietly deploy your service to the wrong machine and everything would look fine until it very much wasn't.&lt;/p&gt;

&lt;p&gt;The principle is &lt;strong&gt;refuse, don't guess.&lt;/strong&gt; An orchestrator that guesses when it's uncertain will eventually guess wrong at 3am, silently, in a way you find out about from a customer. An orchestrator that refuses hands you a clear error at deploy time, when you're right there watching. A loud failure you see beats a quiet success you don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I got wrong on the way
&lt;/h2&gt;

&lt;p&gt;Here's the honest bit. When I shipped the exact-match fix, I audited the production config to make sure every existing pin still resolved. I checked the pins against the node &lt;em&gt;addresses&lt;/em&gt; I had in memory - &lt;code&gt;217.154.26.121:9443&lt;/code&gt; and friends - and concluded they were fine.&lt;/p&gt;

&lt;p&gt;They weren't. Agents register with a &lt;code&gt;hostname:port&lt;/code&gt; address, not &lt;code&gt;ip:port&lt;/code&gt;. So the ten pins that used a bare IP suddenly matched nothing, and - thanks to the new "refuse, don't guess" behavior - those deploys started failing loudly.&lt;/p&gt;

&lt;p&gt;Which was, ironically, the system working exactly as designed. The fix caught a real config problem (pins pointing at an identity the node doesn't actually register under) instead of silently papering over it. The remedy was to add peer-IP matching so IP pins work regardless of what the agent self-reports - but the meta-lesson stuck: &lt;strong&gt;audit against what the system actually stores, not against what you assume it stores.&lt;/strong&gt; My mental model of the node addresses was wrong, and only the exact-match refusal surfaced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two rules I'd tattoo on a junior engineer
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never substring-match an identity.&lt;/strong&gt; Names, ids, addresses - compare them whole. The prefix collision is always coming; it's just a question of when.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refuse when you're not sure.&lt;/strong&gt; Ambiguity and "no match" are not edge cases to paper over with a default. They're exactly the moments to stop and shout, because a wrong guess in placement means your database is running in two places.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole thing was maybe forty lines of code to fix. The split-brain it prevents is unbounded. That ratio - tiny fix, huge blast radius avoided - is most of what infrastructure work actually is.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>devops</category>
      <category>distributed</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The WebSocket session that was dead for 10 days - and my dashboard said 'healthy'</title>
      <dc:creator>Sharang Parnerkar</dc:creator>
      <pubDate>Fri, 14 Aug 2026 14:16:34 +0000</pubDate>
      <link>https://dev.to/mighty840/the-websocket-session-that-was-dead-for-10-days-and-my-dashboard-said-healthy-4oom</link>
      <guid>https://dev.to/mighty840/the-websocket-session-that-was-dead-for-10-days-and-my-dashboard-said-healthy-4oom</guid>
      <description>&lt;p&gt;&lt;em&gt;This is part of a series on building &lt;a href="https://github.com/mighty840/orca" rel="noopener noreferrer"&gt;Orca&lt;/a&gt;, a single-binary orchestrator for the gap between Coolify and Kubernetes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here's the incident, cold:&lt;/p&gt;

&lt;p&gt;A worker node in my cluster had a control-plane WebSocket session to the master that had been &lt;strong&gt;dead since the 7th&lt;/strong&gt;. It was the 17th. For ten days, every deploy targeting that node timed out with &lt;code&gt;agent did not acknowledge within 10s&lt;/code&gt;. And the entire time, the node showed up &lt;strong&gt;green&lt;/strong&gt; in the dashboard. Its heartbeats were flowing. &lt;code&gt;orca status&lt;/code&gt; cheerfully reported a service as &lt;code&gt;1/1 running&lt;/code&gt; - a container that had, in fact, been crashed for fourteen hours.&lt;/p&gt;

&lt;p&gt;Nothing was on fire. Nothing was alerting. The system was lying to me with a completely straight face, and it had been for a week and a half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the node looked alive
&lt;/h2&gt;

&lt;p&gt;The master↔agent link is a WebSocket. The agent sends a heartbeat every 5 seconds; the master pushes a status ping every 30. The master tracks connected agents in a map keyed by node id. Simple.&lt;/p&gt;

&lt;p&gt;The bug is that &lt;strong&gt;liveness was inferred from two different things that aren't the same thing.&lt;/strong&gt; Heartbeats proved the agent &lt;em&gt;process&lt;/em&gt; was alive. But deploys were dispatched over the &lt;em&gt;WebSocket session&lt;/em&gt;. And those are only the same as long as the socket is actually open.&lt;/p&gt;

&lt;p&gt;Then came a half-open TCP connection. The agent's box dropped off the network in a way that sent no FIN, no RST - a NAT conntrack timeout, a VM freeze, the kind of unclean disappearance that's depressingly common. On the master's side, the read on that socket just... blocked. Forever. No error. The session stayed in the connected map. Every status ping the master sent went into a kernel send buffer and evaporated.&lt;/p&gt;

&lt;p&gt;Meanwhile the agent, on &lt;em&gt;its&lt;/em&gt; side, was in the exact same state: its read blocked forever, and its 5-second heartbeat writes succeeded into a dead send buffer that would only surface an error after the TCP retransmit timeout - about fifteen minutes later, if ever.&lt;/p&gt;

&lt;p&gt;So both ends believed they were connected. The map said "connected." And there was a rule - added to fix an &lt;em&gt;earlier&lt;/em&gt; bug - that a node with a live session must never be pruned, no matter how stale its heartbeat. That rule was correct in spirit and catastrophic in practice, because "has an entry in the map" was being treated as proof of life. A zombie session is immortal under that rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three prior "fixes" that missed
&lt;/h2&gt;

&lt;p&gt;This was the fourth time this class of bug had bitten. Each previous fix had patched a &lt;em&gt;symptom&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One stopped a node from being wrongly pruned while connected.&lt;/li&gt;
&lt;li&gt;One improved the timeout error message.&lt;/li&gt;
&lt;li&gt;One handled stale service state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of them treated a consequence. None of them touched the disease, which was this: &lt;strong&gt;node liveness was tracked separately from the channel the master actually dispatches over.&lt;/strong&gt; As long as those were two different sources of truth, a half-open socket could always desync them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: liveness &lt;em&gt;is&lt;/em&gt; the deploy channel
&lt;/h2&gt;

&lt;p&gt;The permanent fix was to stop having two definitions of "alive." A session is alive if and only if it's carrying traffic. And here's the nice part - both sides &lt;em&gt;already&lt;/em&gt; generate regular traffic (5s heartbeats, 30s pings), so I didn't need a new protocol at all. I just needed to &lt;em&gt;enforce a deadline on it&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Master&lt;/strong&gt;: each session's read loop runs under a read-idle deadline (30s, configurable). Silence past the deadline means the socket is half-open. Tear the session down: remove it from the map, drop the node's placeholder service state, mark it unreachable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent&lt;/strong&gt;: a 90-second read-idle deadline (three missed pings). A half-dead socket breaks the loop, and the existing backoff-reconnect takes over. No human, no restart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A missed deploy ACK now has consequences.&lt;/strong&gt; It doesn't just error - it kills the session, on the theory that an agent that can't ACK a deploy over the channel isn't really connected. The next deploy fails fast with "unreachable until it rejoins" instead of re-timing-out against a corpse.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The stale &lt;code&gt;1/1 running&lt;/code&gt; fixed itself as a &lt;em&gt;consequence&lt;/em&gt;: placeholder state only exists while a live session refreshes it, so when the session dies, the placeholders die with it. The system can no longer report confident, hours-old lies, because the state that would tell the lie is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that would have caught it
&lt;/h2&gt;

&lt;p&gt;The reason this bug survived three fixes is that nobody had written the test that actually reproduces it. Killing a process is easy to test. A &lt;em&gt;half-open socket&lt;/em&gt; is the hard case - the peer is gone but the TCP connection is still open. So the regression test does exactly that: it connects, then goes completely silent while holding the socket open, and asserts the master tears the session down within the deadline.&lt;/p&gt;

&lt;p&gt;If your liveness check can't survive that test, it isn't a liveness check. It's a process check wearing a liveness check's clothes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Prove the thing you actually depend on, not a proxy for it.&lt;/strong&gt; Heartbeats proved the process was up. I was deploying over the socket. Those were different questions, and the gap between them was a ten-day outage that never alerted.&lt;/p&gt;

&lt;p&gt;The follow-on lesson is about recurrence: when the same class of bug bites more than twice, stop patching symptoms and go find the shared assumption underneath. Mine was "an entry in the connected map means the node is reachable." Once that assumption was false-by-construction - because a silent session gets torn down - the entire family of bugs closed at once.&lt;/p&gt;

&lt;p&gt;It's been running in production since, and it's already earned its keep: when I decommissioned a node, its services dropped cleanly out of &lt;code&gt;status&lt;/code&gt; instead of lingering as ghosts. The machinery told the truth about a node leaving. After ten days of it lying about a node staying, that felt like a real win.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>debugging</category>
      <category>distributed</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
