<?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: Othmane ETTAIB</title>
    <description>The latest articles on DEV Community by Othmane ETTAIB (@indiecoredev).</description>
    <link>https://dev.to/indiecoredev</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%2F4105075%2F70960dad-c409-4283-93e0-b560cba08e8f.png</url>
      <title>DEV Community: Othmane ETTAIB</title>
      <link>https://dev.to/indiecoredev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/indiecoredev"/>
    <language>en</language>
    <item>
      <title>ask-first: questions in a card, not a paragraph</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Thu, 01 Oct 2026 20:30:24 +0000</pubDate>
      <link>https://dev.to/indiecoredev/ask-first-questions-in-a-card-not-a-paragraph-7g6</link>
      <guid>https://dev.to/indiecoredev/ask-first-questions-in-a-card-not-a-paragraph-7g6</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/ask-first-claude-code-plugin/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I have released &lt;strong&gt;&lt;a href="https://github.com/IndieCoreDev/ask-first" rel="noopener noreferrer"&gt;ask-first&lt;/a&gt;&lt;/strong&gt;, a free Claude Code&lt;br&gt;
plugin that makes Claude ask clarifying questions properly. When it needs a decision from you,&lt;br&gt;
it stops writing the question into a paragraph and puts it in an interactive card instead. One&lt;br&gt;
question at a time, with the option it recommends first.&lt;/p&gt;

&lt;p&gt;You have seen the card already if you use planning mode — that menu with arrow keys is the&lt;br&gt;
&lt;code&gt;AskUserQuestion&lt;/code&gt; tool. This plugin makes Claude reach for it everywhere else too, and it&lt;br&gt;
works across every session on your machine rather than for as long as you remember to ask.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/plugin marketplace add IndieCoreDev/ask-first
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/plugin install ask-first@ask-first
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then restart Claude Code. That is the whole setup. MIT licensed, no configuration, no&lt;br&gt;
account, nothing to sign up for.&lt;/p&gt;
&lt;h2&gt;
  
  
  The motivation
&lt;/h2&gt;

&lt;p&gt;I did not set out to write a plugin. I set out to stop losing an hour a day to reading.&lt;/p&gt;

&lt;p&gt;Every reply a coding agent writes costs me time twice over, and the two costs stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first is length.&lt;/strong&gt; A reply holds three different things at the same weight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;what was done or found — information, read once, act never&lt;/li&gt;
&lt;li&gt;what was assumed — a decision made for me, quietly&lt;/li&gt;
&lt;li&gt;what is actually needed from me — the only part that blocks progress&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In plain text those three look identical. Same font, same paragraphs, same tone. So I read&lt;br&gt;
the entire answer to locate the one sentence that needs me, and I do that on every turn of&lt;br&gt;
every session. A question sitting in paragraph four gets missed. Then I answer it&lt;br&gt;
vaguely, and we spend another turn establishing what I should have been asked plainly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The second is vocabulary.&lt;/strong&gt; Recent Claude models write with words harder than the work&lt;br&gt;
requires. English is not my first language. A rare word costs me twice — once to read it,&lt;br&gt;
once to be sure I read it right — and multiplied across a day that is real lost time, not a&lt;br&gt;
complaint about style. Most people using a coding agent are not reading their first language&lt;br&gt;
either.&lt;/p&gt;

&lt;p&gt;I tried the obvious thing first: asking for shorter answers, plainer words, questions at the&lt;br&gt;
end. It works for about three turns. Instructions in a conversation fade as the conversation&lt;br&gt;
grows, and I was re-typing the same request every session without ever fixing the cause.&lt;/p&gt;

&lt;p&gt;The cause is that a plain reply has no shape. Nothing limits its length and nothing separates&lt;br&gt;
a question from a fact, so both are left to the model's judgement on every single turn.&lt;/p&gt;

&lt;p&gt;Claude Code already ships the fix and underuses it. &lt;code&gt;AskUserQuestion&lt;/code&gt; draws a card in the&lt;br&gt;
terminal: a real list, arrow keys, one keypress. It cannot run long and it cannot hide a&lt;br&gt;
choice inside a sentence, because the format will not let it. I already keep the rules of&lt;br&gt;
this repo in the repo rather than in prompts — &lt;a href="https://www.indiecore.net/blog/context-engineering-repo/" rel="noopener noreferrer"&gt;I stopped writing prompts, I write the&lt;br&gt;
repo&lt;/a&gt; — so packaging this as a skill was the obvious shape.&lt;/p&gt;

&lt;p&gt;The rule turned out to be short enough to write on one line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A question in prose is a bug.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;

&lt;p&gt;The same moment, both ways. First as prose:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I read the scheduling code — the calendar lives in &lt;code&gt;schedule-rule.mjs&lt;/code&gt;, the allocator in&lt;br&gt;
&lt;code&gt;schedule.mjs&lt;/code&gt;, and the gate runs inside &lt;code&gt;build.mjs&lt;/code&gt;. There are a few ways to do this. A&lt;br&gt;
frontmatter flag would be simplest, though you could also track it in the ledger, or push&lt;br&gt;
the post to the next free day, which changes how the calendar reports it. Let me know which&lt;br&gt;
you prefer and I'll get started.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Sixty words to learn that a decision is waiting. Now as a card:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ☐ Skip shape

 What should "skip" do to a post that's dated and waiting?

 ❯ 1. skip: in frontmatter (Recommended)
      One line in the post itself. The calendar hands the day back.
   2. Track it in the ledger
      Keeps posts untouched, but the reason lives away from the post.
   3. Push to the next free day
      No new field, but it silently moves a date you chose.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same information. One glance, one keypress.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules it follows
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every question goes in a card&lt;/strong&gt;, never in prose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only questions.&lt;/strong&gt; Plain information stays as normal text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One question at a time.&lt;/strong&gt; Never a batch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The first option is the recommendation&lt;/strong&gt;, and it says so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask the goal when the request does not say why&lt;/strong&gt;, and judge every option against it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rule 4 is the one I care about most. A neutral list of three options hands the work back to&lt;br&gt;
you: now you have to become the expert to choose. Claude has read the code and you have not.&lt;br&gt;
It should say which option wins, and let you overrule it.&lt;/p&gt;

&lt;p&gt;Rule 5 is what stops a confident answer to the wrong problem. Told to "make this script&lt;br&gt;
faster", the plugin makes Claude measure first, then ask what faster is &lt;em&gt;for&lt;/em&gt; — a CI run that&lt;br&gt;
cannot hang, or a quicker local run — because those lead to different code.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The plugin bundles an &lt;strong&gt;Agent Skill&lt;/strong&gt; and two &lt;strong&gt;hooks&lt;/strong&gt;, and the split between them is the&lt;br&gt;
interesting part.&lt;/p&gt;

&lt;p&gt;A skill is instruction. It loads rules into Claude's context, and Claude mostly follows them.&lt;br&gt;
A hook is enforcement: it is an event-driven script that Claude Code runs itself, so it does&lt;br&gt;
not depend on the model agreeing with you. Skills are something Claude chooses to use. Hooks&lt;br&gt;
are invisible and simply run. It is the same trade I made with code review — &lt;a href="https://www.indiecore.net/blog/build-reviews-ai-code/" rel="noopener noreferrer"&gt;I don't review AI&lt;br&gt;
code, my build does&lt;/a&gt; — moved up a layer, from the code the model&lt;br&gt;
writes to the way it talks to me.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;How it holds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One question per card&lt;/td&gt;
&lt;td&gt;Hook. Rejected before the card is drawn.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recommendation first, marked&lt;/td&gt;
&lt;td&gt;Hook. Rejected, naming where it actually was.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No question left in prose&lt;/td&gt;
&lt;td&gt;Hook. The turn is blocked until it becomes a card.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only questions in the card&lt;/td&gt;
&lt;td&gt;Skill text.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ask the goal, judge against it&lt;/td&gt;
&lt;td&gt;Skill text.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The skill came first. I rewrote one rule three times and Claude kept slipping past it, each&lt;br&gt;
rewrite helping less than the last. One hook fixed a different rule on the first attempt, while I was&lt;br&gt;
actively telling Claude to break it.&lt;/p&gt;

&lt;p&gt;So: a hook can check a shape. Whether a question named your real goal is a judgement, and&lt;br&gt;
judgement has no shape to check. Sort your own rules into those two piles before you write&lt;br&gt;
them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does it actually help?
&lt;/h2&gt;

&lt;p&gt;Yes, and here is the run that proved it.&lt;/p&gt;

&lt;p&gt;I asked Claude to deal with three pages Search Console said had never been crawled. Instead&lt;br&gt;
of fixing them, it checked whether the finding was true. All three were already linked from&lt;br&gt;
every page on the site, and their inbound links matched indexed sibling pages exactly — so&lt;br&gt;
the obvious fix would have done nothing.&lt;/p&gt;

&lt;p&gt;The real cause was a bug in my own code: my watcher skips its fourteen-day grace period on a&lt;br&gt;
first run, assuming sitemap URLs are older than the watcher. My site is seven days old and&lt;br&gt;
the watcher is two.&lt;/p&gt;

&lt;p&gt;The card that followed recommended closing all three as &lt;code&gt;wontfix&lt;/code&gt;, which lets the grace&lt;br&gt;
period apply again so they reopen by themselves if Google still has not crawled them.&lt;/p&gt;

&lt;p&gt;Without the plugin, that session ends in three cosmetic edits that change nothing.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  How do I install it?
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/plugin marketplace add IndieCoreDev/ask-first
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/plugin install ask-first@ask-first
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then restart Claude Code. Installing at user scope means every project on your machine gets&lt;br&gt;
it, in every session from then on, not just the one you are in.&lt;/p&gt;

&lt;p&gt;There is no slash command to remember and nothing to invoke. The skill loads itself when a&lt;br&gt;
decision is due, and the hooks run on their own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why doesn't my skill show up?
&lt;/h3&gt;

&lt;p&gt;Because &lt;code&gt;/reload-plugins&lt;/code&gt; does not load skills. It reports &lt;code&gt;0 skills&lt;/code&gt; and skills are only read&lt;br&gt;
when a session starts, so restart. This cost me twenty minutes of believing the plugin was&lt;br&gt;
broken when it was fine.&lt;/p&gt;

&lt;p&gt;If a restart does not fix it, run &lt;code&gt;claude plugin validate&lt;/code&gt; on both the plugin folder and the&lt;br&gt;
marketplace root. It names the problem, and almost nobody mentions it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will it ask too many questions?
&lt;/h3&gt;

&lt;p&gt;It is written not to. It does not ask about a small reversible fix with an obvious default,&lt;br&gt;
about anything the code already answers, or about a decision you already made. Those get&lt;br&gt;
done, and reported in one line.&lt;/p&gt;

&lt;p&gt;The hook that catches questions in prose ignores question marks inside code, turns that&lt;br&gt;
already used a card, and reporting language like "the profiler will tell me where the time&lt;br&gt;
goes". It never blocks on input it cannot read.&lt;/p&gt;

&lt;p&gt;If it does interrupt you without a real reason, that is the bug I most want reported. A&lt;br&gt;
plugin that gets in the way gets uninstalled, and I would rather relax a rule than defend&lt;br&gt;
it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe? What do the hooks read?
&lt;/h3&gt;

&lt;p&gt;Both hooks are short, unminified Node scripts with no dependencies, about 50 and 110 lines.&lt;/p&gt;

&lt;p&gt;The first reads the pending question from stdin. The second reads your session transcript at&lt;br&gt;
the path Claude Code passes it, to check whether the turn ended with a question in prose.&lt;br&gt;
Neither writes a file, starts a process, or makes a network request. Nothing leaves your&lt;br&gt;
machine. Read them before you install — you should do that with any plugin that ships hooks.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between a skill and a hook?
&lt;/h3&gt;

&lt;p&gt;Short answer: a skill asks Claude to behave. A hook makes it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Agent Skills&lt;/strong&gt; are Markdown instructions loaded into context. Claude decides when to use
one, based on its description. Good for judgement — taste, tone, what counts as a good
recommendation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hooks&lt;/strong&gt; are scripts wired to events like &lt;code&gt;PreToolUse&lt;/code&gt; or &lt;code&gt;Stop&lt;/code&gt;. Claude Code runs them
whether or not Claude agrees, and a hook can reject a tool call outright. Good for anything
with a checkable shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subagents&lt;/strong&gt; run a separate context for a delegated task, and &lt;strong&gt;MCP servers&lt;/strong&gt; connect
outside tools. Neither is involved here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most plugins that "drift" are trying to enforce with a skill. If your rule has a shape a&lt;br&gt;
script can check, it wants a hook.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I change the rules?
&lt;/h3&gt;

&lt;p&gt;Yes. The skill is one Markdown file and the hooks are two small scripts. Fork it, edit&lt;br&gt;
&lt;code&gt;SKILL.md&lt;/code&gt;, or drop a hook you disagree with from &lt;code&gt;hooks.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you fix something, the repo takes pull requests. There are 22 tests that CI runs on every&lt;br&gt;
push, and the bar for a new rule is that it exists because a real session got past the old&lt;br&gt;
one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does it work with Claude Cowork?
&lt;/h3&gt;

&lt;p&gt;Untested, so it is not claimed. Claude Code only for now.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does it cost?
&lt;/h3&gt;

&lt;p&gt;Nothing. MIT licence, no account, no telemetry.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you build your own plugin, two warnings
&lt;/h2&gt;

&lt;p&gt;Both cost me hours, and both are the same mistake: trusting the documentation over the binary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt hooks did not run.&lt;/strong&gt; Claude Code documents two kinds of hook — a &lt;code&gt;command&lt;/code&gt; hook runs&lt;br&gt;
a script, a &lt;code&gt;prompt&lt;/code&gt; hook runs an LLM evaluation — and the official docs call the prompt kind&lt;br&gt;
&lt;em&gt;recommended&lt;/em&gt; for judgement calls. Mine never fired. One grep explained it: the official&lt;br&gt;
marketplace lists 291 plugins and not one ships a prompt hook. I rewrote the rule as a simple&lt;br&gt;
script that checks the text directly, and the simple version caught the case the clever one&lt;br&gt;
missed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two documented manifest fields are rejected.&lt;/strong&gt; &lt;code&gt;$schema&lt;/code&gt; and &lt;code&gt;displayName&lt;/code&gt; are in the&lt;br&gt;
reference; my CLI fails on both as errors, not warnings. The marketplace review runs&lt;br&gt;
&lt;code&gt;claude plugin validate&lt;/code&gt;, so following the docs would have failed my submission.&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;claude plugin validate&lt;/code&gt; on your plugin folder &lt;em&gt;and&lt;/em&gt; your marketplace root before you&lt;br&gt;
believe anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it goes next
&lt;/h2&gt;

&lt;p&gt;It is on GitHub, MIT, and submitted to the Claude Code community marketplace. Version 0.6.0.&lt;/p&gt;

&lt;p&gt;The one thing I want back from anyone who tries it is a false block — a reply the hook stopped&lt;br&gt;
that was not asking you anything. That is the failure I cannot find on my own, because my&lt;br&gt;
tests only contain the mistakes I already thought of.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/ask-first-claude-code-plugin/" rel="noopener noreferrer"&gt;ask-first: questions in a card, not a paragraph&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The code is on GitHub: &lt;a href="https://github.com/IndieCoreDev/ask-first" rel="noopener noreferrer"&gt;ask-first — the skill, the hooks and the tests&lt;/a&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>workflow</category>
    </item>
    <item>
      <title>Four things break when CI posts to LinkedIn</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Thu, 01 Oct 2026 20:30:24 +0000</pubDate>
      <link>https://dev.to/indiecoredev/four-things-break-when-ci-posts-to-linkedin-3el7</link>
      <guid>https://dev.to/indiecoredev/four-things-break-when-ci-posts-to-linkedin-3el7</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/posting-to-linkedin-from-ci/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I wanted every new blog post announced on LinkedIn without me remembering to do it. A&lt;br&gt;
scheduled job, a line of frontmatter on the post, and nothing else.&lt;/p&gt;

&lt;p&gt;The API call that publishes the post is six lines of JSON. Everything below is what took the&lt;br&gt;
rest of the afternoon, and none of it appears on the happy path in the documentation.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part that goes the way you expect
&lt;/h2&gt;

&lt;p&gt;Register an app in the LinkedIn developer portal, add the &lt;strong&gt;Share on LinkedIn&lt;/strong&gt; product from&lt;br&gt;
the Products tab, and you have &lt;code&gt;w_member_social&lt;/code&gt; — permission to post as yourself. It is&lt;br&gt;
self-serve. No review, no queue, no application form. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST https://api.linkedin.com/rest/posts
Authorization: Bearer &amp;lt;token&amp;gt;
LinkedIn-Version: 202608
X-Restli-Protocol-Version: 2.0.0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"urn:li:person:&amp;lt;id&amp;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;"commentary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"the text of the post"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"visibility"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PUBLIC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"distribution"&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;"feedDistribution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MAIN_FEED"&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;"content"&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;"article"&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;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/post/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lifecycleState"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PUBLISHED"&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;You get a &lt;code&gt;201&lt;/code&gt; and the post URN in the &lt;code&gt;x-restli-id&lt;/code&gt; response header. That works first time.&lt;/p&gt;

