<?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: Deland</title>
    <description>The latest articles on DEV Community by Deland (@deland).</description>
    <link>https://dev.to/deland</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%2F4081066%2F811177b7-309f-4537-80ce-ddfd3dbee741.png</url>
      <title>DEV Community: Deland</title>
      <link>https://dev.to/deland</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/deland"/>
    <language>en</language>
    <item>
      <title>Five things that break when a user pastes 𝕗𝕒𝕟𝕔𝕪 text into your app</title>
      <dc:creator>Deland</dc:creator>
      <pubDate>Mon, 31 Aug 2026 13:45:47 +0000</pubDate>
      <link>https://dev.to/deland/five-things-that-break-when-a-user-pastes-text-into-your-app-2fo2</link>
      <guid>https://dev.to/deland/five-things-that-break-when-a-user-pastes-text-into-your-app-2fo2</guid>
      <description>&lt;p&gt;Someone signs up for your app with the display name &lt;code&gt;𝓯𝓪𝓷𝓬𝔶 𝓷𝓪𝓶𝓮&lt;/code&gt;. Your length check passes. Your profanity filter passes. Your search index has never heard of them again.&lt;/p&gt;

&lt;p&gt;The thing most people get wrong on first contact: this is not a font. Nobody installed anything. &lt;code&gt;𝓯&lt;/code&gt; is not the letter &lt;code&gt;f&lt;/code&gt; wearing a costume — it is U+1D4EF, MATHEMATICAL BOLD SCRIPT SMALL F, a separate character with its own code point. Unicode has a whole parallel Latin alphabet (several, actually), added over the years for maths notation, phonetics and enclosed symbols, and the copy-paste generator sites turned it into a bio decoration.&lt;/p&gt;

&lt;p&gt;That distinction has consequences all the way down your stack. Here are the five that have bitten me.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Your length check counts the wrong thing
&lt;/h2&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;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;𝓯𝓪𝓷𝓬𝔶&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;          &lt;span class="c1"&gt;// 10&lt;/span&gt;
&lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;     &lt;span class="c1"&gt;// 5&lt;/span&gt;
&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;// '𝓯\ud835'  ← a broken half-character&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything above U+FFFF is stored as a surrogate pair in JavaScript strings, so &lt;code&gt;.length&lt;/code&gt; returns UTF-16 code units, not characters. A 15-character display-name limit becomes a 7-character limit for these users, and a naive &lt;code&gt;.slice()&lt;/code&gt; for a preview truncates straight through the middle of a code point and emits a lone surrogate. That lone surrogate is what shows up later as &lt;code&gt;�&lt;/code&gt; in your emails, or as an invalid-UTF-8 error the first time it hits a stricter downstream service.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;[...str]&lt;/code&gt; or &lt;code&gt;Array.from(str)&lt;/code&gt; when you mean characters, and &lt;code&gt;Intl.Segmenter&lt;/code&gt; when you mean what a human would call a character (that one also handles emoji with ZWJ sequences and combining marks).&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Your blocklist stops matching
&lt;/h2&gt;

&lt;p&gt;Whitelist validation is fine — &lt;code&gt;/^\w+$/u&lt;/code&gt; rejects &lt;code&gt;𝓪𝓭𝓶𝓲𝓷&lt;/code&gt; because &lt;code&gt;\w&lt;/code&gt; is ASCII-only. The problem is everywhere you use a &lt;em&gt;blocklist&lt;/em&gt;:&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;reserved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;support&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;billing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nx"&gt;reserved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;𝗮𝗱𝗺𝗶𝗻&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;𝗮𝗱𝗺𝗶𝗻&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reserved usernames, profanity filters, spam-keyword rules, moderation regexes: all of them compare against ASCII and all of them are trivially bypassed by a copy-paste generator. This is not a theoretical attack. It is how "support" impersonation accounts get past signup on platforms that only check the literal string.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Search and sorting silently misbehave
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;𝓯𝓪𝓷𝓬𝔶&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fancy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;banana&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;𝓪pple&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cherry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// ['banana', 'cherry', '𝓪pple']&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sort result is not a bug — default &lt;code&gt;sort()&lt;/code&gt; compares code units, and U+1D4EA sorts after every ASCII letter. But it means the user who styled their name is now at the bottom of every alphabetical list in your product, and unfindable by typing their name into your search box. They will report this as "search is broken."&lt;/p&gt;

&lt;h2&gt;
  
  
  4. NFKD fixes most of it, and then stops
&lt;/h2&gt;

&lt;p&gt;Unicode normalisation is the standard answer, and for the maths alphabets it works beautifully:&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;𝓯𝓪𝓷𝓬𝔶&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFKD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;// 'fancy'&lt;/span&gt;
&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;𝗯𝗼𝗹𝗱&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFKD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;// 'bold'&lt;/span&gt;
&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᵗⁱⁿʸ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFKD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;// 'tiny'&lt;/span&gt;
&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ⓑⓤⓑⓑⓛⓔ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFKD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// 'bubble'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you hit this:&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᴛɪɴʏ ᴛᴇxᴛ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFKD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// 'ᴛɪɴʏ ᴛᴇxᴛ'  ← unchanged&lt;/span&gt;
&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s̶t̶r̶i̶k̶e̶&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFKD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;// 's̶t̶r̶i̶k̶e̶'      ← unchanged&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two different reasons, both worth knowing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small caps are not decorated letters.&lt;/strong&gt; &lt;code&gt;ᴛ&lt;/code&gt; is U+1D1B, LATIN LETTER SMALL CAPITAL T, from the Phonetic Extensions block. Linguists needed it to write IPA. It has no compatibility decomposition to &lt;code&gt;t&lt;/code&gt; because, as far as Unicode is concerned, it is not a &lt;code&gt;t&lt;/code&gt; — it is its own letter that happens to look like a small one. NFKD is defined by those decomposition mappings, so there is nothing for it to do. &lt;code&gt;.toUpperCase()&lt;/code&gt; won't touch it either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strikethrough and Zalgo are combining marks.&lt;/strong&gt; &lt;code&gt;s̶&lt;/code&gt; is a plain ASCII &lt;code&gt;s&lt;/code&gt; followed by U+0336 COMBINING LONG STROKE OVERLAY. The base letter was there all along; normalisation has no reason to remove a mark you deliberately added.&lt;/p&gt;

&lt;p&gt;So the complete fold needs three passes, not one:&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;SMALL_CAPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᴀ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ʙ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;b&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᴄ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;c&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᴅ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᴇ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;e&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ꜰ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;f&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ɢ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;g&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ʜ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;h&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ɪ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;i&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᴊ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;j&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᴋ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;k&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ʟ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;l&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᴍ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;m&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ɴ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᴏ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;o&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᴘ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ǫ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;q&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ʀ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;r&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ꜱ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᴛ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;t&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᴜ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;u&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᴠ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;v&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᴡ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;w&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ʏ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;y&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᴢ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// no x: Unicode's small capital X is unrenderable in most fonts, so&lt;/span&gt;
  &lt;span class="c1"&gt;// generators leave lowercase x alone and there is nothing to fold.&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;SMALL_CAPS_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SMALL_CAPS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;]`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gu&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;foldFancy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SMALL_CAPS_RE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;SMALL_CAPS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;  &lt;span class="c1"&gt;// before NFKD: ǫ would decompose first&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFKD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                    &lt;span class="c1"&gt;// maths alphabets, super/subscript, circled&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{Mn}/gu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;// combining marks: strikethrough, underline, Zalgo&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^\P&lt;/span&gt;&lt;span class="sr"&gt;{C}&lt;/span&gt;&lt;span class="se"&gt;\n\t]&lt;/span&gt;&lt;span class="sr"&gt;/gu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// zero-width and other control characters&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFC&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Results:&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;foldFancy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;𝓯𝓪𝓷𝓬𝔶 𝓷𝓪𝓶𝓮&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// 'fancy name'&lt;/span&gt;
