<?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>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>
    <item>
      <title>Unity IL2CPP vs Mono on Android, measured</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Fri, 11 Sep 2026 18:44:26 +0000</pubDate>
      <link>https://dev.to/indiecoredev/unity-il2cpp-vs-mono-on-android-measured-49ke</link>
      <guid>https://dev.to/indiecoredev/unity-il2cpp-vs-mono-on-android-measured-49ke</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/unity-il2cpp-vs-mono-android-measured/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The advice you find on &lt;code&gt;unity il2cpp vs mono&lt;/code&gt; is mostly repeated from 2019: IL2CPP is faster at&lt;br&gt;
runtime, slower to build, and makes a bigger APK. I built the same project both ways on Unity&lt;br&gt;
6000.4.0f1 to see which parts still hold. One of them is backwards, and the interesting result&lt;br&gt;
is not a number at all.&lt;/p&gt;

&lt;p&gt;The project is deliberately small — one scene, one &lt;code&gt;MonoBehaviour&lt;/code&gt; doing two million&lt;br&gt;
iterations of &lt;code&gt;Math.Sqrt(i) / i&lt;/code&gt; so there is some managed work to compile. Android, release,&lt;br&gt;
built from &lt;code&gt;-batchmode&lt;/code&gt; on an M3 Max.&lt;/p&gt;
&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Mono2x&lt;/th&gt;
&lt;th&gt;IL2CPP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Build, as reported by &lt;code&gt;BuildReport&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;108.9 s&lt;/td&gt;
&lt;td&gt;230.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;APK&lt;/td&gt;
&lt;td&gt;27,301,649 bytes&lt;/td&gt;
&lt;td&gt;14,323,788 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ABI produced&lt;/td&gt;
&lt;td&gt;&lt;code&gt;armeabi-v7a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;arm64-v8a&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.dll&lt;/code&gt; files inside the APK&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;IL2CPP produced an APK 12,977,861 bytes smaller — 47.5% less&lt;/strong&gt; — and took 2.1× as long to&lt;br&gt;
build. The build-time half of the folklore is right. The size half is backwards, at least at&lt;br&gt;
this project size and on this Unity version.&lt;/p&gt;

&lt;p&gt;The native libraries say where it goes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mono, armeabi-v7a                 IL2CPP, arm64-v8a
  libunity.so        20,768,248     libunity.so       13,926,400
  libmonobdwgc-2.0.so 6,013,288     libil2cpp.so       9,651,328
  libmono-native.so   1,169,684     libc++_shared.so   1,292,904
  libMonoPosixHelper.so 350,080     + global-metadata.dat 1,555,108
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mono ships a 6 MB runtime to interpret your code, and a much larger &lt;code&gt;libunity.so&lt;/code&gt;. IL2CPP&lt;br&gt;
ships your code compiled into &lt;code&gt;libil2cpp.so&lt;/code&gt; instead. In a bigger game &lt;code&gt;libil2cpp.so&lt;/code&gt; grows&lt;br&gt;
with your codebase while Mono's runtime stays fixed, so the gap narrows — do not read 47% as a&lt;br&gt;
constant.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.dll&lt;/code&gt; count is the line I would put in front of anyone who has not thought about it. A&lt;br&gt;
Mono APK carries 101 real .NET assemblies, including yours. Renaming the file to &lt;code&gt;.zip&lt;/code&gt; and&lt;br&gt;
opening &lt;code&gt;Assembly-CSharp.dll&lt;/code&gt; in any decompiler gives back readable C#. The IL2CPP build has&lt;br&gt;
one. That is not obfuscation and IL2CPP is not a security feature, but the difference between&lt;br&gt;
"unzip it" and "reverse a native binary" is real.&lt;/p&gt;
&lt;h2&gt;
  
  
  Mono cannot target ARM64
&lt;/h2&gt;

&lt;p&gt;This is where I was wrong, and where checking mattered.&lt;/p&gt;

&lt;p&gt;I assumed Unity would refuse to &lt;em&gt;set&lt;/em&gt; ARM64 with the Mono backend. It does not:&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;PlayerSettings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetScriptingBackend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NamedBuildTarget&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;ScriptingImplementation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mono2x&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&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;targetArchitectures&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AndroidArchitecture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ARM64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// reads back as: ARM64&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No warning, no complaint. The setting sticks. The &lt;em&gt;build&lt;/em&gt; is where it fails, and the message&lt;br&gt;
is wrong about why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;LAB_CFG&lt;/span&gt;    &lt;span class="py"&gt;backend&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Mono2x arch=ARM64&lt;/span&gt;
&lt;span class="err"&gt;LAB_RESULT&lt;/span&gt; &lt;span class="py"&gt;result&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Failed errors=1&lt;/span&gt;
&lt;span class="err"&gt;[Prepare&lt;/span&gt; &lt;span class="err"&gt;For&lt;/span&gt; &lt;span class="err"&gt;Build]&lt;/span&gt; &lt;span class="py"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;UnityException: Target architecture not specified&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The architecture was specified. It is ARM64, and it is printed on the line above. Exit code 1,&lt;br&gt;
no APK. If you search that string you will find people adding architectures to a project that&lt;br&gt;
already had one.&lt;/p&gt;

&lt;p&gt;Then the case that actually costs somebody a release. Select &lt;strong&gt;both&lt;/strong&gt; ARMv7 and ARM64, which&lt;br&gt;
is what you do when you want a universal build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;LAB_CFG&lt;/span&gt;    &lt;span class="py"&gt;backend&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Mono2x arch=ARMv7, ARM64&lt;/span&gt;
&lt;span class="err"&gt;LAB_RESULT&lt;/span&gt; &lt;span class="py"&gt;result&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Succeeded errors=0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 0. No errors. No warnings. I grepped the entire build log for &lt;code&gt;arm64&lt;/code&gt;, &lt;code&gt;64-bit&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;architecture&lt;/code&gt; and found nothing. The APK it produced contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lib/armeabi-v7a/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is all. It is byte-for-byte the same size as the ARMv7-only build, 27,301,649 bytes. You&lt;br&gt;
asked for two architectures, you got one, and the build told you it succeeded. Google Play&lt;br&gt;
requires a 64-bit version of every native library, so this is a green build that gets rejected&lt;br&gt;
at upload with nothing in the Unity log to explain it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The comparison I could not make
&lt;/h2&gt;

&lt;p&gt;I could not measure which backend is faster at runtime, and the reason is the practical&lt;br&gt;
answer to &lt;code&gt;unity android il2cpp or mono&lt;/code&gt; in 2026.&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;adb shell getprop ro.product.cpu.abilist
arm64-v8a

