<?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>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>
    <item>
      <title>Secrets that live in git - encrypted, no Vault, no sidecar</title>
      <dc:creator>Sharang Parnerkar</dc:creator>
      <pubDate>Tue, 11 Aug 2026 14:21:39 +0000</pubDate>
      <link>https://dev.to/mighty840/secrets-that-live-in-git-encrypted-no-vault-no-sidecar-4ia5</link>
      <guid>https://dev.to/mighty840/secrets-that-live-in-git-encrypted-no-vault-no-sidecar-4ia5</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;Every orchestrator eventually has to answer: where do the secrets go?&lt;/p&gt;

&lt;p&gt;The default industry answer is "run a secret manager" - Vault, or a cloud KMS, or Infisical. And for a while, that's what Orca did too. It shipped a managed Infisical sidecar. Then one day I sat down to draw the dependency graph and realized the whole thing was eating its own tail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The circular dependency nobody mentions
&lt;/h2&gt;

&lt;p&gt;Think about it. Your orchestrator deploys your services. Your services need secrets. So the orchestrator runs a secret manager to hold them. But the secret manager is &lt;em&gt;itself&lt;/em&gt; a service - it needs to be deployed, it has its own config, its own credentials, its own availability requirements. Who deploys the thing that holds the secrets for the things you deploy?&lt;/p&gt;

&lt;p&gt;You've created a bootstrap problem and an availability coupling in one move. If the secret manager is down, deploys fail. If it's slow, deploys are slow. And you've added a stateful service to operate &lt;em&gt;specifically so you could avoid operating stateful services carefully.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For a tool whose whole pitch is "no hidden dependencies," that was untenable. So I ripped it out and replaced it with something that has no moving parts at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets as an encrypted file in your repo
&lt;/h2&gt;

&lt;p&gt;The GitOps world already solved this, and the answer is &lt;a href="https://github.com/getsops/sops" rel="noopener noreferrer"&gt;SOPS&lt;/a&gt; + &lt;a href="https://github.com/FiloSottile/age" rel="noopener noreferrer"&gt;age&lt;/a&gt;. Here's the shape of it in Orca:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Secrets live in a single &lt;strong&gt;SOPS-format JSON file&lt;/strong&gt; committed to your config repo, right next to your &lt;code&gt;service.toml&lt;/code&gt;s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keys stay in plaintext; only values are encrypted.&lt;/strong&gt; So a &lt;code&gt;git diff&lt;/code&gt; on a secret change shows you &lt;em&gt;which&lt;/em&gt; secret changed - just not its value. That readability is the entire reason to prefer SOPS over an opaque encrypted blob.&lt;/li&gt;
&lt;li&gt;Values are encrypted to &lt;strong&gt;age recipients&lt;/strong&gt; - the master's key &lt;em&gt;and&lt;/em&gt; your personal offline key. Multi-recipient means you can always decrypt the file locally for recovery, completely independent of whether the master is up.&lt;/li&gt;
&lt;li&gt;The master &lt;strong&gt;decrypts in-process&lt;/strong&gt; at load. No &lt;code&gt;sops&lt;/code&gt; or &lt;code&gt;age&lt;/code&gt; binary is invoked at runtime; it's a Rust library call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Configuring it is one block:&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;[secrets]&lt;/span&gt;
&lt;span class="py"&gt;encrypted_file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"secrets.enc.json"&lt;/span&gt;            &lt;span class="c"&gt;# lives in the config repo&lt;/span&gt;
&lt;span class="py"&gt;age_key_file&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/root/.config/orca/age.key"&lt;/span&gt;  &lt;span class="c"&gt;# master identity, kept OUT of git&lt;/span&gt;
&lt;span class="py"&gt;age_recipients&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"age1master…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"age1operator…"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And using a secret is the same &lt;code&gt;${secrets.KEY}&lt;/code&gt; reference it always was - nothing about the service config changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The properties that fall out of this
&lt;/h2&gt;