&lt;p&gt;Getting to the point where that call is possible took longer than writing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The app, and the Page you probably do not have
&lt;/h2&gt;

&lt;p&gt;Nothing in the developer portal works until an app exists, and an app cannot exist without a&lt;br&gt;
LinkedIn &lt;strong&gt;Page&lt;/strong&gt; to attach it to. The form will not take a personal profile. If you post&lt;br&gt;
under your own name and have never run a company page, that is the wall you hit first, before&lt;br&gt;
any code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Make the Page.&lt;/strong&gt; &lt;code&gt;linkedin.com/company/setup/new&lt;/code&gt;, choose &lt;em&gt;Company&lt;/em&gt;. It wants a name, a&lt;br&gt;
public URL, a website, an industry, a company size, a logo and a description — and the&lt;br&gt;
description has a &lt;strong&gt;250 character minimum&lt;/strong&gt;, which is more than you think when the honest&lt;br&gt;
answer is one line. Tick the box confirming you may act for the organisation.&lt;/p&gt;

&lt;p&gt;Nothing about the Page needs finishing. It exists so the app has something to attach to, and&lt;br&gt;
so that you can approve the app's verification as its admin in the next step. Mine has one&lt;br&gt;
paragraph and a logo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Make the app.&lt;/strong&gt; &lt;code&gt;linkedin.com/developers/apps/new&lt;/code&gt;. App name, the Page from step one, an&lt;br&gt;
optional privacy policy URL, and a square logo of at least 100px. The form warns that the Page&lt;br&gt;
association &lt;strong&gt;cannot be undone once saved&lt;/strong&gt;, and it means it, so pick the right Page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Verify it.&lt;/strong&gt; Settings tab → &lt;strong&gt;Verify&lt;/strong&gt;. It produces a link that a Page admin has to open&lt;br&gt;
and approve. That admin is you, so this is a click rather than a wait — but until it is done&lt;br&gt;
the Products tab hands you nothing, and the token generator will tell you there are no scopes&lt;br&gt;
available for this app. That error message sounds like a permissions problem. It is this step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Add two products, not one.&lt;/strong&gt; The Products tab lists everything LinkedIn offers, most of&lt;br&gt;
it behind an application form and a partner programme. Two of them are not. Each has a&lt;br&gt;
&lt;em&gt;Request access&lt;/em&gt; button, and for these two the word &lt;em&gt;request&lt;/em&gt; is misleading — there is no&lt;br&gt;
queue and nobody reads it. You click, you accept a terms dialog, and the product is on.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Product&lt;/th&gt;
&lt;th&gt;Grants&lt;/th&gt;
&lt;th&gt;Why you need it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Share on LinkedIn&lt;/td&gt;
&lt;td&gt;&lt;code&gt;w_member_social&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;create posts as yourself. This is the whole API surface used here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sign In with LinkedIn using OpenID Connect&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;openid&lt;/code&gt;, &lt;code&gt;profile&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;call &lt;code&gt;/v2/userinfo&lt;/code&gt;, which answers with your member id&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second one is the one that gets skipped, and skipping it is completely reasonable:&lt;br&gt;
you are automating posts, not building a login button. Nothing about the name suggests you&lt;br&gt;
need it.&lt;/p&gt;

&lt;p&gt;What it actually gets you is your own identity. Every post body needs an &lt;code&gt;author&lt;/code&gt; field&lt;br&gt;
holding &lt;code&gt;urn:li:person:&amp;lt;id&amp;gt;&lt;/code&gt;, and the only self-serve way to learn that id is&lt;br&gt;
&lt;code&gt;/v2/userinfo&lt;/code&gt;, which is an OpenID Connect endpoint and therefore lives with the sign-in&lt;br&gt;
product. Without it that call returns 403 and you have no author. Add the product, or find&lt;br&gt;
your member id once and hardcode it — both work, and the second means one fewer request per&lt;br&gt;
run.&lt;/p&gt;

&lt;p&gt;Check it landed before going further. The &lt;strong&gt;Auth&lt;/strong&gt; tab has an &lt;em&gt;OAuth 2.0 scopes&lt;/em&gt; panel: it&lt;br&gt;
should list the scopes you just enabled. While it reads &lt;code&gt;No permissions added&lt;/code&gt;, nothing else&lt;br&gt;
in the portal will work, and the errors you get elsewhere will not mention this tab.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Mint the token&lt;/strong&gt; in the portal's token generator. Pick the app and you get a checkbox per&lt;br&gt;
available scope. Tick three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;w_member_social&lt;/code&gt; — &lt;em&gt;"Create, modify, and delete posts, comments, and reactions on your
behalf"&lt;/em&gt;. The posting itself.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;profile&lt;/code&gt; and &lt;code&gt;openid&lt;/code&gt; — what makes &lt;code&gt;/v2/userinfo&lt;/code&gt; answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Leave &lt;code&gt;email&lt;/code&gt; unticked. Nothing here reads it, and a token that can do less is a better thing&lt;br&gt;
to leave sitting in a CI secret.&lt;/p&gt;

&lt;p&gt;There is also a checkbox confirming the tool will update your app's redirect URL settings.&lt;br&gt;
Tick it. The generator adds its own callback URL and runs the whole OAuth round trip in the&lt;br&gt;
browser for you, which is why this flow needs no redirect URL of your own, no client secret,&lt;br&gt;
and no local server to catch a code. Leave that callback URL in place afterwards; you will be&lt;br&gt;
back in sixty days.&lt;/p&gt;

&lt;p&gt;Approve the consent screen as yourself, copy the token, and that is the credential your CI&lt;br&gt;
job needs. Tick the same three scopes at every renewal — a shorter list invalidates the token&lt;br&gt;
you already have, and the section below is about why that is worse than it sounds.&lt;/p&gt;
&lt;h2&gt;
  
  
  The token dies every 60 days and there is no refresh token
&lt;/h2&gt;

&lt;p&gt;Every LinkedIn access token comes back with &lt;code&gt;"expires_in": 5184000&lt;/code&gt;. Sixty days. The&lt;br&gt;
&lt;a href="https://learn.microsoft.com/en-us/linkedin/shared/authentication/programmatic-refresh-tokens" rel="noopener noreferrer"&gt;documentation on refresh tokens&lt;/a&gt;&lt;br&gt;
is one sentence long on who can have them: approved Marketing Developer Platform partners. An&lt;br&gt;
ordinary app never sees a &lt;code&gt;refresh_token&lt;/code&gt; field in the response. This is a decision, not a&lt;br&gt;
setting you have missed.&lt;/p&gt;

&lt;p&gt;So any unattended pipeline built on this breaks six times a year, and no amount of workflow&lt;br&gt;
design changes that. What the design decides is &lt;em&gt;how&lt;/em&gt; it breaks.&lt;/p&gt;

&lt;p&gt;The failure I care about is not a red job. A red job I will see. The failure is a green job&lt;br&gt;
with nothing in it, on the morning a post was due — the run succeeds, finds a token it&lt;br&gt;
cannot use, and reports success at having done nothing.&lt;/p&gt;

&lt;p&gt;So the token check runs first, on every run, before the job looks at whether there is even&lt;br&gt;
anything to post. A quiet day is exactly when a dead token goes unnoticed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s1"&gt;'https://www.linkedin.com/oauth/v2/introspectToken'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'client_id=…'&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'client_secret=…'&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'token=…'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"expires_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1762&lt;/span&gt;&lt;span class="err"&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;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openid,profile,w_member_social"&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;Introspection takes the app's own credentials rather than the token's authority, so a&lt;br&gt;
scheduled job can read a token's status without being able to use it. Under fourteen days&lt;br&gt;
left, my job prints a warning. Expired or revoked, it fails outright and posts nothing.&lt;/p&gt;

&lt;p&gt;One thing a date comparison misses. A token can be active, unexpired, and still unable to&lt;br&gt;
post, because LinkedIn invalidates every existing token when you request a different scope&lt;br&gt;
set. Renew with a shorter list of ticked boxes than last time and you get exactly that: a&lt;br&gt;
valid token that 403s on the only call you wanted. Check the &lt;code&gt;scope&lt;/code&gt; string in the&lt;br&gt;
introspection response, not only &lt;code&gt;active&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  You cannot ask LinkedIn what you already posted
&lt;/h2&gt;

&lt;p&gt;The obvious way to make a posting job safe to re-run is the way I built the dev.to&lt;br&gt;
cross-poster: fetch what is already on the account, match it, skip or update.&lt;/p&gt;

&lt;p&gt;Reading your own posts needs &lt;code&gt;r_member_social&lt;/code&gt;. The permissions table describes it as&lt;br&gt;
restricted and available to approved users only, alongside the self-serve write scope that&lt;br&gt;
sits directly above it. You can write to the account and you cannot read it back.&lt;/p&gt;

&lt;p&gt;That has one consequence worth stating plainly: the only record of what you have posted is&lt;br&gt;
the one you keep. I keep a JSON file in the repo mapping post slug to the returned&lt;br&gt;
URN, and the CI job commits it back to the default branch after a run. It is written after&lt;br&gt;
every single post rather than at the end of the run, because a job that dies halfway must not&lt;br&gt;
forget what it already put on a public feed.&lt;/p&gt;

&lt;p&gt;That file being committed by a machine is the second exception in this repo to a rule I&lt;br&gt;
otherwise hold to — never commit to the default branch. It earned the exception. Without it,&lt;br&gt;
every scheduled run re-posts the entire backlog.&lt;/p&gt;
&lt;h2&gt;
  
  
  A post cannot be re-run into place
&lt;/h2&gt;

&lt;p&gt;The dev.to cross-poster is idempotent in the strong sense: articles are matched by their&lt;br&gt;
canonical URL, so a second run edits the article the first run made. Running it twice is free,&lt;br&gt;
and a mistake is one re-run away from fixed.&lt;/p&gt;

&lt;p&gt;LinkedIn gives you half of that. The &lt;code&gt;commentary&lt;/code&gt; field can be patched. The link card attached&lt;br&gt;
to the post cannot. There is no version of "run it again and it converges".&lt;/p&gt;