&lt;span class="nv"&gt;$ &lt;/span&gt;adb &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; mono.apk
Failure &lt;span class="o"&gt;[&lt;/span&gt;INSTALL_FAILED_NO_MATCHING_ABIS: Failed to extract native libraries, &lt;span class="nv"&gt;res&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-113&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test device — an Android 16, API 36 emulator image — has no 32-bit support at all. The&lt;br&gt;
Mono build cannot be installed on it. Since Mono cannot produce arm64, there is no&lt;br&gt;
configuration in which a Mono build runs on that device, and every arm64-only Android device&lt;br&gt;
in the field is in the same position.&lt;/p&gt;

&lt;p&gt;So I have no &lt;code&gt;unity android mono vs il2cpp&lt;/code&gt; speed figure to give you. I also will not give you&lt;br&gt;
the IL2CPP numbers I did collect: across four cold launches the same APK measured 570, 1561,&lt;br&gt;
2020 and 2514 ms to first frame, and the managed loop took between 10 and 24 ms. A 4.4× spread&lt;br&gt;
is emulator noise, and quoting the fastest one would be a made-up result. Startup timing needs&lt;br&gt;
real hardware and many more runs than I did.&lt;/p&gt;
&lt;h2&gt;
  
  
  What this changes
&lt;/h2&gt;

&lt;p&gt;For an Android game, &lt;code&gt;unity il2cpp android&lt;/code&gt; is not a performance decision any more. It is the&lt;br&gt;
only backend that produces a binary current devices will install and Google Play will accept.&lt;br&gt;
Mono's remaining use is the editor and fast local iteration, where the shorter build genuinely&lt;br&gt;
helps.&lt;/p&gt;

&lt;p&gt;Two things to take away if you touch these settings. Set the architecture &lt;em&gt;after&lt;/em&gt; the&lt;br&gt;
backend and log what it reads back, because Unity will accept a combination it cannot build.&lt;br&gt;
And check the ABIs in the artifact rather than the project settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;unzip &lt;span class="nt"&gt;-l&lt;/span&gt; app.apk | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s2"&gt;"lib/[a-z0-9-]+/"&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line, and it catches the silent 32-bit-only build before Google Play does.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/unity-il2cpp-vs-mono-android-measured/" rel="noopener noreferrer"&gt;Unity IL2CPP vs Mono on Android, measured&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>unity3d</category>
      <category>android</category>
      <category>il2cpp</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>What AdMob Unity mediation adapter status means</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Fri, 11 Sep 2026 18:44:24 +0000</pubDate>
      <link>https://dev.to/indiecoredev/what-admob-unity-mediation-adapter-status-means-30i6</link>
      <guid>https://dev.to/indiecoredev/what-admob-unity-mediation-adapter-status-means-30i6</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/admob-unity-mediation-adapter-status/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A while back I initialised the Google Mobile Ads SDK in a Unity project with no mediation set&lt;br&gt;
up at all, and the callback handed me this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;adapter com.google.android.gms.ads.MobileAds = Ready
adapter com.google.ads.mediation.vungle.VungleMediationAdapter = NotReady
adapter com.google.ads.mediation.applovin.AppLovinMediationAdapter = NotReady
adapter com.google.ads.mediation.adcolony.AdColonyMediationAdapter = NotReady
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three adapters I had never installed, all reporting &lt;code&gt;NotReady&lt;/code&gt;. That looks like a broken&lt;br&gt;
mediation setup, and people rewrite their configuration over it. So I installed one properly&lt;br&gt;
and compared.&lt;/p&gt;

&lt;p&gt;This continues the lab from the &lt;a href="https://www.indiecore.net/blog/admob-unity-sdk-empty-app-id/" rel="noopener noreferrer"&gt;AdMob SDK post&lt;/a&gt;: Unity&lt;br&gt;
6000.4.0f1, a Pixel 9 Pro emulator on Android 16. The adapter is Liftoff Monetize 5.7.7 —&lt;br&gt;
Vungle, renamed — which is one of the three in that list.&lt;/p&gt;

&lt;p&gt;It is worth saying what this post is not. If you came from &lt;code&gt;admob unity integration&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;admob unity tutorial&lt;/code&gt;, &lt;code&gt;admob unity guide&lt;/code&gt; or &lt;code&gt;how to integrate admob in unity&lt;/code&gt;, the&lt;br&gt;
walkthrough for getting the SDK into a project and building an APK is&lt;br&gt;
&lt;a href="https://www.indiecore.net/blog/admob-unity-sdk-empty-app-id/" rel="noopener noreferrer"&gt;that post&lt;/a&gt;, not this one, and &lt;code&gt;admob unity github&lt;/code&gt; will&lt;br&gt;
take you to the plugin source. This one picks up afterwards, at the line in your log naming&lt;br&gt;
ad networks you have never heard of.&lt;/p&gt;
&lt;h2&gt;
  
  
  Read the description, not the state
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AdapterStatus&lt;/code&gt; has an &lt;code&gt;InitializationState&lt;/code&gt; and a &lt;code&gt;Description&lt;/code&gt;. Everyone logs the state. The&lt;br&gt;
description is the field that carries the information. With the Liftoff adapter genuinely&lt;br&gt;
installed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;adapters&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;4&lt;/span&gt;
&lt;span class="py"&gt;com.google.android.gms.ads.MobileAds&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;Ready    ()&lt;/span&gt;
&lt;span class="py"&gt;com.google.ads.mediation.vungle.VungleMediationAdapter&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;NotReady (Timeout.)&lt;/span&gt;
&lt;span class="py"&gt;com.google.ads.mediation.applovin.AppLovinMediationAdapter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;NotReady (Failed to create Adapter.)&lt;/span&gt;
&lt;span class="py"&gt;com.google.ads.mediation.adcolony.AdColonyMediationAdapter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;NotReady (Failed to create Adapter.)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three still say &lt;code&gt;NotReady&lt;/code&gt;. But the one I installed says &lt;strong&gt;&lt;code&gt;Timeout.&lt;/code&gt;&lt;/strong&gt; and the two I did&lt;br&gt;
not say &lt;strong&gt;&lt;code&gt;Failed to create Adapter.&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the distinction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Failed to create Adapter.&lt;/code&gt;&lt;/strong&gt; — the adapter class is not in your build. The SDK knows the
name, tried to instantiate it, and there was nothing there. Nothing is wrong. This is what
every uninstalled adapter looks like.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Timeout.&lt;/code&gt;&lt;/strong&gt; — the adapter is present and ran, and did not finish initialising. Now you
have something to fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the log that started this was never evidence of a problem. &lt;code&gt;play-services-ads&lt;/code&gt; enumerates&lt;br&gt;
adapters it has heard of, and reports on the ones that are absent in exactly the same &lt;code&gt;NotReady&lt;/code&gt;&lt;br&gt;
state as the ones that are broken. Log the description or the state alone will mislead you:&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;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;kv&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAdapterStatusMap&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;kv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&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;kv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InitializationState&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;kv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Description&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mine timed out for a reason logcat was happy to explain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;W/VungleMediationAdapter: Multiple 'appid' entries found:
  [fake-vungle-app-id, 54d153ece5b12c181f0000b4].
  Using 'fake-vungle-app-id' to initialize the Vungle SDK.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I have no Liftoff account, so AdMob served a placeholder app id and the adapter dutifully tried&lt;br&gt;
to initialise the Vungle SDK with the string &lt;code&gt;fake-vungle-app-id&lt;/code&gt;. Which is honest of it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two errors on the way in
&lt;/h2&gt;

&lt;p&gt;Installing the adapter is where the time actually goes. Both failures below are exact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scoped registry was too narrow.&lt;/strong&gt; The adapters live on OpenUPM, and the obvious scope is&lt;br&gt;
the one matching the package name:&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;"scopes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"com.google.ads.mobile"&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;An error occurred while resolving packages:
  com.google.external-dependency-manager (dependency):
  Package [com.google.external-dependency-manager@1.2.186] cannot be found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 1 in 22 seconds, never reaching the build. The adapter depends on the plugin, and&lt;br&gt;
the plugin depends on the External Dependency Manager, which is a different scope. Add it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"scopes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"com.google.ads.mobile"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"com.google.external-dependency-manager"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Then the two install methods collided.&lt;/strong&gt; My project already had the plugin installed from&lt;br&gt;
&lt;code&gt;GoogleMobileAds-v11.5.0.unitypackage&lt;/code&gt;, under &lt;code&gt;Assets/&lt;/code&gt;. The adapter pulls&lt;br&gt;
&lt;code&gt;com.google.ads.mobile@11.0.0&lt;/code&gt; from UPM, under &lt;code&gt;Packages/&lt;/code&gt;. Same files, two homes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;result=Failed  errors=238
GUID [c0080e86890c24abba53b3f4d2daf6db] for asset
  'Packages/com.google.ads.mobile/GoogleMobileAds/Editor/AndroidBuildPreProcessor.cs'
  conflicts with: …
Found plugins with same names,
  Packages/com.google.ads.mobile/Plugins/Android/googlemobileads-unity.aar and
  Assets/Plugins/Android/googlemobileads-unity.aar.
  Delete the one of the duplicate plugins.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;238 errors. Pick one install method. I deleted the &lt;code&gt;Assets/&lt;/code&gt; copy and let UPM own it, keeping&lt;br&gt;
&lt;code&gt;Assets/GoogleMobileAds/Resources/GoogleMobileAdsSettings.asset&lt;/code&gt;, which holds the App ID and is&lt;br&gt;
not part of the package.&lt;/p&gt;
&lt;h2&gt;
  
  
  The downgrade nobody mentions
&lt;/h2&gt;

&lt;p&gt;Watch what the resolver wrote into &lt;code&gt;mainTemplate.gradle&lt;/code&gt; afterwards:&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;implementation&lt;/span&gt; &lt;span class="s1"&gt;'com.google.ads.mediation:vungle:7.7.4.0'&lt;/span&gt;
&lt;span class="n"&gt;implementation&lt;/span&gt; &lt;span class="s1"&gt;'com.google.android.gms:play-services-ads:25.0.0'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;25.0.0&lt;/code&gt;. Before the switch, the &lt;code&gt;.unitypackage&lt;/code&gt; at 11.5.0 was resolving&lt;br&gt;
&lt;code&gt;play-services-ads:25.4.0&lt;/code&gt;. Taking the OpenUPM route moved me back four minor versions of the&lt;br&gt;
ads SDK, because the adapter pins &lt;code&gt;com.google.ads.mobile@11.0.0&lt;/code&gt; and that is what 11.0.0 asks&lt;br&gt;
for. Nothing warns. If you are on a recent &lt;code&gt;.unitypackage&lt;/code&gt; and add a mediation adapter from&lt;br&gt;
OpenUPM, check the resolved version afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;I nearly published a wrong number here. Comparing the mediation build against my earlier APK&lt;br&gt;
suggested the adapter cost about 11 MB, which would be alarming — but those two builds also&lt;br&gt;
differed in build type and plugin version. With a proper control, same UPM plugin, same&lt;br&gt;
development build, only the adapter changing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;APK bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plugin only&lt;/td&gt;
&lt;td&gt;30,188,822&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plugin + Liftoff Monetize&lt;/td&gt;
&lt;td&gt;30,962,294&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;773,472 bytes, 0.74 MiB, +2.6%.&lt;/strong&gt; One adapter is cheap. The point generalises: if two builds&lt;br&gt;
differ in more than one thing, the difference between them is not a measurement.&lt;/p&gt;

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

&lt;p&gt;A mediation adapter with real credentials, actually filling. That needs a Liftoff account and&lt;br&gt;
a configured waterfall in the AdMob console, so I cannot tell you anything about fill, latency&lt;br&gt;
or revenue. Everything above is about getting the adapter into the build and reading what the&lt;br&gt;
SDK says about it.&lt;/p&gt;

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

&lt;p&gt;Log &lt;code&gt;Description&lt;/code&gt; alongside &lt;code&gt;InitializationState&lt;/code&gt;, because on its own the state cannot&lt;br&gt;
distinguish an adapter you never installed from one that is failing. &lt;code&gt;Failed to create&lt;br&gt;
Adapter.&lt;/code&gt; on a network you do not use is the correct, healthy output, and it is not worth a&lt;br&gt;
single minute of debugging.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/admob-unity-mediation-adapter-status/" rel="noopener noreferrer"&gt;What AdMob Unity mediation adapter status means&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>The AdMob Unity SDK check that stops nothing</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Thu, 10 Sep 2026 18:41:22 +0000</pubDate>
      <link>https://dev.to/indiecoredev/the-admob-unity-sdk-check-that-stops-nothing-bn1</link>
      <guid>https://dev.to/indiecoredev/the-admob-unity-sdk-check-that-stops-nothing-bn1</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/admob-unity-sdk-empty-app-id/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I imported the AdMob Unity SDK into an empty Unity 6000.4.0f1 project, built a release APK,&lt;br&gt;
and then left the one field the plugin says it cannot build without empty — to see what the&lt;br&gt;
error looked like.&lt;/p&gt;

&lt;p&gt;There was no error. There was a 19,627,503-byte APK that installs, launches, and dies before&lt;br&gt;
Unity draws a single frame.&lt;/p&gt;

&lt;p&gt;Everything below came off this laptop: an M3 Max on macOS 26.5.1, Unity 6000.4.0f1 Personal&lt;br&gt;
with the Android module, and &lt;code&gt;GoogleMobileAds-v11.5.0.unitypackage&lt;/code&gt;, published four days&lt;br&gt;
before I ran this.&lt;/p&gt;

&lt;p&gt;However you phrased the search that brought you here — &lt;code&gt;admob unity integration&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;admob unity tutorial&lt;/code&gt;, &lt;code&gt;admob unity guide&lt;/code&gt;, &lt;code&gt;how to integrate admob in unity&lt;/code&gt;, or&lt;br&gt;
&lt;code&gt;admob unity github&lt;/code&gt; when you wanted the source instead of somebody's screenshots — the steps&lt;br&gt;
are further down, under Getting it to work. The reason they are not at&lt;br&gt;
the top is that the interesting part of this run was the failure the plugin promises to give&lt;br&gt;
you and does not.&lt;/p&gt;
&lt;h2&gt;
  
  
  What version 11.5.0 actually pulls in
&lt;/h2&gt;

&lt;p&gt;Before building anything, it is worth reading what the package declares, because the numbers&lt;br&gt;
people quote in forum threads are usually two years old. &lt;code&gt;GoogleMobileAdsDependencies.xml&lt;/code&gt;, on&lt;br&gt;
Android:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;androidPackage&lt;/span&gt; &lt;span class="na"&gt;spec=&lt;/span&gt;&lt;span class="s"&gt;"com.google.android.gms:play-services-ads:25.4.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;androidPackage&lt;/span&gt; &lt;span class="na"&gt;spec=&lt;/span&gt;&lt;span class="s"&gt;"androidx.constraintlayout:constraintlayout:2.1.4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;androidPackage&lt;/span&gt; &lt;span class="na"&gt;spec=&lt;/span&gt;&lt;span class="s"&gt;"androidx.lifecycle:lifecycle-process:2.6.2"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;androidPackage&lt;/span&gt; &lt;span class="na"&gt;spec=&lt;/span&gt;&lt;span class="s"&gt;"androidx.fragment:fragment:1.7.1"&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;And &lt;code&gt;GoogleUmpDependencies.xml&lt;/code&gt; adds &lt;code&gt;com.google.android.ump:user-messaging-platform:4.0.0&lt;/code&gt;.&lt;br&gt;
The bundled External Dependency Manager is 1.2.188. There is a second, newer ads library the&lt;br&gt;
plugin knows about and does not use by default —&lt;br&gt;
&lt;code&gt;com.google.android.libraries.ads.mobile.sdk:ads-mobile-sdk:1.4.0&lt;/code&gt;, which the source calls&lt;br&gt;
NextGen and which raises the minimum API level from 23 to 24.&lt;/p&gt;

&lt;p&gt;Importing the package headless is the first thing that bit me. This does nothing at all:&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="c1"&gt;// returns immediately, imports nothing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It logged my "done" line, exited 0, and left &lt;code&gt;Assets/&lt;/code&gt; exactly as it was. &lt;code&gt;ImportPackage&lt;/code&gt; is&lt;br&gt;
asynchronous and &lt;code&gt;-quit&lt;/code&gt; tears the editor down before it runs. The command-line argument is&lt;br&gt;
synchronous and works:&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="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;-importPackage&lt;/span&gt; GoogleMobileAds-v11.5.0.unitypackage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The check that stops nothing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AndroidBuildPreProcessor.cs&lt;/code&gt; opens with a class comment listing what it does. Second item:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Throw an exception if the Android Google Mobile Ads app ID is not set.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No such check exists in that class. It lives in a different file, &lt;code&gt;ManifestProcessor.cs&lt;/code&gt;:&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;string&lt;/span&gt; &lt;span class="n"&gt;appId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GoogleMobileAdsAndroidAppId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Trim&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;appId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;StopBuildWithMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"Android Google Mobile Ads app ID is empty. Please enter a valid app ID to run ads properly."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two classes have different &lt;code&gt;callbackOrder&lt;/code&gt; values — &lt;code&gt;-1&lt;/code&gt; for the Gradle pre-processor,&lt;br&gt;
&lt;code&gt;0&lt;/code&gt; for the manifest one. So the ordering is: resolve every Android dependency, download&lt;br&gt;
&lt;code&gt;play-services-ads&lt;/code&gt;, write the Gradle templates, and &lt;em&gt;then&lt;/em&gt; look at the text box.&lt;/p&gt;

&lt;p&gt;I expected that to waste two minutes and fail. Here is what the build report said instead:&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=Succeeded seconds=151.1 totalSize=135445990 errors=1 warnings=0
LAB_MSG [Preprocess Player] Exception: BuildMethodException: [GoogleMobileAds]
  Android Google Mobile Ads app ID is empty. Please enter a valid app ID to run ads properly.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;result=Succeeded&lt;/code&gt; with &lt;code&gt;errors=1&lt;/code&gt;. The process exited 0. The APK was on disk.&lt;/p&gt;

&lt;p&gt;The reason is in Unity's own stack trace, and it is not the plugin's fault:&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;Line 505 logs the exception. Line 492 keeps going. Unity catches whatever an&lt;br&gt;
&lt;code&gt;IPreprocessBuildWithReport&lt;/code&gt; throws, writes it to the console, and continues the build. A&lt;br&gt;
plugin cannot stop your build by throwing, however loudly it says it will.&lt;/p&gt;
&lt;h2&gt;
  
  
  What ended up in the APK
&lt;/h2&gt;

&lt;p&gt;The plugin never got to write the App ID, so I checked what the artifact actually contained&lt;br&gt;
with &lt;code&gt;aapt2&lt;/code&gt; — the tool in your Android SDK, reading the built file, not the project.&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 noappid.apk &lt;span class="nt"&gt;--file&lt;/span&gt; AndroidManifest.xml &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"com.google.android.gms.ads.APPLICATION_ID"&lt;/span&gt;
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero. But everything else the SDK merges was there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;com.google.android.gms.ads.MobileAdsInitProvider
com.google.android.gms.ads.AdActivity
com.google.android.gms.ads.AdService
com.google.android.gms.ads.OutOfContextTestingActivity
com.google.android.gms.ads.flag.OPTIMIZE_INITIALIZATION
com.google.android.gms.ads.flag.OPTIMIZE_AD_LOADING
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two dex files, 8,385,264 and 7,960,200 bytes, carrying 1,002 references to&lt;br&gt;
&lt;code&gt;com/google/android/gms/ads&lt;/code&gt; between them. So the APK ships an ad SDK that boots itself&lt;br&gt;
through a &lt;code&gt;ContentProvider&lt;/code&gt;, and no App ID for it to boot with.&lt;/p&gt;
&lt;h2&gt;
  
  
  What that does on a real device
&lt;/h2&gt;

&lt;p&gt;I installed it on a Pixel 9 Pro emulator, Android 16, API 36, Play Store system image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="no"&gt;E&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nl"&gt;AndroidRuntime:&lt;/span&gt; &lt;span class="no"&gt;FATAL&lt;/span&gt; &lt;span class="nl"&gt;EXCEPTION:&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;
&lt;span class="nl"&gt;Process:&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;indiecore&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;admoblab&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;PID:&lt;/span&gt; &lt;span class="mi"&gt;3041&lt;/span&gt;
&lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RuntimeException&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Unable&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;
  &lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;google&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;android&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;gms&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ads&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MobileAdsInitProvider&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
  &lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;IllegalStateException&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;span class="nc"&gt;Missing&lt;/span&gt; &lt;span class="n"&gt;application&lt;/span&gt; &lt;span class="no"&gt;ID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt; &lt;span class="nc"&gt;AdMob&lt;/span&gt; &lt;span class="n"&gt;publishers&lt;/span&gt; &lt;span class="n"&gt;should&lt;/span&gt; &lt;span class="n"&gt;follow&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;instructions&lt;/span&gt;    &lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nl"&gt;here:&lt;/span&gt; &lt;span class="nl"&gt;https:&lt;/span&gt;&lt;span class="c1"&gt;//goo.gle/admob-android-update-manifest.                       *&lt;/span&gt;
&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;add&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;valid&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt; &lt;span class="no"&gt;ID&lt;/span&gt; &lt;span class="n"&gt;inside&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="nc"&gt;AndroidManifest&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;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;android&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ActivityThread&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;installProvider&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ActivityThread&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;java&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8647&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;android&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ActivityThread&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;installContentProviders&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ActivityThread&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;java&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8157&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;android&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ActivityThread&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;handleBindApplication&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ActivityThread&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;java&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;7814&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;Caused&lt;/span&gt; &lt;span class="nl"&gt;by:&lt;/span&gt; &lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
  &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;google&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;android&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;gms&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ads&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;internal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zzev&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attachInfo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
     &lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;google&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;android&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;gms&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;play&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;ads&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="err"&gt;@@&lt;/span&gt;&lt;span class="mf"&gt;25.4&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It dies in &lt;code&gt;handleBindApplication&lt;/code&gt;. That is a &lt;code&gt;ContentProvider&lt;/code&gt; installed before&lt;br&gt;
&lt;code&gt;Application.onCreate&lt;/code&gt;, which is before Unity exists — so nothing of yours runs, no splash&lt;br&gt;
screen appears, and no Unity log line is written. If you have ever seen a Unity game that&lt;br&gt;
force-closes instantly on launch with nothing in the player log, this shape of failure is&lt;br&gt;
worth checking first.&lt;/p&gt;

&lt;p&gt;Note the version in the trace: &lt;code&gt;play-services-ads-api@@25.4.0&lt;/code&gt;, matching the dependency file&lt;br&gt;
exactly. The resolver did its whole job. Only the text box was missing.&lt;/p&gt;
&lt;h2&gt;
  
  
  Getting it to work
&lt;/h2&gt;

&lt;p&gt;The fix is the App ID, and Google publishes a sample one so you never have to put a real unit&lt;br&gt;
in a test build:&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;TestAppId&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="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;TestBanner&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GoogleMobileAdsSettings.LoadInstance()&lt;/code&gt; is &lt;code&gt;internal&lt;/code&gt;, so from your own editor code the way&lt;br&gt;
in is the asset:&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;settings&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="n"&gt;LoadAssetAtPath&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ScriptableObject&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"Assets/GoogleMobileAds/Resources/GoogleMobileAdsSettings.asset"&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;so&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SerializedObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;so&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FindProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"adMobAndroidAppId"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;stringValue&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TestAppId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;so&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ApplyModifiedPropertiesWithoutUndo&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;SaveAssets&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That build came out clean — &lt;code&gt;errors=0&lt;/code&gt;, and &lt;code&gt;aapt2&lt;/code&gt; now shows&lt;br&gt;
&lt;code&gt;APPLICATION_ID = ca-app-pub-3940256099942544~3347511713&lt;/code&gt; in the manifest. It launched, and a&lt;br&gt;
banner loaded:&lt;/p&gt;

&lt;p&gt;&lt;a href="/assets/blog/admob-test-banner.jpg" class="article-body-image-wrapper"&gt;&lt;img src="/assets/blog/admob-test-banner.jpg" alt="A 320x50 AdMob test banner at the bottom of an Android emulator screen, reading Test Ad, Nice job! This is a 320x50 test ad." width="1200" height="216"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One more thing had to be fixed on the way, and it is quiet enough to miss:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Assembly 'Assets/GoogleMobileAds/GoogleMobileAds.Unity.dll' will not be loaded due to errors:
Unable to resolve reference 'UnityEngine.UI'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My project had &lt;code&gt;com.unity.modules.ui&lt;/code&gt; but not &lt;code&gt;com.unity.ugui&lt;/code&gt;, so the assembly providing&lt;br&gt;
&lt;code&gt;UnityEngine.UI&lt;/code&gt; did not exist. The plugin's Unity-side DLLs need it. That is a warning, the&lt;br&gt;
build succeeds without them, and you find out later. Adding &lt;code&gt;"com.unity.ugui": "2.0.0"&lt;/code&gt; to&lt;br&gt;
&lt;code&gt;Packages/manifest.json&lt;/code&gt; cleared it. A project made from a Hub template already has it; mine&lt;br&gt;
was made with &lt;code&gt;-createProject&lt;/code&gt;, which does not add it.&lt;/p&gt;
&lt;h2&gt;
  
  
  What adding the SDK costs
&lt;/h2&gt;

&lt;p&gt;Same project, same settings, one variable:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Build&lt;/th&gt;
&lt;th&gt;APK bytes&lt;/th&gt;
&lt;th&gt;Gradle-stage time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No AdMob&lt;/td&gt;
&lt;td&gt;14,316,448&lt;/td&gt;
&lt;td&gt;143.9 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AdMob, valid App ID&lt;/td&gt;
&lt;td&gt;19,640,015&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is &lt;strong&gt;+5,323,567 bytes, or 5.08 MiB — a 37% increase&lt;/strong&gt; on an otherwise empty game. For a&lt;br&gt;
small puzzle game that is a real number, and it arrives before you have shown a single ad.&lt;/p&gt;

&lt;p&gt;Two toolchain facts worth writing down, because both contradict what the machine looks like&lt;br&gt;
from the outside. Unity used its own bundled JDK, Temurin 17.0.9, not the OpenJDK 25.0.2 that&lt;br&gt;
&lt;code&gt;java -version&lt;/code&gt; reports here; and its own copy of the Android SDK under &lt;code&gt;PlaybackEngines&lt;/code&gt;, not&lt;br&gt;
&lt;code&gt;~/Library/Android/sdk&lt;/code&gt;. Gradle 8.13, Android Gradle Plugin 8.10.0.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two smaller things
&lt;/h2&gt;

&lt;p&gt;I set &lt;code&gt;PlayerSettings.Android.minSdkVersion = AndroidApiLevel23&lt;/code&gt;, which is what AdMob's&lt;br&gt;
documentation asks for. Unity logged an error and overruled 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;Two lines later, AdMob's pre-processor logged &lt;code&gt;Verified Minimum API Level is &amp;gt;= 23.&lt;/code&gt; It still&lt;br&gt;
carries &lt;code&gt;const int StandardMinimumAPILevel = 23;&lt;/code&gt; and is checking against a floor Unity 6 has&lt;br&gt;
already moved. Nothing breaks, but AdMob's documented minimum of 23 is not reachable here;&lt;br&gt;
the shipped APK reports &lt;code&gt;minSdkVersion:'25'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The second thing is the one I would have searched &lt;code&gt;admob unity mediation&lt;/code&gt; over, and it&lt;br&gt;
surprised me. With no mediation adapters installed at all, the initialization callback listed&lt;br&gt;
three:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;adapter com.google.android.gms.ads.MobileAds = Ready
adapter com.google.ads.mediation.vungle.VungleMediationAdapter = NotReady
adapter com.google.ads.mediation.applovin.AppLovinMediationAdapter = NotReady
adapter com.google.ads.mediation.adcolony.AdColonyMediationAdapter = NotReady
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;play-services-ads&lt;/code&gt; 25.4.0 enumerates adapters it knows about and reports on ones that are&lt;br&gt;
absent. Three &lt;code&gt;NotReady&lt;/code&gt; lines in your log are not evidence that a mediation setup is broken.&lt;/p&gt;
&lt;h2&gt;
  
  
  The null result I nearly published
&lt;/h2&gt;

&lt;p&gt;For a while I had a good story: the banner code ran on one build and produced no log output on&lt;br&gt;
another, and I could have written that up as a stripping bug.&lt;/p&gt;

&lt;p&gt;It was not one. I checked the scene first — the built &lt;code&gt;level0&lt;/code&gt; was 1,820 bytes without my&lt;br&gt;
component and 2,160 with it, so the object shipped. Then the binary: &lt;code&gt;AdLoader&lt;/code&gt; and my log&lt;br&gt;
strings were both present in &lt;code&gt;global-metadata.dat&lt;/code&gt; in every APK, so IL2CPP had stripped&lt;br&gt;
nothing. Then I reinstalled the identical APK on a freshly booted emulator, and it logged&lt;br&gt;
normally.&lt;/p&gt;

&lt;p&gt;Same binary, different result, so the variable was the device. On a cold emulator, AdMob's&lt;br&gt;
first initialization is slow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t+0s    calling MobileAds.Initialize
t+45s   initialize callback fired
t+85s   banner LOADED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had been sampling logcat for 25 seconds. The null result was impatience, and the honest&lt;br&gt;
version of this article says so rather than shipping a plausible cause I never tested.&lt;/p&gt;

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

&lt;p&gt;Read the guard rather than trusting it. A build check written as a thrown exception in an&lt;br&gt;
&lt;code&gt;IPreprocessBuildWithReport&lt;/code&gt; cannot stop a Unity build, so treat those messages as warnings no&lt;br&gt;
matter how they are phrased. Then check the artifact: &lt;code&gt;aapt2 dump xmltree your.apk --file&lt;br&gt;
AndroidManifest.xml&lt;/code&gt; takes a second and tells you what you actually built, which is the only&lt;br&gt;
thing your players will run.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/admob-unity-sdk-empty-app-id/" rel="noopener noreferrer"&gt;The AdMob Unity SDK check that stops nothing&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>AdMob limited ad serving on the wrong app</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Thu, 10 Sep 2026 18:40:48 +0000</pubDate>
      <link>https://dev.to/indiecoredev/admob-limited-ad-serving-on-the-wrong-app-392b</link>
      <guid>https://dev.to/indiecoredev/admob-limited-ad-serving-on-the-wrong-app-392b</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/admob-limited-ad-serving-wrong-app/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.indiecore.net/games/logo-quiz-guess-brand/" rel="noopener noreferrer"&gt;Logo Quiz: Guess Brand&lt;/a&gt; is Android-only and has been on Play&lt;br&gt;
for a few versions. At some point I created a second AdMob app for it. I no longer remember&lt;br&gt;
why — probably to start clean after fumbling something in the first one.&lt;/p&gt;

&lt;p&gt;The result was &lt;strong&gt;AdMob limited ad serving&lt;/strong&gt; on the game that was actually live — ads coming&lt;br&gt;
from an app AdMob would not verify, next to a verified app with nothing in it, and no way to&lt;br&gt;
swap them from the Play Console.&lt;/p&gt;
&lt;h2&gt;
  
  
  What "limited" actually means here
&lt;/h2&gt;

&lt;p&gt;AdMob will happily give you an app ID and ad unit IDs before it knows the app exists in a&lt;br&gt;
store. Until you link that AdMob app to its store listing, it stays unverified, and unverified&lt;br&gt;
apps get &lt;strong&gt;limited ad serving&lt;/strong&gt; — low fill, low eCPM, no programmatic demand. The ads are not&lt;br&gt;
off. They are just worth very little, which is harder to notice than an outage.&lt;/p&gt;

&lt;p&gt;It is worth being precise about which limit this is, because the phrase is overloaded. Search&lt;br&gt;
for &lt;strong&gt;admob limited ads&lt;/strong&gt; and most of what comes back is the Policy centre kind: a violation, a&lt;br&gt;
suspension, a temporary ad serving limit placed on your AdMob account while somebody reviews it.&lt;br&gt;
That was not my problem, and nothing below will lift one. Mine was the boring administrative&lt;br&gt;
kind — an app nobody had ever linked to a store listing — and the fix has nothing to do with&lt;br&gt;
policy.&lt;/p&gt;
&lt;h2&gt;
  
  
  The rule nobody states until you hit it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A Play Store listing links to exactly one AdMob app.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That single sentence is the whole problem. My second AdMob app had claimed the Logo Quiz&lt;br&gt;
listing and been verified. My first AdMob app — the one whose IDs were compiled into the APK&lt;br&gt;
people had actually installed — could not be linked, because the listing it needed was already&lt;br&gt;
spoken for. AdMob does not offer to move the link. The console simply does not show the&lt;br&gt;
listing as available any more.&lt;/p&gt;

&lt;p&gt;So there were two apps, and each was missing exactly what the other had:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;App A (in the build)&lt;/th&gt;
&lt;th&gt;App B (linked)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ad units&lt;/td&gt;
&lt;td&gt;banner, interstitial, rewarded&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Store listing&lt;/td&gt;
&lt;td&gt;cannot link — taken&lt;/td&gt;
&lt;td&gt;linked and verified&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ad serving&lt;/td&gt;
&lt;td&gt;limited&lt;/td&gt;
&lt;td&gt;nothing to serve&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  How to remove the AdMob app ad serving limit
&lt;/h2&gt;

&lt;p&gt;For the verification kind, there are two ways, and the cheap one is not the obvious one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delete App B.&lt;/strong&gt; It had no ad units, so there was nothing in it to lose. Deleting or&lt;br&gt;
unlinking it frees the listing, and App A — already shipping, already wired up — can then be&lt;br&gt;
linked and verified. No code change. No new release. The version already on people's phones&lt;br&gt;
starts serving properly once verification goes through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or move to App B.&lt;/strong&gt; Create the three ad units there, swap four IDs in the project, build,&lt;br&gt;
upload, and wait for players to update.&lt;/p&gt;

&lt;p&gt;I recommended the first one. We went with the second — the three ad units in App B got created&lt;br&gt;
while we were still deciding, and at that point shipping a release we were going to ship anyway&lt;br&gt;
felt easier than unpicking it.&lt;/p&gt;

&lt;p&gt;I still think deleting the empty app is the better default. It is the option with no tail:&lt;br&gt;
nothing to roll out, nothing to wait for, no window where half your installs are on old IDs.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the swap actually cost
&lt;/h2&gt;

&lt;p&gt;Four IDs, and I would have got it wrong if I had only changed the obvious ones.&lt;/p&gt;

&lt;p&gt;The app ID lived in &lt;strong&gt;two&lt;/strong&gt; places, not one. There is the &lt;code&gt;react-native-google-mobile-ads&lt;/code&gt;&lt;br&gt;
block in &lt;code&gt;app.json&lt;/code&gt;, which is the one you find first:&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;"react-native-google-mobile-ads"&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;"android_app_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ca-app-pub-3940256099942544~3347511713"&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;And there is the same ID again in &lt;code&gt;AndroidManifest.xml&lt;/code&gt;, carrying a &lt;code&gt;tools:replace&lt;/code&gt; so that it&lt;br&gt;
wins the manifest merge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta-data&lt;/span&gt;
  &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"com.google.android.gms.ads.APPLICATION_ID"&lt;/span&gt;
  &lt;span class="na"&gt;android:value=&lt;/span&gt;&lt;span class="s"&gt;"ca-app-pub-3940256099942544~3347511713"&lt;/span&gt;
  &lt;span class="na"&gt;tools:replace=&lt;/span&gt;&lt;span class="s"&gt;"android:value"&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 ID in both blocks is Google's published sample app ID rather than mine, which is redacted.&lt;br&gt;
The shape is the part worth copying.&lt;/p&gt;

&lt;p&gt;Change only &lt;code&gt;app.json&lt;/code&gt; and the build still succeeds — with the old app ID baked into the APK,&lt;br&gt;
because the manifest entry is the one that ends up in the package. That is a silent wrong&lt;br&gt;
answer, which is the kind I care about most.&lt;/p&gt;

&lt;p&gt;So I stopped trusting the source and checked the artifact instead. The app ID is readable&lt;br&gt;
straight out of the bundle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;unzip &lt;span class="nt"&gt;-p&lt;/span&gt; app-release.aab base/manifest/AndroidManifest.xml | strings | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"ca-app-pub-[0-9]*~[0-9]*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line, and it answers the only question that matters: what did I actually ship.&lt;/p&gt;

&lt;p&gt;The other cost is the one that keeps arriving. Because the fix travels inside a release, it&lt;br&gt;
spends a &lt;code&gt;versionCode&lt;/code&gt; — the swap went out as 1.0.10. Then a second pass at the same build,&lt;br&gt;
later the same night, needed an upload of its own, and 10 was gone. Play rejects a re-used&lt;br&gt;
number, so that became 1.0.11.&lt;/p&gt;

&lt;p&gt;Nothing about that is hard. It is just a cost that only exists because I chose the option that&lt;br&gt;
needs a release. Deleting the empty AdMob app would have spent no version numbers at all,&lt;br&gt;
because it changes nothing in the build.&lt;/p&gt;

&lt;p&gt;One thing that did not cost anything: the publisher ID was the same for both apps, so&lt;br&gt;
&lt;code&gt;app-ads.txt&lt;/code&gt; never had to change. If you end up moving between two different AdMob accounts,&lt;br&gt;
that file does need updating, and it is a separate way to end up with poor fill.&lt;/p&gt;

&lt;h2&gt;
  
  
  You cannot check this on an emulator
&lt;/h2&gt;

&lt;p&gt;I installed the release build on an emulator to confirm the banner still loaded, and it did —&lt;br&gt;
labelled &lt;strong&gt;Test Ad&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is not a bug and not a configuration mistake. AdMob treats emulators as test devices&lt;br&gt;
unconditionally, so a "Test Ad" is the correct and only outcome there. It proves the SDK&lt;br&gt;
initialised and the ad unit was requested. It proves nothing whatsoever about whether the&lt;br&gt;
limit has lifted.&lt;/p&gt;

&lt;p&gt;The actual verification is in the AdMob console: the app has to stop showing a prompt to link&lt;br&gt;
a store listing. If it still shows one, shipping the release changed nothing about the&lt;br&gt;
&lt;strong&gt;admob limited ad serving&lt;/strong&gt; state, and you have spent a release finding that out.&lt;/p&gt;

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

&lt;p&gt;Create the AdMob app once, and link it to the Play listing before wiring a single ID into the&lt;br&gt;
build. The linking step is free at that point and expensive later, because later means the&lt;br&gt;
listing is claimed by whichever app you created first — including the one you have decided to&lt;br&gt;
abandon.&lt;/p&gt;

&lt;p&gt;And if a second app already exists, look at which of the two is empty before deciding which&lt;br&gt;
one to keep. The instinct is to keep the app you have already integrated. The cheaper move is&lt;br&gt;
usually to delete the one with nothing in it and let the app you already shipped inherit the&lt;br&gt;
listing it should have had.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/admob-limited-ad-serving-wrong-app/" rel="noopener noreferrer"&gt;AdMob limited ad serving on the wrong app&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>googleplay</category>
      <category>admob</category>
      <category>android</category>
      <category>release</category>
    </item>
    <item>
      <title>Gradle build failed is not the error</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Thu, 10 Sep 2026 18:40:46 +0000</pubDate>
      <link>https://dev.to/indiecoredev/gradle-build-failed-is-not-the-error-55bi</link>
      <guid>https://dev.to/indiecoredev/gradle-build-failed-is-not-the-error-55bi</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/unity-gradle-build-failed-is-not-the-error/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I made a throwaway Unity 6000.4.0f1 project on a Mac, built a release APK from it, and then&lt;br&gt;
broke it four different ways to see what the console would say. The APK took 155 seconds in&lt;br&gt;
the Gradle stage and came out at 27 MB. Every failure below is from a log on this laptop, not&lt;br&gt;
from a thread I read.&lt;/p&gt;

&lt;p&gt;The reason for doing it that way: the message everybody searches for says nothing about the&lt;br&gt;
project that produced it, and I wanted to know exactly where the part that does say something&lt;br&gt;
had gone.&lt;/p&gt;
&lt;h2&gt;
  
  
  The error you searched for
&lt;/h2&gt;

&lt;p&gt;However you phrased it — &lt;code&gt;unity gradle build failed&lt;/code&gt;, &lt;code&gt;unity error gradle build failed&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;unity commandinvokationfailure gradle build failed&lt;/code&gt;, &lt;code&gt;unity android build failed gradle&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;unity 6 gradle build failed&lt;/code&gt;, &lt;code&gt;unity 2022 gradle build failed&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;unity firebase gradle build failed&lt;/code&gt; — you got here from the same eleven words in the console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;CommandInvokationFailure: Gradle build failed. 
/Applications/Unity/Hub/Editor/6000.4.0f1/PlaybackEngines/AndroidPlayer/OpenJDK/bin/java &lt;span class="nt"&gt;-classpath&lt;/span&gt; &lt;span class="s2"&gt;"/Applications/Unity/Hub/Editor/6000.4.0f1/PlaybackEngines/AndroidPlayer/Tools/gradle/lib/gradle-launcher-8.13.jar"&lt;/span&gt; org.gradle.launcher.GradleMain &lt;span class="s2"&gt;"-Dorg.gradle.jvmargs=-Xmx4096m"&lt;/span&gt; &lt;span class="s2"&gt;"assembleRelease"&lt;/span&gt; 

Environment Variables:
XPC_FLAGS &lt;span class="o"&gt;=&lt;/span&gt; 0x0
CLAUDE_CODE_ENTRYPOINT &lt;span class="o"&gt;=&lt;/span&gt; claude-vscode
ANDROID_NDK_ROOT &lt;span class="o"&gt;=&lt;/span&gt; /Applications/Unity/.../AndroidPlayer/NDK
LANG &lt;span class="o"&gt;=&lt;/span&gt; C.UTF-8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sixty-seven of those variables in my log, and I counted them because I kept scrolling through&lt;br&gt;
them looking for the reason. There isn't one in there. Unity started a Java process, the&lt;br&gt;
process exited 1, and this block is Unity reporting &lt;em&gt;which&lt;/em&gt; process it started. It would look&lt;br&gt;
identical if the failure were a missing semicolon or a full disk.&lt;/p&gt;

&lt;p&gt;Which is why the answers on the first page of results are all different from each other, and&lt;br&gt;
why none of them worked for you. They are answers to different questions that produce the same&lt;br&gt;
sentence.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the cause actually is
&lt;/h2&gt;

&lt;p&gt;Here is the shape of the whole log for one failing build of an otherwise empty project — 1,572&lt;br&gt;
lines, from a project with one scene and no gameplay in it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line&lt;/th&gt;
&lt;th&gt;What is there&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;171&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CommandInvokationFailure: Unable to list connected devices&lt;/code&gt; — unrelated, no phone plugged in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;174&lt;/td&gt;
&lt;td&gt;environment variables, first dump&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;921&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FAILURE: Build failed with an exception.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;923&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;* What went wrong:&lt;/code&gt; — the cause&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;926–1336&lt;/td&gt;
&lt;td&gt;410 lines listing every duplicate class&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1140&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BUILD FAILED in 6s&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1158&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CommandInvokationFailure: Gradle build failed.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1161&lt;/td&gt;
&lt;td&gt;environment variables, second dump, 67 of them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1231&lt;/td&gt;
&lt;td&gt;the whole thing again, quoted inside the exception&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The line that tells you what to fix comes &lt;strong&gt;235 lines before&lt;/strong&gt; the line that tells you&lt;br&gt;
something failed. Everyone scrolls down from the error. The answer is up.&lt;/p&gt;

&lt;p&gt;Two other things that log shows. There is a &lt;code&gt;CommandInvokationFailure&lt;/code&gt; at line 171 that has&lt;br&gt;
nothing to do with the build — it is ADB failing to list devices, with its own full&lt;br&gt;
environment dump, and in a headless build it is always there. And the real Gradle output is&lt;br&gt;
printed twice: once as it happens, once again quoted inside the exception body at line 1231,&lt;br&gt;
which is why the log looks twice as bad as the problem is.&lt;/p&gt;

&lt;p&gt;So stop reading the console and grep the log:&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-A6&lt;/span&gt; &lt;span class="s1"&gt;'^\* What went wrong:'&lt;/span&gt; ~/Library/Logs/Unity/Editor.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That worked on every failure I produced. The Editor log is at&lt;br&gt;
&lt;code&gt;~/Library/Logs/Unity/Editor.log&lt;/code&gt; on macOS, &lt;code&gt;%LOCALAPPDATA%\Unity\Editor\Editor.log&lt;/code&gt; on&lt;br&gt;
Windows, &lt;code&gt;~/.config/unity3d/Editor.log&lt;/code&gt; on Linux.&lt;/p&gt;

&lt;p&gt;Unity also saves the Gradle run on its own, which is cleaner to read and easier to link to a&lt;br&gt;
colleague:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Library/Bee/Android/Prj/&amp;lt;Mono2x|IL2CPP&amp;gt;/Gradle/launcher/build/outputs/logs/unity-assembleRelease-build.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Four builds I broke on purpose
&lt;/h2&gt;

&lt;h3&gt;
  
  
  One jar, twice
&lt;/h3&gt;

&lt;p&gt;I copied &lt;code&gt;unity-classes.jar&lt;/code&gt; out of the generated Gradle project into&lt;br&gt;
&lt;code&gt;Assets/Plugins/Android/&lt;/code&gt; under a second name, which is structurally what happens when two&lt;br&gt;
plugins each vendor the same library.&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="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;What&lt;/span&gt; &lt;span class="n"&gt;went&lt;/span&gt; &lt;span class="nl"&gt;wrong:&lt;/span&gt;
&lt;span class="n"&gt;Execution&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="s1"&gt;':launcher:checkReleaseDuplicateClasses'&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="n"&gt;failure&lt;/span&gt; &lt;span class="n"&gt;occurred&lt;/span&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;executing&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;android&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;gradle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;internal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CheckDuplicatesRunnable&lt;/span&gt;
   &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Duplicate&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;com&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unity3d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;player&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UnityPlayer&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;modules&lt;/span&gt; &lt;span class="n"&gt;unity&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;classes&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;copy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;jar&lt;/span&gt;
     &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;jetified&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;unity&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;classes&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;copy&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unity&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;classes&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;copy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;jar&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="n"&gt;unity&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;classes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;jar&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;410 lines of that, one per class. The task name is the useful part:&lt;br&gt;
&lt;code&gt;checkReleaseDuplicateClasses&lt;/code&gt; means two artifacts on the classpath contain the same class,&lt;br&gt;
and the two module names on the first line are the two to reconcile. In a real project the&lt;br&gt;
pair is usually AdMob and In-App Purchase pulling different versions of Play Billing or&lt;br&gt;
&lt;code&gt;play-services-basement&lt;/code&gt; through the External Dependency Manager, and the first thing to try&lt;br&gt;
is &lt;em&gt;Assets → External Dependency Manager → Android Resolver → Delete Resolved Libraries&lt;/em&gt;,&lt;br&gt;
then a force resolve.&lt;/p&gt;
&lt;h3&gt;
  
  
  A repository in the wrong file
&lt;/h3&gt;

&lt;p&gt;This one is specific to Unity 2022.2 and later, and it is worth knowing because the&lt;br&gt;
instructions shipped with a lot of ad and analytics SDKs are older than the change.&lt;/p&gt;

&lt;p&gt;I added a JitPack-only dependency to &lt;code&gt;Assets/Plugins/Android/mainTemplate.gradle&lt;/code&gt; and, in the&lt;br&gt;
same file, the repository it lives in:&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="k"&gt;dependencies&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;implementation&lt;/span&gt; &lt;span class="nf"&gt;fileTree&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;dir:&lt;/span&gt; &lt;span class="s1"&gt;'libs'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;include:&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'*.jar'&lt;/span&gt;&lt;span class="o"&gt;])&lt;/span&gt;
&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;DEPS&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;    &lt;span class="n"&gt;implementation&lt;/span&gt; &lt;span class="s1"&gt;'com.github.PhilJay:MPAndroidChart:v3.1.0'&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;repositories&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;maven&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="s1"&gt;'https://jitpack.io'&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gradle"&gt;&lt;code&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;What&lt;/span&gt; &lt;span class="n"&gt;went&lt;/span&gt; &lt;span class="nl"&gt;wrong:&lt;/span&gt;
&lt;span class="n"&gt;Execution&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="s1"&gt;':launcher:checkReleaseDuplicateClasses'&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Could&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="n"&gt;resolve&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;configuration&lt;/span&gt; &lt;span class="s1"&gt;':launcher:releaseRuntimeClasspath'&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
   &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Could&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="n"&gt;find&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;github&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PhilJay&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nl"&gt;MPAndroidChart:&lt;/span&gt;&lt;span class="n"&gt;v3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
     &lt;span class="n"&gt;Searched&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;following&lt;/span&gt; &lt;span class="nl"&gt;locations:&lt;/span&gt;
       &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nl"&gt;https:&lt;/span&gt;&lt;span class="c1"&gt;//dl.google.com/dl/android/maven2/com/github/PhilJay/...&lt;/span&gt;
       &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nl"&gt;https:&lt;/span&gt;&lt;span class="c1"&gt;//repo.maven.apache.org/maven2/com/github/PhilJay/...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;JitPack is not in that list. The repository block was read, parsed, and ignored, because&lt;br&gt;