&lt;span class="nf"&gt;foldFancy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᴛɪɴʏ ᴛᴇxᴛ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;// 'tiny text'&lt;/span&gt;
&lt;span class="nf"&gt;foldFancy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s̶t̶r̶i̶k̶e̶&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;// 'strike'&lt;/span&gt;
&lt;span class="nf"&gt;foldFancy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ⓐⓓⓜⓘⓝ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// 'admin'&lt;/span&gt;
&lt;span class="nf"&gt;foldFancy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;𝗮𝗱𝗺𝗶𝗻&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// 'admin'&lt;/span&gt;
&lt;span class="nf"&gt;foldFancy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ᵃᵈᵐⁱⁿ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;// 'admin'&lt;/span&gt;
&lt;span class="nf"&gt;foldFancy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ad​min&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;// 'admin'  ← that one had a zero-width space in it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the one I'd flag to your security reviewer. Zero-width characters are invisible in every UI you own, and they are the same bypass as the fancy letters with none of the visual tell.&lt;/p&gt;

&lt;p&gt;Two caveats on that function. &lt;code&gt;\p{Mn}&lt;/code&gt; strips &lt;em&gt;all&lt;/em&gt; non-spacing marks, so &lt;code&gt;foldFancy('café')&lt;/code&gt; returns &lt;code&gt;'cafe'&lt;/code&gt;. For a search key that is usually what you want. For anything you intend to display back to the user it is data loss, so keep this on the comparison path only. And the small-caps ranges cover the Latin ones; if you care about Cyrillic or Greek lookalikes you are in homoglyph-attack territory, which is a bigger problem with a real answer (Unicode TR39 and the confusables table).&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Screen readers give up
&lt;/h2&gt;

&lt;p&gt;This is the part that turns a curiosity into an accessibility issue.&lt;/p&gt;

&lt;p&gt;Screen readers pronounce characters using their pronunciation dictionaries, which are built for the letters people actually write with. Hand them the maths alphabets and the behaviour ranges from reading the Unicode character name aloud, to spelling the word out letter by letter, to skipping the run entirely — it varies by screen reader, by version, and by the TTS voice underneath. A bio that says &lt;code&gt;𝓱𝓮𝓵𝓵𝓸 𝓲'𝓶 𝓪 𝓭𝓮𝓼𝓲𝓰𝓷𝓮𝓻&lt;/code&gt; can come out as silence, or as forty seconds of character names.&lt;/p&gt;

&lt;p&gt;Same story for search engines and for anything doing NLP on your content. The text looks like words to a sighted human and like noise to every machine that processes it.&lt;/p&gt;

&lt;p&gt;You cannot stop users from doing this, and honestly it's their bio. What you can do is keep the folded version alongside the original, and use it for everything except display.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern that actually works
&lt;/h2&gt;

&lt;p&gt;Store what the user typed. Never rewrite their name for them — it's theirs, and they picked it deliberately.&lt;/p&gt;

&lt;p&gt;Alongside it, store a folded key, and use &lt;em&gt;that&lt;/em&gt; for the comparison paths:&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="c1"&gt;// on write&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;// '𝓯𝓪𝓷𝓬𝔶 𝓷𝓪𝓶𝓮'  — render this&lt;/span&gt;
  &lt;span class="na"&gt;displayNameFolded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;foldFancy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// 'fancy name'  — index, search, sort, moderate on this&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then: uniqueness checks against the folded column, blocklists against the folded column, full-text index on the folded column, &lt;code&gt;ORDER BY&lt;/code&gt; on the folded column, moderation on the folded column. Render the original, with the folded text as an &lt;code&gt;aria-label&lt;/code&gt; when the field is user-facing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"fancy name"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;𝓯𝓪𝓷𝓬𝔶 𝓷𝓪𝓶𝓮&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line gets you most of the accessibility win for free.&lt;/p&gt;

&lt;p&gt;If you're on Postgres, the same idea lives in a generated column plus an index on it, which keeps the two from drifting. The application-level version drifts the first time someone writes to the table from a migration script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test data
&lt;/h2&gt;

&lt;p&gt;Fixtures for this are annoying to type by hand, because you cannot type these characters — you have to generate them. I maintain &lt;a href="https://tinytextgen.com/" rel="noopener noreferrer"&gt;tinytextgen.com&lt;/a&gt; partly because I needed a fast way to produce them: paste a string, get the small caps, superscript and subscript versions side by side, copy one out. The &lt;a href="https://tinytextgen.com/fonts-copy-and-paste" rel="noopener noreferrer"&gt;full set of styles&lt;/a&gt; covers the maths alphabets, circled letters and combining-mark effects, which is roughly the space you want your test suite to cover.&lt;/p&gt;

&lt;p&gt;A reasonable fixture list: one maths-alphabet string, one small-caps string, one with combining marks, one with a zero-width space, and one plain-ASCII control. If those five pass through your signup, your search and your moderation intact, you're in decent shape.&lt;/p&gt;

&lt;p&gt;Anything I missed? I'd be curious whether anyone has hit this in a database collation rather than in application code — that's the version I suspect is out there and under-reported.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>unicode</category>
      <category>a11y</category>
    </item>
    <item>
      <title>iOS Safari can't decode your .mov, and the reason is 2 bytes deep in the container</title>
      <dc:creator>Deland</dc:creator>
      <pubDate>Mon, 31 Aug 2026 09:14:53 +0000</pubDate>
      <link>https://dev.to/deland/ios-safari-cant-decode-your-mov-and-the-reason-is-2-bytes-deep-in-the-container-386p</link>
      <guid>https://dev.to/deland/ios-safari-cant-decode-your-mov-and-the-reason-is-2-bytes-deep-in-the-container-386p</guid>
      <description>&lt;p&gt;Our tool transcribes audio in the browser — Whisper running locally via &lt;code&gt;transformers.js&lt;/code&gt;, no upload. It worked fine, until analytics showed something too clean to be a coincidence:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.mov&lt;/code&gt; uploads on mobile failed 100% of the time. Not 90%. Every single one.&lt;/strong&gt; Desktop had never reported a single &lt;code&gt;.mov&lt;/code&gt; failure.&lt;/p&gt;

&lt;p&gt;This is what I found, and how it got fixed without pulling in &lt;code&gt;ffmpeg.wasm&lt;/code&gt; or WebCodecs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-second reproduction
&lt;/h2&gt;

