<?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>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>
    <item>
      <title>I added scroll animations to three live pages — and silently corrupted two of them</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Fri, 04 Sep 2026 20:37:46 +0000</pubDate>
      <link>https://dev.to/dhseadev/i-added-scroll-animations-to-three-live-pages-and-silently-corrupted-two-of-them-pfa</link>
      <guid>https://dev.to/dhseadev/i-added-scroll-animations-to-three-live-pages-and-silently-corrupted-two-of-them-pfa</guid>
      <description>&lt;h2&gt;
  
  
  What I was adding
&lt;/h2&gt;

&lt;p&gt;Three DHSeaDev project pages — Dota Companion, Annoying Dino, CaptureTools — were due for scroll animation. Nothing exotic: GSAP 3.12.5 plus ScrollTrigger, cards fading up as they enter the viewport, a screenshot strip that parallaxes on scroll. GSAP went fully free for every plugin at some point this year, which is the only reason this was worth doing at all.&lt;/p&gt;

&lt;p&gt;The site has no build step and no staging environment. Every page is a single WordPress Custom HTML block, and every edit is a raw REST write against the live URL. That constraint is what made the next two hours interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pin that would not hold still
&lt;/h2&gt;

&lt;p&gt;Before touching a real page, I ran a spike on a private draft: pin a card in place while the user scrolls past it, the standard ScrollTrigger &lt;code&gt;pin: true&lt;/code&gt; pattern. It failed in a specific way — the pinned element didn't stay pinned. It &lt;em&gt;drifted&lt;/em&gt;, about 1,260px over the course of the scroll, like the browser was measuring its position against the wrong coordinate space.&lt;/p&gt;

&lt;p&gt;It was. The site's page wrapper does a full-bleed break-out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.dhsea-page&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;relative&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100vw&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-50%&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;Any element with a &lt;code&gt;transform&lt;/code&gt; on it becomes a new &lt;em&gt;containing block&lt;/em&gt; for its fixed/absolutely-positioned descendants. ScrollTrigger's pin mechanism auto-detects which positioning strategy to use, and its detection doesn't account for an ancestor transform three levels up. It picked the wrong one and pinned against the wrong reference frame.&lt;/p&gt;

&lt;p&gt;I didn't trust that read until I had a control. Same test, same page structure, no transform wrapper — the pin held at the exact same offset across the whole scroll range. Then the fix, on the transformed version:&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="nx"&gt;ScrollTrigger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#target&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pin&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="na"&gt;pinType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transform&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// stop guessing&lt;/span&gt;
  &lt;span class="na"&gt;pinReparent&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rock solid, matching the control exactly. &lt;strong&gt;On this site, any pinned ScrollTrigger needs &lt;code&gt;pinType&lt;/code&gt; set explicitly — auto-detect is not safe inside a transformed containing block, and it fails silently enough that you won't notice until you're staring at drift.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug my own length check couldn't see
&lt;/h2&gt;

&lt;p&gt;The actual write recipe for a single-block page looks like this: fetch the block's raw content, splice new markup in at a verified anchor, check that the output is exactly &lt;code&gt;original length + inserted length&lt;/code&gt; longer, then POST it back. That last check is supposed to be the safety net — if the delta doesn't match exactly, something touched more than intended.&lt;/p&gt;

&lt;p&gt;It passed. Every time. And it was still wrong.&lt;/p&gt;

&lt;p&gt;Here's the part I missed: the page's &lt;code&gt;content.raw&lt;/code&gt; already comes back from WordPress &lt;em&gt;with&lt;/em&gt; its own &lt;code&gt;&amp;lt;!-- wp:html --&amp;gt;...&amp;lt;!-- /wp:html --&amp;gt;&lt;/code&gt; wrapper — sometimes three separate ones, stitched together. My splice was correct. But then, out of habit, I wrapped the &lt;em&gt;result&lt;/em&gt; in a fresh pair of those comments before sending it, because that's what you do when you're building a block from scratch. I wasn't building one from scratch. I was editing one that already had its wrapper attached.&lt;/p&gt;

&lt;p&gt;The length math still balanced, because I was adding a fixed, known amount of text and checking for exactly that much growth — the check never asked &lt;em&gt;where&lt;/em&gt; the growth was structurally valid, only that the total was right. The result: two pages ended up with a duplicated block boundary, four open comments and four closes where there should have been three of each.&lt;/p&gt;

&lt;p&gt;Nothing rendered wrong. WordPress strips every &lt;code&gt;&amp;lt;!-- wp:* --&amp;gt;&lt;/code&gt; comment at render time, so the live HTML was byte-identical either way. That's exactly why it survived a "did the page look right" check. It only surfaced when I diffed an independent re-fetch of the stored content against the literal string I'd sent — same length, same bytes, and &lt;em&gt;still&lt;/em&gt; wrong, because "wrong" here was a structural property the byte-length gate was never built to see.&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;// wrong: re-wraps content that's already wrapped&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;!-- wp:html --&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;!-- /wp:html --&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// right: content.raw already carries its own wrapper(s) — send the splice directly&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix was mechanical once I saw it: strip the one extra layer, re-POST, re-verify block count. &lt;strong&gt;The lesson is the gate, not the bug&lt;/strong&gt; — a length check only proves the size is right. It takes a structural check (here, counting block-comment pairs) to prove the &lt;em&gt;shape&lt;/em&gt; is right, and I'd been treating "the math balances" as proof of both.&lt;/p&gt;

&lt;h2&gt;
  
  
  A "free" plugin that was not
&lt;/h2&gt;

&lt;p&gt;The last page, CaptureTools, called for a small SVG path-draw animation on its icon — GSAP's DrawSVGPlugin, part of the "everything's free now" bundle. Except it isn't served from the same place as the rest of GSAP. The plain &lt;code&gt;gsap&lt;/code&gt; package 404s on it, on both CDNs I tried. It only resolves from a package literally named &lt;code&gt;gsap-trial&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I opened the file before shipping it. No hard expiry that I could find, but the internals still reference "trial" and "club" — leftovers from before the license changed, maybe, or maybe not. Either way, I wasn't going to ship a script named &lt;code&gt;trial&lt;/code&gt; to a page real visitors load, on the strength of "probably fine." Swapped it for a plain scale-and-fade reveal using the ScrollTrigger already on the page. Same restraint, no unverified dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd verify differently next time
&lt;/h2&gt;