Unity's generated &lt;code&gt;settings.gradle&lt;/code&gt; sets:&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;dependencyResolutionManagement&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;repositoriesMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RepositoriesMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PREFER_SETTINGS&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under &lt;code&gt;PREFER_SETTINGS&lt;/code&gt;, Gradle discards repositories declared anywhere else. No warning&lt;br&gt;
appears — the only symptom is a search list that quietly lacks the one place the artifact&lt;br&gt;
exists.&lt;/p&gt;

&lt;p&gt;The fix is the same declaration, moved. Copy &lt;code&gt;settingsTemplate.gradle&lt;/code&gt; next to&lt;br&gt;
&lt;code&gt;mainTemplate.gradle&lt;/code&gt; and put the repository in &lt;em&gt;its&lt;/em&gt; &lt;code&gt;dependencyResolutionManagement&lt;/code&gt; block:&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;dependencyResolutionManagement&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;repositoriesMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RepositoriesMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PREFER_SETTINGS&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;repositories&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;ARTIFACTORYREPOSITORY&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;
        &lt;span class="n"&gt;maven&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="s1"&gt;'https://jitpack.io'&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;google&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;mavenCentral&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same dependency, same project, next build succeeded. If a plugin's README tells you to edit&lt;br&gt;
&lt;code&gt;mainTemplate.gradle&lt;/code&gt; and the artifact still cannot be found, the README predates this.&lt;/p&gt;
&lt;h3&gt;
  
  
  A mainTemplate.gradle from an older Unity
