<?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: Dhardingsea Developer</title>
    <description>The latest articles on DEV Community by Dhardingsea Developer (@dhseadev).</description>
    <link>https://dev.to/dhseadev</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%2F4061252%2F544eff56-3bdc-44e9-a8f5-9818e2a764a2.png</url>
      <title>DEV Community: Dhardingsea Developer</title>
      <link>https://dev.to/dhseadev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dhseadev"/>
    <language>en</language>
    <item>
      <title>I made an idle game where the healer can't attack</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Mon, 21 Sep 2026 12:56:24 +0000</pubDate>
      <link>https://dev.to/dhseadev/i-made-an-idle-game-where-the-healer-cant-attack-45k3</link>
      <guid>https://dev.to/dhseadev/i-made-an-idle-game-where-the-healer-cant-attack-45k3</guid>
      <description>&lt;p&gt;&lt;strong&gt;Mosslight&lt;/strong&gt; is a free idle game that runs in the browser. You play a healer who cannot attack. The party fights, and your job is to keep them standing. There is no account, no ads, and the game makes no network calls.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Play it on itch.io: &lt;a href="https://dhseadev.itch.io/mosslight" rel="noopener noreferrer"&gt;https://dhseadev.itch.io/mosslight&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Play it on my arcade: &lt;a href="https://play.dhseadev.online/games/mosslight/" rel="noopener noreferrer"&gt;https://play.dhseadev.online/games/mosslight/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post covers what's in the game, and then the part I think other web devs will find useful: running one source tree on two hosts with different security rules, and the save bug my own tests missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is in the game?
&lt;/h2&gt;

&lt;p&gt;Version 0.3.0 has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3 regions with 24 roads between them, and 12 creatures to meet along the way&lt;/li&gt;
&lt;li&gt;4 classes and 6 songs&lt;/li&gt;
&lt;li&gt;18 gifts across 5 rarities&lt;/li&gt;
&lt;li&gt;9 materials, 7 provisions, and a sanctuary with 6 buildings&lt;/li&gt;
&lt;li&gt;8 Insight upgrades, 7 store items, 10 titles, a 21-entry Ledger, and 15 story requests&lt;/li&gt;
&lt;li&gt;A Sabbatical (prestige) that opens at level 50&lt;/li&gt;
&lt;li&gt;Offline progress for up to 12 hours at half pace&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All the art is SVG generated from code, and all the sound is made live with the Web Audio API. There are no image or audio files in the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who can play it comfortably?
&lt;/h2&gt;

&lt;p&gt;I wanted an idle game you can leave open all day without it wearing on you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two themes, Heather and Lantern, both checked against WCAG AA contrast&lt;/li&gt;
&lt;li&gt;4 text sizes, a high-contrast mode, and reduced motion&lt;/li&gt;
&lt;li&gt;Zen mode, which hides everything except the party&lt;/li&gt;
&lt;li&gt;Full keyboard play&lt;/li&gt;
&lt;li&gt;Ailments are marked by shape as well as color&lt;/li&gt;
&lt;li&gt;Autosave, 3 manual save slots, and save export/import&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How does one codebase run on itch.io and my own site?
&lt;/h2&gt;

&lt;p&gt;itch.io gets a single self-contained HTML file. My arcade serves plain files from Cloudflare Pages. Two copies of the game would drift apart, so I keep only one.&lt;/p&gt;

&lt;p&gt;The source lives as a normal folder: &lt;code&gt;index.html&lt;/code&gt;, &lt;code&gt;style.css&lt;/code&gt;, and five scripts. The arcade serves that folder as-is. A small build script turns it inside out for itch: it inlines the stylesheet and the five scripts and swaps the page's security policy. Before I changed any game code, that script's output matched the file already on itch.io byte for byte. That match is what shows the folder and the itch file come from one source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why didn't the itch.io file just work on my arcade?
&lt;/h2&gt;

&lt;p&gt;Because of Content Security Policy, and because browsers enforce every policy a page has at the same time.&lt;/p&gt;

&lt;p&gt;The itch.io build carried its own policy in a meta tag, allowing inline scripts:&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;meta&lt;/span&gt; &lt;span class="na"&gt;http-equiv=&lt;/span&gt;&lt;span class="s"&gt;"Content-Security-Policy"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"script-src 'unsafe-inline'; connect-src 'none'; ..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The arcade sends one header for every page, allowing only script files from itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Content-Security-Policy: script-src 'self'; ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a page has both, the browser applies both, so a script runs only if it satisfies both rules. An inline script fails &lt;code&gt;'self'&lt;/code&gt;, and an external file fails &lt;code&gt;'unsafe-inline'&lt;/code&gt;. Nothing passes, so copying the itch file over would have loaded an empty page.&lt;/p&gt;

&lt;p&gt;The fix: on the arcade, Mosslight loads its scripts as files, and its meta tag now says &lt;code&gt;script-src 'self'&lt;/code&gt;. I kept &lt;code&gt;connect-src 'none'&lt;/code&gt; in the meta tag on purpose. The arcade header allows a few outside hosts for other games, and the stricter meta rule shuts those out of Mosslight's page. After deploying, I tried a fetch to one of those hosts from the live page. The browser refused it and named Mosslight's &lt;code&gt;connect-src 'none'&lt;/code&gt; as the reason.&lt;/p&gt;

&lt;p&gt;Combining policies can only tighten, never loosen. That works for you when you want a stricter page, and against you when two rules have nothing in common.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do saves stay separate when every game shares one website?
&lt;/h2&gt;

&lt;p&gt;Every game on the arcade shares one origin, and so one &lt;code&gt;localStorage&lt;/code&gt;. Each game keeps its keys under its own prefix, like &lt;code&gt;mosslight:&lt;/code&gt;. The arcade's test suite checks this for every entry, and there is no way to exempt one.&lt;/p&gt;

&lt;p&gt;Mosslight was written for the web and saved under plain key names, so it failed those checks. Now it has a small storage adapter: it uses the arcade's namespaced storage when that is there and plain &lt;code&gt;localStorage&lt;/code&gt; everywhere else, so itch.io behaves exactly as before. Because the arcade storage is asynchronous, the adapter reads every save key into a cache before the game boots and writes through on every save.&lt;/p&gt;

&lt;h2&gt;
  
  
  What bug did my tests miss?
&lt;/h2&gt;

&lt;p&gt;Slot 3.&lt;/p&gt;

&lt;p&gt;The game offers manual save slots 1, 2 and 3. My first version of the adapter loaded slots 0, 1 and 2 at boot. On the arcade, anything in slot 3 would have looked empty after every reload. My test only saved to slot 1, so everything passed.&lt;/p&gt;

&lt;p&gt;A panel of three reviewers found it. Each one read the change against a short spec, without seeing my reasoning. They also found three smaller problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a boot error that would have been silently swallowed&lt;/li&gt;
&lt;li&gt;a failed read that could have let a new game save over a real one&lt;/li&gt;
&lt;li&gt;a slot that showed as full when storage was blocked&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All four are fixed. The list of slots to load now comes from the same constant the Settings screen draws its buttons from:&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;var&lt;/span&gt; &lt;span class="nx"&gt;SLOTS&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="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test no longer types slot numbers. It reads them from the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I know the new test actually works?
&lt;/h2&gt;

&lt;p&gt;I watch it fail first. A test that has never gone red on a broken build proves nothing when it goes green.&lt;/p&gt;

&lt;p&gt;Mosslight's save test has 15 checks. Before trusting it, I ran it against three broken versions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the original itch.io code failed 5 checks&lt;/li&gt;
&lt;li&gt;the slots 0-to-2 draft failed exactly 1&lt;/li&gt;
&lt;li&gt;a build that crashes on boot failed 7 and named the error&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then the real version passed all 15. After that I still opened the game in both themes and looked at it, because several bugs on my site have passed every automated check and only showed up on screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is not done yet?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Mobile layouts are tested in a browser at phone sizes, not on actual phones.&lt;/li&gt;
&lt;li&gt;There is no cloud save. A save lives in the browser you play in, so a save on itch.io doesn't follow you to the arcade. Export and import move it by hand.&lt;/li&gt;
&lt;li&gt;Cinder Steps is the last region for now.&lt;/li&gt;
&lt;li&gt;Pet swapping, song loadouts and a codex are designed but not built.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Play on itch.io: &lt;a href="https://dhseadev.itch.io/mosslight" rel="noopener noreferrer"&gt;https://dhseadev.itch.io/mosslight&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Play on the arcade: &lt;a href="https://play.dhseadev.online/games/mosslight/" rel="noopener noreferrer"&gt;https://play.dhseadev.online/games/mosslight/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Project page: &lt;a href="https://dhseadev.online/projects/mosslight/" rel="noopener noreferrer"&gt;https://dhseadev.online/projects/mosslight/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Devlog, what Mosslight is: &lt;a href="https://dhseadev.online/2026/09/20/mosslight-an-idle-healer-who-cannot-attack/" rel="noopener noreferrer"&gt;https://dhseadev.online/2026/09/20/mosslight-an-idle-healer-who-cannot-attack/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Devlog, how the arcade works: &lt;a href="https://dhseadev.online/2026/09/21/how-the-arcade-works/" rel="noopener noreferrer"&gt;https://dhseadev.online/2026/09/21/how-the-arcade-works/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Arcade source code (public): &lt;a href="https://github.com/DHSeaDev/dhsea-arcade" rel="noopener noreferrer"&gt;https://github.com/DHSeaDev/dhsea-arcade&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;How I add a game to the arcade: &lt;a href="https://github.com/DHSeaDev/dhsea-arcade/blob/main/docs/adding-an-entry.md" rel="noopener noreferrer"&gt;https://github.com/DHSeaDev/dhsea-arcade/blob/main/docs/adding-an-entry.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you play it, I'd like to hear what felt slow or confusing.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I cut my Claude skill registry from 89 skills to 38 without deleting a rule</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Sat, 19 Sep 2026 22:42:30 +0000</pubDate>
      <link>https://dev.to/dhseadev/i-cut-my-claude-skill-registry-from-89-skills-to-38-without-deleting-a-rule-ad0</link>
      <guid>https://dev.to/dhseadev/i-cut-my-claude-skill-registry-from-89-skills-to-38-without-deleting-a-rule-ad0</guid>
      <description>&lt;p&gt;In early August I trimmed my Claude skill registry from 107 skills to 80. By mid-September it had grown back to 95 folders, 89 of them custom skills I wrote. This time I went further: 89 custom skills became 38, and not one rule was deleted.&lt;/p&gt;

&lt;p&gt;This post covers how the merge worked, how I checked it, and what it did not fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did 89 skills become a problem?
&lt;/h2&gt;

&lt;p&gt;A Claude skill is a folder with a SKILL.md file. The body loads only when the skill fires. The description loads every turn, for every skill, whether it fires or not.&lt;/p&gt;

&lt;p&gt;With 89 custom skills, those descriptions added up to 70,918 characters in every conversation. That is roughly 17,700 tokens by the usual four-characters-per-token estimate, spent before I typed anything.&lt;/p&gt;

&lt;p&gt;Size was not the part that kept biting me, though. The repeat failures were contracts split across two files. One skill wrote a field that another skill read, and the two drifted apart. A gate's check count was quoted in three places with three different numbers. Pairs of skills that had to deploy together got uploaded one half at a time. Every one of those failures lived in the gap between two files.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you merge skills without losing what they do?
&lt;/h2&gt;