&lt;p&gt;Three separate failures, three different shapes: a physics assumption that broke against a CSS property three ancestors away, a correctness check that measured the wrong dimension of "correct," and a dependency that was free in spirit but not in its actual distribution. None of them showed up by looking at the rendered page. All three showed up by re-fetching the thing I'd actually stored or actually shipped, and checking it against something more specific than "is it the right size" or "does it look fine."&lt;/p&gt;

&lt;p&gt;The length gate isn't wrong. It's just not sufficient on its own — and on a site with no staging environment, "sufficient" is the only bar that matters, because there's no second chance to catch it before a visitor does.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Found while shipping dhseadev.online's GSAP rollout, September 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>wordpress</category>
      <category>css</category>
    </item>
    <item>
      <title>My first Firefox add-on was a manifest change</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Sun, 30 Aug 2026 03:02:53 +0000</pubDate>
      <link>https://dev.to/dhseadev/my-first-firefox-add-on-was-a-manifest-change-1k00</link>
      <guid>https://dev.to/dhseadev/my-first-firefox-add-on-was-a-manifest-change-1k00</guid>
      <description>&lt;p&gt;KH4 Companion is a small extension I built: it counts down to Kingdom Hearts IV, puts the days remaining on the toolbar badge, pulls series news and trailers from public feeds, carries a lore compendium, and hides a three-lane rhythm minigame in the popup. It has been on the Chrome Web Store since 19 August. As of this week it is also on &lt;a href="https://addons.mozilla.org/firefox/addon/kh4-companion-fan-countdown/" rel="noopener noreferrer"&gt;addons.mozilla.org&lt;/a&gt;, which makes it my first Mozilla listing.&lt;/p&gt;

&lt;p&gt;I had been putting the port off, because "port" sounds like work. It was not. Same build, same version number, same feature set — what changed was four keys in &lt;code&gt;manifest.json&lt;/code&gt;. This is the writeup I wanted to find before I started.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing nobody tells you first
&lt;/h2&gt;

&lt;p&gt;The blocker is not your code. It is that AMO rejects the package before it ever shows you a listing form. So the order of operations is: fix the manifest, get the linter to zero errors, &lt;em&gt;then&lt;/em&gt; worry about icons and screenshots and copy. Assets built against a package that cannot upload are wasted.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;npx addons-linter@latest &amp;lt;extension-dir&amp;gt;&lt;/code&gt; is the gate. Run it before you touch anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Firefox needs an explicit add-on ID
&lt;/h2&gt;

&lt;p&gt;Chrome derives an extension ID for you. Firefox does not — in MV3 you must state it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"browser_specific_settings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gecko"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kh4-companion@dhseadev.online"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The email-ish form or a &lt;code&gt;{8-4-4-4-12}&lt;/code&gt; GUID both work. Pick carefully: this ID &lt;em&gt;is&lt;/em&gt; your update identity forever. Changing it later means a new listing, not an update.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. There are no extension service workers in Firefox
&lt;/h2&gt;

&lt;p&gt;This is the real difference, and it is smaller than it sounds. Firefox runs an event page where Chrome runs a service worker. &lt;code&gt;background.service_worker&lt;/code&gt; is simply ignored, with a &lt;code&gt;BACKGROUND_SERVICE_WORKER_IGNORED&lt;/code&gt; warning. The cross-browser answer is the dual key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"background"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&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="s2"&gt;"core/lib.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"background.js"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"service_worker"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"background.js"&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;Chrome reads &lt;code&gt;service_worker&lt;/code&gt;. Firefox reads &lt;code&gt;scripts&lt;/code&gt;. One file, both browsers.&lt;/p&gt;

&lt;p&gt;Two traps live in here, and both pass a manifest review and fail at runtime:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;importScripts&lt;/code&gt; does not exist in an event page.&lt;/strong&gt; If your service worker starts with &lt;code&gt;importScripts('core/lib.js')&lt;/code&gt;, that is a &lt;code&gt;ReferenceError&lt;/code&gt; on Firefox. The fix is to list the dependency in &lt;code&gt;background.scripts&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; the entry file and guard the call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;importScripts&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;KH_CORE&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&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="nf"&gt;importScripts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;core/lib.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second half of that condition is not paranoia — it stops a double-load if some future Firefox does provide &lt;code&gt;importScripts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Script order is load order.&lt;/strong&gt; &lt;code&gt;background.scripts&lt;/code&gt; executes top to bottom in one shared global. A dependency listed after its consumer is &lt;code&gt;undefined&lt;/code&gt; at module top level. Chrome's import graph forgave you; an array does not.&lt;/p&gt;

&lt;p&gt;While you are in there: no &lt;code&gt;window&lt;/code&gt;, no &lt;code&gt;document&lt;/code&gt;, no &lt;code&gt;self.clients&lt;/code&gt; in the background. Write to the intersection of both runtimes.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Declare what you collect
&lt;/h2&gt;

&lt;p&gt;Since November 2025, every &lt;em&gt;new&lt;/em&gt; AMO extension has to declare its data collection in the manifest. It is machine-readable and Firefox shows it in the install prompt.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"browser_specific_settings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gecko"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"data_collection_permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"required"&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="s2"&gt;"none"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;["none"]&lt;/code&gt; is the whole declaration when nothing leaves the device — which for this extension is the truth: no analytics, no account, no telemetry, everything in local extension storage. If data does leave, you list types (&lt;code&gt;browsingActivity&lt;/code&gt;, &lt;code&gt;personallyIdentifyingInfo&lt;/code&gt;, and friends) and they had better match your privacy policy and your description word for word. A reviewer reading three different stories about your data handling is not a style problem, it is a rejection.&lt;/p&gt;

&lt;p&gt;This was the key I most enjoyed filling in. Answering an honest &lt;code&gt;none&lt;/code&gt; took about four seconds and is the payoff for having built the thing without a tracker in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;strict_min_version&lt;/code&gt; is arithmetic, not a guess
&lt;/h2&gt;