&lt;/h3&gt;

&lt;p&gt;I dropped in the template Unity 2019 and 2020 generated — the one with&lt;br&gt;
&lt;code&gt;minSdkVersion **MINSDKVERSION**&lt;/code&gt;, &lt;code&gt;lintOptions&lt;/code&gt; and &lt;code&gt;aaptOptions&lt;/code&gt; — which is exactly what a&lt;br&gt;
project carries when it has been upgraded and the file was committed years ago.&lt;/p&gt;

&lt;p&gt;Gradle never ran. Unity 6 stopped at the prerequisites step:&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;Assets&lt;/span&gt;&lt;span class="s"&gt;/Plugins/&lt;/span&gt;&lt;span class="n"&gt;Android&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;mainTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;gradle&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt; &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;CMake&lt;/span&gt; &lt;span class="n"&gt;arguments&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;GameActivity&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="n"&gt;Framepacing&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;work&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt; &lt;span class="n"&gt;To&lt;/span&gt; &lt;span class="n"&gt;fix&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;add&lt;/span&gt; &lt;span class="s2"&gt;"**DEFAULT_CONFIG_SETUP**"&lt;/span&gt;
&lt;span class="n"&gt;inside&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;defaultConfig&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt; &lt;span class="n"&gt;If&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="n"&gt;fixed&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;your&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt; &lt;span class="n"&gt;can&lt;/span&gt; &lt;span class="n"&gt;fail&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;