&lt;p&gt;I stopped treating a skill as the unit and started treating a mode as the unit.&lt;/p&gt;

&lt;p&gt;Each merged skill holds the full, unedited body of every skill folded into it, one per &lt;code&gt;# Mode:&lt;/code&gt; section. A table at the top lists the modes and says which section to read. Routing names a skill and a mode together, like &lt;code&gt;verify:claim&lt;/code&gt; or &lt;code&gt;run-control:loop&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;31 merged skills and 7 that stayed on their own&lt;/li&gt;
&lt;li&gt;107 documented modes&lt;/li&gt;
&lt;li&gt;80 old skill folders retired&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most merge boundaries follow those split contracts. The state board and the loop that writes to it became one skill, &lt;code&gt;run-control&lt;/code&gt;. The pre-ship gate and its extension-specific checks became one skill, &lt;code&gt;preship&lt;/code&gt;, so there is only one place a check count can live. Claim verification and "can this measuring tool fail?" became two modes of &lt;code&gt;verify&lt;/code&gt;. The instrument check had failed to fire nine times in a row as a separate skill.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you rename 80 skills without breaking every reference?
&lt;/h2&gt;

&lt;p&gt;Skills reference each other constantly, so every old name had to point at its new &lt;code&gt;skill:mode&lt;/code&gt; token.&lt;/p&gt;

&lt;p&gt;The repoint followed four rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rewrite names in live instructions only.&lt;/li&gt;
&lt;li&gt;Leave code blocks alone.&lt;/li&gt;
&lt;li&gt;Leave changelog history alone, because history should name what it was.&lt;/li&gt;
&lt;li&gt;Leave headings alone, because other skills find sections by heading text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The router also got a legacy table mapping all 80 old names to their new tokens. An old name or slash command still resolves.&lt;/p&gt;

&lt;p&gt;The first pass missed something. It only rewrote names nine characters or longer, to avoid false matches on short words. Two short names, &lt;code&gt;run-loop&lt;/code&gt; and &lt;code&gt;html-css&lt;/code&gt;, survived as plain words in 62 places. A review after install found them, and a patch, not yet installed, fixes them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you prove nothing was lost?
&lt;/h2&gt;

&lt;p&gt;I did not trust my own reading of 89 files. I wrote a gate and made it prove itself before I trusted it.&lt;/p&gt;

&lt;p&gt;It checks nine things for every skill:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The frontmatter parses.&lt;/li&gt;
&lt;li&gt;The name matches the folder.&lt;/li&gt;
&lt;li&gt;The description is under the 1,024-character limit.&lt;/li&gt;
&lt;li&gt;The version matches the highest changelog entry.&lt;/li&gt;
&lt;li&gt;Code fences are balanced.&lt;/li&gt;
&lt;li&gt;No changelog table is broken.&lt;/li&gt;
&lt;li&gt;No old skill name remains in live text.&lt;/li&gt;
&lt;li&gt;Every heading from every source still exists.&lt;/li&gt;
&lt;li&gt;Every source body appears word for word in its merged skill.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Before trusting any check, I broke a copy on purpose and confirmed the check went red. A heading renamed, a line edited, an old name planted, a description padded past the limit. Each check had to catch its own failure first.&lt;/p&gt;

&lt;p&gt;That habit paid off twice. The old-name check had a blind spot: its exemption for mode tables spread across table columns, so an old name in any table row could hide. I found it because two nearly identical rows scored differently. Later, a rebuild read my own merged &lt;code&gt;python&lt;/code&gt; skill back in as one of its sources, because the stale file copy held two folders with that name. A tripwire now refuses any already-merged file as input.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did it buy?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Per-turn skill descriptions went from 70,918 characters to 33,962, a 52% cut.&lt;/li&gt;
&lt;li&gt;My standing instructions file drops from 6,837 characters to 3,881 in the rewrite, a 43% cut. Rules a skill already enforces now live only in that skill.&lt;/li&gt;
&lt;li&gt;Every one of the 38 skills and every mode token has at least one route in the router.&lt;/li&gt;
&lt;li&gt;31 platform and vendor skills that had no route get one in the pending patch, each with a boundary against the custom skill it could displace.&lt;/li&gt;
&lt;li&gt;The registry is capped at 50. A new capability becomes a mode on an existing skill first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What did it cost, and what is not done?
&lt;/h2&gt;

&lt;p&gt;Load cost moved from every turn to trigger time. Six merged skills are over 80 KB, and when one fires the whole file loads. The next step is to split those into one reference file per mode, so a trigger loads only the mode it needs.&lt;/p&gt;

&lt;p&gt;Install state is not fully confirmed. The file copy my chat sessions read lags the account, so the check that the installed versions match the build still has to run in a fresh session. One updated skill cannot be confirmed from its description at all.&lt;/p&gt;

&lt;p&gt;64 section references in prose were already unresolved before the merge and still are. The merge added none, and I have not claimed them fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I do it again?
&lt;/h2&gt;

&lt;p&gt;Yes, but for the right reason. Last time I trimmed skills expecting better routing, and found nothing in my failure log that the skill count had caused. This time the goal was different: fewer cross-file contracts, a smaller always-on load, and the same abilities behind a clearer address. On those three measures, it held.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>productivity</category>
      <category>tooling</category>
    </item>
    <item>
      <title>The Underrated Superpower of Games — Why I Built Creature Camp</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Fri, 18 Sep 2026 21:21:17 +0000</pubDate>
      <link>https://dev.to/dhseadev/the-underrated-superpower-of-games-why-i-built-creature-camp-2pbk</link>
      <guid>https://dev.to/dhseadev/the-underrated-superpower-of-games-why-i-built-creature-camp-2pbk</guid>
      <description>&lt;p&gt;The screenshot on my phone is always the same: a small fox with a flower crown sitting by a stone. Her name is Ember, and she doesn't do anything particularly useful. She just exists in my browser, wandering around a camp with other woodland creatures, occasionally befriending a raccoon or stealing a snack.&lt;/p&gt;

&lt;p&gt;Building Creature Camp changed how I think about what games can actually do for us. And I think developers are sleeping on this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With "Productivity"
&lt;/h2&gt;

&lt;p&gt;We're all drowning in optimization. Every app wants to make us better, faster, smarter. Every break is monitored. Every minute has to count. And somewhere in that relentless efficiency, we started treating anything that doesn't move us toward a goal like we're wasting time.&lt;/p&gt;

&lt;p&gt;Games got caught in the crossfire. They became "time killers" — something you do when you should be doing something else. The language alone makes them sound frivolous.&lt;/p&gt;

&lt;p&gt;But that's backwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Creature Camp Taught Me
&lt;/h2&gt;

&lt;p&gt;I built this game because I needed a way to think about something outside my brain for a while. Not a distraction that rewires your dopamine and leaves you needing a reset afterward. Something that actually lets you rest while staying engaged.&lt;/p&gt;

&lt;p&gt;The creatures just live. They wander. They interact with each other. There's no timer, no "beat the level," no achievement unlock that feels hollow the moment you earn it. You play because watching them exist is... pleasant. Calming. Your brain gets to be present without having to perform.&lt;/p&gt;

&lt;p&gt;But here's where it gets interesting: while your brain is busy being calm, something sneaky happens. You notice patterns in how creatures befriend each other. You start thinking about what the next friend should look like. You find yourself imagining the camp differently tomorrow. Your hands relax, but your mind doesn't stop — it just stops grinding.&lt;/p&gt;

&lt;p&gt;That's the real value. Not the distraction. The insight that comes because you're not frantically trying to extract it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters For Developers
&lt;/h2&gt;

&lt;p&gt;We're the worst at this. We game hard — we optimize every mechanic, chase completion, speed-run everything. But somewhere between building systems and perfecting them, we forgot that the best ideas don't come from intensity. They come from that weird space where your attention is lowered but not off.&lt;/p&gt;

&lt;p&gt;Games can be that space. Not the ones designed to extract your time, but the ones designed to give it back to you.&lt;/p&gt;

&lt;p&gt;Creature Camp isn't a game that competes for your focus. It's one that plays alongside your actual life. You come back because you want to see what Ember's doing today. Not because the algorithm told you to.&lt;/p&gt;

&lt;p&gt;Building it taught me something I should have known: the most valuable tool you can create isn't always the one that solves a problem faster. Sometimes it's the one that lets you breathe while you're solving it.&lt;/p&gt;

&lt;p&gt;Every creature I drew in code, every small interaction, every choice to make nothing require energy — that was a small argument for your right to rest.&lt;/p&gt;

&lt;p&gt;And somehow, building that argument made me better at my actual work.&lt;/p&gt;

&lt;p&gt;If you want to wander a woodland camp with some creatures (and maybe pet a fox wearing a flower crown), you can play Creature Camp for free at play.dhseadev.online. It's offline, no accounts, no data leaving your browser. Just you and a camp that's waiting to see what tomorrow brings.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>From Python Script to .exe: Building Batch PDF Tools That Users Actually Want to Run</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Thu, 17 Sep 2026 13:24:42 +0000</pubDate>
      <link>https://dev.to/dhseadev/from-python-script-to-exe-building-batch-pdf-tools-that-users-actually-want-to-run-13hg</link>
      <guid>https://dev.to/dhseadev/from-python-script-to-exe-building-batch-pdf-tools-that-users-actually-want-to-run-13hg</guid>
      <description>&lt;p&gt;I've spent the last few years building internal tools for a legal process serving company. Some of them are Python scripts that only I use. Others are tools that filing staff need to run daily. Those tools taught me something: &lt;strong&gt;the easiest Python script is worthless if it requires the user to open a terminal.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This post is about the complete pipeline: writing Python tools for PDF manipulation, packaging them as standalone .exe files, and automating the build process so you can iterate without friction.&lt;/p&gt;

&lt;p&gt;Let's build a real tool: a batch PDF page extractor that processes an entire folder of documents when you double-click it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: From Manual to Bulk
&lt;/h2&gt;

&lt;p&gt;Early on, we built a tool to extract the first page of PDFs—useful for forms processing. It worked fine as a Python script if you dragged a file onto it. But then the ask came: "Can it process a whole folder at once?"&lt;/p&gt;

&lt;p&gt;That simple question exposed the real challenge: &lt;strong&gt;batch processing and user experience are inseparable.&lt;/strong&gt; A tool that works on one file is a toy. A tool that scans a folder, processes everything, and shows results? That's infrastructure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: Writing the Tool (PyMuPDF)
&lt;/h2&gt;

&lt;p&gt;Let's start with a simple but practical example: extracting the last page of PDFs. You could use PyPDF2 or pdfplumber, but &lt;strong&gt;PyMuPDF (fitz) is faster, lighter, and handles edge cases better.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's the core:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;  &lt;span class="c1"&gt;# PyMuPDF
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_last_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;input_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_pdf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  ✗ Not found: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;input_pdf&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_pdf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;page_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;page_count&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  ✗ No pages in: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;input_pdf&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

        &lt;span class="c1"&gt;# Create new PDF with only the last page