&lt;p&gt;Three floors stack up, and you take the highest one that applies to you:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Constraint&lt;/th&gt;
&lt;th&gt;Floor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Certificate expiry (updates break below)&lt;/td&gt;
&lt;td&gt;115 ESR / 128&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dual-key background actually starts&lt;/td&gt;
&lt;td&gt;121&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;data_collection_permissions&lt;/code&gt; supported&lt;/td&gt;
&lt;td&gt;140 desktop / 142 Android&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Below 121, Firefox refused to start the background page at all when &lt;code&gt;service_worker&lt;/code&gt; was present. And declaring the data-collection key under a lower minimum earns you &lt;code&gt;KEY_FIREFOX_UNSUPPORTED_BY_MIN_VERSION&lt;/code&gt; warnings. If you are shipping a new MV3 add-on with the data declaration, 140 is the value that lints clean with no version warnings. Go lower only if pre-140 reach is worth carrying the noise.&lt;/p&gt;

&lt;p&gt;Also worth knowing: omit &lt;code&gt;gecko_android&lt;/code&gt; and you are desktop-only, which may be exactly what you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lint clean is not loaded
&lt;/h2&gt;

&lt;p&gt;The linter is static. Load the thing for real: &lt;code&gt;about:debugging&lt;/code&gt; → &lt;em&gt;This Firefox&lt;/em&gt; → &lt;em&gt;Load Temporary Add-on&lt;/em&gt; → pick &lt;code&gt;manifest.json&lt;/code&gt;. Open the popup, exercise the background path, and watch the Browser Console for a &lt;code&gt;ReferenceError&lt;/code&gt;. A green lint plus an add-on that never boots is the precise failure the dual-key trap produces.&lt;/p&gt;

&lt;p&gt;A few runtime differences that survive a clean lint, in case they bite you where they did not bite me: host permissions are user-grantable and revocable rather than install-time; Firefox maintains a quarantined-domains list where an extension is inert until the user opts it in; &lt;code&gt;web_accessible_resources&lt;/code&gt; supports &lt;code&gt;resources&lt;/code&gt;/&lt;code&gt;matches&lt;/code&gt;/&lt;code&gt;extension_ids&lt;/code&gt; but not &lt;code&gt;use_dynamic_url&lt;/code&gt;; and &lt;code&gt;chrome.*&lt;/code&gt; works in Firefox as an alias, so you do not need the polyfill unless you want promises on old Chrome.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually made it cheap
&lt;/h2&gt;

&lt;p&gt;The port took the time it did because of decisions made months earlier, none of which were about Firefox.&lt;/p&gt;

&lt;p&gt;The extension has no bundler and no framework. That means the code AMO receives is the code I wrote, which means no source-code submission — the requirement that kicks in when minification or bundling makes the shipped file hard to read, and that ends with a reviewer rebuilding your project and diffing the result. Staying unbundled skipped that gate entirely.&lt;/p&gt;

&lt;p&gt;The logic modules are pure: no DOM, no clock of their own, time passed in as an argument. They run identically in Node tests and in the extension, which also means they run identically in a service worker and an event page. There was nothing browser-specific hiding in the parts that matter, because the parts that matter never touched a browser API.&lt;/p&gt;

&lt;p&gt;The background is a relay. It holds no durable state, so the service-worker-versus-event-page distinction — which is mostly a distinction about lifetime and what survives a restart — had nothing to disagree with.&lt;/p&gt;

&lt;p&gt;I did not do any of that so I could ship on Firefox. I did it because it made the thing testable. The second store was the dividend.&lt;/p&gt;




&lt;p&gt;KH4 Companion is an unofficial fan project. It is not affiliated with, endorsed by, or connected to Square Enix or Disney, and it ships no game assets, no logos and no copyrighted text — all art and all compendium prose are original.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://addons.mozilla.org/firefox/addon/kh4-companion-fan-countdown/" rel="noopener noreferrer"&gt;Firefox&lt;/a&gt; · &lt;a href="https://chromewebstore.google.com/detail/kh4-companion-%E2%80%94-fan-count/lklmmecaddkggjfcflolcipleaoakjcb" rel="noopener noreferrer"&gt;Chrome&lt;/a&gt; · &lt;a href="https://dhseadev.online/projects/kh4-companion/" rel="noopener noreferrer"&gt;Project page and privacy policy&lt;/a&gt;&lt;/p&gt;

</description>
      <category>development</category>
      <category>showdev</category>
      <category>software</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Five Tools, Zero Interference - CaptureTools Isolation Architecture</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Fri, 28 Aug 2026 03:00:04 +0000</pubDate>
      <link>https://dev.to/dhseadev/five-tools-zero-interference-capturetools-isolation-architecture-2aca</link>
      <guid>https://dev.to/dhseadev/five-tools-zero-interference-capturetools-isolation-architecture-2aca</guid>
      <description>&lt;p&gt;Building five separate tools inside a single Chrome extension sounds straightforward until you realize it's a recipe for disaster. Without deliberate isolation, one tool's storage leaks into another's, message handlers step on each other, and a single runtime collision brings everything down.&lt;/p&gt;

&lt;p&gt;This post walks through four structural mechanisms that keep &lt;a href="https://dhseadev.online/projects/capturetools/" rel="noopener noreferrer"&gt;CaptureTools&lt;/a&gt; running smoothly—no tool interference, no data leaks, no architectural finger-pointing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;A single Chrome extension is one runtime, one message bus, one storage layer.&lt;/p&gt;

&lt;p&gt;When five tools run in that space, they're sharing everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;chrome.storage.sync&lt;/code&gt; (the same key namespace)&lt;/li&gt;
&lt;li&gt;Message event listeners (all broadcasting to the same channel)&lt;/li&gt;
&lt;li&gt;Content script registry (one global scope)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without boundaries, tool A's data ends up in tool B's key namespace. Tool C's message handler fires on tool D's request. Unregistering one tool leaves the others hung. It's not &lt;em&gt;if&lt;/em&gt; something breaks—it's &lt;em&gt;which&lt;/em&gt; failure mode hits first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: Four Isolation Layers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Storage Isolation via Prefix-Locked Adapters
&lt;/h3&gt;

&lt;p&gt;Every tool gets a dedicated storage key prefix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ct:sealshot:state&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ct:backtrack:history&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ct:lantern:settings&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A storage adapter wraps calls to &lt;code&gt;chrome.storage.sync&lt;/code&gt; and enforces the prefix on every read and write. If Sealshot somehow tries to touch &lt;code&gt;ct:backtrack:state&lt;/code&gt;, the adapter rejects it.&lt;/p&gt;