&lt;span class="nl"&gt;UnityException:&lt;/span&gt; &lt;span class="n"&gt;Error&lt;/span&gt;
&lt;span class="n"&gt;mainTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;gradle&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt; &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="n"&gt;using&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt; &lt;span class="n"&gt;androidResources&lt;/span&gt; &lt;span class="n"&gt;noCompress&lt;/span&gt; &lt;span class="n"&gt;property&lt;/span&gt; &lt;span class="n"&gt;definition&lt;/span&gt;
&lt;span class="n"&gt;which&lt;/span&gt; &lt;span class="n"&gt;does&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="n"&gt;include&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt; &lt;span class="n"&gt;defined&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt; &lt;span class="n"&gt;unityStreamingAssets&lt;/span&gt; &lt;span class="n"&gt;constant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a genuine improvement, and it changes the advice. The Unity 6 token names differ from&lt;br&gt;
the old ones — &lt;code&gt;**MINSDK**&lt;/code&gt; rather than &lt;code&gt;**MINSDKVERSION**&lt;/code&gt;, &lt;code&gt;compileSdk&lt;/code&gt; rather than&lt;br&gt;
&lt;code&gt;compileSdkVersion&lt;/code&gt;, &lt;code&gt;lint&lt;/code&gt; rather than &lt;code&gt;lintOptions&lt;/code&gt; — so if you have a custom template, the&lt;br&gt;
reliable move after an editor upgrade is to copy the current one out of&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;editor&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;/PlaybackEngines/&lt;/span&gt;&lt;span class="n"&gt;AndroidPlayer&lt;/span&gt;&lt;span class="s"&gt;/Tools/&lt;/span&gt;&lt;span class="n"&gt;GradleTemplates&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;mainTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;gradle&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and reapply your handful of edits to it, rather than patching the old file forward.&lt;/p&gt;