&lt;/span&gt;        &lt;span class="n"&gt;new_doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;last_page_num&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page_count&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;new_doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;from_page&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;last_page_num&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_page&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;last_page_num&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# Save as &amp;lt;name&amp;gt;_last_page.pdf
&lt;/span&gt;        &lt;span class="n"&gt;stem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;
        &lt;span class="n"&gt;output_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_last_page.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;output_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;output_name&lt;/span&gt;

        &lt;span class="n"&gt;new_doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;new_doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  ✓ &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (page &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;page_count&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  ✗ &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;input_pdf&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this approach:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;fitz.open()&lt;/code&gt; is fast and memory-efficient&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;insert_pdf()&lt;/code&gt; copies pages without re-encoding—critical for large PDFs&lt;/li&gt;
&lt;li&gt;Return boolean so caller knows if it succeeded&lt;/li&gt;
&lt;li&gt;Descriptive output (checkmarks, file names) for user feedback&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Part 2: The UX Layer—Folder Scanning
&lt;/h2&gt;

&lt;p&gt;Here's where we separate "works for me" from "works for staff":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_folder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Scan current folder for PDFs and process all.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;cwd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;pdf_files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;pdf_files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;No PDF files found in: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Press Enter to exit...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;Found &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; PDF file(s)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Processing...&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;success&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;pdf_file&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pdf_files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;extract_last_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_file&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
            &lt;span class="n"&gt;success&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;✓ Processed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;success&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; files&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Press Enter to exit...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key design decisions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Path.glob("*.pdf")&lt;/code&gt; finds all PDFs in the current directory—no config needed&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sorted()&lt;/code&gt; ensures predictable order (matters when staff review output)&lt;/li&gt;
&lt;li&gt;Two usage modes:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No args:&lt;/strong&gt; scan folder (double-click scenario)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Args:&lt;/strong&gt; process dropped files (drag-drop scenario)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;input("Press Enter...")&lt;/code&gt; pauses so users see the results—critical on Windows where console windows close instantly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The main script branches based on arguments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;process_folder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# Double-click: scan &amp;amp; process all
&lt;/span&gt;    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;process_arguments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&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;# Drag-drop: process these
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is &lt;strong&gt;invisible complexity.&lt;/strong&gt; Users see one tool with two behaviors. The code knows which one from context.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: Building the .exe—PyInstaller
&lt;/h2&gt;

&lt;p&gt;Now the hard part: getting a Python script into the hands of non-technical users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PyInstaller&lt;/strong&gt; converts Python + dependencies into a standalone executable. No Python installation needed. One file, double-click, done.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;pyinstaller pymupdf
pyinstaller &lt;span class="nt"&gt;--onefile&lt;/span&gt; &lt;span class="nt"&gt;--console&lt;/span&gt; last_page_extractor.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates &lt;code&gt;dist/last_page_extractor.exe&lt;/code&gt;—a fully self-contained binary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;--onefile&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Users get one file, not a folder of DLLs&lt;/li&gt;
&lt;li&gt;No "where do I put this" confusion&lt;/li&gt;
&lt;li&gt;Simpler to version and distribute&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;--console&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Shows output (checkmarks, error messages, file count)&lt;/li&gt;
&lt;li&gt;Windows habit: console window closes when done, giving users a natural "exit" moment&lt;/li&gt;
&lt;li&gt;Staff can screenshot the output for auditing&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Part 4: Automating the Build (The Real Lesson)
&lt;/h2&gt;

&lt;p&gt;Here's where most tutorials stop. Here's where the real work begins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; Every time you fix a bug or add a feature, rebuilding is friction. Create venv, install deps, run PyInstaller, manage output. One extra step and you stop iterating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The solution:&lt;/strong&gt; A PowerShell script that does all of it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="bp"&gt;$Error&lt;/span&gt;&lt;span class="n"&gt;ActionPreference&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Stop"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$scriptDir&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$PSScriptRoot&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Find Python (works with py, python, python3)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$py&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$candidate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@(&lt;/span&gt;&lt;span class="s2"&gt;"py -3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python3"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;try&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$ver&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;scriptblock&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$candidate&lt;/span&gt;&lt;span class="s2"&gt; --version"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ver&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-match&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Python 3"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$py&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$candidate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;break&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;catch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$py&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;Write-Host&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ERROR: Python 3 not found"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ForegroundColor&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Red&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;exit&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Create venv &amp;amp; install&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Write-Host&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Creating venv..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;scriptblock&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$py&lt;/span&gt;&lt;span class="s2"&gt; -m venv .venv"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$pip&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Join-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".venv"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Scripts\pip.exe"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$pyVenv&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Join-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".venv"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Scripts\python.exe"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="n"&gt;Write-Host&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Installing dependencies..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$pip&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;install&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;pymupdf&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;pyinstaller&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--quiet&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Build&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Write-Host&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Building .exe..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$pyVenv&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-m&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PyInstaller&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--onefile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--console&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;last_page_extractor&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;last_page_extractor.py&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="nv"&gt;$exe&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Join-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dist"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"last_page_extractor.exe"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Test-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$exe&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;Write-Host&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SUCCESS: &lt;/span&gt;&lt;span class="nv"&gt;$exe&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ForegroundColor&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Green&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;else&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;Write-Host&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ERROR: .exe not found"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ForegroundColor&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Red&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;exit&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrap it in a .cmd file for Windows users:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@echo off
powershell -ExecutionPolicy Bypass -File "%~dp0build_exe.ps1"
pause
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users (or you) just &lt;strong&gt;double-click BuildExe.cmd&lt;/strong&gt; and walk away. No terminal knowledge required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Removes friction:&lt;/strong&gt; Bug found? Fix Python file, double-click build script, done. New .exe is ready.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handles edge cases:&lt;/strong&gt; The script finds Python (whether installed as &lt;code&gt;py&lt;/code&gt;, &lt;code&gt;python&lt;/code&gt;, or &lt;code&gt;python3&lt;/code&gt;), creates a fresh venv each time, avoids dependency conflicts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UNC path handling:&lt;/strong&gt; If you're building from a network share, the script stages to &lt;code&gt;%LOCALAPPDATA%&lt;/code&gt; (Windows won't run PyInstaller from UNC paths). Users never see this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reproducible:&lt;/strong&gt; Every build starts fresh. No "works on my machine" surprises.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Part 5: Learnings &amp;amp; Best Practices
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. &lt;strong&gt;Two-Mode UX is Invisible Complexity&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Your tool shouldn't make users think about how to use it. Detect the context (folder vs. files) and do the right thing. This is the difference between "a tool that works" and "a tool people actually use."&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;strong&gt;Output Matters More Than You Think&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Console output is user feedback. Checkmarks (✓) vs. crosses (✗) are scannable. File names in output let users verify what happened. &lt;code&gt;input("Press Enter...")&lt;/code&gt; is the pause button—users won't miss the result window closing.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;strong&gt;PyInstaller is Fast Enough&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;You'll hear "PyInstaller is slow, use Nuitka." For CLI tools that run occasionally, it doesn't matter. A 2-3 second startup is fine. The complexity of Nuitka isn't worth it unless you're shipping a game or real-time app.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. &lt;strong&gt;Venv Per Build, Not Shared&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Some scripts reuse a venv. Don't. Create fresh each time. It adds 5 seconds, guarantees clean builds, and avoids "works on mine" errors from stale packages.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. &lt;strong&gt;Build Script Ownership is Underrated&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Teams ship code. Great teams ship &lt;strong&gt;with build infrastructure.&lt;/strong&gt; A 50-line PowerShell script that removes friction for future you is worth more than a perfect Python module. Invest in it.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. &lt;strong&gt;Console vs. GUI is a UX Choice, Not a Technical One&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;We use &lt;code&gt;--console&lt;/code&gt; because staff can see results and know when to close the window. For a GUI tool, use &lt;code&gt;--windowed&lt;/code&gt;. But don't add GUI complexity unless users need it. A good console tool beats a mediocre GUI every time.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. &lt;strong&gt;Name Your Outputs Consistently&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;basename&amp;gt;_last_page.pdf&lt;/code&gt; is predictable. Staff can tell at a glance what happened. Avoid generic names like &lt;code&gt;output.pdf&lt;/code&gt; or &lt;code&gt;temp_1.pdf&lt;/code&gt;. Your naming scheme is part of the UX.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 6: Scaling This Approach
&lt;/h2&gt;

&lt;p&gt;This pattern scales to dozens of tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First tool:&lt;/strong&gt; Do it right. Spend time on the build script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Second tool:&lt;/strong&gt; Copy the build script, change the filename. You've already paid the setup cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tenth tool:&lt;/strong&gt; You have a repeatable template. New tools are hours, not days.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We've built:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Batch PDF splitters (first page, last page, even pages, ranges)&lt;/li&gt;
&lt;li&gt;Court filing fee lookups (CSV processing)&lt;/li&gt;
&lt;li&gt;Case metadata extraction (PDF + web scraping)&lt;/li&gt;
&lt;li&gt;File renaming utilities&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All built on the same foundation: Python + PyMuPDF or similar, PyInstaller, and PowerShell build automation.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Complete Example
&lt;/h2&gt;

&lt;p&gt;Here's what you need:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;last_page_extractor.py&lt;/strong&gt; — The tool (100 lines of clear Python)&lt;br&gt;
&lt;strong&gt;build_exe.ps1&lt;/strong&gt; — The build script (50 lines, copy-paste for new projects)&lt;br&gt;
&lt;strong&gt;BuildExe.cmd&lt;/strong&gt; — The launcher (2 lines, so non-technical users can build)&lt;/p&gt;

&lt;p&gt;Put all three in a folder. Double-click BuildExe.cmd. Get last_page_extractor.exe in dist/. &lt;/p&gt;

&lt;p&gt;Staff double-clicks the .exe in a folder of PDFs. All last pages extract automatically. They see results. Done.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The gap between "works locally" and "users can run it" is bigger than most tutorials suggest. It's not about language features or libraries. It's about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Context-aware UX&lt;/strong&gt; (fold vs. files—do the right thing)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clear output&lt;/strong&gt; (so users know what happened)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Friction-free building&lt;/strong&gt; (so you iterate without pain)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reproducible packaging&lt;/strong&gt; (so it works everywhere)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A Python script plus PyInstaller plus a good build script is a complete tool. Not a prototype—a shipped product.&lt;/p&gt;

&lt;p&gt;The legal tech space taught me that. But it applies everywhere: finance, HR, ops, QA—any role with repeatable file tasks where someone would otherwise do it manually.&lt;/p&gt;

&lt;p&gt;Write the tool. Automate the build. Watch staff use it daily.&lt;/p&gt;

&lt;p&gt;That's the real shipping.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What build automation patterns do you use? Comment below—I'd love to hear how others handle this.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>pdf</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building Creature Camp: A Chrome Extension That Breaks the Pet Game Mold</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Thu, 17 Sep 2026 04:02:04 +0000</pubDate>
      <link>https://dev.to/dhseadev/building-creature-camp-a-chrome-extension-that-breaks-the-pet-game-mold-59i4</link>
      <guid>https://dev.to/dhseadev/building-creature-camp-a-chrome-extension-that-breaks-the-pet-game-mold-59i4</guid>
      <description>&lt;p&gt;I just started working on a Chrome extension called &lt;strong&gt;Creature Camp&lt;/strong&gt; — a free, offline-first pet collection game with no ads, no accounts, and no dark patterns. After shipping it, I want to talk about the technical and design decisions that made it possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Premise
&lt;/h2&gt;

&lt;p&gt;You click a Chrome icon and get a cozy woodland playground. A starter creature (Ember, a shy fox) greets you. As you play, you collect 24 more creatures — each with authored personality, distinct voice, and body language that responds to their mood. They interact with each other, you see what they did while you were gone, and there's a live activity feed of their conversations.&lt;/p&gt;