&lt;p&gt;This isn't about trust. It's about making mistakes structurally impossible at the API boundary.&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;// Example adapter interface&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;storageAdapter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keys&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="c1"&gt;// Prefixes all keys with tool ID before calling chrome.storage.sync&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prefixed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;keys&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;k&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`ct:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;toolId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prefixed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&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="c1"&gt;// Enforces prefix; rejects any attempt to cross the boundary&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prefixed&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;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&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;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&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="s2"&gt;`ct:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;toolId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sync&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;prefixed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern is lightweight. Tools use the same storage API they'd use anywhere else; the isolation just sits underneath.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Message Addressing by Tool ID
&lt;/h3&gt;

&lt;p&gt;Chrome's message API broadcasts listeners across the extension. Any script can send a message; any listener can receive it.&lt;/p&gt;

&lt;p&gt;CaptureTools routes by tool ID. Every message carries a &lt;code&gt;tool&lt;/code&gt; field:&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;// Sending&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sealshot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;capture&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Receiving&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sendResponse&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="c1"&gt;// Only process messages meant for this tool&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;backtrack&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Handle message...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents broadcast storms, message handler collisions, and one tool accidentally triggering another's side effects. It's a simple protocol, but it moves responsibility to the caller—and that's exactly where it belongs.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Database Namespacing
&lt;/h3&gt;

&lt;p&gt;CaptureTools uses IndexedDB for larger datasets. Each tool gets a separate object store namespace:&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;// Sealshot's stores&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sealshot_cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sealshot_cache&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;sealshot_metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sealshot_metadata&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Backtrack's stores&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;backtrack_history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;backtrack_history&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;backtrack_snapshots&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;backtrack_snapshots&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A database adapter wraps all IndexedDB calls and routes them to the correct namespace. One tool's query never touches another tool's data.&lt;/p&gt;

&lt;p&gt;This layer is especially critical because IndexedDB doesn't have built-in ACLs. Namespacing is the only control plane.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Content Script Lifecycle Management
&lt;/h3&gt;

&lt;p&gt;When a tool is disabled, its content scripts must unregister completely.&lt;/p&gt;