&lt;h3&gt;
  
  
  The one that refused to break
&lt;/h3&gt;

&lt;p&gt;I tried to reproduce the most-cited conflict of the past two years — the app declaring a&lt;br&gt;
lower minimum SDK than a library it depends on — by setting the project to API 22 and adding&lt;br&gt;
&lt;code&gt;com.google.android.gms:play-services-ads:24.3.0&lt;/code&gt;, which needs 23. The build failed, but not&lt;br&gt;
where I expected:&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;Assets&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="n"&gt;Editor&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="n"&gt;BuildAndroid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;26&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;48&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="n"&gt;CS0619&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="err"&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;AndroidApiLevel22&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;obsolete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;Minimum&lt;/span&gt; &lt;span class="n"&gt;supported&lt;/span&gt; &lt;span class="n"&gt;Android&lt;/span&gt; &lt;span class="n"&gt;API&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="m"&gt;25&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Android&lt;/span&gt; &lt;span class="m"&gt;7.1&lt;/span&gt; &lt;span class="n"&gt;Nougat&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;
&lt;span class="n"&gt;Please&lt;/span&gt; &lt;span class="n"&gt;use&lt;/span&gt; &lt;span class="n"&gt;AndroidApiLevel25&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;higher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unity 6000.4 will not compile a script that names API 22. The floor is 25, above every&lt;br&gt;
current Play Services minimum, so the classic&lt;br&gt;
&lt;code&gt;uses-sdk:minSdkVersion 22 cannot be smaller than version 23 declared in library&lt;/code&gt; is no longer&lt;br&gt;
reachable from a modern editor — it is still the top answer on threads dated July 2025, and&lt;br&gt;
still the right answer if you are on 2021 or 2022 LTS. With the floor left alone, that same&lt;br&gt;
AdMob dependency resolved and built with no changes at all.&lt;/p&gt;