&lt;p&gt;The key constraint: &lt;strong&gt;nothing is random about who joins your camp&lt;/strong&gt;. Every creature unlock is deterministic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Built This
&lt;/h2&gt;

&lt;p&gt;I shipped LumenReel (125 randomly-drawn critters) last year and learned that players form genuine attachment to procedural creatures when their &lt;em&gt;personality&lt;/em&gt; is authored, not their art. Creature Camp extends that: 25 hand-authored creatures, each with personality tier, voice register, and body-language rig.&lt;/p&gt;

&lt;p&gt;But the real driver was negative. I got tired of seeing pet games use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dark patterns (anxiety loops, near-miss probability)&lt;/li&gt;
&lt;li&gt;Countdown timers designed to create FOMO&lt;/li&gt;
&lt;li&gt;Paywalls that hide core features&lt;/li&gt;
&lt;li&gt;Manipulative notifications&lt;/li&gt;
&lt;li&gt;"Loss as win" framing (you lost, but it feels rewarding)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Finch proved people &lt;em&gt;want&lt;/em&gt; pet companions (4.9★, 712K reviews). They don't need the tricks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No build step. No bundler. No remote assets.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;manifest.json
├── service worker (background)
│   └── sw.js (game loop, unlocks, interactions)
├── side panel UI
│   ├── sidepanel.html (structural)
│   ├── sidepanel.css (Gravity Falls palette)
│   ├── creatures.css (canvas animation styles)
│   └── app.js (UI orchestration)
├── game engine
│   ├── lib/engine.js (tick, spawn, interactions)
│   ├── lib/state.js (save/load)
│   ├── lib/species.js (creature blueprints)
│   ├── lib/chatter.js (authored dialogue)
│   └── lib/unlocks.js (code redemption)
└── rendering
    ├── art/creature.js (canvas draw)
    ├── art/scene.js (background, lighting)
    └── art/bodies-a.js + bodies-b.js (creature rigs)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything is modules (&lt;code&gt;type: "module"&lt;/code&gt; in manifest). No eval, no inline scripts, no unsafe CSP. MV3 compliant out of the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Core Design Decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Deterministic Creature Unlocks
&lt;/h3&gt;

&lt;p&gt;The handoff said "no coupon-collector tail" — don't let players get stuck chasing one last creature forever.&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;// Creatures unlock in waves via:&lt;/span&gt;
&lt;span class="c1"&gt;// - Base roster: 20 creatures, no randomness&lt;/span&gt;
&lt;span class="c1"&gt;// - Code pack 1: 5 legendary creatures (MOSSGATHERER, LUNA, etc.)&lt;/span&gt;
&lt;span class="c1"&gt;// - Time-locked: Day 3, Day 7 reveals&lt;/span&gt;
&lt;span class="c1"&gt;// - Interaction-triggered: Thistle appears at 20 interactions&lt;/span&gt;

&lt;span class="c1"&gt;// Every unlock path is deterministic and finite.&lt;/span&gt;
&lt;span class="c1"&gt;// The collection finishes. That is the design.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I simulated 1000 playthroughs to verify: median completion is 3–4 hours of casual play. Nobody hits a brick wall.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Species as Immutable Data Rows
&lt;/h3&gt;

&lt;p&gt;Each creature is authored once, never reordered. Every field is a design decision.&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;S&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&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;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rarity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;freeze&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&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;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rarity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lore&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;const&lt;/span&gt; &lt;span class="nx"&gt;SPECIES&lt;/span&gt; &lt;span class="o"&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;freeze&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nc"&gt;S&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ember&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;Ember&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;fox kit&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;chibi&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;common&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;shy&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;shy&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;base&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;ears&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 small fox who watched the campfire begin, and never quite stopped watching it.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nc"&gt;S&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pip&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;Pip&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;chipmunk&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;chibi&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;common&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;cheerful&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;happy&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;base&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;cheeks&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;Keeps a seed for everyone. Has never once remembered where.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="c1"&gt;// ... 23 more, each one authored.&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;voice&lt;/code&gt; field picks a personality register. The &lt;code&gt;plan&lt;/code&gt; field routes to the right body-language rig. The &lt;code&gt;sig&lt;/code&gt; field is the expressive channel (ears for foxes, glow for fireflies, etc.). Adding a new creature is just a new row.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Canvas Rendering Without Assets
&lt;/h3&gt;

&lt;p&gt;Creatures are drawn in code, not loaded from sprite sheets. Each creature has a rig:&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;// Creature rigs define: base shape, body parts, animation channels&lt;/span&gt;
&lt;span class="c1"&gt;// Example: Ember (shy fox)&lt;/span&gt;
&lt;span class="c1"&gt;// - body: chibi quadruped&lt;/span&gt;
&lt;span class="c1"&gt;// - ears: main expression channel&lt;/span&gt;
&lt;span class="c1"&gt;// - face: mood-responsive&lt;/span&gt;
&lt;span class="c1"&gt;// - tail: gesture&lt;/span&gt;

&lt;span class="c1"&gt;// Each body part is a canvas path or composite shape&lt;/span&gt;
&lt;span class="c1"&gt;// Animation states (idle, happy, curious, sleeping) change which parts move&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps the extension under 2MB total. No network requests. Works offline immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The Chatter System (Authored, Not AI)
&lt;/h3&gt;

&lt;p&gt;Every interaction line is hand-written. The chatter engine has &lt;strong&gt;zero dark-pattern vocabulary&lt;/strong&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;// No: "You almost had it!", "One more spin!", "Come back soon!"&lt;/span&gt;
&lt;span class="c1"&gt;// Yes:&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MEETINGS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;shy&lt;/span&gt;&lt;span class="p"&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} left a pinecone next to {b} and pretended not to have.&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} sat almost next to {b}. That counts.&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} and {b} watched the same cloud for a while.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;cheerful&lt;/span&gt;&lt;span class="p"&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} taught {b} a song with no words and too many verses.&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} and {b} raced to the big rock. They both say they won.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="c1"&gt;// ... 8 voice registers, 50+ unique lines&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote a test that scans every string in the game for countdown language, near-miss framing, and loss-as-win phrasing. The test blocks the build if it finds any.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Chrome Storage Only
&lt;/h3&gt;

&lt;p&gt;No cloud, no accounts, no data collection.&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;// All state lives in chrome.storage.local&lt;/span&gt;
&lt;span class="c1"&gt;// - Creatures (25 instances max, ~50KB)&lt;/span&gt;
&lt;span class="c1"&gt;// - Activity feed (last 50 events, ~10KB)&lt;/span&gt;
&lt;span class="c1"&gt;// - Player preferences (a few KB)&lt;/span&gt;

&lt;span class="c1"&gt;// Total: ~2MB with buffer. Well under Chrome's 10MB limit.&lt;/span&gt;

&lt;span class="c1"&gt;// Game works with browser closed. Creatures "live" offline.&lt;/span&gt;
&lt;span class="c1"&gt;// Install extension → first launch → starter creature spawns → play forever.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6. Accessibility as Baseline
&lt;/h3&gt;

&lt;p&gt;Not an afterthought:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full WCAG AA compliance (color contrast, font sizes)&lt;/li&gt;
&lt;li&gt;Keyboard navigation: Tab through all UI, Enter/Space to activate&lt;/li&gt;
&lt;li&gt;Screen reader: All interactive elements labeled, live regions for updates&lt;/li&gt;
&lt;li&gt;Reduce motion: &lt;code&gt;prefers-reduced-motion&lt;/code&gt; media query → creatures hold still&lt;/li&gt;
&lt;li&gt;16px base font size, no font smaller than 14px&lt;/li&gt;
&lt;li&gt;Skip link to main content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ran through &lt;code&gt;test-env-web&lt;/code&gt; before submission. Zero accessibility violations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Without Dark Patterns
&lt;/h2&gt;