&lt;p&gt;CaptureTools tracks which content script belongs to which tool and performs full cleanup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deregister from the message bus&lt;/li&gt;
&lt;li&gt;Clear DOM state&lt;/li&gt;
&lt;li&gt;Remove event listeners&lt;/li&gt;
&lt;li&gt;Unsubscribe from runtime events
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Cleanup on disable&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unregisterTool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toolId&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="c1"&gt;// Remove all message listeners for this tool&lt;/span&gt;
  &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;handlers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;toolId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_messageHandler`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Unregister content scripts from tabs&lt;/span&gt;
  &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tabs&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;tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tab&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;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;toolId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unregister&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&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="c1"&gt;// Tab may not have script&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents ghost listeners firing on messages meant for other tools and orphaned state lingering after uninstall. It's the most underrated failure mode: a tool that's gone in the UI but still listening in the background.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design Tradeoffs
&lt;/h2&gt;

&lt;p&gt;This architecture trades runtime overhead for complete failure isolation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Runtime cost:&lt;/strong&gt; Adapter calls, namespace checks, lifecycle management&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benefit:&lt;/strong&gt; A tool crash doesn't cascade. Storage errors in one tool don't corrupt others.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's not zero-cost, but it's predictable, testable, and scales with the number of tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions?
&lt;/h2&gt;

&lt;p&gt;This is a working implementation. Things might shift as tooling evolves. If you have questions about isolation strategy, multi-tool extension design, or how this compares to other approaches, I'm curious to hear your thoughts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For you:&lt;/strong&gt; If you're building a multi-tool extension or wondering how to keep separate systems in one runtime, does this pattern help? If you're using CaptureTools and curious about stability, now you know.&lt;/p&gt;

&lt;p&gt;Also—if you're building something similar, what's your isolation strategy? I'd love to know what works for you and what doesn't.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Read the full post with examples and FAQ:&lt;/strong&gt; &lt;a href="https://dhseadev.online/2026/08/27/capturetools-isolation-architecture/" rel="noopener noreferrer"&gt;https://dhseadev.online/2026/08/27/capturetools-isolation-architecture/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>programming</category>
      <category>software</category>
    </item>
    <item>
      <title>Counting down to a date that does not exist</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Wed, 19 Aug 2026 13:51:53 +0000</pubDate>
      <link>https://dev.to/dhseadev/counting-down-to-a-date-that-does-not-exist-378h</link>
      <guid>https://dev.to/dhseadev/counting-down-to-a-date-that-does-not-exist-378h</guid>
      <description>&lt;p&gt;I build small Chrome extensions under #DHSeaDev, and the newest one is a fan project: &lt;strong&gt;KH4 Companion&lt;/strong&gt;, an unofficial countdown to Kingdom Hearts IV — days-left toolbar badge, series anniversary milestones, news and trailer feeds, a lore compendium in original prose, and a three-lane rhythm minigame called Keyblade Strike. It's packaged at 0.2.0 and the Chrome Web Store listing is in preparation. Three design problems turned out to be interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The target is a window, not a date
&lt;/h2&gt;

&lt;p&gt;Kingdom Hearts IV has no release date. It has a window — Late 2027 — and a countdown app that pretends otherwise is lying with a number. So the countdown carries two modes on its face: window mode, which counts to a conservative end-of-window anchor and says so in its label, and date mode, which activates only when a real announced date is entered in settings. The storage listener flips the toolbar badge the moment the setting changes. The dishonest version of this feature is one line shorter, and I wrote it first.&lt;/p&gt;

&lt;h2&gt;
  
  
  A countdown you can unit test
&lt;/h2&gt;

&lt;p&gt;The countdown module never calls the clock. Time arrives as an argument — &lt;code&gt;computeRemaining(nowMs, target)&lt;/code&gt; — and the module has no DOM, no &lt;code&gt;chrome.*&lt;/code&gt;, and no fetch, so the same file runs in Node tests and in the extension unchanged. The service worker is relay-only: it holds no durable state, wakes on a six-hour alarm to re-render the badge text, and everything it knows it reads back out of storage. Milestones are the same shape — an annual month/day pair plus a pure next-occurrence function — so the anniversary list is testable across year boundaries without mocking anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feeds without a backend
&lt;/h2&gt;

&lt;p&gt;News and trailers come from public feeds — KHWiki, Reddit, Google News, YouTube — fetched directly from the popup with &lt;code&gt;credentials: 'omit'&lt;/code&gt;, so no cookie ever rides along. Responses cache in &lt;code&gt;chrome.storage.local&lt;/code&gt; for fifteen minutes, and every cache write goes through a per-key serialize queue, because two concurrent refreshes doing read-modify-write on the same key is exactly the storage race that has bitten this shelf of extensions before. When a feed is down, the panel serves the stale cache and says it's stale — which beats an empty panel every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fan project's honesty posture
&lt;/h2&gt;

&lt;p&gt;This is an extension about somebody else's story. So the project page carries a "what is ours, and what is not" section stating the line plainly: Kingdom Hearts belongs to Square Enix and Disney; series names appear in the extension as facts about the series; every line of compendium prose is original writing with a KHWiki source link; the art — in the extension and in the store assets — is original, with no game assets and no character likenesses anywhere. The store listing will say the same thing in the same words.&lt;/p&gt;

&lt;p&gt;The listing chip on the project page reads exactly what's true today: &lt;strong&gt;in preparation, not submitted&lt;/strong&gt;. When it clears review, the chip becomes a button.&lt;/p&gt;

&lt;p&gt;Project page (features + full privacy policy): &lt;a href="https://dhseadev.online/projects/kh4-companion/" rel="noopener noreferrer"&gt;https://dhseadev.online/projects/kh4-companion/&lt;/a&gt;&lt;br&gt;
The rest of the shelf: &lt;a href="https://dhseadev.online/projects/" rel="noopener noreferrer"&gt;https://dhseadev.online/projects/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>chrome</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I hand-built four things my tooling already shipped. The command I never ran was `catalog`.</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Tue, 11 Aug 2026 13:25:55 +0000</pubDate>
      <link>https://dev.to/dhseadev/i-hand-built-four-things-my-tooling-already-shipped-the-command-i-never-ran-was-catalog-2obb</link>
      <guid>https://dev.to/dhseadev/i-hand-built-four-things-my-tooling-already-shipped-the-command-i-never-ran-was-catalog-2obb</guid>
      <description>&lt;p&gt;A build log about agent skills, HTML-to-MP4 video, and the specific cost of not reading the manual — with measured numbers."&lt;/p&gt;

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

&lt;p&gt;The extension is &lt;a href="https://dhseadev.online/projects/dota-companion/" rel="noopener noreferrer"&gt;Dota Companion&lt;/a&gt; — a Dota 2&lt;br&gt;
stats popup that also prices your Steam inventory. I needed intro videos: a 30 and a 60 for&lt;br&gt;
YouTube, shorts for TikTok.&lt;/p&gt;

&lt;p&gt;The stack is &lt;a href="https://github.com/heygen-com/hyperframes" rel="noopener noreferrer"&gt;HyperFrames&lt;/a&gt; (Apache 2.0) — HTML and&lt;br&gt;
CSS in, MP4 out, no bundler. You write a composition as a plain HTML file, declare timing with&lt;br&gt;
&lt;code&gt;data-*&lt;/code&gt; attributes, register one paused GSAP timeline, and a headless Chrome + FFmpeg pipeline&lt;br&gt;
renders it. It's genuinely good for agent work because the artifact is a file an agent can read&lt;br&gt;
and edit, not a React tree it has to reason about.&lt;/p&gt;

&lt;p&gt;I also keep a personal "skill" file for it — a markdown doc the agent loads that carries facts&lt;br&gt;
about &lt;em&gt;my&lt;/em&gt; environment, as opposed to facts about the framework. The framework has its own docs.&lt;br&gt;
Mine holds things like "this install flag hangs in a non-TTY shell."&lt;/p&gt;
&lt;h2&gt;
  
  
  Round one was bad in an instructive way
&lt;/h2&gt;

&lt;p&gt;First pass: screenshots on a gradient, an animated outline ring around whatever the narration&lt;br&gt;
was naming, a caption bar, a slow zoom.&lt;/p&gt;

&lt;p&gt;Two problems, both visible in the first render:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ring sat on the text.&lt;/strong&gt; At video scale a 3px border plus a glow lands on the glyphs of&lt;br&gt;
the thing it's pointing at. The highlight was actively making the subject harder to read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It looked like a slideshow&lt;/strong&gt; because it &lt;em&gt;was&lt;/em&gt; one — a flat rectangle pasted on a gradient,&lt;br&gt;
with a Ken Burns zoom.&lt;/p&gt;
&lt;h2&gt;
  
  
  Round two: the fixes that actually worked
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Focus became a hole, not a ring.&lt;/strong&gt; Instead of drawing &lt;em&gt;on&lt;/em&gt; the subject, dim everything&lt;br&gt;
&lt;em&gt;except&lt;/em&gt; it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.spot&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c"&gt;/* sized to the measured target rect */&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;4000px&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.58&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.5px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;126&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;198&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;232&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;outline-offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3px&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 scrim is the shadow this box casts outward, clipped by the device frame. The lit region is&lt;br&gt;
negative space, so it is &lt;em&gt;geometrically incapable&lt;/em&gt; of covering the text it points at. This is&lt;br&gt;
the Apple/Samsung how-to grammar — the frame goes quiet, the subject stays lit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coordinates got measured, not eyeballed.&lt;/strong&gt; The screenshot script records&lt;br&gt;
&lt;code&gt;getBoundingClientRect()&lt;/code&gt; for every control the video will point at, normalised to the image:&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="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;targets&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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;vp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;vp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;vp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;vp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&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;Reading coordinates off a rendered PNG by eye puts the highlight &lt;em&gt;next to&lt;/em&gt; the button, and&lt;br&gt;
nothing downstream can detect that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Depth became three planes.&lt;/strong&gt; Ambient gradient → the same screenshot blown up and blurred&lt;br&gt;
behind the device (colour spill) → the crisp shot in a window with a chrome bar and a real&lt;br&gt;
shadow. Light behaving like light is what reads as modern; a drop-shadow alone reads flat at&lt;br&gt;
video scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Seams got a rulebook.&lt;/strong&gt; One direction for the whole film, every cut landing mid-motion on&lt;br&gt;
both sides with mirrored &lt;code&gt;power4&lt;/code&gt; eases, and exactly one reserved "arrival" vector spent on&lt;br&gt;
the payoff beat.&lt;/p&gt;

&lt;p&gt;Before / after, same product, same screenshots:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Before: &lt;a href="https://www.youtube.com/shorts/6oBcZolFoDQ" rel="noopener noreferrer"&gt;https://www.youtube.com/shorts/6oBcZolFoDQ&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;After: &lt;a href="https://www.youtube.com/shorts/SQRhmxUIYlc" rel="noopener noreferrer"&gt;https://www.youtube.com/shorts/SQRhmxUIYlc&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The numbers that changed how I plan
&lt;/h2&gt;

&lt;p&gt;Three measurements did more for my throughput than any visual change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Render cost is dominated by one CSS property.&lt;/strong&gt; &lt;code&gt;check&lt;/code&gt; reports&lt;br&gt;
&lt;code&gt;browserGpuMode probe → software&lt;/code&gt; — no GPU, every frame rasterised on CPU:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Composition&lt;/th&gt;
&lt;th&gt;Wall-clock&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Flat planes, no full-frame filters&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;~3–4× realtime&lt;/strong&gt; (63.9s film → 3m17s)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One blurred backdrop plane + &lt;code&gt;backdrop-filter&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;~10–23× realtime&lt;/strong&gt; (64.2s film → 21m39s)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same film, same length, ~6× apart. A four-film set cost 43 minutes of wall-clock. That's not a&lt;br&gt;
detail, that's the shape of your afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Script length is arithmetic.&lt;/strong&gt; Across 33 TTS segments — 338 words, 131.5 seconds — the rate&lt;br&gt;
was &lt;strong&gt;2.57 words/sec&lt;/strong&gt;. So a 60-second film is about 144 words. I wrote my first 60s script by&lt;br&gt;
feel; it measured 92 seconds. 53% over, and a full regeneration of every segment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three lint errors fire on every first build.&lt;/strong&gt; &lt;code&gt;gsap_exit_missing_hard_kill&lt;/code&gt; (a fade ending&lt;br&gt;
on a clip boundary needs a matching &lt;code&gt;tl.set&lt;/code&gt; or a seek restores stale visibility — it fired 7×),&lt;br&gt;
&lt;code&gt;duplicate_media_discovery_risk&lt;/code&gt; (two &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; sharing a &lt;code&gt;src&lt;/code&gt;; the documented failure mode is a&lt;br&gt;
&lt;em&gt;blank render&lt;/em&gt;), and &lt;code&gt;text_occluded&lt;/code&gt; (a caption inside a clip paints under any overlay declared&lt;br&gt;
after the clips — DOM order decides).&lt;/p&gt;

&lt;p&gt;And one no linter catches: &lt;strong&gt;exit opacity has to land exactly on the cut.&lt;/strong&gt; Fading at 55% of&lt;br&gt;
the exit travel left ~0.14s of empty stage between every scene — four black frames per seam,&lt;br&gt;
passing every automated check.&lt;/p&gt;

&lt;p&gt;I wrote all of this into the skill file. Felt good. Version 1.3.0.&lt;/p&gt;
&lt;h2&gt;
  
  
  Then I ran one command I'd never run
&lt;/h2&gt;

&lt;p&gt;Later, unrelated, someone asked whether I could use stock footage or animated characters. So I&lt;br&gt;
went looking at what the tooling could actually reach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;npx hyperframes catalog &lt;span class="nt"&gt;--json&lt;/span&gt;
358 items    &lt;span class="c"&gt;# 145 blocks, 213 components&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three hundred and fifty-eight prebuilt, installable motion components. &lt;code&gt;hyperframes add &amp;lt;name&amp;gt;&lt;/code&gt;&lt;br&gt;
drops one into your project as a local sub-composition with a paste-ready snippet.&lt;/p&gt;

&lt;p&gt;Here is what was in there:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I hand-built&lt;/th&gt;
&lt;th&gt;What already existed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A GSAP proxy tween counting a number up&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;apple-money-count&lt;/code&gt; — counts up, flashes green, &lt;strong&gt;bursts money icons with sound&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSS device chrome with three fake traffic lights&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;device-frame-stage&lt;/code&gt;, or &lt;code&gt;macos-tahoe-liquid-glass&lt;/code&gt; — a real 3D laptop with a screen slot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A blurred backdrop plane for fake depth&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;camera-dolly-zoom&lt;/code&gt; with focal length solved per frame; &lt;code&gt;rack-focus&lt;/code&gt; with real aperture bokeh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hand-timed emphasis on headlines&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;char-slam-explode&lt;/code&gt;, &lt;code&gt;caption-particle-burst&lt;/code&gt;, &lt;code&gt;confetti&lt;/code&gt;, &lt;code&gt;vfx-shatter&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four for four. Better versions of all of them. Sitting behind a command I never ran because I&lt;br&gt;
went straight from "read the authoring docs" to "write the composition."&lt;/p&gt;

&lt;p&gt;The honest kicker: &lt;strong&gt;the before/after above was won entirely by hand.&lt;/strong&gt; Not one frame of it&lt;br&gt;
uses the registry. I found the registry &lt;em&gt;after&lt;/em&gt; those videos shipped. The visible improvement&lt;br&gt;
came from craft; the invisible waste came from not shopping first.&lt;/p&gt;

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

&lt;p&gt;The skill file gained a section that now runs before any authoring:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Shop the registry BEFORE hand-authoring motion.&lt;/strong&gt; 358 items. &lt;code&gt;catalog --json&lt;/code&gt; first — the&lt;br&gt;
bare form times out per-item. Skipping this is the single largest waste measured in this&lt;br&gt;
skill's history.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Plus a routing table for the things I'd been guessing at: humanoid presenters route to an&lt;br&gt;
avatar-video path (which needs an interactive OAuth login — a &lt;em&gt;human&lt;/em&gt; action, not something an&lt;br&gt;
agent can complete headlessly); a 3D rigged character is flatly &lt;strong&gt;not viable&lt;/strong&gt; on a 2-core,&lt;br&gt;
no-GPU box when a CSS blur alone already costs 20× realtime; stock footage is legally clean&lt;br&gt;
from Pexels but works as &lt;em&gt;texture, not actors&lt;/em&gt;; and extracted game assets are a hard no.&lt;/p&gt;

&lt;p&gt;And I cut it down while adding that. The file went 264 → 236 lines, because a comparison table&lt;br&gt;
re-arguing a decision I'd already made, and a &lt;code&gt;## Requirements&lt;/code&gt; section holding two commands,&lt;br&gt;
were both bloat.&lt;/p&gt;

&lt;h2&gt;
  
  
  The transferable bit
&lt;/h2&gt;

&lt;p&gt;The lesson isn't "read the docs." I read the docs. I read the authoring model, the data&lt;br&gt;
attributes, the determinism rules — and then implemented, from those primitives, four&lt;br&gt;
components that shipped in the box.&lt;/p&gt;

&lt;p&gt;The docs told me &lt;strong&gt;how to build&lt;/strong&gt;. Nothing prompted me to ask &lt;strong&gt;what already exists&lt;/strong&gt;. Those&lt;br&gt;
are different questions and I only asked the first one.&lt;/p&gt;

&lt;p&gt;If you're pointing an agent at an unfamiliar framework, the highest-leverage line you can put&lt;br&gt;
in its instructions isn't a code sample. It's: &lt;em&gt;inventory the catalog before you write&lt;br&gt;
anything.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>automation</category>
    </item>
    <item>
      <title>Weather that cannot touch the game: building an ambient layer that stays out of the way</title>
      <dc:creator>Dhardingsea Developer</dc:creator>
      <pubDate>Mon, 10 Aug 2026 14:50:43 +0000</pubDate>
      <link>https://dev.to/dhseadev/weather-that-cannot-touch-the-game-building-an-ambient-layer-that-stays-out-of-the-way-3cn7</link>
      <guid>https://dev.to/dhseadev/weather-that-cannot-touch-the-game-building-an-ambient-layer-that-stays-out-of-the-way-3cn7</guid>
      <description>&lt;p&gt;A 1D spring water surface, wind gusts drawn as one polyline, and an aurora faked with three additive strokes — plus the one comment in the file that explains a bug I would otherwise have shipped."&lt;br&gt;
tags: javascript, canvas, gamedev, webdev&lt;/p&gt;

&lt;p&gt;I built a small Chrome arcade game called Prism Cascade. You throw light-orbs at crystal prisms, the shards fall into a pool, and every splash throws up bubbles carrying points. Thirty-second rounds. The gameplay is deliberately tiny.&lt;/p&gt;

&lt;p&gt;The part I want to write about is the part that does nothing.&lt;/p&gt;

&lt;p&gt;Underneath the game there is a layer of wind, mist, aurora, drifting motes, fireflies and fish. It is the first thing anyone comments on and it has &lt;strong&gt;zero&lt;/strong&gt; effect on play. That was the design constraint, and it turned out to be the thing that made the whole layer cheap to build and impossible to break.&lt;/p&gt;

&lt;p&gt;Here is the entire declaration:&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;// ============================== Ambient world (visual only — no gameplay physics) ==============================&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fish&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;gusts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;mist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;foamBlobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;fireflies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;motes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;spires&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;drops&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight arrays. No classes, no entity system, no update graph. Let me go through the three that were actually interesting to write.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The water surface is a row of springs, and it wants to explode
&lt;/h2&gt;

&lt;p&gt;The pool is not a sine wave. It is a 1D array of columns, each with a height and a velocity, coupled to its neighbours — the classic Hugo Elias water model that has been floating around demoscene articles since about 2000.&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;water&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;n&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="na"&gt;spacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;initWater&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;water&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;W&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;water&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spacing&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;water&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;water&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&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="na"&gt;h&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="na"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;disturbWater&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;power&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;waterIndex&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;water&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;power&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;water&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="nx"&gt;water&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;power&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;water&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="nx"&gt;water&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;power&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.6&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;Each frame, every column gets a Hooke restoring force toward zero, gets damped, and then leaks energy into its neighbours over two passes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;stepWater&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.025&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;damp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.976&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spread&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.14&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hMax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;34&lt;/span&gt; &lt;span class="o"&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;vMax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;46&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;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;water&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;water&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;col&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="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="nx"&gt;damp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;vMax&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vMax&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;vMax&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;vMax&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;hMax&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hMax&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;hMax&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;hMax&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;pass&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;pass&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;pass&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;water&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&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="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;spread&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;water&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&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="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;spread&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks fine and is not fine. Run it for a while with a lot of splashes and the surface develops a shimmering sawtooth — every column high, its neighbour low, alternating all the way across the pool. It does not decay, because the neighbour-coupling term is what is &lt;em&gt;feeding&lt;/em&gt; it: at that wavelength each column is always being pushed away from both of its neighbours simultaneously.&lt;/p&gt;

&lt;p&gt;That is the Nyquist mode. It is the highest frequency the grid can represent — one full wave every two columns — and an explicit integrator on a coupled grid will happily pump energy into it forever.&lt;/p&gt;

&lt;p&gt;The fix is three lines and a comment I left in on purpose:&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;// spatial smoothing — kills the alternating-column (Nyquist) instability mode&lt;/span&gt;
  &lt;span class="c1"&gt;// while preserving long-wavelength waves&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;water&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;smoothed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&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="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;smoothed&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 1-2-1 kernel is a low-pass filter. At the Nyquist wavelength it has almost no gain, so the sawtooth is annihilated. At the long wavelengths you actually want — the roll of a wave crossing the pool after a big splash — it barely does anything. Blending at &lt;code&gt;0.55&lt;/code&gt; rather than replacing outright keeps a little crispness.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;prev&lt;/code&gt; variable matters more than it looks. Reading &lt;code&gt;c[i-1].h&lt;/code&gt; directly would read the &lt;em&gt;already-smoothed&lt;/em&gt; value from this same pass, which turns a symmetric filter into a directional one and makes waves drift left. Caching the pre-smoothing value is the difference between a filter and a bug.&lt;/p&gt;

&lt;p&gt;Two clamps are also doing quiet work. &lt;code&gt;vMax&lt;/code&gt; stops a single enormous splash from launching one column into orbit, and &lt;code&gt;hMax&lt;/code&gt; bounds the visual amplitude so the surface can never draw outside the pool. Neither is physical. Both mean the sim cannot produce a frame that looks broken, no matter what the game throws at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Everything couples through exactly one function
&lt;/h2&gt;

&lt;p&gt;Fish leaping, waterfall droplets landing, crystal debris hitting the pool — all of it reaches the water through &lt;code&gt;disturbWater(x, power)&lt;/code&gt; and nothing else.&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;// a fish landing&lt;/span&gt;
&lt;span class="nf"&gt;disturbWater&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// a waterfall droplet, only sometimes&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waterY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;drops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;disturbWater&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&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="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// debris from a shattered crystal&lt;/span&gt;
&lt;span class="nf"&gt;disturbWater&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;26&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;energy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One integer of API surface. The ambient systems do not know about each other, do not know about the game, and cannot be broken by either. When I later changed how splash energy was calculated, the water sim needed no edits — it only ever sees a number.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;Math.random() &amp;lt; 0.12&lt;/code&gt; on the droplets is worth calling out. Ninety pixel-streaks a second all ringing the surface produces mush, not rain. Letting roughly one in eight actually strike gives you distinguishable individual rings and, incidentally, cuts the work by 88%. Sparse is both cheaper and better looking, which is not a trade-off you get very often.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Wind is one polyline, and the aurora is three strokes
&lt;/h2&gt;

&lt;p&gt;I did not want a particle system for wind. Particles for wind look like snow.&lt;/p&gt;

&lt;p&gt;A gust is a single object with a lifetime, and it is drawn as one 26-segment polyline whose vertical offset is a sine wave — with the amplitude tapering along the tail so it looks like it is trailing off rather than ending:&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;segs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;26&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;i2&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;i2&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;segs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i2&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;px&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;i2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;13&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;py&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;i2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wl&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                 &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amp&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;i2&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;segs&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;i2&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;cx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;moveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;py&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lineTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;py&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// little curl at the head&lt;/span&gt;
&lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beginPath&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wl&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One stroke, one arc, per gust. A new gust arrives every 4 to 10 seconds, 70% of the time from the left, and self-culls when it leaves the screen. There are usually one or two on screen and often none — the gaps are what makes it read as weather rather than as an effect that is always on.&lt;/p&gt;

&lt;p&gt;The aurora is the same trick applied to glow. No shader, no blur filter, no offscreen canvas:&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="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalCompositeOperation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lighter&lt;/span&gt;&lt;span class="dl"&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;rib&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;rib&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;rib&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baseY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;H&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rib&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.07&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;hueA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rib&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;280&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;160&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;pass&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;pass&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;pass&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalAlpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reduceFx&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.03&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pass&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pass&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ... two summed sines, sampled every 36px&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;Three passes: progressively wider, progressively fainter, drawn additively on top of each other. That is a bloom. It costs six strokes a frame sampled every 36 pixels, and it is convincing because real glow &lt;em&gt;is&lt;/em&gt; a stack of decreasing intensities at increasing radii — you do not need to compute it, you can just draw it that way.&lt;/p&gt;

&lt;p&gt;The ribbon shape is two summed sines at different frequencies and opposite time directions. One sine reads as a mechanical wobble. Two never quite repeat.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that made all of it safe
&lt;/h2&gt;

&lt;p&gt;Every one of these populations is bounded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mist&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;lt;&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* spawn */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;drops&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;lt;&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* spawn */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;36 motes, 8 fireflies, 5 fish, 15 spires, 1400 particles hard cap. Not one of them grows with time, level, score or how long the tab has been open. The frame cost of the ambient layer at minute one is the frame cost at hour three, which is precisely why I never had to profile it.&lt;/p&gt;

&lt;p&gt;There is one deliberate asymmetry. The rock spires on the horizon are generated from a &lt;em&gt;seeded&lt;/em&gt; RNG:&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;srng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;L&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mulberry32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0xA17A&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;while everything else uses &lt;code&gt;Math.random&lt;/code&gt;. The skyline is the same every single session; the weather in front of it never repeats. That split is the whole feel of the thing — a fixed place, with something moving through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where reduced-motion actually landed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;prefers-reduced-motion&lt;/code&gt; maps to a &lt;code&gt;game.reduceFx&lt;/code&gt; flag, and I will be honest about how far it goes, because "we support reduced motion" is a claim people make loosely.&lt;/p&gt;

&lt;p&gt;It gates the things that move fast or arrive suddenly: wind gusts stop spawning entirely, waterfall droplets stop spawning, fish stop leaping, and the aurora drops from 0.05 to 0.03 alpha. It does &lt;strong&gt;not&lt;/strong&gt; gate the slow continuous drift — mist, motes and fireflies keep going.&lt;/p&gt;

&lt;p&gt;That is a judgement call, not an oversight. The vestibular-trigger guidance is about large, fast, unexpected motion, and a mote drifting four pixels a second is not that. But it is a judgement call, and if someone tells me it is the wrong one, the flag is already threaded through every spawn site — it is a one-line change per system.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Prism Cascade&lt;/strong&gt; is a 30-second physics arcade for Chrome. It makes zero network requests, asks for exactly one permission (&lt;code&gt;storage&lt;/code&gt;), and the Chrome Web Store listing is still in preparation at the time of writing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Watch it in motion: &lt;a href="https://www.youtube.com/shorts/6oBcZolFoDQ" rel="noopener noreferrer"&gt;https://www.youtube.com/shorts/6oBcZolFoDQ&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Full write-up, mechanics and privacy policy: &lt;a href="https://dhseadev.online/projects/prism-cascade/" rel="noopener noreferrer"&gt;https://dhseadev.online/projects/prism-cascade/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;More things I have built: &lt;a href="https://dhseadev.online/projects/" rel="noopener noreferrer"&gt;https://dhseadev.online/projects/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Built solo — physics, rendering, sound and the level generator are all hand-written, no engine and no third-party libraries.&lt;/p&gt;

</description>
      <category>design</category>
      <category>frontend</category>
      <category>gamedev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