&lt;p&gt;A second one that would not break: putting &lt;code&gt;&amp;lt;uses-sdk android:minSdkVersion="34" /&amp;gt;&lt;/code&gt; in the&lt;br&gt;
manifest of an &lt;code&gt;.androidlib&lt;/code&gt; under &lt;code&gt;Assets/Plugins/Android/&lt;/code&gt;. Unity regenerates that module's&lt;br&gt;
&lt;code&gt;build.gradle&lt;/code&gt; with the player's minimum, so the manifest declaration is overridden and the&lt;br&gt;
build succeeds. Library minimums bite when they arrive as an AAR from a repository, not when&lt;br&gt;
they are sitting in your own project.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two pieces of stale advice
&lt;/h2&gt;

&lt;p&gt;Both come from a time when Unity used your machine's tooling. It does not.&lt;/p&gt;

&lt;p&gt;The prerequisites step prints what it is actually going to use, and mine looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt; &lt;span class="na"&gt;JDK&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/Applications/Unity/Hub/Editor/6000.4.0f1/PlaybackEngines/AndroidPlayer/OpenJDK'&lt;/span&gt;
 &lt;span class="na"&gt;Android SDK&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/Applications/Unity/Hub/Editor/6000.4.0f1/PlaybackEngines/AndroidPlayer/SDK'&lt;/span&gt;
 &lt;span class="na"&gt;Android NDK&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/Applications/Unity/Hub/Editor/6000.4.0f1/PlaybackEngines/AndroidPlayer/NDK'&lt;/span&gt;
 &lt;span class="na"&gt;Gradle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.../AndroidPlayer/Tools/gradle'&lt;/span&gt; &lt;span class="na"&gt;(Gradle Version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8.13'&lt;/span&gt; &lt;span class="na"&gt;Android Plugin Version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8.10.0')&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Installing a JDK will not help.&lt;/strong&gt; Unity ran its own bundled OpenJDK 17.0.9. The system Java&lt;br&gt;
on this laptop is 25 and was never invoked. &lt;strong&gt;Pointing Unity at your Android SDK will not&lt;br&gt;
help either&lt;/strong&gt; — &lt;code&gt;ANDROID_HOME&lt;/code&gt; was set to &lt;code&gt;~/Library/Android/sdk&lt;/code&gt; in the environment dump&lt;br&gt;
Unity printed, and Unity used its own SDK anyway. Both answers were correct in 2019. They now&lt;br&gt;
send people to change something that has no effect, then conclude their install is corrupt.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is actually breaking builds this year
&lt;/h2&gt;

&lt;p&gt;The failures I could not stage on an empty project, from reports with dates on them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gradle running out of heap&lt;/strong&gt;, which surfaces as something else entirely. One November 2025
report on Unity 6000.0.58f2 shows it as
&lt;code&gt;Execution failed for task ':launcher:signReleaseBundle'&lt;/code&gt; and
&lt;code&gt;java.lang.IllegalArgumentException: Self-suppression not permitted&lt;/code&gt;, fixed by raising
Maximum JVM Heap Size from 4096 to 8192 in &lt;em&gt;Preferences → External Tools&lt;/em&gt;. If your
&lt;code&gt;unity android build error&lt;/code&gt; mentions bundling or signing and the project is large, try this
before anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;16 KB page alignment.&lt;/strong&gt; From 31 May 2026 Play stops accepting updates whose native
libraries are 4 KB-aligned. NDK r27 and later emit aligned segments by default; the fix is
an editor and NDK upgrade, not a Gradle setting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-ASCII characters or spaces in the project path&lt;/strong&gt;, still, still on Windows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two plugins, one library.&lt;/strong&gt; The duplicate-class case above, arriving through the External
Dependency Manager rather than by hand.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;code&gt;CommandInvokationFailure: Gradle build failed.&lt;/code&gt; is a category, in the way that "the car won't&lt;br&gt;
start" is a category. Reading the console top to bottom is how you end up believing it is&lt;br&gt;
about environment variables, and reinstalling the editor over a two-line Gradle problem.&lt;/p&gt;

&lt;p&gt;Grep for &lt;code&gt;* What went wrong:&lt;/code&gt;. Whatever it says next — a task name, a class name, a coordinate&lt;br&gt;
that could not be found — that is the thing to search, and it has an answer.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/unity-gradle-build-failed-is-not-the-error/" rel="noopener noreferrer"&gt;Gradle build failed is not the error&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>unity3d</category>
      <category>android</category>
      <category>gradle</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>One engine, many games</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Fri, 04 Sep 2026 01:56:41 +0000</pubDate>
      <link>https://dev.to/indiecoredev/one-engine-many-games-2mcf</link>
      <guid>https://dev.to/indiecoredev/one-engine-many-games-2mcf</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/one-engine-many-games/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part 1 of 10 on building a word-block puzzle engine. Series index at the bottom.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The second game was a copy of the first. That is not a confession, it is what everybody does,&lt;br&gt;
and for about six weeks it is the correct decision — you do not know which parts are the game&lt;br&gt;
and which parts are that game until you have built a second one.&lt;/p&gt;

&lt;p&gt;The bill arrives later. I fixed a rounding bug in the star calculation, shipped it, and then&lt;br&gt;
found the same bug sitting untouched in the sibling title, because the sibling was a folder&lt;br&gt;
copy from before the fix existed. The fix took four minutes. Finding out the other game had it&lt;br&gt;
took three weeks, because nothing told me.&lt;/p&gt;
&lt;h2&gt;
  
  
  What a copy actually costs
&lt;/h2&gt;

&lt;p&gt;The honest number is not "duplicate code". It is the number of places a decision lives.&lt;/p&gt;

&lt;p&gt;At the point I stopped, one game's &lt;code&gt;src/&lt;/code&gt; held &lt;strong&gt;70 files&lt;/strong&gt;: screens, the board renderer, the&lt;br&gt;
store, hooks, the economy, the difficulty tiers, the sound layer, the ad wiring. The other&lt;br&gt;
game held 70 files that were 90% the same, drifting apart one hotfix at a time.&lt;/p&gt;

&lt;p&gt;Every one of those pairs is a place where two games can disagree. Not in theory — in practice,&lt;br&gt;
what diverged first was exactly the stuff nobody looks at twice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The interstitial cooldown, changed in one game after a review complaint.&lt;/li&gt;
&lt;li&gt;The daily-chest table, retuned in one game and not the other.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;catalog.json&lt;/code&gt; fetch that got an error handler in one game after a crash report.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those are interesting enough to remember to port. All of them are the kind of thing&lt;br&gt;
that turns two products into two codebases.&lt;/p&gt;
&lt;h2&gt;
  
  
  The split I landed on
&lt;/h2&gt;

&lt;p&gt;There is a package that is the game, and an app directory per published title that is&lt;br&gt;
&lt;em&gt;nothing but identity&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;packages/word-engine/     the game itself — 92 files
apps/&amp;lt;title&amp;gt;/src/          3 files
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three files. That is the whole shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/main.jsx     mount the engine, hand it the content source
src/brand.js     turn brand.json into what the engine reads
brand.json       this build, as data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything else in an app directory is not code: an icon source, a board pack under &lt;code&gt;public/&lt;/code&gt;,&lt;br&gt;
a generated &lt;code&gt;android/&lt;/code&gt;, a &lt;code&gt;capacitor.config.json&lt;/code&gt;. The parts a designer or a store listing&lt;br&gt;
would change, and none of the parts a bug lives in.&lt;/p&gt;
&lt;h2&gt;
  
  
  brand.json is data, deliberately
&lt;/h2&gt;

&lt;p&gt;The temptation is to make the brand a module — a JS file exporting a config object, so you can&lt;br&gt;
compute things in it. I did that first and had to undo it.&lt;/p&gt;

&lt;p&gt;The problem is who needs to read it. The Vite config needs the board-pack directory so it can&lt;br&gt;
shrink the pack on the way into &lt;code&gt;dist/&lt;/code&gt;. The release script needs the app name and the store&lt;br&gt;
identity. A provisioning check needs the leaderboard ids. None of those run inside the app's&lt;br&gt;
module graph, and none of them should have to resolve &lt;code&gt;src/&lt;/code&gt; — or a JSX transform, or an alias&lt;br&gt;
— to find out what folder the boards are in.&lt;/p&gt;

&lt;p&gt;So the declaration is plain JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"defaultLanguage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en-US"&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;"stagesDir"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/stages-blocks-en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dictionaryUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/words-blocks-en.txt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stageCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;392&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and &lt;code&gt;src/brand.js&lt;/code&gt; is two lines that hand it to the engine's &lt;code&gt;defineBrand()&lt;/code&gt;. Interpretation&lt;br&gt;
lives in the engine, where every sibling gets the same interpretation. The rule I kept&lt;br&gt;
tripping over and eventually wrote down: &lt;strong&gt;if a value is worth setting per game it belongs in&lt;br&gt;
&lt;code&gt;brand.json&lt;/code&gt;; if it needs interpreting, that belongs in the engine.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The alias, and why it is not a setter
&lt;/h2&gt;

&lt;p&gt;The engine has to reach the brand from ninety-odd files. The obvious approach is an&lt;br&gt;
initialiser — &lt;code&gt;setBrand(brand)&lt;/code&gt; at the top of &lt;code&gt;main.jsx&lt;/code&gt;, before rendering.&lt;/p&gt;

&lt;p&gt;That does not work, and the failure is quiet. Several engine modules read the brand &lt;em&gt;as they&lt;br&gt;
load&lt;/em&gt;: the string catalogues bake the game's name into seventeen languages at import time. A&lt;br&gt;
setter runs after the imports it needs to precede, so those modules capture whatever the&lt;br&gt;
default was and the game ships with the placeholder name in nine of its seventeen locales.&lt;/p&gt;

&lt;p&gt;The fix is to make it a resolution concern rather than a runtime one. The shared Vite config&lt;br&gt;
points an alias at the app's own file:&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;resolve&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;alias&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;@brand&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;resolve&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="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="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;Engine modules &lt;code&gt;import brand from '@brand'&lt;/code&gt; and get this game's identity at module-evaluation&lt;br&gt;
time, because that is what an alias is for. No ordering to get wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  The build has to be shared too, or it isn't shared
&lt;/h2&gt;

&lt;p&gt;This is the part I underestimated. You can move all the game code into a package and still end&lt;br&gt;
up with a fleet, because the &lt;em&gt;build&lt;/em&gt; stays per-app. The first game that needs a Vite plugin&lt;br&gt;
adds it to its own &lt;code&gt;vite.config.js&lt;/code&gt;, the next game copies that file, and now there are as many&lt;br&gt;
build configs as there are titles — which is the thing you just spent a month removing.&lt;/p&gt;