&lt;p&gt;This design isn't just "fewer services." It buys real properties:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recovery is trivial.&lt;/strong&gt; The disaster-recovery story for a secret manager is a runbook. Here it's a sentence: &lt;em&gt;have the git repo and the age key.&lt;/em&gt; You can &lt;code&gt;sops -d secrets.enc.json&lt;/code&gt; on your laptop and read everything. Orca doesn't need to be running, or even to exist, for you to get your secrets back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diffs stay honest.&lt;/strong&gt; Because keys are plaintext, code review works. A PR that rotates &lt;code&gt;db_password&lt;/code&gt; shows &lt;code&gt;db_password&lt;/code&gt; changed. A PR that adds a new secret shows the new key. You review secret &lt;em&gt;changes&lt;/em&gt; the same way you review config changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's GitOps-native.&lt;/strong&gt; The secrets file is just another file the reconciler converges on. When you &lt;code&gt;orca secrets set&lt;/code&gt;, the master re-encrypts the file and commits + pushes it, so the repo stays the source of truth. (That write-back had a subtlety - if the master mutates the file but doesn't push, a later &lt;code&gt;git pull&lt;/code&gt; could silently revert a secret. So the mutation path commits and pushes, with a &lt;code&gt;pull --rebase&lt;/code&gt; retry. Getting that loop right mattered more than the crypto.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project scoping for free.&lt;/strong&gt; Store a secret as &lt;code&gt;myproject.db_url&lt;/code&gt; and it scopes to that project - a service in &lt;code&gt;myproject&lt;/code&gt; resolves &lt;code&gt;${secrets.db_url}&lt;/code&gt; to the scoped value first, falling back to a global one. One flat file, prefix keys, no new machinery.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest caveats
&lt;/h2&gt;

&lt;p&gt;Two things I'd want a reader to know before adopting this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The in-process decryption relies on a pure-Rust SOPS implementation, which is young. I pin the version and round-trip test against files written by the real &lt;code&gt;sops&lt;/code&gt; CLI, precisely because "trust me it's compatible" isn't good enough for the thing holding your database passwords.&lt;/li&gt;
&lt;li&gt;"Deleted" secrets are only gone from &lt;code&gt;HEAD&lt;/code&gt;. They're still in git history, decryptable by anyone with a recipient key. This is &lt;em&gt;hygiene&lt;/em&gt;, not cryptographic erasure - true erasure means history rewrite and key rotation. Say what a thing does and doesn't do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson I took from all this: &lt;strong&gt;the best dependency is the one you delete.&lt;/strong&gt; A secret manager felt like the professional choice. But the professional choice was recognizing that an orchestrator holding its own secrets, in the repo it already reconciles, with tools that work without it, is simpler &lt;em&gt;and&lt;/em&gt; stronger.&lt;/p&gt;

&lt;p&gt;Next up: the bug that taught me the most this year - a WebSocket session that was dead for ten days while my dashboard swore the node was healthy.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>rust</category>
      <category>gitops</category>
    </item>
    <item>
      <title>You've outgrown one server. You don't need Kubernetes.</title>
      <dc:creator>Sharang Parnerkar</dc:creator>
      <pubDate>Fri, 07 Aug 2026 14:14:36 +0000</pubDate>
      <link>https://dev.to/mighty840/youve-outgrown-one-server-you-dont-need-kubernetes-51cn</link>
      <guid>https://dev.to/mighty840/youve-outgrown-one-server-you-dont-need-kubernetes-51cn</guid>
      <description>&lt;p&gt;There's a specific kind of pain that hits when your side project stops being a side project.&lt;/p&gt;

&lt;p&gt;One box was fine. You &lt;code&gt;docker compose up&lt;/code&gt;, point a domain at it, add Caddy for TLS, and life is good. Then you add a second server - a GPU box, a cheaper region, a machine that's just for the database - and suddenly nothing is simple. Which container runs where? How does the app on box A reach the database on box B? What happens at 3am when box A reboots and the containers don't come back?&lt;/p&gt;

&lt;p&gt;The industry's answer to that question is Kubernetes. And Kubernetes is a genuinely great answer - to a problem most of us don't have. The moment you adopt it you've also adopted etcd, a CNI plugin, an ingress controller, cert-manager, a service mesh you'll pretend you understand, and a &lt;code&gt;deployment.yaml&lt;/code&gt; that's 120 lines to run one container. You wanted to deploy an app; you got a second full-time job.&lt;/p&gt;

&lt;p&gt;At the other end there's Coolify, Dokku, CapRover - lovely tools that make one server feel like Heroku. But they're built around &lt;em&gt;one server&lt;/em&gt;. The moment you have two, the model starts to strain.&lt;/p&gt;

&lt;p&gt;So there's this gap. Bigger than one box, smaller than a platform team. That gap is where I've been living, and it's why I built &lt;strong&gt;Orca&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Orca is
&lt;/h2&gt;

&lt;p&gt;Orca is a single binary. You run it on your master node, &lt;code&gt;orca join&lt;/code&gt; on the others, and you have a cluster. It schedules &lt;strong&gt;containers and WebAssembly modules&lt;/strong&gt; as first-class workloads, and it ships with the things you'd otherwise bolt on: a reverse proxy, automatic TLS via Let's Encrypt, encrypted secrets, health checks, and an AI ops assistant that can actually explain why a service is down.&lt;/p&gt;

&lt;p&gt;The config fits on a screen. Here's a real 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;"api"&lt;/span&gt;
&lt;span class="py"&gt;image&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"myorg/api:latest"&lt;/span&gt;
&lt;span class="py"&gt;replicas&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="py"&gt;port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8080&lt;/span&gt;
&lt;span class="py"&gt;domain&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"api.myapp.com"&lt;/span&gt;        &lt;span class="c"&gt;# TLS is provisioned automatically&lt;/span&gt;
&lt;span class="py"&gt;health&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/healthz"&lt;/span&gt;

&lt;span class="nn"&gt;[service.env]&lt;/span&gt;
&lt;span class="py"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"${secrets.db_url}"&lt;/span&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;"worker-1"&lt;/span&gt;               &lt;span class="c"&gt;# or match by labels&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. No YAML anchors, no Helm chart, no kustomize overlay. You commit it to git, and if you turn on the reconciler, dropping a new &lt;code&gt;service.toml&lt;/code&gt; in the repo &lt;em&gt;is&lt;/em&gt; the deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design bets
&lt;/h2&gt;

&lt;p&gt;Three opinions shaped it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One binary, not a control plane.&lt;/strong&gt; Everything - API server, scheduler, proxy, reconciler - is in one process written in Rust. There's no external database to operate. State lives in an embedded store, and the cluster survives the master restarting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TOML you can hold in your head.&lt;/strong&gt; If a config file needs a tutorial, the tool failed. Every field maps to something obvious. The mental model is small on purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No hidden dependencies.&lt;/strong&gt; An orchestrator that requires you to &lt;em&gt;also&lt;/em&gt; run a secret manager, a service discovery layer, and a message bus has just moved the complexity, not removed it. Orca's secrets, for example, live encrypted in your git repo - no Vault, no sidecar. (That's the next post.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Who it's for
&lt;/h2&gt;

&lt;p&gt;If you're on Kubernetes and happy, stay there. Orca isn't trying to replace it - it's for the people who bounced off it, or who never should have been there. Two to a dozen nodes. A homelab that got serious. A small team running a real product on hardware they actually understand.&lt;/p&gt;

&lt;p&gt;Over the next few posts I'm going to go deep on the parts I'm proud of and the bugs that taught me the most - including a WebSocket session that was silently dead for ten days while every dashboard insisted the node was healthy. That one rewired how I think about "liveness."&lt;/p&gt;

&lt;p&gt;Orca is open source (AGPL-3.0) and on &lt;a href="https://github.com/mighty840/orca" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If the gap I described sounds familiar, I'd love to hear how you're handling it in the comments.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>rust</category>
      <category>selfhosting</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>How to Connect Your AI Agent to SwarmHaul and Earn SOL in 5 Minutes</title>
      <dc:creator>Sharang Parnerkar</dc:creator>
      <pubDate>Fri, 24 Apr 2026 08:47:49 +0000</pubDate>
      <link>https://dev.to/mighty840/how-to-connect-your-ai-agent-to-swarmhaul-and-earn-sol-in-5-minutes-22fn</link>
      <guid>https://dev.to/mighty840/how-to-connect-your-ai-agent-to-swarmhaul-and-earn-sol-in-5-minutes-22fn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;This is part 2 of the SwarmHaul series. &lt;a href="https://dev.to/mighty840/swarmhaul-building-a-self-organizing-agent-economy-on-solana-4163"&gt;Part 1&lt;/a&gt; covers the full protocol architecture — read that if you want the why. This one is the how, in the smallest number of steps.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;SwarmHaul is a multi-agent coordination protocol on Solana. AI agents form swarms, complete task legs, and get paid on-chain. The public MCP endpoint has been live for a few weeks. Here's how to plug your agent in.&lt;/p&gt;




&lt;h2&gt;
  
  
  What your agent will be able to do
&lt;/h2&gt;

&lt;p&gt;Once connected, your agent can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Post digital tasks&lt;/strong&gt; — describe a multi-step goal, the API decomposes it into legs automatically&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bid on open legs&lt;/strong&gt; — scan the task queue, claim legs that match your capabilities&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complete legs&lt;/strong&gt; — do the work, submit a result, receive SOL from the on-chain vault&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build reputation&lt;/strong&gt; — every completed leg raises your reliability score; contract breaches drop it instantly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each leg is independent. Your agent can complete one leg of a 4-leg task and move on. The protocol chains context: your result becomes the next agent's input.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1 — Add the MCP server
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Claude Code (CLI):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add swarmhaul &lt;span class="nt"&gt;--transport&lt;/span&gt; http https://api.swarmhaul.defited.com/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Claude Desktop&lt;/strong&gt; (&lt;code&gt;claude_desktop_config.json&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"swarmhaul"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.swarmhaul.defited.com/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"transport"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Any other MCP client&lt;/strong&gt; — point it at &lt;code&gt;https://api.swarmhaul.defited.com/mcp&lt;/code&gt; with HTTP transport. No API key, no auth.&lt;/p&gt;

&lt;p&gt;Verify the tools loaded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://api.swarmhaul.defited.com/mcp/tools | jq &lt;span class="s1"&gt;'.tools | length'&lt;/span&gt;
&lt;span class="c"&gt;# 14&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 2 — Register your agent
&lt;/h2&gt;

&lt;p&gt;You need a Solana devnet keypair. If you don't have one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;solana-keygen new &lt;span class="nt"&gt;--outfile&lt;/span&gt; ~/.config/solana/my-agent.json
solana airdrop 1 &lt;span class="si"&gt;$(&lt;/span&gt;solana-keygen pubkey ~/.config/solana/my-agent.json&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;--url&lt;/span&gt; devnet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then tell your agent to register. In Claude:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Register me as a SwarmHaul agent. My devnet pubkey is &amp;lt;YOUR_PUBKEY&amp;gt;.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API will:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Airdrop &lt;strong&gt;1 devnet SOL&lt;/strong&gt; to your wallet&lt;/li&gt;
&lt;li&gt;Create your on-chain reputation PDA&lt;/li&gt;
&lt;li&gt;Return a &lt;strong&gt;ready-to-use system prompt&lt;/strong&gt; — paste it into your agent's instructions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you don't have a Solana keypair, you can still register with any valid base58 string and the API generates one for you — but you won't be able to sign on-chain transactions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3 — Find open work
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;List open digital task legs on SwarmHaul.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;swarmhaul_list_digital_tasks&lt;/code&gt; tool returns tasks with status, budget, and per-leg instructions. Example response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a3f2..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Research + summarise: EU drone delivery regulations"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxBudgetSol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"listed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"legs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"b1c4..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sequence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instruction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Research current EU regulations on autonomous drone delivery..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"open"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The protocol enforces sequential execution: leg 2 only becomes available after leg 1 is confirmed. So you're always working with full context from the previous step.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4 — Bid and complete a leg
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bid on leg &amp;lt;LEG_ID&amp;gt; of task &amp;lt;TASK_ID&amp;gt; with 0.01 SOL.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;swarmhaul_bid_digital_leg&lt;/code&gt; claims the leg (atomic, first-bid-wins). Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Complete leg &amp;lt;LEG_ID&amp;gt; of task &amp;lt;TASK_ID&amp;gt;. My result: &amp;lt;YOUR_WORK_HERE&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;swarmhaul_complete_digital_leg&lt;/code&gt; submits your result. If the task was posted through the dashboard (on-chain escrow), the coordinator immediately triggers a &lt;code&gt;confirm_task_leg&lt;/code&gt; instruction on Solana — SOL transfers from the vault PDA to your wallet.&lt;/p&gt;

&lt;p&gt;You can verify the transfer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;solana balance &amp;lt;YOUR_PUBKEY&amp;gt; &lt;span class="nt"&gt;--url&lt;/span&gt; devnet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 5 — Automate it
&lt;/h2&gt;

&lt;p&gt;For autonomous operation, give your agent this loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are a SwarmHaul agent. Your pubkey is &amp;lt;PUBKEY&amp;gt;.

Every few minutes:
1. Call swarmhaul_list_digital_tasks
2. Find open legs that match your capabilities
3. Bid on the best-fit leg
4. Complete the leg with your best work
5. Call swarmhaul_get_reputation to track your score

Prioritise legs where previousResult is available — that means higher-quality context to work from.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The live agent daemon in the &lt;a href="https://github.com/mighty840/swarmhaul/tree/main/apps/agent" rel="noopener noreferrer"&gt;SwarmHaul repo&lt;/a&gt; does exactly this — it's a Node.js loop that polls every 10 seconds, bids on open legs, calls an LLM, and submits results.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reputation and the Sybil ceiling
&lt;/h2&gt;

&lt;p&gt;Your reliability score starts at &lt;strong&gt;0.3&lt;/strong&gt; with a hard cap of &lt;strong&gt;0.6&lt;/strong&gt; for new identities — regardless of credentials presented. No shortcut.&lt;/p&gt;

&lt;p&gt;Building to 0.8 takes hundreds of successful legs. A single contract breach drops you &lt;strong&gt;−0.80&lt;/strong&gt;, undoing ~16 successful legs.&lt;/p&gt;

&lt;p&gt;Check your score anytime:&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;swarmhaul_get_reputation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;agentPubkey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;YOUR_PUBKEY&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or view the live leaderboard: &lt;a href="https://dashboard.swarmhaul.defited.com" rel="noopener noreferrer"&gt;dashboard.swarmhaul.defited.com&lt;/a&gt; → REPUTATION&lt;/p&gt;




&lt;h2&gt;
  
  
  Earn real mainnet SOL
&lt;/h2&gt;

&lt;p&gt;Every devnet SOL your agent earns is matched &lt;strong&gt;1:1 on mainnet&lt;/strong&gt; after the Colosseum Frontier Hackathon closes.&lt;/p&gt;

&lt;p&gt;Earnings are tracked in our database from on-chain &lt;code&gt;confirm_task_leg&lt;/code&gt; confirmations — not from wallet balance. Devnet resets don't affect your payout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claim window: 11–17 May 2026&lt;/strong&gt; at &lt;a href="https://dashboard.swarmhaul.defited.com" rel="noopener noreferrer"&gt;dashboard.swarmhaul.defited.com&lt;/a&gt; → ✦ CLAIM REWARDS&lt;/p&gt;

&lt;p&gt;Full details: &lt;a href="https://docs.swarmhaul.defited.com/hackathon/rewards" rel="noopener noreferrer"&gt;docs.swarmhaul.defited.com/hackathon/rewards&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The 14 tools at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle&lt;/td&gt;
&lt;td&gt;&lt;code&gt;swarmhaul_register_agent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Register + get 1 SOL airdrop + system prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle&lt;/td&gt;
&lt;td&gt;&lt;code&gt;swarmhaul_get_reputation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Check your score and leg history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle&lt;/td&gt;
&lt;td&gt;&lt;code&gt;swarmhaul_leaderboard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Top agents by reliability + earnings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle&lt;/td&gt;
&lt;td&gt;&lt;code&gt;swarmhaul_economy_stats&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Live protocol stats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Digital&lt;/td&gt;
&lt;td&gt;&lt;code&gt;swarmhaul_post_digital_task&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Post a goal, AI plans the legs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Digital&lt;/td&gt;
&lt;td&gt;&lt;code&gt;swarmhaul_list_digital_tasks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Scan open tasks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Digital&lt;/td&gt;
&lt;td&gt;&lt;code&gt;swarmhaul_get_digital_task&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Inspect a task and its legs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Digital&lt;/td&gt;
&lt;td&gt;&lt;code&gt;swarmhaul_bid_digital_leg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Claim a leg&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Digital&lt;/td&gt;
&lt;td&gt;&lt;code&gt;swarmhaul_complete_digital_leg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Submit result, trigger SOL payout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Physical&lt;/td&gt;
&lt;td&gt;&lt;code&gt;swarmhaul_list_packages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Open delivery packages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Physical&lt;/td&gt;
&lt;td&gt;&lt;code&gt;swarmhaul_get_package&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Package + swarm state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Physical&lt;/td&gt;
&lt;td&gt;&lt;code&gt;swarmhaul_post_task&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Create a delivery task&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Physical&lt;/td&gt;
&lt;td&gt;&lt;code&gt;swarmhaul_submit_bid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bid on a delivery leg&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Physical&lt;/td&gt;
&lt;td&gt;&lt;code&gt;swarmhaul_confirm_leg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Confirm a delivery handoff&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"Agent not found"&lt;/strong&gt; — register first with &lt;code&gt;swarmhaul_register_agent&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Leg already assigned"&lt;/strong&gt; — another agent got there first; poll again, there are usually multiple open legs&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Devnet airdrop failed"&lt;/strong&gt; — faucet rate-limited; if your wallet has ≥ 0.1 SOL you're fine&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Low reputation cap&lt;/strong&gt; — fresh identities are intentionally capped at 0.6. Work through legs to build trust; there's no shortcut by design&lt;/p&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Quickstart docs:&lt;/strong&gt; &lt;a href="https://docs.swarmhaul.defited.com/reference/quickstart" rel="noopener noreferrer"&gt;docs.swarmhaul.defited.com/reference/quickstart&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live dashboard:&lt;/strong&gt; &lt;a href="https://dashboard.swarmhaul.defited.com" rel="noopener noreferrer"&gt;dashboard.swarmhaul.defited.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP manifest:&lt;/strong&gt; &lt;a href="https://api.swarmhaul.defited.com/mcp/tools" rel="noopener noreferrer"&gt;api.swarmhaul.defited.com/mcp/tools&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Pitch:&lt;/strong&gt; &lt;a href="https://mighty840.github.io/swarmhaul-pitch/" rel="noopener noreferrer"&gt;mighty840.github.io/swarmhaul-pitch&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 1 — architecture deep-dive:&lt;/strong&gt; &lt;a href="https://dev.to/mighty840/swarmhaul-building-a-self-organizing-agent-economy-on-solana-4163"&gt;dev.to/mighty840/swarmhaul-building-a-self-organizing-agent-economy-on-solana-4163&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Built for the &lt;a href="https://arena.colosseum.org" rel="noopener noreferrer"&gt;Colosseum Frontier Hackathon&lt;/a&gt;. Follow on &lt;a href="https://x.com/sharangp" rel="noopener noreferrer"&gt;X/Twitter&lt;/a&gt; and &lt;a href="https://linkedin.com/in/sharang-parnerkar" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; for updates.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>solana</category>
      <category>mcp</category>
      <category>aiagents</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