&lt;p&gt;I took one AAC audio track and put it in two containers — same encoder, same bytes for the audio itself, only the wrapper differs:&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;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AudioContext&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decodeAudioData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On an iPhone 17 Pro simulator (iOS 18.7 / Safari 26.5):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;iOS Safari&lt;/th&gt;
&lt;th&gt;Chromium&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;sample.mov&lt;/code&gt; (ftyp &lt;code&gt;qt&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;EncodingError: Decoding failed&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;sample.mp4&lt;/code&gt; (ftyp &lt;code&gt;isom&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So it isn't the codec. It's the container.&lt;/p&gt;

&lt;h3&gt;
  
  
  The obvious fix that doesn't work
&lt;/h3&gt;

&lt;p&gt;First instinct: it's the brand in the &lt;code&gt;ftyp&lt;/code&gt; box. Patch &lt;code&gt;qt&lt;/code&gt; → &lt;code&gt;isom&lt;/code&gt;, four bytes, done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It still fails.&lt;/strong&gt; I'm writing this down so nobody else burns an afternoon on it. The &lt;code&gt;ftyp&lt;/code&gt; brand is not what Safari looks at. The difference lives inside &lt;code&gt;moov&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual root cause
&lt;/h2&gt;

&lt;p&gt;Dig down to &lt;code&gt;moov → trak → mdia → minf → stbl → stsd&lt;/code&gt; — the sample description that tells the decoder how the audio is encoded. Both files carry an &lt;code&gt;mp4a&lt;/code&gt; entry. They are not the same &lt;code&gt;mp4a&lt;/code&gt; entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;QuickTime writes:                  MP4 expects:
  version       = 1        &amp;lt;—        version       = 0
  compressionID = -2 (fffe) &amp;lt;—       compressionID = 0
  + 16 bytes of v1 extension &amp;lt;—      (absent)
  esds wrapped in a 'wave' box &amp;lt;—    esds is a direct child
  extra 'chan' channel layout        (absent)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;iOS Safari's &lt;code&gt;decodeAudioData&lt;/code&gt; only accepts a version 0 audio sample entry.&lt;/strong&gt; Chromium accepts both — which is exactly why desktop never saw this and mobile never survived it.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;version&lt;/code&gt; field is a &lt;code&gt;uint16&lt;/code&gt;. Two bytes decide whether the file plays.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: rebuild the container, don't touch the codec
&lt;/h2&gt;

&lt;p&gt;Since the audio bitstream is already valid AAC, nothing needs to be re-encoded. The job is pure byte plumbing: extract the audio samples, write a fresh audio-only MP4 with a well-formed &lt;code&gt;stsd&lt;/code&gt;, and hand it back to the browser's native &lt;code&gt;decodeAudioData&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That last part matters. Because we never decode audio ourselves, we don't need &lt;code&gt;WebCodecs&lt;/code&gt; — and therefore don't inherit &lt;code&gt;AudioDecoder&lt;/code&gt;'s iOS 17+ floor — and we don't ship a multi-megabyte &lt;code&gt;ffmpeg.wasm&lt;/code&gt; to fix a container-level problem.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.mov ──► scan top-level boxes to find moov (read box headers only, never touch mdat)
      ──► parse the audio track's stsd / stts / stsc / stsz / stco|co64
      ──► sliding window (8 MB) to copy out audio samples
      ──► rebuild ftyp + moov (stsd forced to version 0) + mdat
      ──► decodeAudioData
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sample entry we emit is deliberately boring:&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;box&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mp4a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;zeros&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;u16&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="c1"&gt;// data_reference_index&lt;/span&gt;
  &lt;span class="nf"&gt;u16&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="c1"&gt;// version — the only one iOS Safari accepts&lt;/span&gt;
  &lt;span class="nf"&gt;u16&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="c1"&gt;// revision&lt;/span&gt;
  &lt;span class="nf"&gt;u32&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="c1"&gt;// vendor&lt;/span&gt;
  &lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// samplesize&lt;/span&gt;
  &lt;span class="nf"&gt;u16&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="c1"&gt;// compressionID — QuickTime writes -2; MP4 says 0&lt;/span&gt;
  &lt;span class="nf"&gt;u16&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="c1"&gt;// packetsize&lt;/span&gt;
  &lt;span class="nf"&gt;u32&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;min&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="mi"&gt;65535&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;65536&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// 16.16 fixed point&lt;/span&gt;
  &lt;span class="nx"&gt;esds&lt;/span&gt;     &lt;span class="c1"&gt;// pulled out of QuickTime's 'wave' wrapper if needed&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One parsing subtlety worth stealing: the &lt;code&gt;esds&lt;/code&gt; descriptor sits as a direct child of &lt;code&gt;mp4a&lt;/code&gt; in MP4, but QuickTime buries it inside a &lt;code&gt;wave&lt;/code&gt; box. So the lookup scans all siblings &lt;em&gt;first&lt;/em&gt;, then recurses — otherwise a &lt;code&gt;wave&lt;/code&gt;-nested &lt;code&gt;esds&lt;/code&gt; can shadow the one you actually want.&lt;/p&gt;

&lt;p&gt;Version 1 entries also carry 16 extra bytes before their child boxes, and version 2 carries 36 with the real sample rate in a &lt;code&gt;float64&lt;/code&gt;. Get those offsets wrong and you'll parse garbage as a box header:&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;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUint16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&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;childStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;version&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;childStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sampleRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFloat64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;childStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The bug we weren't looking for
&lt;/h2&gt;

&lt;p&gt;The old code started with this:&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;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// 161 MB average on mobile&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mobile video files in our logs average &lt;strong&gt;161.9 MB&lt;/strong&gt; — and &lt;strong&gt;99% of those bytes are video frames we don't need for a transcript.&lt;/strong&gt; Reading the whole file into memory, then decoding it into an equally long PCM buffer, is a great way to get killed by iOS Safari's memory limits.&lt;/p&gt;

&lt;p&gt;Locating boxes by reading only their 8–16 byte headers, then copying samples through an 8 MB sliding window, changes the peak from &lt;em&gt;whole file&lt;/em&gt; to &lt;em&gt;8 MB + the audio itself&lt;/em&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Native decode&lt;/th&gt;
&lt;th&gt;After extraction&lt;/th&gt;
&lt;th&gt;Extract time&lt;/th&gt;
&lt;th&gt;Decode after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;sample.mov&lt;/code&gt; (0.10 MB / 5s)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;FAIL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.08 MB&lt;/td&gt;
&lt;td&gt;16 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OK&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;hevc.mov&lt;/code&gt; (HEVC video + AAC)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;FAIL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.12 MB&lt;/td&gt;
&lt;td&gt;4 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OK&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;huge5.mov&lt;/code&gt; (158.6 MB / 900s)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;FAIL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13.88 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;378 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;OK&lt;/strong&gt; (476 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;audio.m4a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;td&gt;0.08 MB&lt;/td&gt;
&lt;td&gt;3 ms&lt;/td&gt;
&lt;td&gt;OK (no regression)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sample.mp4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;td&gt;0.08 MB&lt;/td&gt;
&lt;td&gt;3 ms&lt;/td&gt;
&lt;td&gt;OK (no regression)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;huge5.mov&lt;/code&gt; row is the one that mattered: 158.6 MB is the real-world mobile average, the old path read all of it and &lt;em&gt;still&lt;/em&gt; threw &lt;code&gt;EncodingError&lt;/code&gt;, the new path produces a decoded 15-minute audio buffer in 854 ms total. Decoded output was verified non-silent (&lt;code&gt;peak &amp;gt; 0&lt;/code&gt;) and length-accurate (900.023s → 900.1s), because "it returned a buffer" is not the same as "it worked".&lt;/p&gt;

&lt;h2&gt;
  
  
  Fail backwards, never sideways
&lt;/h2&gt;

&lt;p&gt;Every step returns &lt;code&gt;null&lt;/code&gt; the moment reality stops matching the assumptions, and the caller falls back to the original direct-decode path:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fragmented MP4 (&lt;code&gt;empty_moov&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;null&lt;/code&gt; — sample tables live in &lt;code&gt;moof&lt;/code&gt;, assumption broken&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PCM audio track in &lt;code&gt;.mov&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;null&lt;/code&gt; — only &lt;code&gt;mp4a&lt;/code&gt; is handled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.webm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;never enters this path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.mov&lt;/code&gt; with no audio track&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;null&lt;/code&gt; — no &lt;code&gt;soun&lt;/code&gt; track found&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is the rule that kept the change safe to ship: &lt;strong&gt;a module that exists to rescue guaranteed failures must never make a working case worse.&lt;/strong&gt; Every &lt;code&gt;null&lt;/code&gt; is a case where the old path was already going to run anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take away from this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;When a format works in one browser and not another, suspect the container before the codec.&lt;/strong&gt; Codec support is well-documented; container tolerance is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ffmpeg.wasm&lt;/code&gt; is not the default answer.&lt;/strong&gt; If the bitstream is already valid, you have a plumbing problem, and plumbing is a few hundred lines of &lt;code&gt;DataView&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reading a whole file to decode it is a bug on mobile&lt;/strong&gt;, even when it doesn't crash today.&lt;/li&gt;
&lt;li&gt;And afterwards: delete the warning banner. We used to tell mobile users "large .mov files may fail". Leaving that up after the fix would be worse than never having shown it — the file size no longer correlates with any real risk, because we only ever move the audio bytes now.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This shipped in &lt;a href="https://transcriptsnap.com" rel="noopener noreferrer"&gt;TranscriptSnap&lt;/a&gt;, a browser-local transcription tool — Whisper runs on your machine via &lt;code&gt;transformers.js&lt;/code&gt;, files never leave the device, which is also why a container quirk in Safari became our problem instead of a server's. The full write-up with the on-device measurements lives &lt;a href="https://transcriptsnap.com/guides/mov-decode-ios-safari" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>ios</category>
      <category>webperf</category>
    </item>
    <item>
      <title>Every test passed and the level was unwinnable</title>
      <dc:creator>Deland</dc:creator>
      <pubDate>Mon, 31 Aug 2026 08:39:26 +0000</pubDate>
      <link>https://dev.to/deland/every-test-passed-and-the-level-was-unwinnable-4339</link>
      <guid>https://dev.to/deland/every-test-passed-and-the-level-was-unwinnable-4339</guid>
      <description>&lt;p&gt;I shipped a wilderness survival game with eight scenarios. The hardest one — a five-star map behind the paywall — could not be finished. Not "was brutally hard." Could not be finished. Walking onto the exact tile the design calls the ending did nothing at all.&lt;/p&gt;

&lt;p&gt;Every test was green. That is the part worth writing down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;Locations connect through exits, and some exits stay hidden until you find them. &lt;code&gt;travel()&lt;/code&gt; handled that discovery like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;exit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hiddenUntilDiscovered&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;discoveredLocationIds&lt;/span&gt;&lt;span class="o"&gt;.&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;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;discoveredLocationIds&lt;/span&gt;&lt;span class="o"&gt;.&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;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// marked before the move&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"log.hidden_passage_found"&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;arrive()&lt;/code&gt; decided first-visit rewards like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;discoveredLocationIds&lt;/span&gt;&lt;span class="o"&gt;.&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;location&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;effects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onFirstVisitEffects&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&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;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;rng&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;One set, written before the move and read after it. Any location reached through a hidden exit is already a member by the time arrival checks, so its entire &lt;code&gt;onFirstVisitEffects&lt;/code&gt; block is skipped in silence. Getting lost and retreating did the same thing: the destination got marked, and the real arrival later never counted as a first.&lt;/p&gt;

&lt;p&gt;Sixteen locations across all eight maps lost their first-visit payload that way — standing shelters, supply caches, morale, story flags. On one of them, the payload was the ending.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Location&lt;/th&gt;
&lt;th&gt;Never fired&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;highland_mine&lt;/td&gt;
&lt;td&gt;vent_shaft&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;victory&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;highland_mine&lt;/td&gt;
&lt;td&gt;timber_stope&lt;/td&gt;
&lt;td&gt;shelter, flag, morale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;forest_lake&lt;/td&gt;
&lt;td&gt;abandoned_camp&lt;/td&gt;
&lt;td&gt;three supplies, flag, morale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tide_island&lt;/td&gt;
&lt;td&gt;old_lighthouse&lt;/td&gt;
&lt;td&gt;shelter, two supplies, morale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;burnt_range&lt;/td&gt;
&lt;td&gt;lookout_tower&lt;/td&gt;
&lt;td&gt;shelter, supply, flag, morale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;highland_mine&lt;/code&gt;'s only win condition is reaching &lt;code&gt;vent_shaft&lt;/code&gt;. &lt;code&gt;vent_shaft&lt;/code&gt;'s only entrance is a hidden exit. The win was unreachable through the single door that led to it, and the player starves in a dead end that the content files consider the finish line.&lt;/p&gt;

&lt;p&gt;The fix is boring: let &lt;code&gt;arrive()&lt;/code&gt; own discovery exclusively, and move the secret-passage log line to after arrival is confirmed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing caught it
&lt;/h2&gt;

&lt;p&gt;Run down what a content-driven game normally validates. Map connected from spawn. Every recipe's ingredients exist as items. Every scenario has a victory path. No dangling ids anywhere. All green — and all still green with the bug in place.&lt;/p&gt;

&lt;p&gt;Every one of those asks &lt;em&gt;is this configuration valid&lt;/em&gt;. The configuration was valid. The victory location existed, was reachable, and carried a victory effect. The effect simply never ran.&lt;/p&gt;

&lt;p&gt;That's the shape: &lt;strong&gt;structure correct, execution never happens.&lt;/strong&gt; You cannot find it by validating the graph. Something has to walk it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The instrument: a bot that plays
&lt;/h2&gt;

&lt;p&gt;So I wrote one. The important decision wasn't writing it, it was splitting it into instruments that don't contaminate each other.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Can you win when survival isn't the obstacle?
&lt;/h3&gt;

&lt;p&gt;Every step, the harness tops up hydration, body heat, stamina, calories, and clears injury and infection. Everything from the content side stays: location tags, &lt;code&gt;disabledActionIds&lt;/code&gt;, per-location resource exhaustion, flag gates, recipe materials.&lt;/p&gt;

&lt;p&gt;The bot pathfinds ignoring flag gates, and when &lt;code&gt;travel&lt;/code&gt; refuses the step, it degrades to "look for the key here" — trying every action that can set the flag it's missing, which it collects once by scanning effects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;lazy&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;flagActions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&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="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;map&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;eff&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;successEffects&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;eff&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flagSet&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;flag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;eff&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flag&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;map&lt;/span&gt;
&lt;span class="p"&gt;}()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The win condition itself is inferred from content rather than a hardcoded table, because a hardcoded table keeps passing after a scenario is redesigned and hides exactly the problem you wrote the test for.&lt;/p&gt;

&lt;p&gt;If you can't win under those conditions, it is a materials or path problem, full stop. Currently 8/8.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Do the winning materials exist at all?
&lt;/h3&gt;

&lt;p&gt;Pure static pass, no simulation: given &lt;code&gt;disabledActionIds&lt;/code&gt; and the location tags actually present, compute the theoretical ceiling on every material a raft or a signal fire needs — direct gathering plus anything craftable. A ceiling of zero means unwinnable.&lt;/p&gt;

&lt;p&gt;This exists because survival scenarios are designed by subtraction. The desert has no water source, the burnt forest has no standing wood, the mine has no vegetation — &lt;code&gt;highland_mine&lt;/code&gt; disables eight gathering actions and &lt;code&gt;burnt_range&lt;/code&gt; disables cutting firewood. Subtracting the interesting thing and accidentally subtracting a victory material is one edit apart.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Win rate under normal play — printed, never asserted
&lt;/h3&gt;

&lt;p&gt;This is the one I'd defend hardest, and it asserts nothing.&lt;/p&gt;

&lt;p&gt;Partway through, I gave the bot one piece of common sense: don't travel below 62 body heat. Reasonable. Humans do that.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;frost_ridge&lt;/code&gt; went from 7/8 to 0/8.&lt;/p&gt;

&lt;p&gt;On a five-star snow map body heat is almost never above 62, so the bot sat by the fire and froze in place. Nothing about the scenario changed. The number moved 88 points because I edited the player.&lt;/p&gt;

&lt;p&gt;A metric with your agent's competence baked into it cannot be an assertion. It's a trend line and nothing more. The moment I let that number gate CI, every future improvement to the bot reads as a content regression and every content regression can be argued away as bot noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that needed no bot at all
&lt;/h2&gt;

&lt;p&gt;Separately: every scenario, crossed with every row of its own weather table, must survive its first night when the player does everything right.&lt;/p&gt;

&lt;p&gt;Push the clock to sunset. Then simulate the night hour by hour with the fire lit, fuelled, and rain-protected, and a shelter at the tier that difficulty has the right to demand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Difficulty should decide how much the player must invest,&lt;/span&gt;
&lt;span class="c1"&gt;// not whether a solution exists at all.&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ShelterLevel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scenario&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;difficultyStars&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;leanTo&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;basic&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Night length comes from the scenario's own sunset and sunrise, not a hardcoded 12 hours — long nights and short nights differ by a factor of two, and hardcoding it means the hardest maps are tested on the easiest assumption.&lt;/p&gt;

&lt;p&gt;Then assert body heat above zero, and condition above 30, because surviving with 4 health left is not a passing grade.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;highland_mine&lt;/code&gt; failed this too, arithmetically. −11 °C at night with snow drains 26.8 body heat per hour. The best insulation the entire game can assemble — coat 2.5, natural cover 1, fire 9 — is 12.5. A twelve-hour night takes 136 points. You start the night with 76.&lt;/p&gt;

&lt;p&gt;No balance pass gets you out of that. The fix was per-location microclimate: inside a mountain doesn't take the weather.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;indoors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shieldsFromWeather&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;ambient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;baseTemp&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indoors&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;weather&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tempOffsetC&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="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tempOffsetC&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Baselines relative, never absolute
&lt;/h2&gt;

&lt;p&gt;The older, dumber probe — the one that only forages in place and answers "does the player die on day two" — compares each new scenario's worst seed against a known-good free scenario, with one day of slack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;XCTAssertGreaterThanOrEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseline&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="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;scenario&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;: worst seed died on day &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;worst&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;, baseline &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;baseline&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Absolute survival days measure how dumb my bot is. The same dumb bot across different scenarios measures the scenario. Anchoring to a baseline instead of a constant is what makes an unreliable instrument produce a reliable comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  The determinism bug sitting underneath all of it
&lt;/h2&gt;

&lt;p&gt;None of this reproduces without deterministic randomness. Same seed, same run, forever — otherwise a failing probe is a rumour. Each action draws from its own stream derived from one save seed, SplitMix64.&lt;/p&gt;

&lt;p&gt;The obvious way to derive stream &lt;em&gt;s&lt;/em&gt; is to offset the initial state by &lt;em&gt;s&lt;/em&gt;. Do not.&lt;/p&gt;

&lt;p&gt;SplitMix64 advances by a fixed gamma — &lt;code&gt;0x9E3779B97F4A7C15&lt;/code&gt; — on every step. If your stream offset is any integer multiple of that gamma, then stream &lt;em&gt;s&lt;/em&gt;'s i-th output is exactly stream &lt;em&gt;s−k&lt;/em&gt;'s (i+k)-th output. Individually each stream still passes every randomness smell test you'd casually apply. Collectively they're one sequence walking in lockstep, and "independent stream per action" is a comment, not a fact.&lt;/p&gt;

&lt;p&gt;Avalanche the stream index before it ever becomes state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UInt64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&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="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;mixed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UInt64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;truncatingIfNeeded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;*&lt;/span&gt; &lt;span class="mh"&gt;0xD1B54A32D192ED03&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mixed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mixed&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mixed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;33&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;*&lt;/span&gt; &lt;span class="mh"&gt;0xFF51AFD7ED558CCD&lt;/span&gt;
    &lt;span class="n"&gt;mixed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mixed&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mixed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;33&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;*&lt;/span&gt; &lt;span class="mh"&gt;0xC4CEB9FE1A85EC53&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mixed&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mixed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;33&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;There's a second-order constraint that comes with this, and it caught me on the discovery fix above: &lt;strong&gt;the repair had to preserve RNG call order.&lt;/strong&gt; Move a generator call earlier or later and every existing save and every seeded result shifts underneath you — the bug is fixed and the whole test suite goes red for unrelated reasons. Once determinism is load-bearing, "fix this without changing how many times you call the generator" becomes part of the diff review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it landed
&lt;/h2&gt;

&lt;p&gt;216 tests. Eight scenarios reachable once survival pressure is removed, no material gaps, and both the iOS and watchOS builds walked through the previously-broken vent shaft on a real simulator before I believed any of it.&lt;/p&gt;

&lt;p&gt;The thing I actually changed my mind about: for a content-driven game, the valuable test is not the one that validates your data. It's the one that consumes your data the way a player does and then tells you what it couldn't reach.&lt;/p&gt;

&lt;p&gt;The game is &lt;strong&gt;Wilds Survivor&lt;/strong&gt; — &lt;a href="https://apps.apple.com/us/app/wilds-survivor-survival-sim/id6794646703" rel="noopener noreferrer"&gt;on the App Store&lt;/a&gt;. Eight ways to be stranded, five difficulty tiers, fully offline, no ads, no accounts, no energy timers, and a watchOS app that runs the entire engine standalone with the phone left at home. Two scenarios are free, which is enough to see whether the decision loop is your kind of thing. Every number in this post came out of its repo.&lt;/p&gt;

&lt;p&gt;Still short of a good answer on the win-rate problem: a bot competent enough that its win rate describes the scenario rather than the bot. For now I just refuse to assert on it. If you've solved that for a game with hidden information, I'd like to hear how.&lt;/p&gt;

</description>
      <category>swift</category>
      <category>gamedev</category>
      <category>testing</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Delta E is a distance, not a score</title>
      <dc:creator>Deland</dc:creator>
      <pubDate>Mon, 31 Aug 2026 08:10:24 +0000</pubDate>
      <link>https://dev.to/deland/delta-e-is-a-distance-not-a-score-68b</link>
      <guid>https://dev.to/deland/delta-e-is-a-distance-not-a-score-68b</guid>
      <description>&lt;p&gt;I run a game where you get a brand name — Spotify, IKEA, Home Depot — and rebuild its primary color from memory with three sliders: hue, saturation, lightness. On submit, the guess and the target both go to CIE Lab and the score comes from the distance between them.&lt;/p&gt;

&lt;p&gt;The scoring line was this:&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;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;deltaE&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks defensible. ΔE around 2 is the classic just-noticeable-difference; ΔE 50 is "those are not the same color." Map 0–50 onto 100–0, done.&lt;/p&gt;

&lt;p&gt;It was wrong for three months and I couldn't see it, because I kept reading the formula instead of the distribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number I couldn't query
&lt;/h2&gt;

&lt;p&gt;The scores were on screen the whole time and nowhere in my analytics. GA4 only reports event parameters you have registered as custom dimensions or metrics, and registration is &lt;strong&gt;not retroactive&lt;/strong&gt; — the day you create it is the day data starts existing. So there was no query to run against the past. I registered &lt;code&gt;score&lt;/code&gt; as an event-scoped custom metric and waited.&lt;/p&gt;

&lt;p&gt;Eight days later, 232 first-round samples, mean score per round:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Round&lt;/th&gt;
&lt;th&gt;Mean score&lt;/th&gt;
&lt;th&gt;Rating shown&lt;/th&gt;
&lt;th&gt;Share square&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;21.3&lt;/td&gt;
&lt;td&gt;Miss&lt;/td&gt;
&lt;td&gt;⬜&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;22.1&lt;/td&gt;
&lt;td&gt;Miss&lt;/td&gt;
&lt;td&gt;⬜&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;29.2&lt;/td&gt;
&lt;td&gt;Miss&lt;/td&gt;
&lt;td&gt;⬜&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;18.6&lt;/td&gt;
&lt;td&gt;Miss&lt;/td&gt;
&lt;td&gt;⬜&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;40.0&lt;/td&gt;
&lt;td&gt;Miss&lt;/td&gt;
&lt;td&gt;⬜&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rating bands are ≥96 Perfect, ≥86 Great match, ≥70 Close, ≥45 Off, below that Miss. &lt;strong&gt;Every round averaged below the worst threshold.&lt;/strong&gt; An average player finished a five-round game without once being told they did anything but badly, and the Wordle-style result they were invited to share was five white squares.&lt;/p&gt;

&lt;p&gt;Nobody shares five white squares. Result-page arrivals: 247. Shares and copies: 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the formula was actually asking for
&lt;/h2&gt;

&lt;p&gt;Work backwards from the bands through &lt;code&gt;100 - ΔE × 2&lt;/code&gt; and you get the ΔE each rating required:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rating&lt;/th&gt;
&lt;th&gt;Old curve needs&lt;/th&gt;
&lt;th&gt;New curve needs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Perfect&lt;/td&gt;
&lt;td&gt;ΔE &amp;lt; 2&lt;/td&gt;
&lt;td&gt;ΔE &amp;lt; 9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Great match&lt;/td&gt;
&lt;td&gt;ΔE &amp;lt; 7&lt;/td&gt;
&lt;td&gt;ΔE &amp;lt; 22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Close&lt;/td&gt;
&lt;td&gt;ΔE &amp;lt; 15&lt;/td&gt;
&lt;td&gt;ΔE &amp;lt; 38&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Off&lt;/td&gt;
&lt;td&gt;ΔE &amp;lt; 27.5&lt;/td&gt;
&lt;td&gt;ΔE &amp;lt; 59&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;zero points&lt;/td&gt;
&lt;td&gt;ΔE ≥ 50&lt;/td&gt;
&lt;td&gt;ΔE ≥ 90&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Perfect required a guess a human eye could not distinguish from the target. The whole rating vocabulary — five labels — lived inside ΔE 0 to 27.5, and everything past 50 was a flat zero.&lt;/p&gt;

&lt;p&gt;Measured play sits at a mean ΔE of about 37.&lt;/p&gt;

&lt;p&gt;That is the entire bug. Not a coefficient that was slightly off — a curve whose expressive range and the actual distribution of play barely overlapped. 27.8% of guesses scored exactly 0, which also means the formula stopped conveying anything at all for more than a quarter of its inputs: badly-off and hopelessly-off scored the same.&lt;/p&gt;

&lt;p&gt;And ΔE 37 is not a bad guess. Two independent errors compound in this game — your memory of the color, and your ability to steer toward a remembered color through hue/saturation/lightness, which are not the axes perception uses. Landing 37 away is what a competent human does. The formula was calling normal human performance a failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&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;MAX_DELTA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&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;CURVE_EXPONENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scoreForDelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;MAX_DELTA&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;MAX_DELTA&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&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;normalized&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;CURVE_EXPONENT&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two knobs, each doing one job.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MAX_DELTA&lt;/code&gt; decides where zero lives. At 90 it sits out past anything a player who is genuinely trying will produce, so the score keeps carrying information across the whole real range instead of saturating inside it. Zeros went from 27.8% of guesses to 2.3%.&lt;/p&gt;

&lt;p&gt;The exponent above 1 bends the curve so it is generous early and steep late. At ΔE 37 you now get 71 (Close) instead of 26 (Miss); to be told you're Perfect you still have to land inside ΔE 9. Difficulty didn't leave, it moved to the top of the range where it belongs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ΔE&lt;/th&gt;
&lt;th&gt;Old&lt;/th&gt;
&lt;th&gt;New&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;95&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;79&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;71&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;56&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;75&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Simulated against the real brand pool before shipping: mean score 35.7 → 68.4, median game total 152 → 339 out of 500, squares 🟩23% / 🟨34% / 🟧25% / ⬜18%.&lt;/p&gt;

&lt;h2&gt;
  
  
  The discipline that made the change measurable
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;I did not touch the 45/70/90 thresholds.&lt;/strong&gt; That was tempting — since I was in there anyway, why not retune the bands too?&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;score&lt;/code&gt; is the metric I'm judging the change by. Move the curve and the bands in the same deploy and the before/after comparison answers nothing: you cannot tell whether players started scoring better or you just repainted the ruler. Change the thing under measurement or change the measuring instrument, never both in one release.&lt;/p&gt;

&lt;p&gt;Same reason the outcome of a share attempt is encoded in the event &lt;em&gt;name&lt;/em&gt; (&lt;code&gt;share_image_copied&lt;/code&gt;, &lt;code&gt;share_image_downloaded&lt;/code&gt;) instead of a parameter. Parameters need registered dimensions, registration isn't retroactive, and the quota is 50. Event names cost nothing and work the day you ship them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things that bit me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Verifying in a hidden browser panel.&lt;/strong&gt; The per-round number counts up through a &lt;code&gt;requestAnimationFrame&lt;/code&gt; animation. rAF is throttled to a stop in a background or hidden tab, so the round score renders &lt;code&gt;0&lt;/code&gt; forever and never moves. I nearly concluded the deploy hadn't taken. The rating text next to it is not animated and neither is the game total — read those. Anything driven by rAF is not evidence when nothing is compositing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading the local file as though it were production.&lt;/strong&gt; The fix sat uncommitted in my working tree for two days while a &lt;em&gt;different&lt;/em&gt; change went out around it. The live bundle still contained &lt;code&gt;Math.max(0,Math.round(100-2*n))&lt;/code&gt;, and GA4 agreed: daily first-round means of 14–30 the whole time. The five-second version of this check is to grep the deployed JS for your formula. Local source is a claim about production, not an observation of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The color-nerd footnote
&lt;/h2&gt;

&lt;p&gt;This is ΔE*76 — plain Euclidean distance in Lab, D65 white point. CIEDE2000 is more perceptually faithful, particularly for saturated blues, and I may move to it. It would not have changed anything above: at the distances real play produces, the correction is small next to being wrong by a factor of three about where the scale should end.&lt;/p&gt;

&lt;p&gt;The game is at &lt;a href="https://logocolorquiz.com/" rel="noopener noreferrer"&gt;logocolorquiz.com&lt;/a&gt;. Every number here came out of it.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>ux</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Ranking by exponential decay without a cron job</title>
      <dc:creator>Deland</dc:creator>
      <pubDate>Tue, 25 Aug 2026 02:13:59 +0000</pubDate>
      <link>https://dev.to/deland/ranking-by-exponential-decay-without-a-cron-job-4ifp</link>
      <guid>https://dev.to/deland/ranking-by-exponential-decay-without-a-cron-job-4ifp</guid>
      <description>&lt;p&gt;I built a board of 1,000 tiles where anyone can pay to take a tile away from whoever currently holds it. Tiles are sorted by "heat" — a value that decays over time — so the board reorders itself continuously.&lt;/p&gt;

&lt;p&gt;The obvious way to do that is a scheduled job that recomputes every row on a timer. I didn't want a scheduled job. Here's how you avoid one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Heat is the sum of every payment a tile has received, each one decaying exponentially:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;heat(t) = Σ Aᵢ · 2^(-(t - tᵢ) / H)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Aᵢ&lt;/code&gt; is a payment, &lt;code&gt;tᵢ&lt;/code&gt; is when it landed, &lt;code&gt;H&lt;/code&gt; is the half-life (3 days, in my case).&lt;/p&gt;

&lt;p&gt;Every term shrinks as &lt;code&gt;t&lt;/code&gt; advances, so every tile's heat changes every second. Sorting by that looks like it requires recomputing all 1,000 rows on a timer. With a million rows, that timer stops being a detail and becomes the whole system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Collapse the sum first
&lt;/h2&gt;

&lt;p&gt;You never need to store individual payments. The entire sum can be represented by a single pair &lt;code&gt;(P, t_P)&lt;/code&gt;. When a new payment &lt;code&gt;A&lt;/code&gt; arrives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;P'   = P · 2^(-(t_new - t_P)/H) + A
t_P' = t_new
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row, two columns, no matter how many payments a tile has taken over its life.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then take the log
&lt;/h2&gt;

&lt;p&gt;Let &lt;code&gt;k = ln2 / H&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ln(heat(t)) = ln(P) - k·(t - t_P)
            = [ln(P) + k·t_P] - k·t
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the two halves separately. The &lt;code&gt;-k·t&lt;/code&gt; term depends only on the current time, which means &lt;strong&gt;it is identical for every row in the table&lt;/strong&gt;. A term that is the same for every row cannot affect their relative order. Drop it.&lt;/p&gt;

&lt;p&gt;What's left is &lt;code&gt;ln(P) + k·t_P&lt;/code&gt;. There is no &lt;code&gt;t&lt;/code&gt; in it. It's a constant that changes only when someone pays.&lt;/p&gt;

&lt;p&gt;Store it as &lt;code&gt;rank_key&lt;/code&gt;, put an index on it, and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;tiles&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;rank_key&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is a permanently correct real-time ranking. Nothing is recomputed on a schedule. A row is rewritten only when money actually moves. The ordering is right one second after launch and still right three years later, with no job having ever run.&lt;/p&gt;

&lt;p&gt;Log space is also where you add heat. Use a numerically stable log-add so a small payment on top of a large one doesn't vanish into floating point:&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;logAddExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;a&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;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;b&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;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// lo - hi &amp;lt;= 0, so exp can't overflow; log1p is accurate near zero&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hi&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;log1p&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;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two things that will bite you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose the time unit deliberately.&lt;/strong&gt; &lt;code&gt;rank_key&lt;/code&gt; contains &lt;code&gt;k · t_P&lt;/code&gt;. If &lt;code&gt;t&lt;/code&gt; is unix seconds, then &lt;code&gt;k = 2.674e-6&lt;/code&gt; and &lt;code&gt;k·t&lt;/code&gt; is roughly 4800 today — that burns four significant digits of a double before you've stored anything useful. Use days instead: &lt;code&gt;k = 0.231&lt;/code&gt;, &lt;code&gt;k·t ≈ 55&lt;/code&gt;, and you still have around 12 decimal digits of headroom, which is sub-microsecond resolution. Pick an epoch near your launch date and count days from it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never exponentiate &lt;code&gt;rank_key&lt;/code&gt; directly.&lt;/strong&gt; It grows linearly with time. Mine is ~55 today and will be ~1687 in twenty years, and &lt;code&gt;Math.exp(1687)&lt;/code&gt; is &lt;code&gt;Infinity&lt;/code&gt;. To get a real heat value for display, exponentiate in the domain shifted to &lt;em&gt;now&lt;/em&gt;:&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="c1"&gt;// rankKey - K·days(now) == ln(heat(now)), which stays in the 6..10 range&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heat&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;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rankKey&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;K&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;toDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nowUnixS&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule: &lt;code&gt;rank_key&lt;/code&gt; is for ordering. Never do arithmetic that leaves log space.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug this nearly gave me
&lt;/h2&gt;

&lt;p&gt;All 1,000 tiles live in a single Cloudflare Durable Object. A DO gives you one JS execution stack — only one thing runs at a time — which sounds like it makes critical sections free. It doesn't.&lt;/p&gt;

&lt;p&gt;Every &lt;code&gt;await&lt;/code&gt; is a yield point. The input gate only blocks delivery during &lt;em&gt;storage&lt;/em&gt; operations; it does nothing across a D1 query or an outbound &lt;code&gt;fetch()&lt;/code&gt;. So this is broken:&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;locked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT ... WHERE tile_id = ?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&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;throw&lt;/span&gt; &lt;span class="nf"&gt;conflict&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;// two requests both reach here, both see zero locks, both proceed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check and the claim have to happen in a purely synchronous block, before any &lt;code&gt;await&lt;/code&gt; exists:&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="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;reserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ==== synchronous critical section: not one await in here ====&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;conflicts&lt;/span&gt; &lt;span class="o"&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;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tileIds&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;frozen&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;conflicts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &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;inflight&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;isBlocking&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;lock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;conflicts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;conflicts&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TileConflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;conflicts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;retryAfter&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;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tileIds&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;inflight&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ==== end of critical section ====&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&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;reserveSlow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// D1 writes, payment intent, etc.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&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;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tileIds&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;inflight&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;inflight&lt;/code&gt; is a plain in-memory &lt;code&gt;Set&lt;/code&gt;. JavaScript's single thread guarantees that block can't be interrupted, so staking the claim there is atomic in the only sense that matters here. Everything slow happens afterwards, and &lt;code&gt;finally&lt;/code&gt; releases the claim even if the slow part throws.&lt;/p&gt;

&lt;p&gt;The division of labour is worth being precise about: the DO owns serialization, alarms, and snapshot generation. D1 owns every persistent fact — ownership, amounts, &lt;code&gt;rank_key&lt;/code&gt;, the ledger. The DO's own storage holds nothing but the alarm, so crash recovery is a single &lt;code&gt;hydrate()&lt;/code&gt; from D1 and there's no dual-write to reconcile.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision that mattered more than the math
&lt;/h2&gt;

&lt;p&gt;When someone takes a tile, does the new owner inherit its heat or reset it?&lt;/p&gt;

&lt;p&gt;Inheriting feels natural — the tile &lt;em&gt;is&lt;/em&gt; hot, that's why it got taken. But the steal price is 1.5x current heat, so inheriting compounds: new heat = old + 1.5·old = 2.5·old. After four steals the tile sits at 39x its original heat and nobody can afford it again. An iron throne. The most interesting tile on the board becomes the one nobody can touch.&lt;/p&gt;

&lt;p&gt;Resetting heat to the amount actually paid gives 1.5^n instead — 3.4x after four steals — and the 3-day half-life pulls that back down within a week. Contests stay winnable.&lt;/p&gt;

&lt;p&gt;Identical decay math either way. One choice produces a living board, the other produces a museum. Worth remembering that the interesting part of this kind of system is usually not the formula.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing it run
&lt;/h2&gt;

&lt;p&gt;The board is at &lt;a href="https://tilesquat.com" rel="noopener noreferrer"&gt;tilesquat.com&lt;/a&gt;. 1,000 tiles, $5 to take one, and the thing the Million Dollar Homepage never had: nothing you buy stays yours. Everything above is taken from its source.&lt;/p&gt;

&lt;p&gt;Tile #0000 is mine at the moment. Feel free to evict me.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>javascript</category>
      <category>architecture</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I gave my coding agent its own computer</title>
      <dc:creator>Deland</dc:creator>
      <pubDate>Mon, 17 Aug 2026 08:33:52 +0000</pubDate>
      <link>https://dev.to/deland/i-gave-my-coding-agent-its-own-computer-4l99</link>
      <guid>https://dev.to/deland/i-gave-my-coding-agent-its-own-computer-4l99</guid>
      <description>&lt;p&gt;I run coding agents with shell access all day, and for a long time there was a low-grade dread underneath it. The permission prompts get annoying enough that everyone eventually turns them off, and then you are one confidently wrong &lt;code&gt;rm&lt;/code&gt; away from a bad afternoon.&lt;/p&gt;

&lt;p&gt;What fixed it was not better prompting. It was giving the agent its own computer.&lt;/p&gt;

&lt;p&gt;The setup is a VM on the same Mac. The agent gets a filesystem with nothing of mine in it, credentials that only work on throwaway repos, and a snapshot taken before every session. If it does something destructive I do not debug it. I roll back. Thirty seconds and the mistake never happened.&lt;/p&gt;

&lt;p&gt;That part is easy to describe and, on Apple Silicon, surprisingly annoying to actually build. Here is what I learned doing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Apple's Virtualization framework will actually boot
&lt;/h2&gt;

&lt;p&gt;This is the first thing that trips people up, so it is worth being blunt about it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Virtualization.framework&lt;/code&gt; boots two things: &lt;strong&gt;macOS on ARM&lt;/strong&gt; and &lt;strong&gt;Linux on ARM&lt;/strong&gt;. That is the entire list. There is no Windows on ARM, and no amount of configuration will produce one. It is not a licensing gate you can argue your way past, there is simply nothing to enable. Anything you read that claims otherwise is describing UTM's QEMU backend or VMware Fusion, both of which use their own hypervisor rather than Apple's.&lt;/p&gt;

&lt;p&gt;For the agent sandbox case this does not matter, because you want Linux anyway. Linux guests are smaller on disk, boot faster, and you are not burning one of your two permitted macOS guests on a machine that mostly runs &lt;code&gt;npm install&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The two guest types are configured differently in ways the docs do not really foreground:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;macOS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;// Boots the macOS bootloader, and needs a separate&lt;/span&gt;
    &lt;span class="c1"&gt;// auxiliary storage file next to the disk image.&lt;/span&gt;
    &lt;span class="n"&gt;bootLoader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;macOS&lt;/span&gt;
    &lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;auxiliaryStoragePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;auxPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;linux&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;// Boots EFI, and needs a variable store that&lt;/span&gt;
    &lt;span class="c1"&gt;// persists across reboots. Lose this file and the&lt;/span&gt;
    &lt;span class="c1"&gt;// guest forgets where its bootloader is.&lt;/span&gt;
    &lt;span class="n"&gt;bootLoader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;linuxEFI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;variableStorePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;efiVars&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generic&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The EFI variable store is the one people lose. It is a small file that lives next to the disk image and holds the boot entries. Copy the disk image somewhere without it and you get a guest that boots to an EFI shell and looks broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  The serial console will change your guest's behavior
&lt;/h2&gt;

&lt;p&gt;This one cost me an evening, and I have never seen it written down.&lt;/p&gt;

&lt;p&gt;Attaching a serial console to a Linux guest is the obvious move when the guest fails silently. You get &lt;code&gt;hvc0&lt;/code&gt; piped to a log file and you can finally see what the kernel is doing.&lt;/p&gt;

&lt;p&gt;Except Ubuntu's installer detects the serial port and decides that is where you want to be. Subiquity moves the installation UI off the graphical display and onto the serial console, in a reduced text mode, and your VM window sits there showing what looks like a hang. The guest is fine. You just moved its face.&lt;/p&gt;

&lt;p&gt;So the serial console has to be opt in, not always on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Only attach the console when actively debugging.&lt;/span&gt;
&lt;span class="nv"&gt;consoleLogPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;linux&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="kt"&gt;UserDefaults&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;standard&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Serial"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;bundle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;consoleLogURL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The general lesson generalizes past this one bug: on this framework, adding a device is never free. Every device you attach is visible to the guest and the guest may make decisions about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting code in and out without defeating the point
&lt;/h2&gt;

&lt;p&gt;My first version of this mounted my real project directory into the VM through VirtioFS. It worked immediately and it was completely pointless, because an agent that can write to my actual project folder is just my main machine with extra steps.&lt;/p&gt;

&lt;p&gt;The version that works: copy in, work, copy the diff out. The share is read only, or there is no share at all and everything moves over SSH.&lt;/p&gt;

&lt;p&gt;If you do want a share, the tag matters and differs by guest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Linux guests: pick your own tag, then mount it:&lt;/span&gt;
&lt;span class="c1"&gt;//   mount -t virtiofs myshare /mnt/share&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"myshare"&lt;/span&gt;

&lt;span class="c1"&gt;// macOS guests: use Apple's automount tag and the&lt;/span&gt;
&lt;span class="c1"&gt;// share shows up at /Volumes/My Shared Files with&lt;/span&gt;
&lt;span class="c1"&gt;// no mount command at all.&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;VZVirtioFileSystemDeviceConfiguration&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;macOSGuestAutomountTag&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The macOS automount tag is genuinely nice and almost nobody knows it exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rosetta inside a Linux guest
&lt;/h2&gt;

&lt;p&gt;If your toolchain has an x86-64 binary in it somewhere, and it usually does, you can share Rosetta into an ARM Linux guest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="kt"&gt;VZLinuxRosettaDirectoryShare&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;availability&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;installed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="c1"&gt;// attach the share&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;notInstalled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;// offer installRosetta { ... }&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;notSupported&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;// hide the feature entirely&lt;/span&gt;
&lt;span class="kd"&gt;@unknown&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="c1"&gt;// treat as unsupported&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mount it in the guest with &lt;code&gt;mount -t virtiofs rosetta /mnt/rosetta&lt;/code&gt;, register it with &lt;code&gt;binfmt_misc&lt;/code&gt;, and x86-64 binaries start running.&lt;/p&gt;

&lt;p&gt;Two caveats before you build anything load bearing on this. Apple has signalled that Rosetta 2 is being wound down in future macOS releases, and has not committed to the Linux virtualization path surviving that. Check Apple's current guidance rather than mine. And handle &lt;code&gt;@unknown default&lt;/code&gt; as unsupported rather than crashing, because the day this enum grows a case is the day your app stops launching.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I got wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Snapshot before, not after.&lt;/strong&gt; Obvious in hindsight. Cost me a session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real credentials get used.&lt;/strong&gt; If the agent can reach it, treat it as in scope. Scope the tokens to throwaway repos and assume anything reachable from that VM is reachable by the agent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disk images only grow.&lt;/strong&gt; The image expands toward whatever you provisioned and does not shrink when you free space inside the guest. Do not oversize "just in case". Keep one clean base image you never boot, clone off it, and delete the clone when you are done. One image that stays reasonable beats five that have all ballooned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two macOS guests per host, maximum.&lt;/strong&gt; That is Apple's license terms, not a technical limit. Nothing stops you, which means it is on you. Irrelevant for Linux guests, which is another reason to use them here.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed
&lt;/h2&gt;

&lt;p&gt;I run with permissions fully open now. The blast radius is a disk image I can throw away, so the prompt that used to make me think twice does not anymore.&lt;/p&gt;

&lt;p&gt;The odd side effect is that I review the final diff more carefully than I used to. I am not spending attention on "is this &lt;code&gt;rm&lt;/code&gt; safe", so there is attention left over for whether the code is any good.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;

&lt;p&gt;Any of these will get you there. Apple's framework directly if you want to write the roughly two hundred lines yourself, and honestly it is a good weekend, the API is one of the better ones Apple ships. UTM if you want something free with a GUI and do not mind the setup. lume or Tart if you would rather script it.&lt;/p&gt;

&lt;p&gt;Disclosure: I got annoyed enough at the setup friction that I built a Mac app for this, called &lt;a href="https://kyvenza.com" rel="noopener noreferrer"&gt;Kyvenza&lt;/a&gt;. Every code sample above is from its engine. It is 49 dollars one time with a 7 day trial, and it will not run Windows either, for the reason in the second section. The approach is the point of the post though, and UTM does it for free if you do not mind the assembly.&lt;/p&gt;

&lt;p&gt;Still curious whether anyone has a cleaner pattern for handing credentials to a sandboxed agent. That is the part I like least.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>macos</category>
      <category>swift</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