&lt;p&gt;Three things followed from that, and all three are the opposite of how I built the dev.to job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No push trigger.&lt;/strong&gt; A commit landing on the default branch must not put anything on a feed
by itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cap of one new post per run.&lt;/strong&gt; Not an API limit — LinkedIn allows around 150 requests
per member per day, and a post costs about four. It is a feed limit. Past roughly one a day
the reach of each one drops, and a burst of daily self-links to one domain reads as
automation whether or not it is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A confirmation prompt when a human is present&lt;/strong&gt;, and an explicit flag when one is not, so
an unattended post is always something a config file asked for.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  A filename became a link to a host that does not exist
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;commentary&lt;/code&gt; field is not plain text. It is a format LinkedIn calls &lt;code&gt;little&lt;/code&gt;, and it&lt;br&gt;
reserves fourteen characters — &lt;code&gt;| { } @ [ ] ( ) &amp;lt; &amp;gt; # \ * _ ~&lt;/code&gt; — every one of which must be&lt;br&gt;
backslash-escaped even where it is obviously not markup. An unescaped &lt;code&gt;(&lt;/code&gt; in a title does&lt;br&gt;
not render as a bracket. It fails the whole request. Hashtags are a template rather than a&lt;br&gt;
literal, so &lt;code&gt;#gamedev&lt;/code&gt; is sent as &lt;code&gt;{hashtag|\#|gamedev}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I got all of that right, and shipped a bug anyway.&lt;/p&gt;

&lt;p&gt;My first automated post contained the word &lt;code&gt;mainTemplate.gradle&lt;/code&gt;, in a sentence about reading&lt;br&gt;
Android build logs. LinkedIn published it as a hyperlink to &lt;code&gt;http://maintemplate.gradle/&lt;/code&gt; — a&lt;br&gt;
host that does not exist and never will. It autolinks anything shaped like a domain and does&lt;br&gt;
not check that the domain resolves.&lt;/p&gt;

&lt;p&gt;There is no fix inside the format. Dots are not escapable. A zero-width character between the&lt;br&gt;
name and the extension would break anyone copying the text. The only fix is to not write the&lt;br&gt;
token, so the check I added scans the composed text, strips real URLs first, allows my own&lt;br&gt;
domain, and refuses to publish when anything domain-shaped survives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LinkedIn would publish these as links to hosts that do not exist:
  unity-gradle-build-failed-is-not-the-error: mainTemplate.gradle
Rewrite them without the dot — "a mainTemplate gradle file". Nothing was posted.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refused rather than warned about. A warning on a step nobody watches is how the first one&lt;br&gt;
shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order to do it in
&lt;/h2&gt;

&lt;p&gt;If you are starting this tomorrow, this is the sequence that would have saved me the&lt;br&gt;
afternoon. All of it is free — no paid tier, no partner programme, no approval queue.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a LinkedIn Page. You need one before an app exists, and the description wants 250
characters.&lt;/li&gt;
&lt;li&gt;Create the app against that Page, then verify it from the Settings tab. An unverified app
offers no products, and the error you get instead points at permissions.&lt;/li&gt;
&lt;li&gt;Add both products: Share on LinkedIn, and Sign In with LinkedIn using OpenID Connect.&lt;/li&gt;
&lt;li&gt;Mint a token in the portal's generator with &lt;code&gt;openid&lt;/code&gt;, &lt;code&gt;profile&lt;/code&gt; and &lt;code&gt;w_member_social&lt;/code&gt;.
Write those three down somewhere; you need the identical set at every renewal.&lt;/li&gt;
&lt;li&gt;Post one thing by hand first, with a confirmation prompt, before any of it runs unattended.
The request body is the easy part to get right and the easy part to get wrong.&lt;/li&gt;
&lt;li&gt;Then, and only then, the guards: token introspection before anything else in the run, a
ledger written after every post, no push trigger, and a scan for domain-shaped words.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I would tell someone starting this tomorrow
&lt;/h2&gt;

&lt;p&gt;The posting call is the easy part and you will have it working in twenty minutes. Budget the&lt;br&gt;
afternoon for the other four: a credential that expires six times a year with no way to renew&lt;br&gt;
it in code, a read permission you cannot have, a publish operation with no undo, and a text&lt;br&gt;
format with a rule about dots that is documented nowhere.&lt;/p&gt;

&lt;p&gt;If you only build one guard, build the token one. Everything else fails loudly on its own.&lt;br&gt;
That one fails silently, and it fails on the exact day you had something to say.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/posting-to-linkedin-from-ci/" rel="noopener noreferrer"&gt;Four things break when CI posts to LinkedIn&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>githubactions</category>
      <category>linkedin</category>
    </item>
    <item>
      <title>The check that passed while broken</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Sat, 26 Sep 2026 18:43:19 +0000</pubDate>
      <link>https://dev.to/indiecoredev/the-check-that-passed-while-broken-d33</link>
      <guid>https://dev.to/indiecoredev/the-check-that-passed-while-broken-d33</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/the-check-that-passed-while-broken/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I wrote &lt;a href="https://www.indiecore.net/blog/build-reviews-ai-code/" rel="noopener noreferrer"&gt;a verifier that reviews my code so I don't have to&lt;/a&gt;.&lt;br&gt;
It has grown since. Every rule in it exists because something got past me once, which is the&lt;br&gt;
only rule I have for adding rules.&lt;/p&gt;

&lt;p&gt;Working through a batch of changes recently I found two of those rules had stopped checking&lt;br&gt;
anything. Not failing — passing, on every build, while testing nothing. One had been that way&lt;br&gt;
for months. The other I introduced and shipped in the same afternoon.&lt;/p&gt;

&lt;p&gt;Neither is an interesting bug. That is rather the problem with them.&lt;/p&gt;
&lt;h2&gt;
  
  
  One: a guard clause that turned into an off switch
&lt;/h2&gt;

&lt;p&gt;My privacy policy claims the site writes exactly one thing to your device and names it. That&lt;br&gt;
is checkable, so it is checked: read the JavaScript bundle, pull out every storage key, and&lt;br&gt;
fail if the policy does not mention one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;appJs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DIST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assets/app.js&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;pri&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DIST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;privacy/index.html&lt;/span&gt;&lt;span class="dl"&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;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appJs&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;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pri&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// …extract keys, compare against the policy…&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reasonable-looking defensive code. Then I fingerprinted the bundle for cache-busting, so&lt;br&gt;
&lt;code&gt;app.js&lt;/code&gt; became &lt;code&gt;app.4ba98ea8.js&lt;/code&gt;, and &lt;code&gt;fs.existsSync&lt;/code&gt; started returning false.&lt;/p&gt;

&lt;p&gt;The check did not fail. There was nothing to fail on. The &lt;code&gt;if&lt;/code&gt; was false, the block never&lt;br&gt;
ran, and the build printed &lt;code&gt;passed — 0 errors&lt;/code&gt; exactly as it always had. A promise in a legal&lt;br&gt;
document went unguarded and the only signal was an absence.&lt;/p&gt;

&lt;p&gt;The fix is to resolve the file by shape, and to treat a missing bundle as an error rather&lt;br&gt;
than as permission to skip:&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;bundles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DIST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assets&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;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sr"&gt;/^app&lt;/span&gt;&lt;span class="se"&gt;\.[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;js$/&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;f&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;bundles&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;!==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/assets/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`expected exactly one app.&amp;lt;hash&amp;gt;.js bundle, found &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bundles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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 general shape: &lt;code&gt;if (thing exists) { check it }&lt;/code&gt; is a check that disables itself the day&lt;br&gt;
the thing moves. If the thing not existing is itself a problem, say so.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two: a space
&lt;/h2&gt;

&lt;p&gt;Later the same week I added a check that every font weight has a metric-matched fallback.&lt;br&gt;
It parses &lt;code&gt;@font-face&lt;/code&gt; blocks out of the built CSS:&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;faces&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;css&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/@font-face&lt;/span&gt;&lt;span class="se"&gt;\{([^&lt;/span&gt;&lt;span class="sr"&gt;}&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)\}&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Green immediately. I deleted a fallback on purpose to watch it complain, and it stayed green.&lt;/p&gt;

&lt;p&gt;My build produces the font CSS two ways. The stylesheet is minified through a&lt;br&gt;
&lt;code&gt;.replace(/\s+/g, ' ')&lt;/code&gt; pass, which leaves &lt;code&gt;@font-face { … }&lt;/code&gt; with a space before the brace.&lt;br&gt;
The fallback rules are generated as strings by the same script, already compact, and come out&lt;br&gt;
as &lt;code&gt;@font-face{…}&lt;/code&gt; without one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'@font-face[ ]\?{'&lt;/span&gt; dist/index.html | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
   4 @font-face &lt;span class="o"&gt;{&lt;/span&gt;
   3 @font-face&lt;span class="o"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My regex matched three of the seven. It could see the rules I was generating and none of the&lt;br&gt;
real ones, so "is every declared weight covered" was comparing a list against itself. Adding&lt;br&gt;
&lt;code&gt;\s*&lt;/code&gt; fixed it. The sabotage then produced what I wanted the first time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR  fonts: Plus Jakarta Sans 600 has no metric-matched fallback — its swap
       will shift layout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Sabotage is the only evidence
&lt;/h2&gt;

&lt;p&gt;Both of these passed code review, because both are correct-looking code. Both passed the&lt;br&gt;
build, because passing is what they were doing wrong. There is no output to inspect and no&lt;br&gt;
red line to notice. A check that runs and finds nothing looks identical to a check that&lt;br&gt;
cannot find anything.&lt;/p&gt;

&lt;p&gt;So the habit I have landed on: after writing a check, break the thing it protects and watch&lt;br&gt;
it scream. Then put it back. It takes about a minute and it is the only step that&lt;br&gt;
distinguishes a check from a comment.&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;npm run check | &lt;span class="nb"&gt;grep &lt;/span&gt;ERROR | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-2&lt;/span&gt;
  ERROR  /legal/index.html: .toc is styled by 8 selector&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in &lt;/span&gt;src/styles.css
         but only 1 survived this page&lt;span class="s1"&gt;'s inlined CSS
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a check I trust, because I have seen it work. Nine went into the build during this&lt;br&gt;
round of changes and every one of them was confirmed the same way — including a couple that&lt;br&gt;
needed fixing first, of which one is described above.&lt;/p&gt;

&lt;p&gt;It matters more with a model writing the code. Not because the code is worse — most of it is&lt;br&gt;
fine — but because it arrives faster than I can read it properly, and the thing I have&lt;br&gt;
substituted for reading is the build. Every check I do not verify is a hole in the substitute,&lt;br&gt;
and holes in it are invisible by construction.&lt;/p&gt;

&lt;p&gt;The nastiest version is the one that starts working and quietly stops, like the first example&lt;br&gt;
here. It passed for months. Nothing changed about the check. Something changed underneath it,&lt;br&gt;
in a different commit, for an unrelated reason, and no one was ever going to be told.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/the-check-that-passed-while-broken/" rel="noopener noreferrer"&gt;The check that passed while broken&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The code is on GitHub: &lt;a href="https://gist.github.com/IndieCoreDev/6c707bf89e5d215225f3bdac6e4a4b25" rel="noopener noreferrer"&gt;The verifier, with both checks fixed&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>testing</category>
      <category>workflow</category>
      <category>ai</category>
    </item>
    <item>
      <title>Testing a level pack you did not write</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Fri, 25 Sep 2026 19:33:27 +0000</pubDate>
      <link>https://dev.to/indiecoredev/testing-a-level-pack-you-did-not-write-hbf</link>
      <guid>https://dev.to/indiecoredev/testing-a-level-pack-you-did-not-write-hbf</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/testing-a-level-pack-you-did-not-write/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part 7 of 10 on building a word-block puzzle engine. &lt;a href="https://www.indiecore.net/blog/shipping-a-4mb-level-pack/" rel="noopener noreferrer"&gt;Part 6: shipping a 4 MB level pack.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The failure mode of a bad content pack is not a crash. It is a level that cannot be solved,&lt;br&gt;
discovered by a player, three weeks after release, in a review.&lt;/p&gt;

&lt;p&gt;Your unit tests will not find it. They test the code, and the code is correct — it is faithfully&lt;br&gt;
rendering an impossible board.&lt;/p&gt;
&lt;h2&gt;
  
  
  The whole test file
&lt;/h2&gt;

&lt;p&gt;Every game on the engine has exactly one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;describeGameShell&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;@gamefactory/word-engine/testing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;describeGameShell&lt;/span&gt;&lt;span class="p"&gt;(&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;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not a simplification for the article. That is the file.&lt;/p&gt;

&lt;p&gt;The suites live in the engine, not in the game, because they check things that must be true of&lt;br&gt;
&lt;em&gt;any&lt;/em&gt; game built on it. A copy of the suite in each app is a copy that drifts, and the sibling&lt;br&gt;
whose copy is stale is precisely the one that ships the broken pack.&lt;/p&gt;

&lt;p&gt;It takes &lt;code&gt;import.meta.url&lt;/code&gt; rather than a path so a game does not have to know how deep its own&lt;br&gt;
test file sits. The day that changes, it changes in one place.&lt;/p&gt;
&lt;h2&gt;
  
  
  Four things it checks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;pack-suite.js&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The suite a game runs against its own content pack.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// The failure mode of a bad pack is not a crash. It is a level that cannot be&lt;/span&gt;
&lt;span class="c1"&gt;// solved, discovered by a player, three weeks after release, in a review. Unit&lt;/span&gt;
&lt;span class="c1"&gt;// tests will not find it: they test the code, and the code is faithfully&lt;/span&gt;
&lt;span class="c1"&gt;// rendering an impossible board.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// So these check things that must be true of ANY game built on the engine, and&lt;/span&gt;
&lt;span class="c1"&gt;// they live in the ENGINE, not in the game. A copy of the suite in each app is&lt;/span&gt;
&lt;span class="c1"&gt;// a copy that drifts, and the sibling whose copy is stale is precisely the one&lt;/span&gt;
&lt;span class="c1"&gt;// that ships the broken pack.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// A game's whole test file is therefore:&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;//     import { describeGameShell } from '@your-scope/engine/testing'&lt;/span&gt;
&lt;span class="c1"&gt;//     describeGameShell(import.meta.url)&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// import.meta.url rather than a path, so a game does not have to know how deep&lt;/span&gt;
&lt;span class="c1"&gt;// its own test file sits. The day that changes, it changes in one place.&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;describe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&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;vitest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&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;appDirOf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readPack&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readBrand&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;./paths.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&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;walk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;summarise&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;./walk.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;describeGameShell&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testFileUrl&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;appDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;appDirOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testFileUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;describeShell&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appDir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;describePackDeclaration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appDir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;describeOpeningLayout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appDir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;describeBoardWalk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appDir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * The shell still holds nothing but identity.
 *
 * Structural, and the reason it is worth a test: this is what stops "just this
 * once, for this game" from quietly becoming a second codebase.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;describeShell&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appDir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shell&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contains only identity — no components, store or game logic&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="o"&gt;=&amp;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;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appDir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/brand.js&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;src/main.jsx&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="p"&gt;});&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * brand.json is honest about the pack it points at.
 *
 * Quiet when wrong: the leaderboard ceiling is stageCount * 3, so an inflated
 * count creates a score nobody can reach and a deflated one truncates the
 * campaign. Neither throws.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;describePackDeclaration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appDir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pack declaration&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stageCount matches the catalogue on disk&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="o"&gt;=&amp;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;brand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readBrand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appDir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;catalog&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readPack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stageCount&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Every opening arrangement is legible, and is not the answer.
 *
 * The scramble rule is re-checked HERE, against the shipped data, in a third
 * implementation — the solver has one and the runtime fallback dealer has one.
 * Three, because it is the constraint whose violation is invisible in a
 * screenshot and obvious the moment a player notices it.
 *
 * A piece's implied board origin is `seat - home`. Two pieces sharing an origin
 * are sitting in their solved relationship, which hands the player part of the
 * answer. Pieces that are not neighbours in the solution are left alone: at
 * their home offset they do not touch, so there is nothing there to read.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;describeOpeningLayout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appDir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;opening layout&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no two filled cells overlap, and nothing overlaps the board&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;board&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;eachBoard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appDir&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;overlappingCellPairs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&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="p"&gt;});&lt;/span&gt;

    &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no two solution-neighbours share an implied origin&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;board&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;eachBoard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appDir&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;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;neighbourPairs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;originOf&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;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;originOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Every board can be walked to a solve — and the shipped metrics are true.
 *
 * The second assertion is the one worth copying. Three numbers ride on every
 * board, written months ago by a different implementation in another repo. The
 * runtime walk recomputes them and they must match exactly.
 *
 * That single check ties together the generator's difficulty model, the ratings
 * the campaign order is built from, the power-ups that use the walk, and the
 * coin rewards priced off the decision load. If any one drifts from the others
 * the game keeps working and starts LYING — prices stop matching difficulty, a
 * level rated 8.1 plays like a 4, and nothing throws.
 *
 * Deliberately not sampled. A sampled content check is a check that passes on
 * the run where it mattered: the interesting board is always the one you did
 * not draw.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;describeBoardWalk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appDir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;board walk&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;every board walks to a complete solve&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;board&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;eachBoard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appDir&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;steps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pieces&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reproduces the metrics the generator shipped&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;board&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;eachBoard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appDir&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;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;summarise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forced&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeCloseTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forcedShare&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeCloseTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;averageChoices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hardestStep&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;// What this suite cannot tell you, and a green run is persuasive enough that it&lt;/span&gt;
&lt;span class="c1"&gt;// is worth writing down: whether a level is GOOD. Solvable, legible, correctly&lt;/span&gt;
&lt;span class="c1"&gt;// rated and fairly priced are all machine-checkable. Satisfying is not.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Every board can be walked to a solve.&lt;/strong&gt; The difficulty walk from part 3 is a solver: fill the&lt;br&gt;
topmost-leftmost empty cell with any block in hand that fits, and continue. Running it to&lt;br&gt;
completion on all 392 boards proves each one has at least one full tiling reachable by the&lt;br&gt;
procedure the game itself uses for its power-ups. A board that dead-ends is a board that will&lt;br&gt;
strand a player.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every opening arrangement is legible and not the answer.&lt;/strong&gt; No two filled cells overlap. Nothing&lt;br&gt;
overlaps the board. The clearance gap holds. And the scramble rule from part 5 — no two blocks&lt;br&gt;
that are neighbours in the solution may share an implied origin — is re-checked here, in a third&lt;br&gt;
implementation, against the shipped data rather than against the solver's intent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;brand.json&lt;/code&gt; is honest about the pack it points at.&lt;/strong&gt; The declaration says &lt;code&gt;stageCount: 392&lt;/code&gt;.&lt;br&gt;
The catalogue on disk says how many boards there actually are. If those disagree the build is&lt;br&gt;
wrong in a way that is quiet: the leaderboard ceiling is &lt;code&gt;stageCount × 3&lt;/code&gt;, so an inflated count&lt;br&gt;
creates a score nobody can reach, and a deflated one truncates the campaign.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The shell still holds nothing but identity.&lt;/strong&gt; The structural assertion from part 1. It fails if&lt;br&gt;
a component, a store or a piece of game logic appears in an app directory, which is what stops&lt;br&gt;
"just this once, for this game" from quietly becoming a second codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check I value most
&lt;/h2&gt;

&lt;p&gt;Separately from the suite, the economy simulator runs a &lt;code&gt;--test&lt;/code&gt; mode that asserts something&lt;br&gt;
narrow and load-bearing: &lt;strong&gt;the walk implemented in the engine reproduces, board for board, the&lt;br&gt;
metrics the generator shipped.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three numbers ride on every board — &lt;code&gt;forcedShare&lt;/code&gt;, &lt;code&gt;averageChoices&lt;/code&gt;, &lt;code&gt;hardestStep&lt;/code&gt; — written by a&lt;br&gt;
tool in another repo, by a different implementation, months ago. The runtime walk recomputes them&lt;br&gt;
and they must match exactly, or the build fails.&lt;/p&gt;

&lt;p&gt;That single assertion ties together things that would otherwise drift silently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the generator's model of difficulty&lt;/li&gt;
&lt;li&gt;the ratings the campaign order is built from&lt;/li&gt;
&lt;li&gt;the power-ups that use the walk to decide what to place or flag&lt;/li&gt;
&lt;li&gt;the coin rewards priced off the decision load (part 8)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any one of those four drifts from the others, the game keeps working and starts lying. Prices&lt;br&gt;
stop matching difficulty; a level rated 8.1 plays like a 4. Nothing throws. It is exactly the&lt;br&gt;
category of bug that is undetectable without a cross-implementation check, and it is why I did&lt;br&gt;
not let the runtime walk be an approximation of the offline one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing content is slow, and that is the point
&lt;/h2&gt;

&lt;p&gt;Walking 392 boards is not a fast test. It is seconds, not milliseconds, and it reads a few&lt;br&gt;
megabytes off disk.&lt;/p&gt;

&lt;p&gt;I let it be slow rather than sampling. A sampled content check is a check that passes on the run&lt;br&gt;
where it mattered — the interesting board is always the one you did not draw. If it ever gets&lt;br&gt;
slow enough to be a problem, the fix is to run it on the pack-build and the release build rather&lt;br&gt;
than on every save, not to look at fewer boards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the suite refuses to help
&lt;/h2&gt;

&lt;p&gt;Two things it cannot tell you, worth being clear about because a green suite is persuasive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It cannot tell you a level is good.&lt;/strong&gt; Solvable, legible, correctly rated and fairly priced —&lt;br&gt;
all machine-checkable. Whether the board is &lt;em&gt;satisfying&lt;/em&gt; is not, and no amount of this replaces&lt;br&gt;
playing them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It cannot check the one board it cannot enumerate.&lt;/strong&gt; From part 2, a single 13×13 has too many&lt;br&gt;
tilings to prove the scoring rule against in reasonable time. It ships flagged. The suite walks&lt;br&gt;
it to a solve like any other, which shows it is playable, and says nothing about whether some&lt;br&gt;
other arrangement of its pieces scores higher. That is a known, written-down hole rather than an&lt;br&gt;
assumption, which is the most I can do with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I would generalise
&lt;/h2&gt;

&lt;p&gt;If your content comes from a generator — yours or anyone's — the tests that matter are the ones&lt;br&gt;
that &lt;strong&gt;re-derive the generator's claims from the shipped artifact&lt;/strong&gt;, not the ones that check your&lt;br&gt;
rendering code.&lt;/p&gt;

&lt;p&gt;Every genuine content bug I have had was of the form "the data says X and the data is wrong". A&lt;br&gt;
test that mocks the data cannot see any of them.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next:&lt;/strong&gt; part 8, &lt;a href="https://www.indiecore.net/blog/pricing-an-economy-off-its-difficulty-model/" rel="noopener noreferrer"&gt;pricing an economy off the difficulty model&lt;/a&gt; — what happens when reward is a flat rate and the levels are not.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/testing-a-level-pack-you-did-not-write/" rel="noopener noreferrer"&gt;Testing a level pack you did not write&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The code is on GitHub: &lt;a href="https://gist.github.com/IndieCoreDev/95eb02285852062865e861573d020faa" rel="noopener noreferrer"&gt;The whole conformance suite&lt;/a&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>gamedev</category>
      <category>puzzle</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Shipping a 4 MB level pack</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Fri, 25 Sep 2026 19:33:26 +0000</pubDate>
      <link>https://dev.to/indiecoredev/shipping-a-4-mb-level-pack-3ej2</link>
      <guid>https://dev.to/indiecoredev/shipping-a-4-mb-level-pack-3ej2</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/shipping-a-4mb-level-pack/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part 6 of 10 on building a word-block puzzle engine. &lt;a href="https://www.indiecore.net/blog/opening-layout-is-part-of-the-puzzle/" rel="noopener noreferrer"&gt;Part 5: the opening layout.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The level pack is 395 JSON files: 392 boards, a catalogue, an index, and a build record. On disk&lt;br&gt;
in the repo it is &lt;strong&gt;4.37 MB&lt;/strong&gt;, and every byte of that was in the app I first put on the store.&lt;/p&gt;

&lt;p&gt;None of it needed to be.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pretty in the repo, minified in the store
&lt;/h2&gt;

&lt;p&gt;The boards come out of the generator pretty-printed, and they should. They are reviewed in pull&lt;br&gt;
requests, diffed when the generator changes, and read by hand when a board plays wrong. A&lt;br&gt;
minified 11 KB board file is not something anyone can look at.&lt;/p&gt;

&lt;p&gt;But nothing reads that whitespace at runtime. It is &lt;strong&gt;45% of the pack&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stage pack 4.37 MB -&amp;gt; 2.36 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the shrink happens on the way into the build output, never in &lt;code&gt;public/&lt;/code&gt;. The repo copies stay&lt;br&gt;
diffable, the shipped copies are one line each, and no one has to remember which is which.&lt;/p&gt;

&lt;p&gt;While it is in there, it deletes &lt;code&gt;index.json&lt;/code&gt; — 92 KB that is the generator's own build record.&lt;br&gt;
The game only ever fetches &lt;code&gt;catalog.json&lt;/code&gt; and individual boards. It took grepping the whole&lt;br&gt;
engine to be sure of that, which is a reminder that "which of these files does the app actually&lt;br&gt;
open" is worth asking of any content folder.&lt;/p&gt;
&lt;h2&gt;
  
  
  The hook choice, and the error it hid
&lt;/h2&gt;

&lt;p&gt;The plugin started on &lt;code&gt;closeBundle&lt;/code&gt;. That was wrong twice.&lt;/p&gt;

&lt;p&gt;The first failure was mundane: Vite may load a config from a temp file elsewhere on disk, so&lt;br&gt;
&lt;code&gt;import.meta.dirname&lt;/code&gt; pointed at &lt;code&gt;node_modules/.vite-temp/&lt;/code&gt;. The out directory has to come from&lt;br&gt;
the resolved config instead, via &lt;code&gt;configResolved&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The second failure cost real time. &lt;code&gt;closeBundle&lt;/code&gt; &lt;strong&gt;also fires when the build has failed&lt;/strong&gt;. So a&lt;br&gt;
genuine error — an import that could not be resolved — tore down the bundle, my hook ran against&lt;br&gt;
a &lt;code&gt;dist/&lt;/code&gt; that had never been created, and the only thing printed was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[shrink-stage-pack] ENOENT: no such file or directory,
    scandir '…/dist/stages-blocks-en'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I spent twenty minutes debugging the plugin. The plugin was fine; it was standing on top of the&lt;br&gt;
real error.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;writeBundle&lt;/code&gt; is the correct hook and it fixes both. It runs only on a successful write, and Vite&lt;br&gt;
copies &lt;code&gt;publicDir&lt;/code&gt; into &lt;code&gt;outDir&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; the write phase, so the files are already there.&lt;/p&gt;

&lt;p&gt;The general shape of this bug is worth carrying: &lt;strong&gt;a cleanup hook that runs on the failure path&lt;br&gt;
and throws will replace every error with its own.&lt;/strong&gt; If you write one, either guard it or pick a&lt;br&gt;
hook that does not run when things went wrong.&lt;/p&gt;

&lt;p&gt;The whole plugin, both fixes included:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;shrink-stage-pack.js&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Shrink a content pack on its way into dist/.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// The level files come out of the generator pretty-printed, and they should:&lt;/span&gt;
&lt;span class="c1"&gt;// they are reviewed in pull requests, diffed when the generator changes, and&lt;/span&gt;
&lt;span class="c1"&gt;// read by hand when a level plays wrong. But nothing reads that whitespace at&lt;/span&gt;
&lt;span class="c1"&gt;// runtime, and it is 45% of the pack — 4.37 MB down to 2.36 MB.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// This runs over dist/, never over public/, so the repo copies stay diffable&lt;/span&gt;
&lt;span class="c1"&gt;// and the shipped copies are one line each.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// TWO THINGS THAT LOOK LIKE DETAILS AND ARE NOT:&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// 1. `writeBundle`, not `closeBundle`. closeBundle ALSO fires when the build&lt;/span&gt;
&lt;span class="c1"&gt;//    failed, so a cleanup hook there will throw its own ENOENT about a dist/&lt;/span&gt;
&lt;span class="c1"&gt;//    that was never created — and replace the real error with it. Vite copies&lt;/span&gt;
&lt;span class="c1"&gt;//    publicDir into outDir *before* the write phase, so at writeBundle the&lt;/span&gt;
&lt;span class="c1"&gt;//    files are already there.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// 2. outDir comes from `configResolved`, not `import.meta`. Vite may load a&lt;/span&gt;
&lt;span class="c1"&gt;//    config from a temp file elsewhere on disk, which makes import.meta.dirname&lt;/span&gt;
&lt;span class="c1"&gt;//    point at node_modules/.vite-temp/.&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;readdirSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;statSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rmSync&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;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&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;join&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;node:path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * @param {{ content: { stagesDir: string } }} brand
 *   Which folder to shrink is the game's to declare, not the plugin's to
 *   hardcode — otherwise every sibling game carries a copy of this plugin with
 *   its own folder name baked in.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shrinkStagePack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brand&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;outDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dist&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shrink-stage-pack&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;build&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="nf"&gt;configResolved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;outDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;

    &lt;span class="nf"&gt;writeBundle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// stagesDir is the URL the app fetches from ('/levels'), which is&lt;/span&gt;
      &lt;span class="c1"&gt;// publicDir-relative, so it lands at this path inside dist/.&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stagesDir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;before&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;before&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;statSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// The generator's own build record. The game only ever fetches the&lt;/span&gt;
        &lt;span class="c1"&gt;// catalogue and individual levels — worth grepping for before you&lt;/span&gt;
        &lt;span class="c1"&gt;// delete anything from a content folder.&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;file&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;index.json&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;rmSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
        &lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&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;after&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;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="c1"&gt;// Printed on every build. This line is how a pack rebuild that quietly&lt;/span&gt;
      &lt;span class="c1"&gt;// shipped 30 extra levels got noticed months later. A number printed on&lt;/span&gt;
      &lt;span class="c1"&gt;// every build is a regression test that costs nothing.&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mb&lt;/span&gt; &lt;span class="o"&gt;=&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="o"&gt;=&amp;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;n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; MB`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`stage pack &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;mb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;mb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;after&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="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;h2&gt;
  
  
  Which folder? Ask the brand
&lt;/h2&gt;

&lt;p&gt;The first version had the pack directory hard-coded. That is fine with one game and wrong with&lt;br&gt;
two, since the sibling ships a different language pack under a different folder name.&lt;/p&gt;

&lt;p&gt;The plugin takes it from the game's &lt;code&gt;brand.json&lt;/code&gt; instead — the same declaration the app hands&lt;br&gt;
the engine at startup. One plugin, in the shared build config, and every game on the engine gets&lt;br&gt;
it without carrying a copy with its own folder name baked in.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it is worth in the download
&lt;/h2&gt;

&lt;p&gt;Compressed inside an Android App Bundle, the pack is &lt;strong&gt;0.61 MB&lt;/strong&gt; and the dictionary another&lt;br&gt;
&lt;strong&gt;0.33 MB&lt;/strong&gt;. Against a total delivered download of 5.88 MB, content is about 16%.&lt;/p&gt;

&lt;p&gt;That ratio is the useful thing to know. Before I measured it I assumed the level data was the&lt;br&gt;
problem and the code was fine. It was the other way round by a factor of four — the Android&lt;br&gt;
runtime was the problem, which is part 9. Minifying the pack was worth doing and it was not&lt;br&gt;
where the download was going.&lt;/p&gt;
&lt;h2&gt;
  
  
  Loading: one board at a time
&lt;/h2&gt;

&lt;p&gt;The runtime side is deliberately boring.&lt;/p&gt;

&lt;p&gt;At startup the game fetches &lt;code&gt;catalog.json&lt;/code&gt; — one small file with every board's identity and&lt;br&gt;
rating, enough to build the whole campaign order without touching a single board. Boards are&lt;br&gt;
~11 KB each and are fetched when a level opens, then cached.&lt;/p&gt;

&lt;p&gt;The catalogue is fetched rather than imported. That matters more than it sounds: bundling it&lt;br&gt;
would let a stale copy survive in &lt;code&gt;src/&lt;/code&gt; after the pack was rebuilt, and the failure mode is a&lt;br&gt;
campaign ordered from ratings that no longer match the boards being played.&lt;/p&gt;

&lt;p&gt;The dictionary is 877 KB and is read as a &lt;strong&gt;string&lt;/strong&gt;, not parsed. It is packed one line per word&lt;br&gt;
length:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3=aahabaabbabcabyaceactadd…
4=…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten lines. A word costs exactly its letters, lookup is an offset and a slice, and nothing builds&lt;br&gt;
a hundred thousand objects on the main thread while the player waits. That format came from&lt;br&gt;
measuring: &lt;code&gt;JSON.parse&lt;/code&gt; on an equivalent object map was the single slowest thing in startup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

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

&lt;p&gt;&lt;strong&gt;Measure before optimising the obvious thing.&lt;/strong&gt; I minified the pack first because it was the&lt;br&gt;
biggest number in &lt;code&gt;du -sh&lt;/code&gt;. The biggest number in &lt;code&gt;du -sh&lt;/code&gt; is not the biggest number in the&lt;br&gt;
download, because everything in the bundle is compressed and JSON compresses extremely well.&lt;br&gt;
2 MB of saved JSON became about 0.4 MB of saved download.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put the size in the pipeline.&lt;/strong&gt; The plugin prints its before and after on every build. That&lt;br&gt;
line is how I noticed, months later, that a pack rebuild had shipped 30 extra boards nobody had&lt;br&gt;
mentioned. A number printed on every build is a regression test that costs nothing.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next:&lt;/strong&gt; part 7, &lt;a href="https://www.indiecore.net/blog/testing-a-level-pack-you-did-not-write/" rel="noopener noreferrer"&gt;testing a level pack you did not write&lt;/a&gt; — the suite that walks all 392 boards to a solve before any of them reach a player.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/shipping-a-4mb-level-pack/" rel="noopener noreferrer"&gt;Shipping a 4 MB level pack&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The code is on GitHub: &lt;a href="https://gist.github.com/IndieCoreDev/788e88e15747179b5ee493057be4821e" rel="noopener noreferrer"&gt;The plugin, both fixes included&lt;/a&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>capacitor</category>
      <category>vite</category>
      <category>performance</category>
    </item>
    <item>
      <title>A release pipeline you cannot forget</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Thu, 24 Sep 2026 19:30:07 +0000</pubDate>
      <link>https://dev.to/indiecoredev/a-release-pipeline-you-cannot-forget-mo8</link>
      <guid>https://dev.to/indiecoredev/a-release-pipeline-you-cannot-forget-mo8</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/release-pipeline-you-cannot-forget/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part 10 of 10 on building a word-block puzzle engine. &lt;a href="https://www.indiecore.net/blog/capacitor-android-build-apk-size/" rel="noopener noreferrer"&gt;Part 9: cutting a Capacitor Android build in half.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The last part is the least interesting to build and the one that has saved me the most: what&lt;br&gt;
happens between "the game works" and "the artifact is on the store".&lt;/p&gt;

&lt;p&gt;Every check below exists because something got past me once.&lt;/p&gt;
&lt;h2&gt;
  
  
  android/ is generated, not committed
&lt;/h2&gt;

&lt;p&gt;Capacitor's &lt;code&gt;android/&lt;/code&gt; directory is scaffolding it wrote. Committing it means a Capacitor or AGP&lt;br&gt;
upgrade is a hand-merge, per game, forever — and my two games had already diverged in their&lt;br&gt;
Gradle files by the time I noticed.&lt;/p&gt;

&lt;p&gt;So it is not committed. It is regenerated, then overwritten from templates every game shares:&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="nb"&gt;cd &lt;/span&gt;apps/&amp;lt;game&amp;gt;
npx cap add android        &lt;span class="c"&gt;# once, or after rm -rf android&lt;/span&gt;
npx game-native android    &lt;span class="c"&gt;# apply the shared templates&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The templates carry everything from part 9 — the R8 block, the packaging excludes, the ProGuard&lt;br&gt;
rules — plus the manifest, the signing config and the Gradle properties. An upgrade is one&lt;br&gt;
template edit and a regenerate per game.&lt;/p&gt;

&lt;p&gt;Placeholders get substituted per game: application id, version, and the prefix for the signing&lt;br&gt;
properties.&lt;/p&gt;
&lt;h2&gt;
  
  
  The version code that reset itself to 1
&lt;/h2&gt;

&lt;p&gt;This is the one that nearly cost me the app.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;android/app/build.gradle&lt;/code&gt; holds &lt;code&gt;versionCode&lt;/code&gt;, and it is the file Play reads, so it was the&lt;br&gt;
source of truth. Reasonable, and correct right up until &lt;code&gt;android/&lt;/code&gt; became a generated directory.&lt;/p&gt;

&lt;p&gt;The first &lt;code&gt;rm -rf android &amp;amp;&amp;amp; npx cap add android&lt;/code&gt; reset the version code to &lt;strong&gt;1&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Play refuses an upload whose version code is not higher than the last one it accepted. The app&lt;br&gt;
was at build 9. There is no way to lower that ceiling, no support ticket that resets it, and a&lt;br&gt;
generated file cannot carry the number across a regenerate. Had I not caught it before uploading,&lt;br&gt;
that package name could never have been updated again.&lt;/p&gt;

&lt;p&gt;The number now lives in &lt;code&gt;package.json&lt;/code&gt;, which survives a regenerate, and the template takes it as&lt;br&gt;
a substitution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0.6"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"buildNumber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&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;build.gradle&lt;/code&gt; and the Xcode project are mirrors, written by the release script, never edited by&lt;br&gt;
hand. The general rule I took from it: &lt;strong&gt;a value that must survive forever cannot live in a file&lt;br&gt;
you delete.&lt;/strong&gt; Obvious in hindsight, invisible while writing the generator.&lt;/p&gt;
&lt;h2&gt;
  
  
  Blockers and warnings are different things
&lt;/h2&gt;

&lt;p&gt;The release script refuses to build on some findings and merely prints others. That split took me&lt;br&gt;
two attempts to get right.&lt;/p&gt;

&lt;p&gt;The rule I settled on: &lt;strong&gt;a blocker is something that gets baked into the artifact and cannot be&lt;br&gt;
walked back once it is on a track.&lt;/strong&gt; A warning is something wrong elsewhere in the repo that this&lt;br&gt;
build does not carry.&lt;/p&gt;

&lt;p&gt;Blockers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A placeholder &lt;code&gt;google-services.json&lt;/code&gt; — that ships a dead backend inside the bundle.&lt;/li&gt;
&lt;li&gt;The services file registered to a different package name than the one being built.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Warnings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No ad units for the platform I am not shipping today.&lt;/li&gt;
&lt;li&gt;A web config that only affects &lt;code&gt;npm run dev&lt;/code&gt; in a browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Getting this wrong in the safe direction is not safe. My first version blocked on everything, and&lt;br&gt;
within a fortnight I was passing &lt;code&gt;--force&lt;/code&gt; reflexively, which is the same as having no checks at&lt;br&gt;
all. A check you learn to override is worse than one you never wrote, because it costs you the&lt;br&gt;
belief that the green ones mean something.&lt;/p&gt;
&lt;h2&gt;
  
  
  Verify the bytes, not the command
&lt;/h2&gt;

&lt;p&gt;Two checks in this category, and they are the ones I would port to any project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The build mode is stamped into the HTML.&lt;/strong&gt; Release builds lock levels and use live ad units,&lt;br&gt;
driven by an environment variable — which means a bundle built without it looks identical from&lt;br&gt;
the outside. A Vite plugin injects the answer into &lt;code&gt;index.html&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"build-mode"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"release"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The release script then reads that meta tag out of &lt;code&gt;android/app/src/main/assets/public/index.html&lt;/code&gt;&lt;br&gt;
— the copy Capacitor actually synced, not the one in &lt;code&gt;dist/&lt;/code&gt; — and aborts before Gradle runs if&lt;br&gt;
it says &lt;code&gt;dev&lt;/code&gt;. That distinction is the entire value: a stale &lt;code&gt;dist/&lt;/code&gt; from a hand-run build is&lt;br&gt;
exactly the mistake it is there to catch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The signature is read back off the artifact.&lt;/strong&gt; Trusting the signing config in &lt;code&gt;build.gradle&lt;/code&gt; is&lt;br&gt;
trusting that Gradle read the properties it needed. An unsigned or debug-signed bundle is rejected&lt;br&gt;
by Play, hours later, after an upload. &lt;code&gt;keytool -printcert -jarfile&lt;/code&gt; on the finished &lt;code&gt;.aab&lt;/code&gt; costs&lt;br&gt;
nothing and answers the question directly.&lt;/p&gt;
&lt;h2&gt;
  
  
  Signing config that does not block a new game
&lt;/h2&gt;

&lt;p&gt;A small thing that mattered more than expected. The signing block reads properties from outside&lt;br&gt;
the repo — the keystore password is never committed — and Gradle evaluates that block for &lt;em&gt;every&lt;/em&gt;&lt;br&gt;
variant.&lt;/p&gt;

&lt;p&gt;Without a guard, a freshly scaffolded game cannot build even a debug APK until someone has&lt;br&gt;
created its keystore alias. That is exactly backwards: a new game should run on a device long&lt;br&gt;
before anyone thinks about releasing it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gradle"&gt;&lt;code&gt;&lt;span class="n"&gt;signingConfigs&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;release&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasProperty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'GAME_UPLOAD_STORE_FILE'&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;storeFile&lt;/span&gt; &lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GAME_UPLOAD_STORE_FILE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="err"&gt;…&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A release build with the properties missing falls through to no signing config and Gradle says so&lt;br&gt;
in plain language, which is the right failure and a legible one.&lt;/p&gt;
&lt;h2&gt;
  
  
  A size budget, printed every time
&lt;/h2&gt;

&lt;p&gt;From part 9: the &lt;code&gt;.aab&lt;/code&gt; on disk is not the download. So the script computes the delivered estimate&lt;br&gt;
and prints it under the file size, with a soft budget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;release/game-1.0.6-10.aab  9.4 MB
5.9 MB downloaded by an xxhdpi phone

! that is over the 7 MB budget — check what grew before uploading.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A budget, not a limit. Crossing it means something large came back, and the place to find that&lt;br&gt;
out is the terminal rather than the Play console two days later. Making it fail the build would&lt;br&gt;
put it in the same category as the over-eager blockers above.&lt;/p&gt;
&lt;h2&gt;
  
  
  One script, every game
&lt;/h2&gt;

&lt;p&gt;All of this lives in the shared tooling, run from a game's directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx game-release &lt;span class="nt"&gt;--patch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bumps the version everywhere it is mirrored, builds the web bundle with the native flags, syncs,&lt;br&gt;
verifies the stamp, builds the bundle, collects it into &lt;code&gt;release/&lt;/code&gt;, checks the signature, prints&lt;br&gt;
the sizes.&lt;/p&gt;

&lt;p&gt;The argument for sharing it is the same as everything else in this series, and it is the one&lt;br&gt;
sentence I would keep if I had to throw the other nine parts away:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every check here was learned the expensive way on one game and is worth exactly as much to the&lt;br&gt;
next. A copy of the script per game is a copy that gets one of those fixes and not the others.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The series
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://www.indiecore.net/blog/one-engine-many-games/" rel="noopener noreferrer"&gt;One engine, many games&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.indiecore.net/blog/generate-levels-offline-not-at-runtime/" rel="noopener noreferrer"&gt;Generate levels offline, not at runtime&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.indiecore.net/blog/rating-puzzle-difficulty-by-decisions/" rel="noopener noreferrer"&gt;Rating puzzle difficulty by decisions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.indiecore.net/blog/difficulty-rates-progression-places/" rel="noopener noreferrer"&gt;Difficulty rates a stage, progression places it&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.indiecore.net/blog/opening-layout-is-part-of-the-puzzle/" rel="noopener noreferrer"&gt;The opening layout is part of the puzzle&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.indiecore.net/blog/shipping-a-4mb-level-pack/" rel="noopener noreferrer"&gt;Shipping a 4 MB level pack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.indiecore.net/blog/testing-a-level-pack-you-did-not-write/" rel="noopener noreferrer"&gt;Testing a level pack you did not write&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.indiecore.net/blog/pricing-an-economy-off-its-difficulty-model/" rel="noopener noreferrer"&gt;Pricing an economy off its difficulty model&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.indiecore.net/blog/capacitor-android-build-apk-size/" rel="noopener noreferrer"&gt;Cutting a Capacitor Android build in half&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A release pipeline you cannot forget&lt;/strong&gt; — you are here&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/release-pipeline-you-cannot-forget/" rel="noopener noreferrer"&gt;A release pipeline you cannot forget&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>capacitor</category>
      <category>android</category>
      <category>release</category>
    </item>
    <item>
      <title>Rating puzzle difficulty by decisions</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Thu, 24 Sep 2026 19:30:05 +0000</pubDate>
      <link>https://dev.to/indiecoredev/rating-puzzle-difficulty-by-decisions-3k5b</link>
      <guid>https://dev.to/indiecoredev/rating-puzzle-difficulty-by-decisions-3k5b</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/rating-puzzle-difficulty-by-decisions/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part 3 of 10 on building a word-block puzzle engine. &lt;a href="https://www.indiecore.net/blog/generate-levels-offline-not-at-runtime/" rel="noopener noreferrer"&gt;Part 2: generate levels offline.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every puzzle game has to answer "how hard is this level" before a player ever plays it, and the&lt;br&gt;
proxies within easy reach are all bad. Grid size is bad. Piece count is bad. Number of words is&lt;br&gt;
worse than either. I shipped a first pass ordered by grid size and it produced a curve that&lt;br&gt;
went sideways for eighty levels and then fell off a cliff.&lt;/p&gt;

&lt;p&gt;The rating I use now measures the one thing that actually varies: how much the player has to&lt;br&gt;
work out.&lt;/p&gt;
&lt;h2&gt;
  
  
  The insight that made it measurable
&lt;/h2&gt;

&lt;p&gt;The player never sees the answer. The blocks carry letters, but they cannot be matched against&lt;br&gt;
anything — there is no ghost image of the finished board. So the board is a &lt;strong&gt;fitting problem&lt;/strong&gt;,&lt;br&gt;
and its difficulty is how often the fit is forced and how often it is a guess.&lt;/p&gt;

&lt;p&gt;That suggests a procedure, and the procedure is what a person actually does with a jigsaw:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Fill the topmost-leftmost empty square. Count how many blocks still in hand could go there.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One means the board placed the block for you and cost you nothing. Five means a real decision,&lt;br&gt;
and a wrong answer that will have to be undone later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;walk.js&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Rating a fitting puzzle by the decisions it forces.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// The player never sees the answer, so the board is a fitting problem and its&lt;/span&gt;
&lt;span class="c1"&gt;// difficulty is how much has to be worked out. That suggests a procedure, and&lt;/span&gt;
&lt;span class="c1"&gt;// the procedure is what a person actually does with a jigsaw:&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;//     fill the topmost-leftmost empty cell,&lt;/span&gt;
&lt;span class="c1"&gt;//     count how many pieces still in hand could go there.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// One means the board placed the piece for you and cost you nothing. Five means&lt;/span&gt;
&lt;span class="c1"&gt;// a real decision, and a wrong answer that will have to be undone.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// Everything downstream is arithmetic on the list this returns: the difficulty&lt;/span&gt;
&lt;span class="c1"&gt;// rating, the coin reward, and the two power-ups (place a piece / flag a wrong&lt;/span&gt;
&lt;span class="c1"&gt;// one) which are this same walk with a different question asked of it.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// Cost: at most ~30 pieces per board, so a walk is a few thousand cell tests.&lt;/span&gt;
&lt;span class="c1"&gt;// Well under a frame on a phone.&lt;/span&gt;

&lt;span class="cm"&gt;/** Occupied cells of a piece, relative to its own bounding box. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pieceCells&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;piece&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nx"&gt;piece&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shape&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;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&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="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;line&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;ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="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;ch&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;out&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;r&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;ch&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Memoised onto the piece. Boards arrive from two places — a script reading the&lt;/span&gt;
&lt;span class="c1"&gt;// pack off disk, and a fetch in the browser — and only the first had any reason&lt;/span&gt;
&lt;span class="c1"&gt;// to decorate them. Going through this means a board works the same whichever&lt;/span&gt;
&lt;span class="c1"&gt;// door it came in.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cellsOf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;piece&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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;piece&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_cells&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;piece&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_cells&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pieceCells&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;piece&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;piece&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_cells&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Walk a board and return the branching factor at each step.
 *
 * `preplaced` models a power-up that puts a piece down for the player.
 * `revealedRows` / `revealedCols` model a hint that shows true letters: a
 * candidate piece must then match every revealed letter it would cover.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;walk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;preplaced&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;revealedRows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;revealedCols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&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;board&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;occ&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="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;C&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="kc"&gt;null&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;hand&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;Map&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;p&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pieces&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;hand&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;p&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;p&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;put&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hc&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;home&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;r&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="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;cellsOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;occ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hr&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;hc&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&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;hand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;preplaced&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;hand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;known&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;revealedRows&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;r&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;revealedCols&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;c&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;steps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&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;hand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// The topmost-leftmost empty cell is the next thing to fill.&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;tr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;tc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;outer&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;r&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;r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;r&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;c&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;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;c&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;occ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;tc&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="k"&gt;break&lt;/span&gt; &lt;span class="nx"&gt;outer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tr&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// How many pieces in hand could legally cover that cell, at any offset.&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;choices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;hand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;fits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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;anchor&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;cellsOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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;or&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tr&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;anchor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;r&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;oc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tc&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;anchor&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;or&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;oc&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;or&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;p&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;R&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;oc&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;p&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;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;C&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;r&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;ch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;cellsOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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;occ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;or&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;oc&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="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;known&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;or&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;oc&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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;solution&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;or&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;oc&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="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="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;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="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;fits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;choices&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;steps&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;choices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Then place the piece that genuinely belongs there and move on.&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;truth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="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;p&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;hand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hc&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;home&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="nf"&gt;cellsOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;hr&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;tr&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hc&lt;/span&gt; &lt;span class="o"&gt;+&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;tc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;truth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="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;truth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;truth&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;steps&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;Walk the whole board that way and you get a list of branching factors, one per step. Everything&lt;br&gt;
else is arithmetic on that list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;board       pieces   forced steps   average choice   worst step
8x8             13            38%              3.5            11
10x10           17            35%              3.9            12
17x10           25            32%              5.6            18
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three numbers ship on every board — plus a fourth the economy needs, which is the same list&lt;br&gt;
summed:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;summarise.js&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The numbers the walk is for.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// `walk()` returns one branching factor per step. Everything downstream is&lt;/span&gt;
&lt;span class="c1"&gt;// arithmetic on that list — three numbers ship on every board and are checked&lt;/span&gt;
&lt;span class="c1"&gt;// against the generator's own figures at build time, and the fourth is the one&lt;/span&gt;
&lt;span class="c1"&gt;// the economy needs.&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * The three numbers that ship on every board, plus the one the economy needs.
 *
 * `decisions` is the sum of (choices - 1) over the whole solve — the total
 * count of real choices the board forced. It runs 7.5 on the easiest tier to
 * 96.7 on the hardest, a 13x spread, which is why nothing in the economy is a
 * flat rate.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;summarise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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;steps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;steps&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;forced&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;avg&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;worst&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;decisions&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;steps&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;s&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&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="na"&gt;steps&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;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;forced&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;filter&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;x&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;length&lt;/span&gt; &lt;span class="o"&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;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;avg&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;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;decisions&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;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why the walk had to be exact
&lt;/h2&gt;

&lt;p&gt;It would be tempting to approximate — sample a few steps, or estimate from piece shapes. I did&lt;br&gt;
not, for a reason that only became obvious later: &lt;strong&gt;the same walk runs on the device&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Two power-ups need it. One places a block for the player, which means choosing &lt;em&gt;which&lt;/em&gt; block&lt;br&gt;
placement helps most; the other flags a block that is currently wrong. Both are the walk with a&lt;br&gt;
different question asked of it. And the economy (part 8) prices a level off the total decision&lt;br&gt;
load, which is the same list summed.&lt;/p&gt;

&lt;p&gt;So the walk exists three times — in the offline rater, in an independent verifier, and in the&lt;br&gt;
engine at runtime — and a test asserts that the runtime one reproduces the numbers the rater&lt;br&gt;
shipped, board for board, or the build fails. Approximation would have made that impossible to&lt;br&gt;
check.&lt;/p&gt;

&lt;p&gt;The cost on device is nothing to worry about: at most 30 blocks per board, so a walk is a few&lt;br&gt;
thousand cell tests, and choosing the best anchor is thirty of those. Well under a frame, and&lt;br&gt;
it only runs when someone taps a power-up.&lt;/p&gt;
&lt;h2&gt;
  
  
  One number hides too much
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;overall: 7.2&lt;/code&gt; tells a player nothing, and it told me nothing either — two boards rating the&lt;br&gt;
same felt completely different to play. So the rating carries four dimensions, each a percentile&lt;br&gt;
within the library, on one 0–10 scale:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;dimension&lt;/th&gt;
&lt;th&gt;what it measures&lt;/th&gt;
&lt;th&gt;weight&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deduction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;how often a genuine choice has to be made&lt;/td&gt;
&lt;td&gt;0.35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;peak&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the worst single decision on the board&lt;/td&gt;
&lt;td&gt;0.25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;scale&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;how much there is to place at all&lt;/td&gt;
&lt;td&gt;0.25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sameness&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;blocks sharing an outline, which the eye cannot separate&lt;/td&gt;
&lt;td&gt;0.15&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;sameness&lt;/code&gt; is the one I would not have thought of from first principles. A board where six&lt;br&gt;
blocks are the same silhouette is harder in a way none of the other three catch, because the&lt;br&gt;
difficulty is not deduction — it is that you cannot &lt;em&gt;see&lt;/em&gt; the difference and have to try them.&lt;/p&gt;

&lt;p&gt;What is deliberately &lt;strong&gt;not&lt;/strong&gt; in the list matters as much. The reference model this follows has&lt;br&gt;
dimensions for precision, reaction, memory, time pressure and randomness. This game has no&lt;br&gt;
timer, no dexterity and no luck, so those are left out rather than filled with zeroes. A&lt;br&gt;
dimension that is always 0 is a column of noise that dilutes every weight next to it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Ratings must overlap across sizes
&lt;/h2&gt;

&lt;p&gt;Here is the test that told me the model worked. If difficulty were really about size, ratings&lt;br&gt;
would form disjoint bands: all the 8×8s below all the 10×10s below all the 13×13s.&lt;/p&gt;

&lt;p&gt;They do not. Across the library ratings run 0.6 to 9.3, and a 10×10 spans &lt;strong&gt;4.0 to 7.7&lt;/strong&gt; — it&lt;br&gt;
meets the 8×8s below it and the 13×13s above it. There are 10×10 boards harder than most 13×13s,&lt;br&gt;
which matches how they play and is invisible to any size-based proxy.&lt;/p&gt;

&lt;p&gt;That overlap is also what makes a smooth campaign possible at all. If every size were its own&lt;br&gt;
band you could only ramp difficulty by ramping size, and the player would watch the board grow&lt;br&gt;
rather than the puzzle deepen.&lt;/p&gt;
&lt;h2&gt;
  
  
  Turning a number into something a player can read
&lt;/h2&gt;

&lt;p&gt;Nobody reads "6.4 / 10, deduction 9.2" and knows what they are in for. So there is exactly one&lt;br&gt;
module that turns the model into player-facing language, and every difficulty surface in the&lt;br&gt;
game — the level grid, the briefing card, the in-game banner — reads from it. Three surfaces&lt;br&gt;
computing their own tiers is three surfaces that eventually disagree.&lt;/p&gt;

&lt;p&gt;The tier boundaries are the pack's own quartiles, not invented round numbers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;tiers.js&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Turning a 0-10 rating into something a player can read.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// Nobody reads "6.4 / 10, deduction 9.2" and knows what they are in for. This&lt;/span&gt;
&lt;span class="c1"&gt;// is the one place the model becomes language, and every difficulty surface in&lt;/span&gt;
&lt;span class="c1"&gt;// the game reads from here — the level grid, the briefing card, the in-game&lt;/span&gt;
&lt;span class="c1"&gt;// banner. Three surfaces computing their own tiers is three surfaces that&lt;/span&gt;
&lt;span class="c1"&gt;// eventually disagree.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// The words are deliberately NOT here. A tier carries a `key` and the caller&lt;/span&gt;
&lt;span class="c1"&gt;// looks it up in the string catalogue, which is what lets the model ship in&lt;/span&gt;
&lt;span class="c1"&gt;// seventeen languages without seventeen copies of the quartiles.&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Tier boundaries are the pack's own quartiles, not invented round numbers:
 * p25 / p50 / p75 / p90 of the shipped library.
 *
 * That splits 392 boards 23 / 25 / 24 / 15 / 12 percent, so the top tier stays
 * rare enough to mean something when one turns up. Round numbers put 60% of the
 * library in a single tier.
 *
 * Rebuild the pack and these want re-measuring — nothing else will tell you.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TIER_BOUNDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;2.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;5.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;8.0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TIERS&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="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;easy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="na"&gt;level&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="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#10b981&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;deep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#047857&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;steady&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#38bdf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;deep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#0369a1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#062f4a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tricky&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#f59e0b&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;deep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#b45309&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#3b2503&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="na"&gt;level&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="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#f43f5e&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;deep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#9f1239&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expert&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#a855f7&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;deep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#6b21a8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&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="cm"&gt;/** The tier a 0-10 rating falls in. Unrated boards fall back to the middle. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tierFor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;overall&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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;overall&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;overall&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;TIERS&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="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="k"&gt;while &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;TIER_BOUNDS&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;overall&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;TIER_BOUNDS&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;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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;TIERS&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="p"&gt;};&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * The four dimensions behind the single number, each a percentile within the
 * library, all on one 0-10 scale.
 *
 * `sameness` is the one that is not obvious from first principles: a board
 * where six pieces share an outline is harder in a way the other three cannot
 * catch, because the difficulty is not deduction — it is that you cannot see
 * the difference and have to try them.
 *
 * What is left out matters as much. The reference model also lists precision,
 * reaction, memory, time pressure and randomness. This game has no timer, no
 * dexterity and no luck, so those are omitted rather than filled with zeroes —
 * a dimension that is always 0 is a column of noise diluting every weight next
 * to it.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DIMENSION_WEIGHTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;deduction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// how often a genuine choice has to be made&lt;/span&gt;
  &lt;span class="na"&gt;peak&lt;/span&gt;&lt;span class="p"&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="c1"&gt;// the worst single decision on the board&lt;/span&gt;
  &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&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="c1"&gt;// how much there is to place at all&lt;/span&gt;
  &lt;span class="na"&gt;sameness&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// pieces sharing an outline&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * The rating is a prior, not a truth.
 *
 * Every board ships `confidence: 0.2` and `sampleCount: 0` because there are no
 * players yet. This measures the *board*, which is not the same as measuring
 * the difficulty a person experiences.
 *
 * The seam is left open deliberately: when telemetry arrives, blend towards
 * observed difficulty and raise confidence with the sample count, rate-limited
 * so a handful of unusual players cannot move a rating far. Consumers already
 * read `confidence`, so the day it changes nothing downstream is rewritten.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isProvisional&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;difficulty&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;difficulty&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;confidence&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;p25, p50, p75, p90. That splits 392 boards &lt;strong&gt;23 / 25 / 24 / 15 / 12 percent&lt;/strong&gt; across five tiers,&lt;br&gt;
which keeps the top tier rare enough that seeing one means something. Round numbers would have&lt;br&gt;
put 60% of the library in one tier.&lt;/p&gt;

&lt;p&gt;Those constants are pack-specific and there is a report script that prints the current split, because&lt;br&gt;
rebuilding the pack moves the quartiles and nothing else will tell you.&lt;/p&gt;

&lt;p&gt;The words themselves are not in that module — a tier carries a key and the caller looks it up in&lt;br&gt;
the string catalogue. That split is what lets the model ship in seventeen languages without&lt;br&gt;
seventeen copies of the quartiles in them.&lt;/p&gt;
&lt;h2&gt;
  
  
  The rating is a prior, not a truth
&lt;/h2&gt;

&lt;p&gt;The most important line in the model is the one that admits what it does not know. Every board&lt;br&gt;
ships with:&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;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"sampleCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are no players yet. The rating is a measurement of the &lt;em&gt;board&lt;/em&gt;, which is not the same&lt;br&gt;
thing as a measurement of the &lt;em&gt;difficulty a person experiences&lt;/em&gt;, and pretending otherwise is how&lt;br&gt;
you end up defending a number against your own players.&lt;/p&gt;

&lt;p&gt;The seam for fixing that is deliberately left open: when telemetry arrives, blend toward&lt;br&gt;
observed difficulty and raise confidence with the sample count, with a rate limit so a handful&lt;br&gt;
of unusual players cannot move a rating far. None of that is built. But &lt;code&gt;confidence&lt;/code&gt; is in the&lt;br&gt;
data and every consumer reads it, so the day it changes nothing downstream has to be rewritten.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell someone starting this
&lt;/h2&gt;

&lt;p&gt;Find the thing the player is actually doing, and count it. Not the thing you can measure easily&lt;br&gt;
— the thing that is hard. For this game it was branching factor; for a match-3 it might be the&lt;br&gt;
number of boards reachable in one move; for a platformer, the length of the longest sequence&lt;br&gt;
with no checkpoint.&lt;/p&gt;

&lt;p&gt;And write the second implementation. The verifier that re-derives the ratings independently has&lt;br&gt;
paid for itself twice, and both times the bug was a shared assumption that a unit test of the&lt;br&gt;
first implementation was structurally incapable of finding.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next:&lt;/strong&gt; part 4, &lt;a href="https://www.indiecore.net/blog/difficulty-rates-progression-places/" rel="noopener noreferrer"&gt;difficulty rates a stage, progression places it&lt;/a&gt; — why sorting your levels by difficulty produces a terrible campaign.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/rating-puzzle-difficulty-by-decisions/" rel="noopener noreferrer"&gt;Rating puzzle difficulty by decisions&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The code is on GitHub: &lt;a href="https://gist.github.com/IndieCoreDev/02835f475522e68083df2567f1a562b5" rel="noopener noreferrer"&gt;The walk and the tiers, ready to adapt&lt;/a&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>difficulty</category>
      <category>puzzle</category>
      <category>proceduralgeneration</category>
    </item>
    <item>
      <title>Pricing an economy off its difficulty model</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Sat, 19 Sep 2026 18:12:32 +0000</pubDate>
      <link>https://dev.to/indiecoredev/pricing-an-economy-off-its-difficulty-model-5bcj</link>
      <guid>https://dev.to/indiecoredev/pricing-an-economy-off-its-difficulty-model-5bcj</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/pricing-an-economy-off-its-difficulty-model/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part 8 of 10 on building a word-block puzzle engine. &lt;a href="https://www.indiecore.net/blog/testing-a-level-pack-you-did-not-write/" rel="noopener noreferrer"&gt;Part 7: testing a level pack you did not write.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first economy paid 20 coins for finishing a level. Every level. It survived about a week of&lt;br&gt;
my own play before it was obviously broken, and the reason is a number from part 3.&lt;/p&gt;

&lt;p&gt;A level's &lt;strong&gt;decision load&lt;/strong&gt; — the total count of real choices the board forces across a full&lt;br&gt;
solve — runs from &lt;strong&gt;7.5 on the easiest tier to 96.7 on the hardest&lt;/strong&gt;. A 13× spread. A flat rate&lt;br&gt;
pays the same for a two-minute board and a twenty-minute one, which teaches the player to grind&lt;br&gt;
the easy end of whatever is unlocked and never touch the interesting boards.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reward follows the thinking, not the rating
&lt;/h2&gt;

&lt;p&gt;The payout is tied to decision load rather than the 0–10 difficulty rating, and that distinction&lt;br&gt;
is deliberate. The rating is a percentile — a comparison against other boards. The decision load&lt;br&gt;
is an absolute count of work done. Coins should pay for work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;economy.js&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A coin economy priced off the difficulty model, as data and pure functions.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// Nothing in here touches React, the store, or storage. That is not tidiness —&lt;/span&gt;
&lt;span class="c1"&gt;// it is what lets the balance SIMULATOR import the same module. A simulator&lt;/span&gt;
&lt;span class="c1"&gt;// that re-implements the economy tells you about a game you are not shipping,&lt;/span&gt;
&lt;span class="c1"&gt;// and it diverges on the first tuning pass, silently, because nothing connects&lt;/span&gt;
&lt;span class="c1"&gt;// them.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// WHERE THE NUMBERS COME FROM&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// A level's difficulty is its DECISION LOAD: walk the board the way the&lt;/span&gt;
&lt;span class="c1"&gt;// difficulty model does — fill the topmost-leftmost empty cell, count how many&lt;/span&gt;
&lt;span class="c1"&gt;// pieces in hand could go there — and sum (choices - 1) over every step.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// It runs 7.5 on the easiest tier to 96.7 on the hardest. A 13x spread, which&lt;/span&gt;
&lt;span class="c1"&gt;// is why nothing here is a flat rate. The first version paid 20 coins for&lt;/span&gt;
&lt;span class="c1"&gt;// finishing a level, every level, and taught players to grind the easy end.&lt;/span&gt;

&lt;span class="cm"&gt;/** Board size bands. Pieces per board runs 4..30, median 15. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BANDS&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;S&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;M&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;L&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bandFor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pieceCount&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;pieceCount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;S&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pieceCount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;17&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;M&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;L&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// ---------------------------------------------------------------------------&lt;/span&gt;
&lt;span class="c1"&gt;// Earning&lt;/span&gt;
&lt;span class="c1"&gt;// ---------------------------------------------------------------------------&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * What a first clear pays.
 *
 * Tied to the decision load rather than the 0-10 rating, and the distinction is
 * deliberate: the rating is a percentile — a comparison against other levels —
 * while the decision load is an absolute count of work done. Coins pay for work.
 *
 * Yields 8 coins on an Easy board, 35 on an Expert one. The floor exists so a
 * tiny 3x3 still feels like it paid something; the slope is what makes a hard
 * board worth choosing.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CLEAR_BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CLEAR_PER_DECISION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clearReward&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decisionLoad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CLEAR_BASE&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;CLEAR_PER_DECISION&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;decisionLoad&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Star bonuses, paid as a DELTA against the highest tier ever banked for that
 * level. Replaying a 3-star level pays nothing; dragging a 1-star up to 3 pays
 * the difference once and never again.
 *
 * Without the delta, replaying your easiest completed level is the optimal way
 * to earn — a broken economy and a boring game.
 *
 * These are deliberately small. Simulation put star bonuses at 45% of ALL
 * income — the single largest faucet, larger than clears, ads or the daily
 * chest — while three stars is earned on about two thirds of levels. A generous
 * bonus here is a tax-free salary rather than a reward for excellence.
 *
 * The top delta is also the ceiling on what any single purchase can unlock, so
 * it sets the price floor: if a hint costs less than this, the optimal strategy
 * is to buy hints until three stars is guaranteed, forever, and the economy
 * becomes a vending machine.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STAR_BONUS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;starDelta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tierAlreadyPaid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STAR_BONUS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;stars&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STAR_BONUS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tierAlreadyPaid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Rewarded-video multiplier on a first clear. Never offered on a replay.
 *
 * A multiplier rather than a flat coin amount, so the ad reward inherits the
 * difficulty scaling for free: doubling a 35-coin Expert clear is worth
 * watching, doubling an 8-coin Easy one is not, and the player self-selects.
 * A flat "watch for 25 coins" inverts that — farm the easiest level, watch,
 * repeat.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DOUBLER&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PEAK_DOUBLER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Daily login chest: a 7-day cycle that grows a little each time it completes,
 * then STOPS.
 *
 * The escalation is capped because the version this replaced multiplied without
 * limit — by the eighth cycle its day-7 chest paid 225 coins, more than three
 * level clears, for opening the app. Retention rewards should be worth showing
 * up for and never worth more than playing.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DAILY_CHEST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&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="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CHEST_GROWTH&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CHEST_MAX_CYCLES&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dailyChest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalDaysClaimed&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="o"&gt;=&amp;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;cycle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CHEST_MAX_CYCLES&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;totalDaysClaimed&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;DAILY_CHEST&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;day&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;totalDaysClaimed&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;DAILY_CHEST&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DAILY_CHEST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;CHEST_GROWTH&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;cycle&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// ---------------------------------------------------------------------------&lt;/span&gt;
&lt;span class="c1"&gt;// The invariants the simulator asserts (--test), rather than numbers it prints&lt;/span&gt;
&lt;span class="c1"&gt;// ---------------------------------------------------------------------------&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;//   - no sequence of legitimate actions produces unbounded coins&lt;/span&gt;
&lt;span class="c1"&gt;//   - every purchasable item is reachable within a bounded number of levels&lt;/span&gt;
&lt;span class="c1"&gt;//     from zero&lt;/span&gt;
&lt;span class="c1"&gt;//   - replaying a completed level never nets positive&lt;/span&gt;
&lt;span class="c1"&gt;//   - the difficulty walk these prices are computed from still matches the&lt;/span&gt;
&lt;span class="c1"&gt;//     metrics shipped on every board&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// The last one is the join to the rest of the system. Prices derive from the&lt;/span&gt;
&lt;span class="c1"&gt;// same walk that rates the levels and drives the power-ups. If that walk drifts,&lt;/span&gt;
&lt;span class="c1"&gt;// the game keeps running and every price silently becomes wrong.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight coins on an Easy board, 35 on an Expert one. The floor exists so a tiny 3×3 still feels&lt;br&gt;
like it paid something; the slope is what makes a hard board worth choosing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The module knows nothing about the game
&lt;/h2&gt;

&lt;p&gt;Everything the game charges, pays or caps is in one file, and nothing in that file touches React,&lt;br&gt;
the store, or storage. It is data and pure functions.&lt;/p&gt;

&lt;p&gt;That is not tidiness. It is what lets the &lt;strong&gt;simulator import the same module&lt;/strong&gt;. A balance&lt;br&gt;
simulator that re-implements the economy is a simulator that tells you about a game you are not&lt;br&gt;
shipping — and it will diverge on the first tuning pass, silently, because nothing connects them.&lt;/p&gt;

&lt;p&gt;Sharing the module means a number tuned in one place cannot drift from the other, and the balance&lt;br&gt;
the simulator reports is the balance that ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the simulation actually found
&lt;/h2&gt;

&lt;p&gt;I would not have found either of these by playing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Star bonuses were 45% of all income.&lt;/strong&gt; The single largest faucet in the game — larger than&lt;br&gt;
level completion, larger than ads, larger than the daily chest. And three stars is earned on&lt;br&gt;
about two thirds of levels, which makes a generous star bonus a tax-free salary rather than a&lt;br&gt;
reward for excellence.&lt;/p&gt;

&lt;p&gt;They came down hard, and &lt;code&gt;STAR_BONUS&lt;/code&gt; above pays them as a &lt;strong&gt;delta&lt;/strong&gt; against the highest tier&lt;br&gt;
ever banked for that level. Replaying a 3-star level pays nothing. Dragging a 1-star up to 3 pays the difference, once, ever.&lt;br&gt;
Without the delta, replaying your easiest completed level is the optimal way to earn, which is&lt;br&gt;
both a broken economy and a boring game.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The daily chest escalated without limit.&lt;/strong&gt; It grows a little each time the seven-day cycle&lt;br&gt;
completes, which is a nice feeling for the first fortnight. Left unbounded, by the eighth cycle&lt;br&gt;
the day-7 chest paid &lt;strong&gt;225 coins&lt;/strong&gt; — more than three level clears — for opening the app.&lt;br&gt;
&lt;code&gt;CHEST_MAX_CYCLES&lt;/code&gt; is the whole fix. Retention rewards should be worth showing up for and never worth more&lt;br&gt;
than playing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The price floor is set by the largest single payout
&lt;/h2&gt;

&lt;p&gt;A subtle constraint I got wrong first. The most any single purchase can unlock is bounded by the&lt;br&gt;
biggest reward it can lead to — here, the top star delta. If a hint costs less than that, the&lt;br&gt;
optimal strategy is to buy hints until three stars is guaranteed, on every level, forever. The&lt;br&gt;
economy becomes a vending machine.&lt;/p&gt;

&lt;p&gt;So the top star delta sets a floor under every price, and the floor is written down next to the&lt;br&gt;
constant that produces it. The relationship between two numbers in different sections of a file&lt;br&gt;
is exactly the thing that gets broken by someone reasonably tuning one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ads pay a multiplier, not a currency
&lt;/h2&gt;

&lt;p&gt;Rewarded video doubles a first clear and never appears on a replay.&lt;/p&gt;

&lt;p&gt;Tying the ad reward to the level rather than to a fixed coin amount means it inherits the&lt;br&gt;
difficulty scaling for free — doubling a 35-coin Expert clear is worth watching, doubling an&lt;br&gt;
8-coin Easy one is not, and the player self-selects. A flat "watch for 25 coins" would have&lt;br&gt;
inverted that: farm the easiest level, watch the ad, repeat.&lt;/p&gt;

&lt;p&gt;There is no interstitial between levels. That was a product decision rather than an economic one&lt;br&gt;
and I do not have data to defend it; what I can say is that it removed an entire class of tuning&lt;br&gt;
problem, because there was no longer a knob whose optimum is "as often as players tolerate".&lt;/p&gt;

&lt;h2&gt;
  
  
  Invariants, not just numbers
&lt;/h2&gt;

&lt;p&gt;The simulator has a &lt;code&gt;--test&lt;/code&gt; mode that asserts properties rather than printing a report:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No sequence of legitimate actions produces unbounded coins.&lt;/li&gt;
&lt;li&gt;Every purchasable item is reachable within a bounded number of levels from zero.&lt;/li&gt;
&lt;li&gt;Replaying a completed level never nets positive.&lt;/li&gt;
&lt;li&gt;The difficulty walk the prices are computed from still matches the metrics shipped on every
board (part 7).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last one is the join between this post and the rest of the series. Prices are derived from&lt;br&gt;
the same walk that rates the levels and drives the power-ups. If that walk drifts, the game keeps&lt;br&gt;
running and every price silently becomes wrong. A test that says so is worth more than a&lt;br&gt;
spreadsheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I still do not know
&lt;/h2&gt;

&lt;p&gt;Whether any of it is right. Every number here is defended against a simulation, and a simulation&lt;br&gt;
is a model of a player I invented.&lt;/p&gt;

&lt;p&gt;The honest position is that this is a &lt;em&gt;starting&lt;/em&gt; balance whose main property is being internally&lt;br&gt;
consistent and cheap to change — one file, no game code, a simulator that shares it. When real&lt;br&gt;
telemetry arrives, most of these constants will move. The structure is built so that moving them&lt;br&gt;
is a one-line change with a test run behind it, rather than an archaeology expedition through the&lt;br&gt;
UI code.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next:&lt;/strong&gt; part 9, &lt;a href="https://www.indiecore.net/blog/capacitor-android-build-apk-size/" rel="noopener noreferrer"&gt;cutting a Capacitor Android build in half&lt;/a&gt; — where the download actually goes, and the consumer ProGuard rule that cost 9,304 classes.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/pricing-an-economy-off-its-difficulty-model/" rel="noopener noreferrer"&gt;Pricing an economy off its difficulty model&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The code is on GitHub: &lt;a href="https://gist.github.com/IndieCoreDev/8f228b50bc004fc45e826368d0d1da22" rel="noopener noreferrer"&gt;The economy module the simulator shares&lt;/a&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>gameeconomy</category>
      <category>freetoplay</category>
      <category>difficulty</category>
    </item>
    <item>
      <title>The opening layout is part of the puzzle</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Fri, 18 Sep 2026 18:38:24 +0000</pubDate>
      <link>https://dev.to/indiecoredev/the-opening-layout-is-part-of-the-puzzle-37cj</link>
      <guid>https://dev.to/indiecoredev/the-opening-layout-is-part-of-the-puzzle-37cj</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/opening-layout-is-part-of-the-puzzle/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part 5 of 10 on building a word-block puzzle engine. &lt;a href="https://www.indiecore.net/blog/difficulty-rates-progression-places/" rel="noopener noreferrer"&gt;Part 4: difficulty rates, progression places.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A level opens with an empty grid and a scatter of loose blocks around it. I assumed this was&lt;br&gt;
presentation — put the pieces somewhere sensible, get on with the game.&lt;/p&gt;

&lt;p&gt;It is not presentation. It decides how big the grid is drawn, and it can give away the answer.&lt;br&gt;
Both of those took a rewrite to learn.&lt;/p&gt;
&lt;h2&gt;
  
  
  The objective is grid size, not fit
&lt;/h2&gt;

&lt;p&gt;The camera frames the board and the loose blocks together, so the zoom is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;availW&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;spanW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;availH&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;spanH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;where the span is the bounding box of the board &lt;strong&gt;and&lt;/strong&gt; every block, in cells. The board is then&lt;br&gt;
drawn at &lt;code&gt;cols × scale&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Every wasted cell of span comes straight off the size of the grid the player is reading letters&lt;br&gt;
from. On a phone that is the difference between comfortable and squinting.&lt;/p&gt;

&lt;p&gt;So this is not "does the arrangement fit on screen". It is a packing problem whose objective is&lt;br&gt;
&lt;em&gt;how large can the grid be drawn&lt;/em&gt;, and a loose arrangement that fits perfectly well can cost 20%&lt;br&gt;
of the board.&lt;/p&gt;

&lt;p&gt;The measure I report is &lt;strong&gt;kept&lt;/strong&gt;: the drawn board size as a fraction of what it would be drawn at&lt;br&gt;
with no loose blocks at all. Raw scale is not comparable between a 3×3 and a 17×10; kept is.&lt;/p&gt;
&lt;h2&gt;
  
  
  The rule that makes it hard
&lt;/h2&gt;

&lt;p&gt;Here is the trap, and it is a good one.&lt;/p&gt;

&lt;p&gt;A cut board is tiled exactly by its own blocks. So the tightest possible way to pack those blocks&lt;br&gt;
is to &lt;strong&gt;rebuild the tiling&lt;/strong&gt; — and a packer whose objective is the smallest bounding box will&lt;br&gt;
find that on its own, because it is genuinely optimal.&lt;/p&gt;

&lt;p&gt;It is also the one arrangement the game cannot use. The blocks come out already assembled and&lt;br&gt;
the level is reduced to sliding a finished slab onto the grid.&lt;/p&gt;

&lt;p&gt;The near-misses are just as bad and much easier to ship by accident: two neighbouring blocks&lt;br&gt;
sitting at the offset their solved positions have. The player sees &lt;code&gt;af&lt;/code&gt; and &lt;code&gt;fo&lt;/code&gt; already&lt;br&gt;
interlocked and has been handed a piece of the answer for free.&lt;/p&gt;

&lt;p&gt;The rule that catches all of it turned out to be one line of algebra. A block's implied board&lt;br&gt;
origin is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;origin = seat − home
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two blocks that share an origin are sitting in their solved relationship. So: &lt;strong&gt;no two blocks&lt;br&gt;
that are neighbours in the solution may share an origin.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Blocks that are not neighbours are left alone deliberately — at their home offset they do not&lt;br&gt;
touch, so there is nothing there for a player to read. Being stricter costs span for no gain.&lt;/p&gt;

&lt;p&gt;That rule is enforced at every seat inside the solver, again in the runtime fallback that deals&lt;br&gt;
a layout when a board has none stored, and a third time as a check in the test harness. Three&lt;br&gt;
places, because it is the constraint whose violation is invisible in a screenshot and obvious the&lt;br&gt;
moment a player notices it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clearance, and the corner exception
&lt;/h2&gt;

&lt;p&gt;A loose block touching the board reads as one already placed. So there is a clearance gap —&lt;br&gt;
&lt;code&gt;AIR = 0.28&lt;/code&gt; cells — between the board and anything loose.&lt;/p&gt;

&lt;p&gt;But only on the axes that matter, not diagonally. A block sitting off one of the board's corners&lt;br&gt;
overlaps it on neither axis and touches it at a single point, which no player reads as placed.&lt;/p&gt;

&lt;p&gt;Forbidding those four corner cells is expensive out of all proportion: it costs a whole column of&lt;br&gt;
margin on every side, and with it any arrangement that wraps the board on all four sides is often&lt;br&gt;
impossible at a size worth having. The tightest arrangements are built by tucking a block&lt;br&gt;
diagonally past a corner, which is precisely what the naive rule bans.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cells, not pixels — and the half-row bug
&lt;/h2&gt;

&lt;p&gt;Positions are stored as &lt;code&gt;(row, col)&lt;/code&gt; in &lt;strong&gt;cells&lt;/strong&gt;, relative to the board origin. Not pixels. That&lt;br&gt;
is what makes an arrangement solved once offline reusable on any screen, any cell size, any zoom.&lt;/p&gt;

&lt;p&gt;Fractional offsets are allowed and useful, which is where it gets subtle.&lt;/p&gt;

&lt;p&gt;Packing happens in whole cells, then each block is slid back toward the board to close most of&lt;br&gt;
the ring of air that packing needed. So a block can end up off the lattice on an axis where it&lt;br&gt;
stands clear of the board — that offset &lt;em&gt;is&lt;/em&gt; the closed gap, and it is what buys the span back.&lt;/p&gt;

&lt;p&gt;But on an axis where it shares the board's extent it has to stay whole. A block in a side strip&lt;br&gt;
sitting half a row out has its letters out of line with the rows it is about to join, and it&lt;br&gt;
looks broken. The solver enforces that while sliding, and the harness checks it independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blocks on all four sides
&lt;/h2&gt;

&lt;p&gt;Wherever an arrangement exists that puts something above, below, left and right of the board,&lt;br&gt;
that is what ships.&lt;/p&gt;

&lt;p&gt;This is a rule rather than a preference, and it costs real span. An arrangement that stacks&lt;br&gt;
everything below the board is almost always tighter. It also looks like a bug in the camera — the&lt;br&gt;
board floats at the top of the screen with a heap underneath, and the player's first read is that&lt;br&gt;
something failed to load.&lt;/p&gt;

&lt;p&gt;Symmetry buys more than it costs even when the arithmetic says otherwise. That is not a&lt;br&gt;
statement I can defend with a number, which is why it is written down as a rule with the reason&lt;br&gt;
attached, instead of being a constant somebody later "optimises".&lt;/p&gt;

&lt;h2&gt;
  
  
  What "good" means, measurably
&lt;/h2&gt;

&lt;p&gt;The harness reports four things per board, and the pipeline refuses to ship a pack that fails&lt;br&gt;
any of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;kept&lt;/strong&gt; — drawn board size as a share of the unobstructed maximum&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fill density&lt;/strong&gt; — &lt;code&gt;blockCells / (spanW × spanH − boardCells)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cells per side&lt;/strong&gt; — above / below / left / right, balanced, with the two side strips equal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;overlapping cell pairs&lt;/strong&gt; — must be zero&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bounding boxes may and should overlap; interlocking silhouettes is the whole point. Only &lt;em&gt;filled&lt;br&gt;
cells&lt;/em&gt; may not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why solve it offline
&lt;/h2&gt;

&lt;p&gt;Layout solving is the slow step of the pack pipeline by a wide margin — it is a packing search&lt;br&gt;
per board, with constraints, over 392 boards. Done at runtime it would be a visible pause on&lt;br&gt;
every level open, on the slowest device you support, for an answer that is identical every time.&lt;/p&gt;

&lt;p&gt;So the arrangement is computed once, stored in the board file, and read at level load. The&lt;br&gt;
runtime keeps a fallback dealer for boards that have no stored layout, and that fallback enforces&lt;br&gt;
the same scramble rule — because the day someone ships a pack without running the layout step,&lt;br&gt;
the failure should be a slightly loose arrangement, not a level that opens pre-solved.&lt;/p&gt;

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

&lt;p&gt;I would not have guessed that where the pieces sit was a design surface at all. It looked like&lt;br&gt;
the last 2% of polish, and it turned out to contain a real optimisation problem, a&lt;br&gt;
solution-leaking bug class, and a constraint that had to be re-implemented three times to be&lt;br&gt;
trustworthy.&lt;/p&gt;

&lt;p&gt;The tell, in hindsight: it was the only part of the level that the level data did not describe. Everything&lt;br&gt;
the board &lt;em&gt;is&lt;/em&gt; was in the file; where it &lt;em&gt;starts&lt;/em&gt; was being decided by whatever code happened to&lt;br&gt;
run first. Anything in that category is worth a second look.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next:&lt;/strong&gt; part 6, &lt;a href="https://www.indiecore.net/blog/shipping-a-4mb-level-pack/" rel="noopener noreferrer"&gt;shipping a 4 MB level pack&lt;/a&gt; — what all this data costs in the download, and how to stop paying for the parts nobody reads.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/opening-layout-is-part-of-the-puzzle/" rel="noopener noreferrer"&gt;The opening layout is part of the puzzle&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>puzzle</category>
      <category>leveldesign</category>
    </item>
    <item>
      <title>opacity 0 does not hide anything</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Fri, 18 Sep 2026 18:38:23 +0000</pubDate>
      <link>https://dev.to/indiecoredev/opacity-0-does-not-hide-anything-515p</link>
      <guid>https://dev.to/indiecoredev/opacity-0-does-not-hide-anything-515p</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/opacity-zero-is-not-hidden/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Lighthouse has a list of accessibility items it will not judge for you. &lt;em&gt;Interactive controls&lt;br&gt;
are keyboard focusable. The page has a logical tab order. Offscreen content is hidden from&lt;br&gt;
assistive technology.&lt;/em&gt; No score, no pass, no fail — a list of things a machine cannot decide.&lt;/p&gt;

&lt;p&gt;I had been treating that list as decoration. Then I actually worked it, one item at a time,&lt;br&gt;
by driving the running page rather than reading my own markup, and three of the eleven turned&lt;br&gt;
out to be real.&lt;/p&gt;

&lt;p&gt;All three were the same component.&lt;/p&gt;
&lt;h2&gt;
  
  
  Invisible is not gone
&lt;/h2&gt;

&lt;p&gt;My screenshot lightbox is built by JavaScript on page load and hidden until someone clicks a&lt;br&gt;
thumbnail. Hidden like this:&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;.lb&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;fixed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;inset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;pointer-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;opacity&lt;/span&gt; &lt;span class="m"&gt;.3s&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.lb.open&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;pointer-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a normal way to write a fade. &lt;code&gt;opacity: 0&lt;/code&gt; makes it invisible. &lt;code&gt;pointer-events: none&lt;/code&gt;&lt;br&gt;
makes it unclickable. Between them they cover mouse users completely.&lt;/p&gt;

&lt;p&gt;They do nothing at all for the keyboard. The element still has layout, still sits in the&lt;br&gt;
accessibility tree, and its three buttons are still tabbable. I counted them in the console:&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;lbButtonsTabbable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Previous screenshot&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;Next screenshot&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;Close&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nx"&gt;totalTabbable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;52&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On every game page, a keyboard user tabbing through the footer would land on three controls&lt;br&gt;
that were not on the screen. Press Enter on one and a dialog they cannot see starts&lt;br&gt;
responding.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;visibility: hidden&lt;/code&gt; fixes it, because visibility is inherited by hit-testing, focus and&lt;br&gt;
assistive technology in a way opacity is not. Tab stops went from 52 to 49.&lt;/p&gt;
&lt;h2&gt;
  
  
  Then I broke the thing that was working
&lt;/h2&gt;

&lt;p&gt;Here is the part I did not see coming. Opening the dialog does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;lb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;lb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.lb-close&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That worked before my change. After it, focus stayed on the thumbnail and the dialog opened&lt;br&gt;
behind the user — the exact failure the &lt;em&gt;managed focus&lt;/em&gt; item on that list describes, newly&lt;br&gt;
introduced by fixing a different item on the same list.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;focus()&lt;/code&gt; on an element that is &lt;code&gt;visibility: hidden&lt;/code&gt; does nothing. It does not throw. It does&lt;br&gt;
not warn. It returns, and &lt;code&gt;document.activeElement&lt;/code&gt; is whatever it was before.&lt;/p&gt;

&lt;p&gt;And at the moment &lt;code&gt;.focus()&lt;/code&gt; runs, the element still is hidden, because I had put visibility&lt;br&gt;
in the transition:&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="nt"&gt;transition&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;opacity&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;3&lt;/span&gt;&lt;span class="nt"&gt;s&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;visibility&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;3&lt;/span&gt;&lt;span class="nt"&gt;s&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a duration on it, the computed value does not flip until the transition starts on the&lt;br&gt;
next frame. The class is applied, the style says &lt;code&gt;.open&lt;/code&gt;, and &lt;code&gt;getComputedStyle&lt;/code&gt; still reports&lt;br&gt;
&lt;code&gt;hidden&lt;/code&gt; for one more tick. I confirmed that by reading it twice — immediately after the&lt;br&gt;
click, and 600 ms later:&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;same&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="nl"&gt;hasOpenClass&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="nx"&gt;visibility&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nx"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;after&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;hasOpenClass&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="nx"&gt;visibility&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visible&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&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;I tried forcing a reflow with &lt;code&gt;void lb.offsetWidth&lt;/code&gt; first. That did not help, which is the&lt;br&gt;
clue that it was never a style-flush problem. The fix is to take visibility out of the timed&lt;br&gt;
part and delay it only on the way 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;.lb&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;visibility&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;opacity&lt;/span&gt; &lt;span class="m"&gt;.3s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;visibility&lt;/span&gt; &lt;span class="m"&gt;0s&lt;/span&gt; &lt;span class="m"&gt;.3s&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.lb.open&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;visibility&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;opacity&lt;/span&gt; &lt;span class="m"&gt;.3s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;visibility&lt;/span&gt; &lt;span class="m"&gt;0s&lt;/span&gt; &lt;span class="m"&gt;0s&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero duration, no delay when opening, so it flips in the same tick and &lt;code&gt;focus()&lt;/code&gt; lands.&lt;br&gt;
Delayed by the length of the fade when closing, so the dialog does not vanish mid-animation.&lt;/p&gt;
&lt;h2&gt;
  
  
  Testing it by pressing the keys
&lt;/h2&gt;

&lt;p&gt;I could not have found either bug by reading the code, because both times the code says the&lt;br&gt;
right thing. The class gets added. &lt;code&gt;.focus()&lt;/code&gt; gets called.&lt;/p&gt;

&lt;p&gt;So I drove it over the DevTools protocol instead — real &lt;code&gt;Tab&lt;/code&gt; and &lt;code&gt;Escape&lt;/code&gt; key events&lt;br&gt;
dispatched at the page, printing &lt;code&gt;document.activeElement&lt;/code&gt; after each one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before open, focus: BUTTON[Enlarge screenshot 1 of 5].shot
after open,  focus: BUTTON[Close].lb-close
tabbing forward:    lb-prev → lb-next → lb-close → lb-prev → lb-next → lb-close
shift+tab:          lb-next
after Escape:       BUTTON[Enlarge screenshot 1 of 5].shot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole contract for a modal, visible as output. Focus enters, cycles without&lt;br&gt;
escaping, and returns to whatever opened it. The first run of that same script printed&lt;br&gt;
&lt;code&gt;BUTTON[Enlarge screenshot 1 of 5]&lt;/code&gt; on the second line, which is how I knew.&lt;/p&gt;

&lt;p&gt;The dialog also needed to say what it was. It had no &lt;code&gt;role&lt;/code&gt;, so a screen reader announced&lt;br&gt;
three buttons with no indication that anything had opened:&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;lb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;role&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;dialog&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;lb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-modal&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;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;lb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-label&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;Screenshot viewer&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;&lt;code&gt;aria-modal&lt;/code&gt; tells assistive technology to ignore the rest of the page. It does not stop Tab&lt;br&gt;
from walking out into it, so the trap is still yours to write.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one I would look at first on your site
&lt;/h2&gt;

&lt;p&gt;Any element you hide with opacity and reveal with a class. Carousels, drawers, tooltips,&lt;br&gt;
cookie banners, off-canvas menus. If it contains a link or a button, tab through your page&lt;br&gt;
with your eyes on the screen and see where focus goes when it disappears.&lt;/p&gt;

&lt;p&gt;Mine had been shipping like that for months, on six pages, and every automated audit I ran&lt;br&gt;
gave the site 100 for accessibility the entire time.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/opacity-zero-is-not-hidden/" rel="noopener noreferrer"&gt;opacity 0 does not hide anything&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The code is on GitHub: &lt;a href="https://gist.github.com/IndieCoreDev/94c7819fac39ad0f3b181aa84013a414" rel="noopener noreferrer"&gt;The lightbox, dependency-free&lt;/a&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>css</category>
      <category>javascript</category>
      <category>web</category>
    </item>
    <item>
      <title>AdMob Unity test IDs, and two error codes</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Thu, 17 Sep 2026 19:16:09 +0000</pubDate>
      <link>https://dev.to/indiecoredev/admob-unity-test-ids-and-two-error-codes-3png</link>
      <guid>https://dev.to/indiecoredev/admob-unity-test-ids-and-two-error-codes-3png</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/admob-unity-test-ids-and-two-error-codes/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The AdMob Unity test IDs are the same three strings everywhere, so there is not much to say&lt;br&gt;
about copying them. What is worth writing down is what happens when the ID is &lt;em&gt;slightly&lt;/em&gt;&lt;br&gt;
wrong, because the SDK reports the two common mistakes with completely different errors and&lt;br&gt;
only one of them looks like a mistake.&lt;/p&gt;

&lt;p&gt;This is the same lab as &lt;a href="https://www.indiecore.net/blog/admob-unity-sdk-empty-app-id/" rel="noopener noreferrer"&gt;the previous post&lt;/a&gt;: Unity&lt;br&gt;
6000.4.0f1, Google Mobile Ads 11.5.0, &lt;code&gt;play-services-ads:25.4.0&lt;/code&gt;, on a Pixel 9 Pro emulator&lt;br&gt;
running Android 16.&lt;/p&gt;
&lt;h2&gt;
  
  
  The three that work
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Banner&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ca-app-pub-3940256099942544/6300978111"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Interstitial&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ca-app-pub-3940256099942544/1033173712"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Rewarded&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ca-app-pub-3940256099942544/5224354917"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;All three loaded on a real device in one run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LAB_AD interstitial LOADED
LAB_AD banner-test LOADED
LAB_AD rewarded LOADED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app ID that goes with them is &lt;code&gt;ca-app-pub-3940256099942544~3347511713&lt;/code&gt;. Note the&lt;br&gt;
separator: an &lt;strong&gt;app&lt;/strong&gt; ID uses &lt;code&gt;~&lt;/code&gt;, an &lt;strong&gt;ad unit&lt;/strong&gt; ID uses &lt;code&gt;/&lt;/code&gt;. The prefix is identical, which&lt;br&gt;
is what makes the next section possible.&lt;/p&gt;

&lt;p&gt;You can confirm you are actually in test mode without an AdMob account open. The SDK says so&lt;br&gt;
in logcat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;I/Ads: This request is sent from a test device.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that line is absent and ads are still loading, the requests are live, and clicking one is&lt;br&gt;
the fastest way to get an account limited.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two ways to get the ID wrong
&lt;/h2&gt;

&lt;p&gt;I loaded two more banners in the same run. One used a well-formed unit that does not exist,&lt;br&gt;
the other used the app ID where the unit belongs — the copy-paste slip the shared prefix&lt;br&gt;
invites.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Bogus&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ca-app-pub-3940256099942544/0000000000"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Malformed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ca-app-pub-3940256099942544~3347511713"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// app ID, wrong place&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The results, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LAB_AD banner-malformed FAILED code=1 domain=com.google.android.gms.ads
  msg=Error building request URL: Cannot determine request type. Is your ad unit id correct?

LAB_AD banner-bogus FAILED code=3 domain=com.google.android.gms.ads
  msg=Publisher data not found. &amp;lt;https://support.google.com/admob/answer/9905175#9&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two different codes, and the difference matters more than it looks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code 1 is honest.&lt;/strong&gt; The SDK could not even build a request, and the message asks you the&lt;br&gt;
right question. You will fix this one in a minute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code 3 is the trap.&lt;/strong&gt; Code 3 is &lt;code&gt;ERROR_CODE_NO_FILL&lt;/code&gt; — the same code you get on a healthy&lt;br&gt;
integration when the network genuinely has no ad to serve. A new app with a correct setup&lt;br&gt;
sees plenty of it, so the reasonable reading of a code 3 is "no inventory yet, check back&lt;br&gt;
later". Here it was produced by an ad unit ID that does not exist, and the only thing telling&lt;br&gt;
the two apart is the message text.&lt;/p&gt;

&lt;p&gt;So read the message, not the code. &lt;code&gt;Publisher data not found&lt;/code&gt; means the unit is wrong or does&lt;br&gt;
not belong to your account. A real no-fill does not say that.&lt;/p&gt;
&lt;h2&gt;
  
  
  The first launch that never fired
&lt;/h2&gt;

&lt;p&gt;Before any of that, the run nearly produced nothing at all. On a freshly booted emulator I&lt;br&gt;
installed the build, launched it, and waited four and a half minutes. The process was alive,&lt;br&gt;
the activity was &lt;code&gt;topResumedActivity&lt;/code&gt;, Play services had logged&lt;br&gt;
&lt;code&gt;Initialized AdMob in container 26.26.34&lt;/code&gt;, and my &lt;code&gt;MobileAds.Initialize&lt;/code&gt; callback had not&lt;br&gt;
fired once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb shell am force-stop com.indiecore.admoblab
adb shell am start &lt;span class="nt"&gt;-n&lt;/span&gt; com.indiecore.admoblab/com.unity3d.player.UnityPlayerGameActivity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every callback landed within a minute of the second launch. Same APK, same emulator, one&lt;br&gt;
force-stop between them.&lt;/p&gt;

&lt;p&gt;I have no mechanism to offer for that and I am not going to invent one — I did not&lt;br&gt;
instrument the SDK's internals, so anything I said about &lt;em&gt;why&lt;/em&gt; would be a guess dressed as a&lt;br&gt;
finding. What I can say is the practical part: on a cold device or a fresh emulator, a first&lt;br&gt;
launch that produces no ad callbacks is not yet evidence of anything. Force-stop it, launch&lt;br&gt;
it again, and judge the second run. I had written most of a bug report before I tried the&lt;br&gt;
obvious thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not test
&lt;/h2&gt;

&lt;p&gt;Real ad unit IDs from a live account. Everything above uses Google's sample units, so none of&lt;br&gt;
it says anything about fill rates, eCPM, mediation behaviour or what a policy-limited account&lt;br&gt;
does. Those need a real AdMob account and real traffic, and a post claiming to have measured&lt;br&gt;
them from an emulator would be worth nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Keep the test IDs in a constant, not inline at the call site, so switching to real ones is one&lt;br&gt;
edit and one diff to review. Check for &lt;code&gt;This request is sent from a test device.&lt;/code&gt; in logcat&lt;br&gt;
before you tap anything. And when a load fails, read the message rather than the code —&lt;br&gt;
&lt;code&gt;code=3&lt;/code&gt; alone will send you off investigating an inventory problem you do not have.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/admob-unity-test-ids-and-two-error-codes/" rel="noopener noreferrer"&gt;AdMob Unity test IDs, and two error codes&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>unity3d</category>
      <category>android</category>
      <category>admob</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>Unity 6 Android build errors that exit 0</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Thu, 17 Sep 2026 19:16:08 +0000</pubDate>
      <link>https://dev.to/indiecoredev/unity-6-android-build-errors-that-exit-0-1phd</link>
      <guid>https://dev.to/indiecoredev/unity-6-android-build-errors-that-exit-0-1phd</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/unity-6-android-build-errors-that-exit-zero/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;unity 6 android build error&lt;/code&gt; is easy to deal with when Unity says the words. What cost me&lt;br&gt;
an evening was the opposite: four builds that reported success, returned exit code 0, and had&lt;br&gt;
not done the thing I asked. On a laptop you notice, eventually. In CI, exit code 0 is the&lt;br&gt;
whole signal, and a green pipeline shipped an APK that crashed on launch.&lt;/p&gt;

&lt;p&gt;These all came out of one week on Unity 6000.4.0f1, building Android from &lt;code&gt;-batchmode&lt;/code&gt; on&lt;br&gt;
macOS. Three of the four are Unity behaving as designed.&lt;/p&gt;
&lt;h2&gt;
  
  
  The build that succeeded with one error
&lt;/h2&gt;

&lt;p&gt;I was building a project with the Google Mobile Ads plugin and had deliberately left its App&lt;br&gt;
ID blank, expecting the plugin's own check to stop me. The build report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;result=Succeeded  seconds=151.1  errors=1  warnings=0
[Preprocess Player] Exception: BuildMethodException: [GoogleMobileAds]
  Android Google Mobile Ads app ID is empty.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Succeeded&lt;/code&gt;, and &lt;code&gt;errors=1&lt;/code&gt;, at the same time. Exit code 0, APK on disk, and that APK died on&lt;br&gt;
launch because the manifest was missing the value the pre-processor never got to write.&lt;/p&gt;

&lt;p&gt;The mechanism is in Unity's own stack trace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UnityEditor.Build.BuildPipelineInterfaces:InvokeCallbackInterfacesPair (…)
  (at Editor/Mono/BuildPipeline/BuildPipelineInterfaces.cs:492)
UnityEngine.Debug:LogException(Exception)
  (at Editor/Mono/BuildPipeline/BuildPipelineInterfaces.cs:505)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unity catches whatever an &lt;code&gt;IPreprocessBuildWithReport&lt;/code&gt; throws, logs it, and carries on to the&lt;br&gt;
next callback. So &lt;strong&gt;no plugin can stop your build by throwing&lt;/strong&gt;, however firmly its&lt;br&gt;
documentation says otherwise. The exception becomes a line in a log nobody reads, and the&lt;br&gt;
build continues without whatever that pre-processor was supposed to do.&lt;/p&gt;

&lt;p&gt;I wrote that one up separately, with the crash trace and the manifest dumps, in&lt;br&gt;
&lt;a href="https://www.indiecore.net/blog/admob-unity-sdk-empty-app-id/" rel="noopener noreferrer"&gt;the AdMob post&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The import that imported nothing
&lt;/h2&gt;

&lt;p&gt;Importing a &lt;code&gt;.unitypackage&lt;/code&gt; from an editor method looks like this, and does nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;AssetDatabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ImportPackage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;AssetDatabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Refresh&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;Debug&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"done"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// prints&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It printed &lt;code&gt;done&lt;/code&gt;. It exited 0. &lt;code&gt;Assets/&lt;/code&gt; was untouched — no error, no warning, no partial&lt;br&gt;
import. &lt;code&gt;ImportPackage&lt;/code&gt; schedules work on the editor's main loop, and &lt;code&gt;-quit&lt;/code&gt; tears the editor&lt;br&gt;
down before that loop runs again.&lt;/p&gt;

&lt;p&gt;The command-line argument is synchronous and does work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Unity &lt;span class="nt"&gt;-batchmode&lt;/span&gt; &lt;span class="nt"&gt;-quit&lt;/span&gt; &lt;span class="nt"&gt;-projectPath&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$P&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-importPackage&lt;/span&gt; thing.unitypackage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything asynchronous is suspect under &lt;code&gt;-quit&lt;/code&gt;. If a call returns &lt;code&gt;void&lt;/code&gt; and its work shows up&lt;br&gt;
in the Project window a moment later when you do it by hand, it will probably no-op in&lt;br&gt;
batchmode.&lt;/p&gt;
&lt;h2&gt;
  
  
  The setting that was quietly overruled
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;PlayerSettings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Android&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minSdkVersion&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AndroidSdkVersions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AndroidApiLevel23&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Unity logged this as an error and then ignored me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Minimum supported Android API level is 25 (Android 7.1 Nougat).
Please use AndroidApiLevel25 or higher.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shipped APK reported &lt;code&gt;minSdkVersion:'25'&lt;/code&gt;. The build still exited 0. A &lt;code&gt;Debug.LogError&lt;/code&gt;&lt;br&gt;
during a build is not a build failure — it does not increment the report's error count and it&lt;br&gt;
does not change the exit code. Worth knowing before you trust a grep for &lt;code&gt;error&lt;/code&gt; in a Unity&lt;br&gt;
log, because you will match hundreds of them that mean nothing.&lt;/p&gt;

&lt;p&gt;The plugin in that project logged &lt;code&gt;Verified Minimum API Level is &amp;gt;= 23.&lt;/code&gt; two lines later,&lt;br&gt;
against its own constant. Both were satisfied. Neither was right.&lt;/p&gt;
&lt;h2&gt;
  
  
  The one that failed properly
&lt;/h2&gt;

&lt;p&gt;For contrast, here is Unity refusing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LAB_RESULT result=Unknown errors=1
LAB_MSG [Build player] Error: Cannot build untitled scene.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 1, no APK. This is what a real failure looks like — &lt;code&gt;result&lt;/code&gt; is not &lt;code&gt;Succeeded&lt;/code&gt;, and&lt;br&gt;
the process returns non-zero. I had passed &lt;code&gt;scenes = new string[0]&lt;/code&gt; and never saved a scene,&lt;br&gt;
so there was nothing to build. Save one first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;scene&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EditorSceneManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewScene&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NewSceneSetup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultGameObjects&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NewSceneMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Single&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;EditorSceneManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveScene&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Assets/Scenes/Main.unity"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The useful part is the contrast. Unity &lt;em&gt;can&lt;/em&gt; fail your build and return non-zero. It just does&lt;br&gt;
not do it for any of the three cases above.&lt;/p&gt;
&lt;h2&gt;
  
  
  What to check instead of the exit code
&lt;/h2&gt;

&lt;p&gt;The build report already knows. Read both fields, not one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BuildPipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BuildPlayer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&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="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;BuildResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Succeeded&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;totalErrors&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&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="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;LogType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;LogType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;Debug&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"[&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;] &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;EditorApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;totalErrors &amp;gt; 0&lt;/code&gt; is the line that catches the swallowed pre-processor exception, and printing&lt;br&gt;
&lt;code&gt;report.steps&lt;/code&gt; is what tells you which step produced it — the console alone will not, because&lt;br&gt;
by then it is thousands of lines of Gradle output.&lt;/p&gt;

&lt;p&gt;Then check the artifact rather than the log, because a build that lies in its report will also&lt;br&gt;
lie in its log. For an Android build that is one command, using the &lt;code&gt;aapt2&lt;/code&gt; already in your&lt;br&gt;
Android SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aapt2 dump xmltree app.apk &lt;span class="nt"&gt;--file&lt;/span&gt; AndroidManifest.xml | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"YOUR_REQUIRED_META_DATA"&lt;/span&gt;
aapt2 dump badging app.apk | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"minSdkVersion|targetSdkVersion"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is what caught the missing App ID for me, after the report and the exit code had both&lt;br&gt;
said everything was fine. If a value has to be in the manifest for the app to start, assert on&lt;br&gt;
it in CI. It costs a second and it is the only check that reads what your players will run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;When a &lt;code&gt;unity build not working&lt;/code&gt; search sends you looking for the error message, consider that&lt;br&gt;
there may not be one. Unity's exit code covers a narrower set of failures than most people&lt;br&gt;
assume: it catches a build it refused to start, and not much else. A pre-processor that threw,&lt;br&gt;
a setting that was overruled, and an asynchronous call that never ran all look identical to a&lt;br&gt;
passing pipeline.&lt;/p&gt;

&lt;p&gt;Read &lt;code&gt;summary.totalErrors&lt;/code&gt;, print &lt;code&gt;report.steps&lt;/code&gt;, and inspect the file you built.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/unity-6-android-build-errors-that-exit-zero/" rel="noopener noreferrer"&gt;Unity 6 Android build errors that exit 0&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>unity3d</category>
      <category>android</category>
      <category>cicd</category>
      <category>gradle</category>
    </item>
  </channel>
</rss>