&lt;p&gt;An app's Vite config is now one line:&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;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineGameConfig&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;&lt;code&gt;import.meta.url&lt;/code&gt; rather than a path, because that is the single thing an app knows about&lt;br&gt;
itself that the shared config cannot work out. Vite may load a config from a temp file&lt;br&gt;
somewhere else on disk, which makes both &lt;code&gt;process.cwd()&lt;/code&gt; and the shared module's own&lt;br&gt;
&lt;code&gt;import.meta.url&lt;/code&gt; unreliable — I found this the way everyone does, with an &lt;code&gt;ENOENT&lt;/code&gt; naming a&lt;br&gt;
path under &lt;code&gt;node_modules/.vite-temp/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same argument applies to the native project, the release script and the pack tooling.&lt;br&gt;
Those are parts 9 and 10.&lt;/p&gt;
&lt;h2&gt;
  
  
  What this does not fix
&lt;/h2&gt;

&lt;p&gt;Two things, and they are worth saying because the pitch for "one engine" usually skips them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The engine has to actually be general, and generality is a cost you pay per feature.&lt;/strong&gt; A&lt;br&gt;
change that would have been ten minutes inside one game is now a change to a shared package,&lt;br&gt;
which means asking whether the sibling wants it, and often adding a brand field so it can say&lt;br&gt;
no. That is the correct amount of friction and it is still friction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Divergence pressure does not go away, it moves.&lt;/strong&gt; When a store listing needs one game to&lt;br&gt;
behave differently, the pressure lands on &lt;code&gt;brand.json&lt;/code&gt; — and a declaration file with forty&lt;br&gt;
fields in it is a copy of the old problem, wearing JSON. I do not have a clean rule for this&lt;br&gt;
yet. The one heuristic that has held: if a field would only ever have two values and one of&lt;br&gt;
them is "the way it already works", it is a bug in the engine, not a brand field.&lt;/p&gt;
&lt;h2&gt;
  
  
  The check that keeps a shell a shell
&lt;/h2&gt;

&lt;p&gt;None of the above survives contact with a deadline unless something enforces it. The engine&lt;br&gt;
ships a test suite that a shell runs against itself, and one of its assertions is simply that&lt;br&gt;
the shell contains nothing but identity — no components, no store, no game logic.&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 the entire test file in each app. The suites live in the engine, because a copy of the&lt;br&gt;
suite in each app is a copy that drifts, and the sibling whose copy is stale is exactly the one&lt;br&gt;
that ships the broken pack. Part 7 is about what else it checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it
&lt;/h2&gt;

&lt;p&gt;The measurable part: adding the second title on the engine took a brand file, a board pack, an&lt;br&gt;
icon and a native project. No source files. The next one will be the same, and that is the&lt;br&gt;
whole return — not code reuse, which nobody can feel, but the fact that a bug fixed once is&lt;br&gt;
fixed everywhere without anyone remembering to go and look.&lt;/p&gt;

&lt;p&gt;The unmeasurable part is that I now think about "what is this game" and "what is a game" as&lt;br&gt;
two different questions, and the answer to the second one is a package with a version number.&lt;/p&gt;




&lt;h2 id="the-series"&gt;The series&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One engine, many games&lt;/strong&gt; — you are here&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;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;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Originally published at &lt;strong&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;/strong&gt;.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>architecture</category>
      <category>monorepo</category>
      <category>capacitor</category>
    </item>
    <item>
      <title>Lighthouse's image budget is w h 6</title>
      <dc:creator>Othmane ETTAIB</dc:creator>
      <pubDate>Fri, 04 Sep 2026 01:56:39 +0000</pubDate>
      <link>https://dev.to/indiecoredev/lighthouses-image-budget-is-w-x-h-6-1p76</link>
      <guid>https://dev.to/indiecoredev/lighthouses-image-budget-is-w-x-h-6-1p76</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.indiecore.net/blog/lighthouse-image-budget/" rel="noopener noreferrer"&gt;indiecore.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Lighthouse told me my home page was carrying 345 KiB of unnecessary image bytes, on a page&lt;br&gt;
already scoring 99. The panel is called &lt;em&gt;Improve image delivery&lt;/em&gt; and it gives you a list of&lt;br&gt;
files, a savings figure, and one of two sentences under each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Increasing the image compression factor could improve this image's download size.&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;This image file is larger than it needs to be (400×710) for its displayed dimensions
(254×452). Use responsive images to reduce the image download size.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those look like the same complaint. They are not, they behave differently, and only one of&lt;br&gt;
them can ever reach zero on a site that serves retina screens.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reading the constant out of the report
&lt;/h2&gt;

&lt;p&gt;The HTML panel rounds everything. The JSON does not, and it gives you both the file size and&lt;br&gt;
the bytes it considers wasted, which means the target it has in mind is just subtraction.&lt;/p&gt;

&lt;p&gt;A 256×256 icon on my site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;totalBytes:   18804
wastedBytes:   7881   ("increasing the image compression factor…")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So Lighthouse wanted 10,923 bytes. The image has 65,536 pixels. 65536 ÷ 10923 = &lt;strong&gt;6.0000&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One more, a 400×712 screenshot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;totalBytes:   55558
wastedBytes:   8091
target:       47467      →  284800 px ÷ 47467 = 6.0000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The compression target is one sixth of a byte per pixel, or 1.333 bits per pixel. Under that&lt;br&gt;
line, no complaint. Over it, a complaint — unless the saving comes to less than 4 KiB, which&lt;br&gt;
is the reporting floor and explains the files that are technically over and stay quiet&lt;br&gt;
anyway.&lt;/p&gt;

&lt;p&gt;That is a number you can build against. My encoder now computes &lt;code&gt;w × h ÷ 6&lt;/code&gt; for every&lt;br&gt;
derivative and re-encodes only the ones that overshoot.&lt;/p&gt;
&lt;h2&gt;
  
  
  Treat it as a ceiling, never a target
&lt;/h2&gt;

&lt;p&gt;The obvious move once you have a target is to encode everything to it. That would have&lt;br&gt;
roughly doubled several of my files.&lt;/p&gt;

&lt;p&gt;Most images were already far under. One 400px-wide screenshot was 21 KB against a 47 KB&lt;br&gt;
ceiling, because it is a simple picture that compresses well. Encoding it &lt;em&gt;to&lt;/em&gt; the budget&lt;br&gt;
would have added 26 KB of nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cap&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;statSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;execFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cwebp&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-size&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cap&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;6&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;-sharp_yuv&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;-pass&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;8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only files above the line get touched. The rest keep whatever quality they landed on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second complaint is geometry, and it does not negotiate
&lt;/h2&gt;

&lt;p&gt;The other sentence has its own arithmetic, and it is not about compression at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;waste = bytes × (1 − displayedPixels ÷ intrinsicPixels)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked that against all eight flagged files on my home page. It reproduces every figure&lt;br&gt;
Lighthouse reported to within 7 bytes.&lt;/p&gt;

&lt;p&gt;That tells you exactly when it reaches zero: when the file has the same number of pixels as&lt;br&gt;
the CSS box it lands in. A 254px slot wants a 254px-wide file. That is a 1× image, and on the&lt;br&gt;
phone Lighthouse is emulating — &lt;code&gt;deviceScaleFactor: 1.75&lt;/code&gt; — a 254px file in a 254px slot is&lt;br&gt;
visibly soft.&lt;/p&gt;

&lt;p&gt;So the audit compares intrinsic pixels against CSS pixels while simulating a screen that has&lt;br&gt;
1.75 device pixels per CSS pixel. Satisfying it completely means shipping images that look&lt;br&gt;
worse on the device it is pretending to be.&lt;/p&gt;

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

&lt;p&gt;Compression: hold everything under &lt;code&gt;w × h ÷ 6&lt;/code&gt;, using cwebp's method 6 with &lt;code&gt;-sharp_yuv&lt;/code&gt;,&lt;br&gt;
which reaches the same fidelity in fewer bytes than the default method. Every quality value&lt;br&gt;
was picked by measuring PSNR against what the old encoder produced, so nothing could come out&lt;br&gt;
worse than what it replaced.&lt;/p&gt;

&lt;p&gt;Icons are exempt. Their art is lettering and gradients, where WebP shows its seams first.&lt;br&gt;
Holding one to the ceiling cost 2.8 to 3.8 dB and read as blotchy type at 3× magnification,&lt;br&gt;
to save about 1.4 KB. I looked at them side by side before deciding. The same ceiling on a&lt;br&gt;
photographic screenshot cost 0.15 dB and I could not tell the two apart at 250%.&lt;/p&gt;

&lt;p&gt;Resolution: I stopped the candidate ladder at 320px for a 254px slot. That is 1.26×, above 1×&lt;br&gt;
and below what the audit calls waste. Rendered at the size it actually appears, it is&lt;br&gt;
indistinguishable from the 400px file it replaced. I compared those too, at the real display&lt;br&gt;
size rather than at full resolution, where a difference exists that no visitor ever sees.&lt;/p&gt;

&lt;p&gt;The result on the home page: image waste 345 KiB → 0, the insight passing with nothing&lt;br&gt;
flagged, page total 520 KiB → 353 KiB. On a game page it went 482 KiB → 40 KiB, and the 40&lt;br&gt;
that remain are the geometry complaint on two files I have decided to keep sharp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother reverse-engineering it
&lt;/h2&gt;

&lt;p&gt;I could have twiddled the quality number until the warning went away. I have done that&lt;br&gt;
before. What you get is a setting that satisfies today's audit and no idea which direction is&lt;br&gt;
correct, so the next person to touch it — including you, in four months — starts over.&lt;/p&gt;

&lt;p&gt;Knowing it is &lt;code&gt;w × h ÷ 6&lt;/code&gt; turns a warning into a build rule. The other formula told me&lt;br&gt;
something more useful: one of these two complaints was a bug in my markup, and the other is a&lt;br&gt;
disagreement about how sharp images should be. Only one of those was worth fixing.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;strong&gt;&lt;a href="https://www.indiecore.net/blog/lighthouse-image-budget/" rel="noopener noreferrer"&gt;Lighthouse's image budget is w × h ÷ 6&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/d653221d437be0389aa4b4281f929e99" rel="noopener noreferrer"&gt;The pipeline and the markup, ready to adapt&lt;/a&gt;&lt;/p&gt;

</description>
      <category>performance</category>
      <category>lighthouse</category>
      <category>images</category>
      <category>web</category>
    </item>
  </channel>
</rss>