&lt;p&gt;The hardest part wasn't the code. It was deciding what NOT to build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I didn't ship:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hunger timers (creatures never suffer for neglect)&lt;/li&gt;
&lt;li&gt;Login requirements (no accounts, no friction)&lt;/li&gt;
&lt;li&gt;Randomized loot boxes (you always know what you're getting)&lt;/li&gt;
&lt;li&gt;Paywalls hiding features (every creature is free)&lt;/li&gt;
&lt;li&gt;Notifications pushing you back (only app-initiated, never pushed)&lt;/li&gt;
&lt;li&gt;Daily login streaks (play when you want)&lt;/li&gt;
&lt;li&gt;Creature "death" or permanent loss&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I did ship:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Persistent world (creatures exist when you're gone)&lt;/li&gt;
&lt;li&gt;Visible consequences of play (creatures change mood based on care)&lt;/li&gt;
&lt;li&gt;Discovery (unlocking creatures via codes and milestones)&lt;/li&gt;
&lt;li&gt;Social presence (creatures interact with each other)&lt;/li&gt;
&lt;li&gt;Cozy aesthetic (Gravity Falls woodland vibe, warm palette)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result: people don't &lt;em&gt;have&lt;/em&gt; to play. They &lt;em&gt;want&lt;/em&gt; to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technical Wins
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Storage optimization&lt;/strong&gt;: Species data is immutable; only instances are stored. Saves ~80% of what a naive approach would use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deterministic RNG&lt;/strong&gt;: Used Mulberry32 seeded with save ID + tick number. Same creatures always spawn in the same order for the same player. No "lucky" vs. "unlucky" streaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Animation perf&lt;/strong&gt;: 20 creatures animating at 60 FPS without dropping frames. Profiled with Chrome DevTools; each creature takes ~3ms to render and animate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code size&lt;/strong&gt;: No build step means no tree-shaking overhead. The whole extension is ~40KB of JavaScript, gzipped ~12KB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chrome Web Store Launch
&lt;/h2&gt;

&lt;p&gt;Submitted 1.0.0 to the Chrome Web Store with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Privacy policy (offline-only, no data collection)&lt;/li&gt;
&lt;li&gt;Screenshots (gameplay, bestiary, creature interactions)&lt;/li&gt;
&lt;li&gt;Icons at every size (128px down to 16px, all hand-drawn in a vector tool)&lt;/li&gt;
&lt;li&gt;Description: "Raise woodland creatures who meet, play and chat in a cozy side-panel camp. No ads, no accounts, nothing leaves your browser."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Approval&lt;/strong&gt;: 2 hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Font rendering&lt;/strong&gt;: SVG text rendering is noisy on some systems. Next version will use canvas text or prerendered glyphs.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Save format&lt;/strong&gt;: Should have versioned the save JSON from day one. Now I'm stuck with forward compatibility forever. (Don't do this.)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Code redemption UI&lt;/strong&gt;: The modal could feel more magical. A simple sprite effect when a code is redeemed would delight players.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Creature count&lt;/strong&gt;: 25 is perfect for v1. Next packs (via codes) should come out slowly, spaced weeks apart. Scarcity keeps discovery feeling special.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;25 creatures&lt;/strong&gt;: Each with unique personality, voice, lore, and body-language rig&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;50+ authored interaction lines&lt;/strong&gt;: Two creatures meet every 5–10 minutes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;8 voice registers&lt;/strong&gt;: Shy, cheerful, smug, curious, sleepy, gruff, dreamy, mysterious&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;6 body plans&lt;/strong&gt;: Chibi, winged, serpent, quadruped, colossus, wisp&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4 rarity tiers&lt;/strong&gt;: Common, uncommon, rare, legendary&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0 dark patterns&lt;/strong&gt;: Tested and verified&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reflection
&lt;/h2&gt;

&lt;p&gt;Building Creature Camp taught me that constraints breed creativity. By refusing to add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Timers&lt;/li&gt;
&lt;li&gt;Notifications&lt;/li&gt;
&lt;li&gt;Paywalls&lt;/li&gt;
&lt;li&gt;Random loot&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I &lt;em&gt;had&lt;/em&gt; to make the core experience compelling. The creatures needed authored personality, not gacha randomness. Interactions needed to be surprising, not punishing. The aesthetic needed to be beautiful and inviting.&lt;/p&gt;

&lt;p&gt;The result is a game where playing feels like visiting friends, not completing tasks.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Creature Camp is free on the Chrome Web Store.&lt;/strong&gt; Install it, get a starter creature, and see what happens. The code is authored, the creatures are deterministic, and your save lives on your device forever.&lt;/p&gt;

&lt;p&gt;If you're building a pet game or any game with progression, ask yourself: &lt;em&gt;What would this look like without dark patterns?&lt;/em&gt; The answer might surprise you.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tech stack recap:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chrome MV3 extension&lt;/li&gt;
&lt;li&gt;No bundler, no build step&lt;/li&gt;
&lt;li&gt;Canvas rendering from code (no assets)&lt;/li&gt;
&lt;li&gt;Deterministic economy simulation&lt;/li&gt;
&lt;li&gt;WCAG AA accessibility&lt;/li&gt;
&lt;li&gt;Zero external dependencies&lt;/li&gt;
&lt;li&gt;Offline-first storage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ship soulful software.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I Finally Got a Mac. Here's What It Taught Me About Only Building for One OS.</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Wed, 16 Sep 2026 01:58:20 +0000</pubDate>
      <link>https://dev.to/dhseadev/i-finally-got-a-mac-heres-what-it-taught-me-about-only-building-for-one-os-40hk</link>
      <guid>https://dev.to/dhseadev/i-finally-got-a-mac-heres-what-it-taught-me-about-only-building-for-one-os-40hk</guid>
      <description>&lt;p&gt;I picked up a Mac recently. Nothing dramatic — no grand migration story, no "Windows finally broke me" moment. I just ended up with one, and it made me realize something a little embarrassing: as someone who's shipped a genuinely large number of Chrome extensions, a few desktop tools, and a browser arcade under the DHSeaDev name, I had put almost zero thought into the Apple ecosystem. Not out of dislike. It just never came up. My tools worked, my users were on what they were on, and I kept building.&lt;/p&gt;

&lt;p&gt;So this is the post where I admit that, and talk about why "what operating system does this run on" deserves more of a developer's attention than it usually gets — even when, like me, you get lucky.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lucky part first
&lt;/h2&gt;

&lt;p&gt;Most of what I build lives in the browser. Chrome MV3 extensions are basically OS-agnostic by design — they run wherever Chrome runs, and Chrome runs everywhere that matters. So the honest first finding here is: nothing broke. I installed Chrome on the Mac, logged into my dev account, and every extension I've shipped just worked, sync and all. Same for the browser-based games and tools I've built for the DHSeaDev arcade — they're just web pages. A browser doesn't care what OS is under it, and that's a genuinely nice property to have accidentally built toward.&lt;/p&gt;

&lt;p&gt;That's not nothing. A lot of developers find out the hard way that "cross-platform" was actually "cross-platform as long as everyone uses Windows." I got that for free because I picked a browser-first architecture years before I had a reason to care about portability. Worth remembering next time you're picking a stack for something new: the platform-independence payoff might not show up for a long time, and then one day you buy a laptop and it's already paid for itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it's not free: native desktop tools
&lt;/h2&gt;

&lt;p&gt;The story changes the moment you leave the browser. I've got a handful of internal PySide6 desktop tools — things like an internal document utility suite and an analytics dashboard for my day job at a legal process-serving firm. PySide6 (Qt) is cross-platform in theory, and mostly in practice, but "runs on macOS" and "feels native on macOS" are two very different bars. Native window chrome, dark mode behavior, file dialogs, keyboard shortcuts that macOS users expect (Cmd instead of Ctrl being the least of it), and packaging are all places where a Windows-built Qt app can feel like a guest wearing someone else's coat on a Mac.&lt;/p&gt;

&lt;p&gt;And packaging is its own adventure. On Windows I'm used to a PyInstaller build and maybe an Inno Setup installer, and I'm done. macOS wants a signed, notarized .app or .dmg if you want it to run without the user fighting Gatekeeper — that means an Apple Developer Program membership (still $99/year as of this writing), codesigning, and submitting the build to Apple's notarization service before it'll launch cleanly on someone else's machine. It's not hard, but it's a real extra step that doesn't exist in the Windows world, and skipping it means every user has to right-click-and-open their way past a scary warning dialog. That's a bad first impression for a tool that's supposed to make someone's day easier.&lt;/p&gt;

&lt;p&gt;None of this is a complaint. It's the kind of gap you don't see until you're standing in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The upside: a whole platform I hadn't been building for
&lt;/h2&gt;

&lt;p&gt;Here's the part I'm actually excited about. Having a Mac in hand isn't just "now I have to test on it" — it's a green light to start building for it on purpose. macOS has its own conventions, its own distribution paths (direct download and notarization, or the Mac App Store if a tool fits those rules), and its own audience that's historically underserved by a lot of indie/internal tooling that defaults to Windows-only. A menu bar utility, a proper native companion app, something built with SwiftUI instead of ported in from Qt — that's a new lane, not just a compatibility checkbox.&lt;/p&gt;

&lt;p&gt;It also reframes how I think about my existing native tools. The PySide6 apps I've built were written Windows-first, which means "port to Mac" is really "go back and find every place I assumed Windows," and fix it properly instead of papering over it. That's worth doing even if I never ship a Mac build to anyone outside myself, because it's the kind of assumption-hunting that also quietly improves the Windows build.&lt;/p&gt;

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

&lt;p&gt;If you build software and you've only ever developed on and for one operating system, it's worth asking yourself why — and whether the answer is "a deliberate architectural choice" or "I just never had a reason to check." Those are very different positions to be in. Mine turned out to be the second one, and I got lucky that my main body of work (browser-based tools and extensions) didn't care. My native desktop tools weren't so lucky, and now I know exactly where the assumptions are hiding.&lt;/p&gt;

&lt;p&gt;Consider this your nudge, the same way mine came in a shipping box: if there's a whole ecosystem you've been building software for millions of people on without ever touching, go touch it. You'll either confirm your architecture was more portable than you gave it credit for, or you'll find the gaps while they're still cheap to fix.&lt;/p&gt;

&lt;p&gt;More on the tools mentioned here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The DHSeaDev Chrome extension lineup and browser arcade: &lt;a href="https://dhseadev.online/" rel="noopener noreferrer"&gt;https://dhseadev.online/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;My other dev writing, cross-posted here and on CoderLegion: &lt;a href="https://coderlegion.com/user/DHSeaDev" rel="noopener noreferrer"&gt;https://coderlegion.com/user/DHSeaDev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;— DHSeaDev (dhseadev.online)&lt;/p&gt;

&lt;p&gt;Sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Apple Developer Program membership and notarization requirements: &lt;a href="https://developer.apple.com/news/" rel="noopener noreferrer"&gt;https://developer.apple.com/news/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;What Apple notarization means for macOS app distribution: &lt;a href="https://www.dice.com/career-advice/apple-app-notarization-macos-apps" rel="noopener noreferrer"&gt;https://www.dice.com/career-advice/apple-app-notarization-macos-apps&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>ios</category>
      <category>html</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Joy in the Build: What the Positivity Wall Taught Me About Making Anything</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Tue, 15 Sep 2026 03:54:07 +0000</pubDate>
      <link>https://dev.to/dhseadev/joy-in-the-build-what-the-positivity-wall-taught-me-about-making-anything-3ffk</link>
      <guid>https://dev.to/dhseadev/joy-in-the-build-what-the-positivity-wall-taught-me-about-making-anything-3ffk</guid>
      <description>&lt;p&gt;I build a lot of things. Chrome extensions, games, internal tools for the day job, weird little browser experiments nobody asked for. Most of it is technical: state management, manifest permissions, load times, edge cases. But every so often a project reminds me that the point of building something isn't the stack you used — it's what it does for the person on the other end of the screen.&lt;/p&gt;

&lt;p&gt;That's what happened with the Positivity Wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea behind it
&lt;/h2&gt;

&lt;p&gt;The Positivity Wall (&lt;a href="https://dhseadev.online/positivity-wall/" rel="noopener noreferrer"&gt;https://dhseadev.online/positivity-wall/&lt;/a&gt;) is a small, quiet corner of my site. No accounts, no typing, no names attached to anything. You show up, and your browser generates a procedural "stamp" — a little visual mark that's yours and yours alone, tied to nine shared panels: Small Wins, Someone Who Helped, Creatures, Made Me Laugh, Outside, On Repeat, Kindness Received, Still Learning, Showed Up. Come back tomorrow and your stamp is still there, identical, still yours. Nothing is uploaded, nothing is recorded beyond a shared count of how many people have marked something good.&lt;/p&gt;

&lt;p&gt;It's a tiny piece of software. There's no monetization angle, no growth loop, no funnel. It exists because I wanted to build something that let people acknowledge a good moment without having to perform it for anyone.&lt;/p&gt;

&lt;p&gt;The project leans on a philosophy I didn't invent: Amanda Gore's Joy Project. Gore is a Hall of Fame keynote speaker (CPAE, CSP) who's spent about 35 years and reached over 2 million people across 20+ countries with a simple, research-backed argument — feelings drive results. Her Joy Intelligence framework isn't fluff; the numbers she cites are things like a 37% boost in sales, a 51% reduction in turnover, and a 31% increase in productivity when people actually feel connected and joyful at work, instead of just "fine." Her core diagnosis is that most workplaces (and honestly, most people) suffer from emotional disconnection despite being constantly around each other. Belonging is supposed to be simple. We've made it complicated.&lt;/p&gt;

&lt;p&gt;One line from her work anchors the Positivity Wall for me: "You are NOT your story, but your story rules your life." That's the whole reason the wall exists in nine small categories instead of one big blank text box. Give someone "Someone Who Helped" or "Made Me Laugh" as a prompt, and suddenly a good moment they'd have let evaporate becomes something they can point to. You're not asked to journal your trauma. You're asked to notice you laughed today, and that's allowed to be enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The candle, for the heavier days
&lt;/h2&gt;

&lt;p&gt;Not everything is a small win, though. Some days call for something quieter. That's what the candle page is for (&lt;a href="https://dhseadev.online/candle/?preview=true&amp;amp;frame-nonce=f833df8a41" rel="noopener noreferrer"&gt;https://dhseadev.online/candle/?preview=true&amp;amp;frame-nonce=f833df8a41&lt;/a&gt;). One button: "Light the Candle." No typing, no sign-in, nothing recorded. You take a moment for someone or something that matters, the candle burns for as long as you're on the page, and then it's gone when you close the tab — like the moment itself. The only shared data point is a live count of how many candles are currently lit, so you know, in some small anonymous way, you're not the only one carrying something right now.&lt;/p&gt;

&lt;p&gt;It pairs with Vigil, a Chrome extension I built that does something similar but persistent — candles that can be relit across days instead of disappearing the moment you look away. Same instinct, different shape: sometimes you need a moment, sometimes you need a place to keep coming back to.&lt;/p&gt;

&lt;p&gt;There's a Futurama quote I keep coming back to that fits both of these projects better than anything I could write myself: "When you do things right, people won't be sure you have done anything at all." A tool that helps someone quietly feel better for thirty seconds isn't going to trend. It's not supposed to. It's supposed to just work, then get out of the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for how you build anything
&lt;/h2&gt;

&lt;p&gt;Here's the part that applies whether you're writing a Chrome extension, standing up a FastAPI service, or just doing your day job in a spreadsheet: joy and care aren't separate from craftsmanship — they're a design constraint like any other.&lt;/p&gt;

&lt;p&gt;When Gore talks about emotional disconnection driving down performance, she's really talking about attention and trust. A team that feels safe and genuinely good communicates better and catches more problems before they ship. A user who feels respected — not tracked, not farmed for data, not nagged — trusts your product more and actually uses it. That's not a soft metric. It shows up in retention, in support load, in whether people recommend the thing at all.&lt;/p&gt;

&lt;p&gt;I think about it at the code level too. A "no-friction, no-account, nothing-recorded" architecture isn't just a privacy stance — it's an act of care. It says: I don't need anything from you to let you have this moment. That constraint shaped every decision on both of these pages, the same way a performance budget or an accessibility requirement shapes a build. Joy, kindness, and consideration for the person on the other end aren't the opposite of rigor. Done right, they are rigor.&lt;/p&gt;

&lt;p&gt;So whether you're serving legal documents, shipping a browser extension, or building a nine-panel wall of good moments nobody has to explain to anyone — leave room for the version of the work that makes someone feel a little more human when they're done with it. It costs you almost nothing as a builder. It can mean everything to the person using what you made.&lt;/p&gt;

&lt;p&gt;Try the Positivity Wall: &lt;a href="https://dhseadev.online/positivity-wall/" rel="noopener noreferrer"&gt;https://dhseadev.online/positivity-wall/&lt;/a&gt;&lt;br&gt;
Light a candle: &lt;a href="https://dhseadev.online/candle/" rel="noopener noreferrer"&gt;https://dhseadev.online/candle/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;— DHSeaDev (dhseadev.online)&lt;/p&gt;

&lt;p&gt;Sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Amanda Gore / The Joy Project: &lt;a href="https://amandagore.com/" rel="noopener noreferrer"&gt;https://amandagore.com/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Amanda Gore speaker bio: &lt;a href="https://premierespeakers.com/speakers/amanda-gore/posts/the-science-of-joyful-leadership-with-amanda-gore-insights-from-a-leading-motivational-speaker" rel="noopener noreferrer"&gt;https://premierespeakers.com/speakers/amanda-gore/posts/the-science-of-joyful-leadership-with-amanda-gore-insights-from-a-leading-motivational-speaker&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>joy</category>
      <category>webdev</category>
      <category>html</category>
      <category>ai</category>
    </item>
    <item>
      <title>CoderLegion - A good, yet different space</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Mon, 14 Sep 2026 13:45:25 +0000</pubDate>
      <link>https://dev.to/dhseadev/coderlegion-a-good-yet-different-space-3leg</link>
      <guid>https://dev.to/dhseadev/coderlegion-a-good-yet-different-space-3leg</guid>
      <description>&lt;p&gt;I got interviewed on CoderLegion about my work and spent some time on the platform. Honestly, it's pretty solid.&lt;/p&gt;

&lt;p&gt;It's smaller than DEV.to or LinkedIn, but that actually matters. You're getting real developers talking about real projects instead of engagement bait and recruiter spam.&lt;/p&gt;

&lt;p&gt;The gamification stuff they do actually works because it pushes you toward connecting with people doing interesting things, not grinding points.&lt;/p&gt;

&lt;p&gt;I came for exposure and got it. Found people worth talking to. I'm happy there, and that's rare.&lt;/p&gt;

&lt;p&gt;If you care about actual developer community, it's worth checking out.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://coderlegion.com/27044/from-html-legal-automation-dhardingsea-developers-journey-building-software-that-solves" rel="noopener noreferrer"&gt;https://coderlegion.com/27044/from-html-legal-automation-dhardingsea-developers-journey-building-software-that-solves&lt;/a&gt;&lt;/p&gt;

</description>
      <category>learning</category>
      <category>socialmedia</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Stress Test? Pen Test? Yes Tests!</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Sat, 12 Sep 2026 22:01:38 +0000</pubDate>
      <link>https://dev.to/dhseadev/stress-test-pen-test-yes-tests-3e7c</link>
      <guid>https://dev.to/dhseadev/stress-test-pen-test-yes-tests-3e7c</guid>
      <description>&lt;h1&gt;
  
  
  Why Your Game's Security Tests Need to Be Adversarial—Not Just Automated
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Published on DHSeaDev. A hard-won lesson from shipping a browser-based TCG.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When we stress-tested Prismwar, our original gate suite reported everything was fine. Then we ran a mutation campaign, and 31 out of 70 carefully crafted bugs survived without raising a flag. Here's what went wrong—and how we fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Test That Tested Itself
&lt;/h2&gt;

&lt;p&gt;The first blind spot was the most embarrassing: &lt;strong&gt;our test harness shared defects with the code it was supposed to verify.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We had a replay system that recorded matches as a seed, both decks, and the player's actions. The Rival's moves are derived at playback time by running the same AI code. When we tested replay fidelity, we recorded and played back ten matches—and all ten matched perfectly.&lt;/p&gt;

&lt;p&gt;Sounds good, right? Wrong.&lt;/p&gt;

&lt;p&gt;The issue: our harness omitted the AI's defence step in the replay player, and our scripted human player omitted it in the recorder. So the test &lt;em&gt;round-tripped&lt;/em&gt; through the same bug and agreed with itself. &lt;strong&gt;A round trip cannot test its own driver.&lt;/strong&gt; Neither can it test its engine. We rebuilt the test to drive the AI's defence window directly and assert the match phase advances—that found it immediately.&lt;/p&gt;

&lt;p&gt;The lesson applies everywhere: when testing a system that has two paths (encode/decode, server/client, record/replay), don't let both paths share the same code on the test side. Property assertions work. Round trips don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  44% of Bugs Stayed Silent
&lt;/h2&gt;

&lt;p&gt;The mutation campaign replaced 70 isolated lines of code with deliberate defects. 39 made the game unplayable. But 31 made the game fail silently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Dropped guards:&lt;/strong&gt; &lt;code&gt;validateDeck&lt;/code&gt; checks deck legality before a match starts. We deleted the lower-bound check. The gate passed. Invalid 4-card decks loaded fine and then threw an uncaught error in &lt;code&gt;checkWin&lt;/code&gt; as soon as someone attacked. The error boundary swallowed it into a "Something went wrong" message, and the assertion "no console errors" stayed green.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Discarded state:&lt;/strong&gt; An attacker declared blockers but the code forgot to store which ones. The match proceeded as if nothing was blocked. Replays still worked and reproduced correctly (for the wrong reason—the entire engine had the same bug).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Unsigned profiles:&lt;/strong&gt; The integrity check exempted unsigned saves to allow mid-session work before signing. An attacker simply wrote a JSON with no &lt;code&gt;sig&lt;/code&gt; field, granting 720 cards and a million wins. It imported as &lt;code&gt;integrity:'ok'&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these would have made it into production without sabotage-driven proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Worked
&lt;/h2&gt;

&lt;p&gt;We stopped relying on "no crash" and "match outcomes are deterministic" as evidence. Instead:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Entry guards at the choke point.&lt;/strong&gt; The game's state machine enters through &lt;code&gt;newGame()&lt;/code&gt;. We made that the single place that refuses invalid inputs: non-array decks, decks under 10 cards, missing players. Everything downstream assumes the precondition is true. One sabotage per boundary, and each one must actually throw (not silently default). We verified that the guard &lt;em&gt;itself&lt;/em&gt; catches that attack—not just that the game doesn't crash.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Property assertions instead of outcome comparison.&lt;/strong&gt; Instead of "does the game end in 50 turns or fewer," we assert that the stall detector measures progress—and if you remove that measurement, the test fails. The property is "this mechanism exists and is consulted," not "bad state doesn't happen."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pinned baselines for non-observable code paths.&lt;/strong&gt; The AI engine has zero randomness once seeded. We computed a SHA-256 fingerprint over the outcomes of a 512-game corpus. Any change to the rules, the AI decision weights, or the game logic moves that hash. We assert it doesn't move. If it does, we decide to bump it—or revert. But "no change" is measured, not assumed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instrument-proved test instrumentality.&lt;/strong&gt; Before we ran our 70 mutations, we proved the harness could &lt;em&gt;see&lt;/em&gt; a break by installing an attacker key and verifying that it unlocked a legendary card it shouldn't. When we replaced the stall detector with a board-count check, we proved the harness caught it by deliberately deleting the right code and watching the test fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Adversarial Mindset
&lt;/h2&gt;

&lt;p&gt;Security tests are not about passing. They're about finding specific ways the code could fail and then proving you catch every one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write a defect&lt;/strong&gt; (a mutation, a forgery, a code path omission).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the test to prove it catches the defect.&lt;/strong&gt; If it doesn't, the defect is a gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify that removing the &lt;em&gt;defence&lt;/em&gt; that catches it makes the test fail.&lt;/strong&gt; If removing the defence doesn't move the needle, you have a false positive.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep the defect as a proof that the test was actually running.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is inverse from "does my happy path work." You're asking: "What specifically am I &lt;em&gt;refusing&lt;/em&gt;?"&lt;/p&gt;

&lt;p&gt;For Prismwar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ECDSA: we proved the harness detects payload tampering, signature forgery, and algorithm confusion. Nine attacks. All caught.&lt;/li&gt;
&lt;li&gt;XSS: 350 real-UI trials on a stripped CSP, zero escapes (our HTML builder uses &lt;code&gt;textContent&lt;/code&gt;, not string concatenation).&lt;/li&gt;
&lt;li&gt;Determinism: identical results across three runs when entropy is disabled.&lt;/li&gt;
&lt;li&gt;Persistence: crossing two concurrent save cycles doesn't lose data (a known limitation, documented as unreachable in the shipped UI).&lt;/li&gt;
&lt;li&gt;State legality: illegal actions are refused with a clean Error, not a crash or silent skip.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What We Shipped
&lt;/h2&gt;

&lt;p&gt;Final count: &lt;strong&gt;122 assertions, 99 sabotages, 0 escapes, control green.&lt;/strong&gt; The gates ran against a real build packaged for the Chrome Web Store. Not a best-case scenario. The actual game.&lt;/p&gt;

&lt;p&gt;Three automated checks broke themselves in the process:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A brace-balance one-liner couldn't see regex literals or comments. Fixed with a real lexer.&lt;/li&gt;
&lt;li&gt;An AWK scan counted lines across multiple files and reported false positives. Replaced with scoped iteration.&lt;/li&gt;
&lt;li&gt;A "no console errors" assertion didn't discriminate real errors from false red runs. Now it routes the specific assertion to the relevant scope.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Ship It Adversarially
&lt;/h2&gt;

&lt;p&gt;When you're shipping something that handles user data—saves, settings, payment—or something competitive where cheating matters, your test suite needs to be adversarial:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Can you add security properties that would break if someone removed the defense?&lt;/strong&gt; If not, you don't have evidence the defense is real.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do your round-trip tests share machinery with the code they're testing?&lt;/strong&gt; If so, rebuild one path to go a different way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is your baseline pinned?&lt;/strong&gt; A green build on "determinism" without a fingerprint is a hope, not a fact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did you prove your test is actually running?&lt;/strong&gt; Before you trust a suite, break something on purpose and watch it fail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Prismwar ships with a ledger wall where users post their best cards. That wall is read-only. The game's state is signed, and the signature is verified on load. Every one of these decisions is backed by a sabotage that proves what fails if you remove it.&lt;/p&gt;

&lt;p&gt;That's not perfection. It's shipping with evidence.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Prismwar is live at &lt;a href="https://play.dhseadev.online" rel="noopener noreferrer"&gt;play.dhseadev.online&lt;/a&gt;./ The gate suite is in the DHSeaDev arcade build at &lt;code&gt;scripts/verify-prismwar.mjs&lt;/code&gt; and &lt;code&gt;sabotage-prismwar.mjs&lt;/code&gt; on GitHub.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>gamedev</category>
      <category>security</category>
      <category>testing</category>
    </item>
    <item>
      <title>Developer? Engineer? Vibin'? What are we now?</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Fri, 11 Sep 2026 18:44:44 +0000</pubDate>
      <link>https://dev.to/dhseadev/developer-engineer-vibin-what-are-we-now-cn6</link>
      <guid>https://dev.to/dhseadev/developer-engineer-vibin-what-are-we-now-cn6</guid>
      <description>&lt;h1&gt;
  
  
  The Vibe Coder's Advantage: Why AI Just Made "Professional Developer" Meaningless
&lt;/h1&gt;

&lt;p&gt;The term "software developer" used to mean something clear. You went to school, learned computer science, memorized algorithms, practiced for years, and eventually got hired. You had the credentials. You had the training. You were &lt;em&gt;legitimate&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I'm not a software developer. I'm a vibe coder.&lt;/p&gt;

&lt;p&gt;I didn't go to school for this. I learned by building things—usually because I needed them to exist and nobody was going to make them for me. I built Chrome extensions because I had ideas. I built internal tools at HSPS because my colleagues needed them. I built my own way into competency through intuition, experimentation, and a willingness to look stupid in front of my code.&lt;/p&gt;

&lt;p&gt;And honestly? That approach just became way more viable for everyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's a Vibe Coder?
&lt;/h2&gt;

&lt;p&gt;A vibe coder learns through construction, not curriculum. You feel your way through problems. You don't need to understand the platonic ideal of an algorithm—you need to know what works &lt;em&gt;right now&lt;/em&gt;. You ship first, optimize later. You fail forward. You're driven by curiosity and immediacy rather than foundational theory.&lt;/p&gt;

&lt;p&gt;Traditional software developers learn the opposite way. Theory first, application second. Algorithms, data structures, design patterns, architecture—you build a mental model of how systems &lt;em&gt;should&lt;/em&gt; work, and then you implement against that model. It's rigorous. It's principled. It's often slower.&lt;/p&gt;

&lt;p&gt;Neither is wrong. They're just different operating systems for the same skill.&lt;/p&gt;

&lt;p&gt;But here's what's changed: AI just collapsed the activation energy for becoming a vibe coder.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Democratization Moment
&lt;/h2&gt;

&lt;p&gt;Two years ago, if you wanted to build a Chrome extension and didn't know how, the friction was brutal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You'd learn JavaScript from scratch&lt;/li&gt;
&lt;li&gt;You'd learn the Chrome API surface&lt;/li&gt;
&lt;li&gt;You'd hit weird bugs and spend hours debugging documentation&lt;/li&gt;
&lt;li&gt;You'd rewrite things three times because you didn't understand the patterns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Today? You describe what you want to Claude or ChatGPT, iterate in real-time, and the system generates the code. You learn by &lt;em&gt;reading&lt;/em&gt; what the AI wrote, not by struggling through a tutorial. You're still vibe coding—you're still learning by building—but the friction of "not knowing how" is gone.&lt;/p&gt;

&lt;p&gt;This is huge. And I don't think the industry has internalized what this actually means.&lt;/p&gt;

&lt;p&gt;It means anyone with curiosity and an idea can now be a developer. Not "kind of" or "technically." Actually. You can ship real, functional tools without a CS degree. Without years of practice. Without memorizing JavaScript event loops.&lt;/p&gt;

&lt;p&gt;That's not hyperbole. That's where we are.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means for "Professional" Developers
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable question: if AI can generate code, and vibe coders can now ship production tools, what exactly is a "professional developer" providing?&lt;/p&gt;

&lt;p&gt;The answer is &lt;em&gt;judgment&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A professional developer (whether they're a vibe coder or classically trained) brings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Taste&lt;/strong&gt;: knowing what solutions are over-engineered vs. undercooked&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tradeoffs&lt;/strong&gt;: understanding the cost of every architectural decision&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sustainability&lt;/strong&gt;: building systems that don't collapse under maintenance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security&lt;/strong&gt;: catching the vulnerabilities AI missed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empathy&lt;/strong&gt;: writing code that other humans can understand&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these things require a degree. None of them require formal training. But they do require experience—which is just a fancy word for &lt;em&gt;accumulated failure&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The AI doesn't fail. It just generates. A human who's shipped a dozen projects and watched three of them break in production has something the AI doesn't: scars.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Future Isn't "No Developers"
&lt;/h2&gt;

&lt;p&gt;I've heard the fears: "AI will replace developers. In five years, nobody will need to code."&lt;/p&gt;

&lt;p&gt;I think this misunderstands how tools work.&lt;/p&gt;

&lt;p&gt;When the printing press arrived, people didn't stop writing. Writers became &lt;em&gt;more valuable&lt;/em&gt; because the distribution layer solved itself. The bottleneck moved upstream. Same with spreadsheets—Excel didn't kill financial analysis, it killed the human calculator and elevated the analyst.&lt;/p&gt;

&lt;p&gt;AI won't kill developers. It'll kill &lt;em&gt;tedious code&lt;/em&gt;. It'll eliminate the friction between "I have an idea" and "this tool exists."&lt;/p&gt;

&lt;p&gt;What it means for the future:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;More tools will exist.&lt;/strong&gt; Not because we have more developers, but because the activation energy disappeared. Anyone with a problem can become a developer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality will matter more.&lt;/strong&gt; When everyone can generate code, the projects that survive will be the ones built with actual judgment and care.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vibe coding becomes the default.&lt;/strong&gt; You'll be able to build without understanding formal computer science, which is democratizing and scary at the same time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Developer" becomes a meaningless title.&lt;/strong&gt; It'll matter less what you're called and more what you've shipped and whether it breaks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Uncomfortable Truth
&lt;/h2&gt;

&lt;p&gt;Here's what actually keeps me up: will the industry adapt to this? Or will it double down on credentials and gatekeeping?&lt;/p&gt;

&lt;p&gt;Right now, there's a lot of resistance. "Real developers know algorithms." "You can't ship production code without understanding systems design." "AI-generated code is garbage."&lt;/p&gt;

&lt;p&gt;Some of this is true. Some of it is just people protecting the value of the credential they paid for.&lt;/p&gt;

&lt;p&gt;The vibe coders are about to have a decade. People who can feel their way through problems, who learn by shipping, who don't need permission to build—those people are about to be unstoppable. Because the thing holding them back (the friction of not knowing how) just evaporated.&lt;/p&gt;

&lt;p&gt;The professionally trained developers will adapt or won't. The ones who lean into judgment, mentorship, and architecture thinking will become more valuable. The ones who relied on their credential and their ability to memorize syntax will find themselves competing with a free AI that doesn't forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Actually Am
&lt;/h2&gt;

&lt;p&gt;I don't have a CS degree. I learn by building. I've failed at more projects than I've shipped. I think in problems, not algorithms. I'm inefficient in ways that a classically trained developer would notice.&lt;/p&gt;

&lt;p&gt;But I can ship things. I know what feels right. I can look at code and know whether it'll break in six months. And I can do all of that now &lt;em&gt;faster&lt;/em&gt; because I have an AI thinking partner.&lt;/p&gt;

&lt;p&gt;That doesn't make me a "real developer" in the old sense. But increasingly, I think the old sense was always wrong.&lt;/p&gt;

&lt;p&gt;Maybe the future isn't about becoming a developer. Maybe it's about becoming someone who can &lt;em&gt;think clearly&lt;/em&gt; about problems, and then having the judgment to know when to use which tool—including the AI—to solve them.&lt;/p&gt;

&lt;p&gt;That was always what development was about anyway.&lt;/p&gt;




&lt;p&gt;Check us out at Dhseadev.online&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What do you think? Are you a vibe coder or a systems thinker? Have you seen this shift play out in your own work? Drop a comment—I'm curious how other people are adapting to this moment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>I set an 18-hour playtime target for my idle game. A simulation says it has 3.7</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Fri, 11 Sep 2026 14:10:04 +0000</pubDate>
      <link>https://dev.to/dhseadev/i-set-an-18-hour-playtime-target-for-my-idle-game-a-simulation-says-it-has-37-32a4</link>
      <guid>https://dev.to/dhseadev/i-set-an-18-hour-playtime-target-for-my-idle-game-a-simulation-says-it-has-37-32a4</guid>
      <description>&lt;p&gt;Ashline is an idle army-vs-army battler I'm building for my browser arcade (not public yet). You get five unit roles, enemy waves that hit back, offline progress, a champion with skill trees and a collection of officers you recruit.&lt;/p&gt;

&lt;p&gt;Before putting it in front of people, I wanted a number instead of a feeling. How long does a realistic player take to run out of new things to unlock? My target was 18 hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Simulating a player instead of guessing
&lt;/h2&gt;

&lt;p&gt;I already had a headless engine and a reference player for balance testing, so &lt;code&gt;playtime.js&lt;/code&gt; puts the two together. The simulated player plays 20 active minutes a day, closes the tab and comes back tomorrow. It records the moment each of the game's 20 achievements first unlocks.&lt;/p&gt;

&lt;p&gt;Offline time isn't a multiplier. It runs the actual combat loop at reduced efficiency, so being away advances waves. That's capped at 8 hours.&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;// the shape of it (simplified)&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unlocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;ACHIEVEMENTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;day&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_DAYS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;TICK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;referencePlayer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// buy, rally, recruit, prestige&lt;/span&gt;
    &lt;span class="nf"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TICK&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;activeTime&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;TICK&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;a&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;ACHIEVEMENTS&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;unlocked&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;unlocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;activeTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;settleOffline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DAY&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// real combat, reduced efficiency, capped&lt;/span&gt;
  &lt;span class="nx"&gt;day&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;The first version hung. The bug wasn't in the game. My reference player had unbounded &lt;code&gt;while&lt;/code&gt; loops for recruiting and spending points. They're capped at 200 iterations now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;9 of 20 achievements unlock in the first 40 minutes.&lt;/li&gt;
&lt;li&gt;All 12 officers are collected by 1.3 hours.&lt;/li&gt;
&lt;li&gt;The last unlock lands at &lt;strong&gt;3.7 active hours&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's about five times short of the target.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious fix is the wrong one
&lt;/h2&gt;

&lt;p&gt;The quickest way to hit 18 hours is to multiply every threshold. Same unlocks, just further apart.&lt;/p&gt;

&lt;p&gt;That adds grind, not playtime. The simulation measures when you run out of &lt;em&gt;new things&lt;/em&gt;, and stretching the thresholds doesn't create any. The gap is content.&lt;/p&gt;

&lt;h2&gt;
  
  
  So I added content, and the number got worse
&lt;/h2&gt;

&lt;p&gt;The biggest addition was &lt;strong&gt;Onslaught&lt;/strong&gt;, a separate mode for the champion:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;12 named waves, then Endless&lt;/li&gt;
&lt;li&gt;one life, and &lt;strong&gt;no full heal&lt;/strong&gt; between waves&lt;/li&gt;
&lt;li&gt;after every cleared wave, pick one of three boons drawn from a pool of 24&lt;/li&gt;
&lt;li&gt;boons last only for that run&lt;/li&gt;
&lt;li&gt;marks (the champion's progression currency) are banked when you die&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I tuned it so a fresh champion dies around wave 2 in about 30 seconds, and an invested one reaches 20+.&lt;/p&gt;

&lt;p&gt;Then I re-ran the playtime simulation: &lt;strong&gt;3.1 hours&lt;/strong&gt;, down from 3.7.&lt;/p&gt;

&lt;p&gt;Onslaught banks marks, and marks buy the champion progression that unlocks achievements. So the new mode wasn't a new ladder. It was an accelerator bolted onto the old one. The achievements are still tied to the army's wave counter. A new mode needs goals of its own, like run depth and build variety, or it only shortens the path to the same finish line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "no full heal"
&lt;/h2&gt;

&lt;p&gt;In the main mode, when your army hits 0 HP it routs: it falls back two waves, heals fully and keeps going. An idle game shouldn't dead-end while you're away.&lt;/p&gt;

&lt;p&gt;The balance suite exposed a side effect. I had written a test claiming "the champion alone cannot progress." That claim is false by construction. With a full heal after every rout, anything with regeneration can grind forever. I replaced it with a comparative test (the army must out-progress a lone champion by a margin) and built Onslaught so that death actually ends something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Splitting the modes quietly broke two tests
&lt;/h2&gt;

&lt;p&gt;Taking the champion out of army combat left two balance bands meaningless, and one of them still looked great:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"No dominant skill tree"&lt;/strong&gt; reported 90 / 90 / 90 / 90. Perfect balance, apparently. In fact, the four trees no longer touched army combat, so every build was identical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The spell band&lt;/strong&gt; read 0 / 0, because spells no longer buffed the army.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I retired both rather than leave them green. Eight Onslaught-specific bands replaced them: the stakes are real, investment helps monotonically, the ladder is beatable, no boon dominates, no boon is dead, death is final, marks scale with depth, and a draft follows every clear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two more things measuring caught
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Correlated randomness.&lt;/strong&gt; Recruiting rolls a rarity, then an officer within that rarity. Both rolls used the same index into the seeded generator, so they were correlated: 7 out of 7 rares came out as the same officer. Deriving a separate index fixed it, and I checked the fix over 10,000 pulls. Pulls stay deterministic on (seed, pull number), so reloading a save can't reroll a result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An optimization I didn't make.&lt;/strong&gt; Officer effects get recomputed several times per tick, and I had flagged it for memoization. Profiling put it at 5.69µs per tick, 0.034% of a 16.7ms frame. Shipping that "fix" would have been claiming a win that didn't exist, so I cut it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Achievements tied to Onslaught depth and build variety, so the new mode extends the ladder instead of shortening it.&lt;/li&gt;
&lt;li&gt;Real attack classes. The five army roles currently fight identically. Only their HP, armor, regen and splash differ, while the names and the 2.5D ranks suggest melee, ranged and magic. That's the biggest gap between how the game looks and how it plays, and it's an engine change that invalidates every balance band, so it gets its own tuning pass.&lt;/li&gt;
&lt;li&gt;Then re-run &lt;code&gt;playtime.js&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The number I didn't want was the most useful thing the simulation produced. 3.7 hours doesn't say the game is bad. It says where the game ends, and that adding a mode isn't the same as adding somewhere to go.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>design</category>
      <category>simulation</category>
    </item>
    <item>
      <title>Three of my test gates had never heard of the game I was shipping</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Fri, 11 Sep 2026 03:47:52 +0000</pubDate>
      <link>https://dev.to/dhseadev/three-of-my-test-gates-had-never-heard-of-the-game-i-was-shipping-317k</link>
      <guid>https://dev.to/dhseadev/three-of-my-test-gates-had-never-heard-of-the-game-i-was-shipping-317k</guid>
      <description>&lt;p&gt;My browser arcade at play.dhseadev.online deploys when I push to &lt;code&gt;main&lt;/code&gt;. Nothing ships unless &lt;code&gt;verify-all.sh&lt;/code&gt; prints ALL GATES PASS. This week I was adding a ninth entry, a card game called Prismwar (not live yet). I built it, the index listed it, &lt;code&gt;dist/&lt;/code&gt; contained it.&lt;/p&gt;

&lt;p&gt;Three of the gates in that suite had no idea it existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The list that didn't grow
&lt;/h2&gt;

&lt;p&gt;The arcade is driven by one table, &lt;code&gt;ENTRIES&lt;/code&gt;. The build reads it, and so do the index, the sitemap and &lt;code&gt;llms.txt&lt;/code&gt;. Three test scripts didn't: the boot/paint/save gate, the stress battery and the SEO-heading gate. Each one had its own hardcoded list of pages, written back when the arcade was smaller.&lt;/p&gt;

&lt;p&gt;So the new row got built, emitted and linked, and none of those three scripts ever loaded it. They reported on everything they knew about, and all of it passed. Nothing in the output was false. It just described a smaller arcade than the one I was shipping.&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;// what the gates had (simplified)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PAGES&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;prism-cascade&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;lumenreel&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;veilfall&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="cm"&gt;/* ...frozen in time */&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// what they should have&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ENTRIES&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../build.mjs&lt;/span&gt;&lt;span class="dl"&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;PAGES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ENTRIES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For today I added the missing rows by hand. After that the full run included Prismwar and passed. The real fix is deriving every gate's list from &lt;code&gt;ENTRIES&lt;/code&gt;, so a new entry can't be built without also being tested.&lt;/p&gt;

&lt;p&gt;This wasn't a one-off. The same week, an idle game I'm building for the arcade gave me five more green results that weren't telling the truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  A sabotage that everything catches proves nothing
&lt;/h2&gt;

&lt;p&gt;Ashline, an idle auto-battler, has an "instrument proof": break the engine in specific ways on purpose, then confirm the balance suite goes red for each one.&lt;/p&gt;

&lt;p&gt;The first run caught four sabotages out of four, and every one reported the same thing: the army stalled at wave 1.&lt;/p&gt;

&lt;p&gt;That's what gave it away. Four different breakages shouldn't produce one identical symptom. The &lt;em&gt;unbroken&lt;/em&gt; game was also stuck at wave 1, because of a cold-start bug where the starting army did zero damage. The suite wasn't detecting my sabotage. It was detecting a softlock that was already there, four times over.&lt;/p&gt;

&lt;p&gt;The fix was a control. Run the unbroken build first and require it to pass. A sabotage only counts as caught if the baseline was green and the broken build fails a check that fits the breakage. After that, each sabotage tripped its own band.&lt;/p&gt;

&lt;h2&gt;
  
  
  Buttons that exist vs. buttons you can press
&lt;/h2&gt;

&lt;p&gt;Ashline's DOM smoke test was 13 for 13: boot, five buy buttons, accessible names, HUD updates, save writes, offline settle, graceful storage failure.&lt;/p&gt;

&lt;p&gt;I opened the game and saw a line across the screen with nothing to click.&lt;/p&gt;

&lt;p&gt;On load, the game called &lt;code&gt;buyLoop()&lt;/code&gt;. That's the automated player the &lt;em&gt;simulation&lt;/em&gt; uses in place of a human. It spent the opening currency before first paint, so every buy button rendered disabled. The test checked that the buttons existed and had names. It never checked whether anyone could press one.&lt;/p&gt;

&lt;p&gt;I added four assertions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an action is available at load&lt;/li&gt;
&lt;li&gt;a purchase is affordable at load&lt;/li&gt;
&lt;li&gt;the starting grant hasn't been spent&lt;/li&gt;
&lt;li&gt;clicking alone clears wave 1 within 12 seconds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I put &lt;code&gt;buyLoop()&lt;/code&gt; back into &lt;code&gt;load()&lt;/code&gt; on purpose. The suite dropped to 15/17, failing on exactly the two grant checks. That's when I started trusting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three asserts that couldn't fail
&lt;/h2&gt;

&lt;p&gt;The quieter version of the problem is an assertion that can't go red at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A gravity check for particles that returned &lt;code&gt;true&lt;/code&gt; no matter what.&lt;/strong&gt; It never read velocity before and after a step. Now it does, and it measures +90 vy per 0.1s at G=900.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;check('champion drawn larger', true)&lt;/code&gt;.&lt;/strong&gt; A literal. I wrote it, and it would pass on a blank canvas forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reduced-motion check that passed for the wrong reason.&lt;/strong&gt; The attack has a 250ms cooldown, so the second call was refused, and "motion on" and "motion off" looked identical. Once I fixed that, the test failed on the &lt;em&gt;good&lt;/em&gt; build. The harness pins &lt;code&gt;Date.now()&lt;/code&gt; to 0, so the cooldown never cleared. That's two harness bugs and zero game bugs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The literal became a spy that records what the renderer actually received:&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;// passes forever&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;champion drawn larger&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// passes only if the renderer actually did it (simplified)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heights&lt;/span&gt; &lt;span class="o"&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;draw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Champion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;Champion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;draw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;heights&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;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nf"&gt;renderLine&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;renderArena&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;champion drawn larger in arena&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;heights&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;heights&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;p&gt;The real numbers are 52px on the battle line and 86px in the arena.&lt;/p&gt;

&lt;h2&gt;
  
  
  The copy that was a version behind
&lt;/h2&gt;

&lt;p&gt;Ashline also has a delivery-path check. It runs the balance bands against the engine &lt;em&gt;extracted from the built HTML&lt;/em&gt;, not the source module, because the HTML is what players get.&lt;/p&gt;

&lt;p&gt;Good idea, bad execution. My extracted copy of the sim was stale. It predated the army-combat rewrite, so it reported 7 bands under an old label when the real suite had 10. Now both derived files are regenerated from the build on every run, and a copy is never reused.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check before I believe a green run
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does the gate's inventory match the product's?&lt;/strong&gt; Compare what it tested with what shipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Was the control green, and did each break fail differently?&lt;/strong&gt; If every break fails the same way, you're looking at one bug that was already there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the assertion test use, or just existence?&lt;/strong&gt; "The button exists" and "the button can be pressed" are different tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can I make it fail?&lt;/strong&gt; Break the thing on purpose. If the test stays green, the test is the bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the artifact current?&lt;/strong&gt; Regenerate anything derived. A stale copy tests yesterday's build.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then open the thing. A gate caught every problem above except the unplayable build. I found that one by clicking on it.&lt;/p&gt;

&lt;p&gt;A passing gate is making a claim. The only way I've found to check that claim is to watch the gate fail first.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>javascript</category>
      <category>gamedev</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
