<?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: Hamber</title>
    <description>The latest articles on DEV Community by Hamber (@hamberluo).</description>
    <link>https://dev.to/hamberluo</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%2F3505361%2Faa670900-da4d-43f0-98ed-c86485758de1.png</url>
      <title>DEV Community: Hamber</title>
      <link>https://dev.to/hamberluo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hamberluo"/>
    <language>en</language>
    <item>
      <title>Your Tests Pass. That Proves Nothing.</title>
      <dc:creator>Hamber</dc:creator>
      <pubDate>Sat, 19 Sep 2026 14:02:07 +0000</pubDate>
      <link>https://dev.to/hamberluo/your-tests-pass-that-proves-nothing-135a</link>
      <guid>https://dev.to/hamberluo/your-tests-pass-that-proves-nothing-135a</guid>
      <description>&lt;p&gt;&lt;em&gt;Writing Flutter tests in the age of AI, where the scarce skill is no longer writing them.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Last year I shipped a fix for an audio glitch in GoGBA — a crackle at the seam where the emulator splices its ring buffer. I wrote four unit tests. All four passed. I committed.&lt;/p&gt;

&lt;p&gt;The fix was inert. The tests never called the function that configured it. The ramp length was zero for the entire test run.&lt;/p&gt;

&lt;p&gt;Worse: when I later went back and disabled the fix &lt;em&gt;completely&lt;/em&gt;, two of those four tests &lt;strong&gt;still passed&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is the story I want to tell, because it is the central problem of testing in 2026. AI has made writing tests nearly free. It has not made them true. The bottleneck moved — from "I don't have time to write tests" to "I have 1400 tests and I don't know which ones are load-bearing."&lt;/p&gt;

&lt;p&gt;GoGBA is my GBA/GBC/GB emulator, built solo in Flutter, shipped on both stores. It has 1423 Dart tests that run in about a minute, plus a native C++ suite for the audio path. Most of them were written with AI assistance. This article is about the discipline that makes that number mean something instead of nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The Asymmetry Nobody Priced In
&lt;/h2&gt;

&lt;p&gt;Here is what actually changed when coding assistants got good.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt; writing a test cost 10 minutes. Verifying it was honest cost 30 seconds. Nobody skipped the verification, because it was free relative to the writing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt; writing a test costs 10 seconds. Verifying it is honest still costs 30 seconds.&lt;/p&gt;

&lt;p&gt;The verification step is now &lt;strong&gt;3x more expensive than the thing it verifies.&lt;/strong&gt; Every incentive in your workflow now points at skipping it. And the output looks identical either way — a green checkmark.&lt;/p&gt;

&lt;p&gt;This is not a complaint about AI. The generated tests are usually syntactically perfect, idiomatically correct, and well-named. That is precisely the problem: they are &lt;em&gt;plausible&lt;/em&gt;. A test that is plausible and wrong is worse than no test, because it occupies the slot where a real test would have gone and it tells you the slot is filled.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The 2026 rule:&lt;/strong&gt; a passing test is not evidence. A test that &lt;em&gt;fails when you break the code&lt;/em&gt; is evidence.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  2. Mutation Testing, By Hand, In 30 Seconds
&lt;/h2&gt;

&lt;p&gt;You do not need a mutation-testing framework. You need a habit.&lt;/p&gt;

&lt;p&gt;For every test you keep: &lt;strong&gt;break the code it covers, and confirm that specific test goes red.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Let me show you this on real GoGBA code, because the abstract version never lands.&lt;/p&gt;

&lt;h3&gt;
  
  
  The bug
&lt;/h3&gt;

&lt;p&gt;GoGBA's D-pad has a dead zone — the centre 10% of the radius, so resting your thumb doesn't send phantom inputs. The original &lt;code&gt;onPanStart&lt;/code&gt; looked roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="nl"&gt;onPanStart:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;localPosition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;globalToLocal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;globalPosition&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Only "activate" if the initial touch already resolved to a direction.&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;_calculateDirection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;localPosition&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNotEmpty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_isActive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;_updateDirection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;localPosition&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;

&lt;span class="nl"&gt;onPanUpdate:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_isActive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;            &lt;span class="c1"&gt;// &amp;lt;-- gated on that flag&lt;/span&gt;
    &lt;span class="n"&gt;_updateDirection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;globalToLocal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;globalPosition&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;Read it and the bug is almost invisible. Play the game and it is brutal: &lt;strong&gt;your thumb rests at the centre of the pad.&lt;/strong&gt; That is where the dead zone is. So the common gesture — land in the centre, push out to the right — starts inside the dead zone, never sets &lt;code&gt;_isActive&lt;/code&gt;, and stays inert for its entire life. The D-pad simply does nothing until you lift off and tap again.&lt;/p&gt;

&lt;p&gt;The fix is one line: a touch anywhere on the pad owns the gesture.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="nl"&gt;onPanStart:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;localPosition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;globalToLocal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;globalPosition&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// A touch anywhere on the pad owns the gesture, even one landing in&lt;/span&gt;
  &lt;span class="c1"&gt;// the dead zone: the thumb rests at the centre and pushes out from&lt;/span&gt;
  &lt;span class="c1"&gt;// there, and gating this on an initial direction left that whole&lt;/span&gt;
  &lt;span class="c1"&gt;// gesture inert until the user lifted off.&lt;/span&gt;
  &lt;span class="n"&gt;hintUserInteractionResume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_isActive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;_updateDirection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;localPosition&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;h3&gt;
  
  
  The test pair
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;testWidgets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'a drag starting in the dead zone still steers'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pumpPad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;centre&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;find&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;byType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DPad&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="c1"&gt;// Land dead centre, then push onto the right arm.&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;gesture&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;startGesture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;centre&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;gesture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;moveBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Offset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;GBAButton&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;right&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="nl"&gt;reason:&lt;/span&gt; &lt;span class="s"&gt;'pushing out of the dead zone must register a direction'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;gesture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;up&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;testWidgets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'resting in the dead zone presses nothing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pumpPad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;centre&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;find&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;byType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DPad&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="c1"&gt;// Hold near the centre and jitter inside the dead zone.&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;gesture&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;startGesture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;centre&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;gesture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;moveBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Offset&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="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;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;events&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;reason:&lt;/span&gt; &lt;span class="s"&gt;'the dead zone must still swallow thumb jitter'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;gesture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;up&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;h3&gt;
  
  
  Now actually run the mutation
&lt;/h3&gt;

&lt;p&gt;I did not reason about whether these tests work. I reverted the fix in the real file and ran them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="p"&gt;00:00 +0 -1: a drag starting in the dead zone still steers [E]
00:00 +1 -1: Some tests failed.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Test one went red. Test two stayed green.&lt;/strong&gt; That is the shape you want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Test one &lt;strong&gt;discriminates&lt;/strong&gt; — it dies without the fix.&lt;/li&gt;
&lt;li&gt;Test two is the &lt;strong&gt;anchor&lt;/strong&gt; — it proves the fix didn't overshoot and delete the dead zone entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two tests, two independent failure modes, neither redundant. That pair is worth more than twenty generated tests asserting that &lt;code&gt;_calculateDirection&lt;/code&gt; returns the right enum for sixteen different angles.&lt;/p&gt;

&lt;p&gt;Notice also what the tests are asserting &lt;em&gt;on&lt;/em&gt;. Not internal state, not &lt;code&gt;_isActive&lt;/code&gt;, not a call count on a private method. They assert on &lt;strong&gt;what the emulator core actually received&lt;/strong&gt; — a recording fake of the session port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_RecordingSession&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="n"&gt;EmulatorSessionPort&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;setButtonState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;pressed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pressed&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kd"&gt;dynamic&lt;/span&gt; &lt;span class="n"&gt;noSuchMethod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Invocation&lt;/span&gt; &lt;span class="n"&gt;invocation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;noSuchMethod&lt;/code&gt; line is the whole reason this stays maintainable: the port has a dozen methods and the test cares about one. When the port grows, this fake does not break.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. A Second Mutation: The Bug That Only Exists in the Framework
&lt;/h2&gt;

&lt;p&gt;Some bugs aren't in your logic at all. They live in a framework default you didn't know you were accepting — which makes them exactly the kind AI-generated tests never find, because the AI generated the test from your code, and your code doesn't mention the default.&lt;/p&gt;

&lt;p&gt;GoGBA centralizes motion into tokens, so no animation writes a bare &lt;code&gt;Duration&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppDuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;instant&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;quick&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;standard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;deliberate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;cartridgeInsert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="mi"&gt;1000&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 page transitions used these tokens. And yet navigation felt subtly &lt;em&gt;wrong&lt;/em&gt; — pushing a page and popping it back didn't feel like the same motion.&lt;/p&gt;

&lt;p&gt;The cause: &lt;code&gt;slideTransition&lt;/code&gt; set only &lt;code&gt;reverseTransitionDuration&lt;/code&gt;. go_router silently defaults &lt;code&gt;transitionDuration&lt;/code&gt; to &lt;strong&gt;300ms&lt;/strong&gt;. So every forward navigation ran at 300ms and every return leg at 250ms. No error, no warning, no lint. A 50ms asymmetry that you feel and cannot name.&lt;/p&gt;

&lt;p&gt;The test doesn't check a constant. It checks a &lt;strong&gt;relationship&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Regression: slideTransition once set only reverseTransitionDuration, so&lt;/span&gt;
&lt;span class="c1"&gt;// go_router defaulted the forward direction to 300ms while the return leg&lt;/span&gt;
&lt;span class="c1"&gt;// ran at 250ms. Enter and exit must travel the same path at the same speed.&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CustomTransitionPage&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="n"&gt;GoRouterState&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&amp;gt;{&lt;/span&gt;
  &lt;span class="s"&gt;'fadeTransition'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PageTransitions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fadeTransition&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
  &lt;span class="s"&gt;'slideTransition'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PageTransitions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;slideTransition&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;testWidgets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="si"&gt;${entry.key}&lt;/span&gt;&lt;span class="s"&gt; is symmetric'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="n"&gt;CustomTransitionPage&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GoRouter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;routes:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="n"&gt;GoRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;path:&lt;/span&gt; &lt;span class="s"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;pageBuilder:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;state:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;SizedBox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;shrink&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="n"&gt;addTearDown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pumpWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MaterialApp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;router&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;routerConfig:&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pumpAndSettle&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;transitionDuration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reverseTransitionDuration&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;transitionDuration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AppDuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;standard&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;Mutation check — I deleted the &lt;code&gt;transitionDuration:&lt;/code&gt; line from the real router file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failing tests:
  test/theme/app_motion_test.dart: page transitions fadeTransition is symmetric
  test/theme/app_motion_test.dart: page transitions slideTransition is symmetric
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both red. The four &lt;code&gt;AppDuration&lt;/code&gt; token tests in the same file stayed green — they cover a different property, so they &lt;em&gt;should&lt;/em&gt; stay green. A mutation that turns your whole file red is telling you your tests are entangled.&lt;/p&gt;

&lt;p&gt;The lesson generalizes: &lt;strong&gt;assert on the invariant, not on the value.&lt;/strong&gt; &lt;code&gt;expect(duration, 250)&lt;/code&gt; would pass for a broken app. &lt;code&gt;expect(forward, reverse)&lt;/code&gt; cannot.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Where AI Genuinely Wins: Property Coverage
&lt;/h2&gt;

&lt;p&gt;I have been hard on generated tests. Here is where they are outstanding, and where I now use them by default.&lt;/p&gt;

&lt;p&gt;Some tests are not about a single failure mode — they are about a &lt;em&gt;combinatorial&lt;/em&gt; claim across a set of inputs. Humans write three cases and get bored. AI writes all of them and never gets bored.&lt;/p&gt;

&lt;p&gt;GoGBA calls Gemini for on-screen text translation. The model id is Remote Config-driven — which means it changes without a release, and a model generation I have never run can arrive in production at any time. Successive Gemini generations have taken &lt;strong&gt;mutually exclusive&lt;/strong&gt; thinking knobs, and sending the wrong one is not an error: thinking stays at the model default, silently eats the 512-token output budget, and truncates the translation mid-sentence.&lt;/p&gt;

&lt;p&gt;This is the class of bug where writing down today's correct answer is worthless — today's answer expires. What you want pinned is the &lt;em&gt;rule&lt;/em&gt; for picking the knob, and the guarantee that you never send two.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'current generations use thinkingLevel'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GeminiTranslator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thinkingConfigFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'gemini-3.5-flash'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thinkingLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ThinkingLevel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;minimal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thinkingBudget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isNull&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'a generation newer than any I have run is treated as current'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GeminiTranslator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thinkingConfigFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'gemini-9-flash'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thinkingLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ThinkingLevel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;minimal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thinkingBudget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isNull&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'unparseable model id falls back to the legacy knob'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GeminiTranslator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thinkingConfigFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'custom'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thinkingBudget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thinkingLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isNull&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// The one that matters most: a property, over the whole input space.&lt;/span&gt;
&lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'the two knobs are never set together'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s"&gt;'gemini-3.5-flash'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'gemini-9-flash'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'custom'&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="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GeminiTranslator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thinkingConfigFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thinkingBudget&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thinkingLevel&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;isTrue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;reason:&lt;/span&gt; &lt;span class="s"&gt;'both knobs set for "&lt;/span&gt;&lt;span class="si"&gt;$id&lt;/span&gt;&lt;span class="s"&gt;"; the API rejects that'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first three are enumeration — AI does this perfectly and I let it. The last one is the &lt;strong&gt;property&lt;/strong&gt;, and it is the one that survives a refactor. Note that it covers &lt;code&gt;gemini-9-flash&lt;/code&gt;, a model that does not exist. That is deliberate: the model id arrives from Remote Config, so the input space includes ids I have never seen, and the property has to hold across all of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Division of labour that works for me:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Enumerate the cases&lt;/td&gt;
&lt;td&gt;AI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set up fakes, fixtures, harness boilerplate&lt;/td&gt;
&lt;td&gt;AI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Port a test to a second platform&lt;/td&gt;
&lt;td&gt;AI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Decide what the invariant is&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Me&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Run the mutation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Me&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Delete the redundant tests&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Me&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The three in bold are the entire job now. They are also the three that feel least like "work," which is why they get skipped.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Physical Properties Beat Incidental Values
&lt;/h2&gt;

&lt;p&gt;The hardest bugs in GoGBA are in the audio path, and they taught me the sharpest version of this rule.&lt;/p&gt;

&lt;p&gt;The GBA core runs at &lt;strong&gt;32040.5 Hz&lt;/strong&gt;. Not 32040. That &lt;code&gt;.5&lt;/code&gt; looks like noise. Round it off and you get a 15.6 ppm drift between producer and consumer, the ring buffer slowly fills, and roughly once every few minutes it overflows and clips — an audible pop with no apparent cause.&lt;/p&gt;

&lt;p&gt;Now here is the trap that actually bit me. A test like this passes with the bug present:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BROKEN: producer and resampler both read the same (rounded) constant,&lt;/span&gt;
&lt;span class="c1"&gt;// so the error cancels and the test is green forever.&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kCoreRate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;resampler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;48000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both sides used one rounded constant, the errors cancelled exactly, and drift was structurally unobservable. The test could never fail. It was, in the most literal sense, testing nothing.&lt;/p&gt;

&lt;p&gt;The fix is to read the rate &lt;strong&gt;independently on each side&lt;/strong&gt;, so a rounding error on one side shows up as drift. And then assert on the physical property, not a sample value.&lt;/p&gt;

&lt;p&gt;This is the general principle, and it is the one I'd carry to any codebase:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When you can, assert on a property the user can &lt;em&gt;perceive&lt;/em&gt; — not on a number that happens to be correct today.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For the audio splice, the assertion that finally caught a real regression was not "the first sample after the seam equals X." It was &lt;strong&gt;"the maximum adjacent-sample step across the splice is no larger than the clean waveform's own maximum step."&lt;/strong&gt; That is the definition of "no click." A first-sample check passed happily while a too-fast fade was still audible; the step-size check killed it immediately.&lt;/p&gt;

&lt;p&gt;For the D-pad, the property was "the core received a right-press." For transitions, "forward equals reverse." Every one of these is a sentence you could say to a user. That is the test for whether it's the right assertion.&lt;/p&gt;

&lt;p&gt;The native suite runs under sanitizers, which is the same idea applied to memory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./run_tests.sh          &lt;span class="c"&gt;# build + run&lt;/span&gt;
./run_tests.sh &lt;span class="nt"&gt;--asan&lt;/span&gt;   &lt;span class="c"&gt;# AddressSanitizer + UBSan&lt;/span&gt;
./run_tests.sh &lt;span class="nt"&gt;--tsan&lt;/span&gt;   &lt;span class="c"&gt;# ThreadSanitizer, checks the ring's locking&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with one detail worth stealing:&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="c"&gt;# Surface the first error instead of stopping at it, and make UBSan fatal --&lt;/span&gt;
&lt;span class="c"&gt;# by default it only prints and carries on, which would let a real defect pass.&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;UBSAN_OPTIONS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"print_stacktrace=1:halt_on_error=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;UBSan defaults to &lt;em&gt;printing and continuing&lt;/em&gt;. A default that turns a caught bug into a green build. Check yours.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. The Tests You Should Delete
&lt;/h2&gt;

&lt;p&gt;A suite where every test passes and no test can fail is a suite with a coverage number and no coverage.&lt;/p&gt;

&lt;p&gt;I delete a test when:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No mutation can kill it.&lt;/strong&gt; Break the code three plausible ways; if it stays green, it is decoration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Another test strictly dominates it.&lt;/strong&gt; If test B fails everywhere test A fails, A is noise in your CI log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It asserts on an implementation detail.&lt;/strong&gt; Private method call counts, internal flags, widget tree shapes that aren't the user-visible outcome. These break on every refactor and catch nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It was generated to hit a coverage target.&lt;/strong&gt; Coverage measures which lines &lt;em&gt;ran&lt;/em&gt;, not which bugs would be &lt;em&gt;caught&lt;/em&gt;. A test with no &lt;code&gt;expect&lt;/code&gt; runs plenty of lines.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The GoGBA suite has 1423 tests — it briefly had more, until I applied this section to it. Not one of them exists because a line was uncovered. Each exists because a real failure mode would otherwise go unnoticed — and the project's own rules say so out loud:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Only the necessary tests.&lt;/strong&gt; One per real failure mode that would otherwise go unnoticed — not one per method. A test must fail without the fix: revert it, watch it go red, put it back.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That last clause is the whole article in one sentence.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. What the Machine Should Enforce Instead
&lt;/h2&gt;

&lt;p&gt;There is a category of correctness that does not belong in tests at all, and it's the highest-leverage thing you can set up in 2026.&lt;/p&gt;

&lt;p&gt;If a rule can be checked statically, a test for it is the wrong tool — it only fires when someone remembered to write the test. A lint fires on every keystroke, for everyone, forever.&lt;/p&gt;

&lt;p&gt;GoGBA ships a &lt;code&gt;gogba_custom_lint&lt;/code&gt; package. Each rule is a bug class that used to recur:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;The bug it ends&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;domain_layer_dependency&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;domain/&lt;/code&gt; importing Flutter or &lt;code&gt;dart:io&lt;/code&gt;, quietly making pure logic untestable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;presentation_no_data_imports&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UI reaching past the port into a datasource&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no_ref_in_dispose&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Riverpod &lt;code&gt;ref.read()&lt;/code&gt; on the teardown path → crash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no_invalidate_app_config&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;invalidate()&lt;/code&gt; after a config write → &lt;code&gt;AsyncLoading&lt;/code&gt; flash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no_hardcoded_ui_string&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A string that never reaches the 24 translation files&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Plus one script that runs identically on my machine and in CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./scripts/check.sh           &lt;span class="c"&gt;# format, analyze, custom_lint, repo invariants&lt;/span&gt;
./scripts/check.sh &lt;span class="nt"&gt;--check&lt;/span&gt;   &lt;span class="c"&gt;# CI mode: no writes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every stage always runs even if an earlier one fails, so one invocation surfaces every problem rather than making you play whack-a-mole.&lt;/p&gt;

&lt;p&gt;The AI angle is the important part: &lt;strong&gt;the assistant reads these rules before it writes code.&lt;/strong&gt; The constraints live in &lt;code&gt;skill.md&lt;/code&gt; files the assistant loads at the start of every session, and the lints catch anything that slips through. The rules stop depending on anyone's memory — mine or the model's.&lt;/p&gt;

&lt;p&gt;That is the real 2026 workflow. Not "AI writes my tests." It's: &lt;em&gt;encode the rules once, and the cost of following them drops to zero for every future contributor, human or not.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Checklist
&lt;/h2&gt;

&lt;p&gt;Everything above, compressed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;strong&gt;For each new test: break the code, watch that test go red, put it back.&lt;/strong&gt; Non-negotiable. It costs 30 seconds.&lt;/li&gt;
&lt;li&gt;[ ] Assert on the &lt;strong&gt;physical/perceivable property&lt;/strong&gt;, not an incidental value.&lt;/li&gt;
&lt;li&gt;[ ] Assert on &lt;strong&gt;relationships&lt;/strong&gt; (forward == reverse) over constants (== 250).&lt;/li&gt;
&lt;li&gt;[ ] In numeric tests, read each side's inputs &lt;strong&gt;independently&lt;/strong&gt; — shared constants cancel errors and make bugs unobservable.&lt;/li&gt;
&lt;li&gt;[ ] Let AI enumerate cases and build fakes. &lt;strong&gt;You&lt;/strong&gt; decide the invariant.&lt;/li&gt;
&lt;li&gt;[ ] Delete tests no mutation can kill, and tests another test dominates.&lt;/li&gt;
&lt;li&gt;[ ] Move every statically-checkable rule from tests into &lt;strong&gt;lints&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Make sanitizers and checkers &lt;strong&gt;fatal&lt;/strong&gt; — verify their defaults don't print-and-continue.&lt;/li&gt;
&lt;li&gt;[ ] One verify script, &lt;strong&gt;identical locally and in CI&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The thing I keep coming back to: AI didn't make testing easier. It made &lt;em&gt;writing&lt;/em&gt; testing artifacts easier, which is not the same activity, and the gap between those two is where bugs now live.&lt;/p&gt;

&lt;p&gt;The skill that mattered in 2020 was knowing how to write a widget test. That skill is now commoditized and I'm glad — it was never the interesting part. The skill that matters in 2026 is &lt;strong&gt;judgment about what a test proves&lt;/strong&gt;, and it is &lt;em&gt;less&lt;/em&gt; commoditized than before, because now there are a thousand plausible tests where there used to be three, and someone still has to know which ones are real.&lt;/p&gt;

&lt;p&gt;You get that judgment exactly one way. Break your own code and watch what happens. If nothing turns red, you have just learned something considerably more valuable than a green checkmark.&lt;/p&gt;




&lt;p&gt;GoGBA is a GBA/GBC/GB emulator for Android and iOS — Flutter UI over a libretro/mGBA core, with AI on-screen translation. Search &lt;strong&gt;GoGBA&lt;/strong&gt; on the App Store or Google Play.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>flutter</category>
      <category>softwareengineering</category>
      <category>testing</category>
    </item>
    <item>
      <title>Six months of running a GBA emulator</title>
      <dc:creator>Hamber</dc:creator>
      <pubDate>Mon, 27 Jul 2026 03:12:22 +0000</pubDate>
      <link>https://dev.to/hamberluo/six-months-of-running-a-gba-emulator-54m5</link>
      <guid>https://dev.to/hamberluo/six-months-of-running-a-gba-emulator-54m5</guid>
      <description>&lt;p&gt;I shipped &lt;strong&gt;GoGBA&lt;/strong&gt; (Android + iOS) to both stores in late December 2025. Six months in: MAU peaked at 8.3k, currently steady around 7.4k. No paid advertising, ever.&lt;/p&gt;

&lt;p&gt;This is a write-up of what the six months actually involved. I'll be specific about the technical work, and equally specific about the mistake that cost me RetroAchievements hardcore certification — because that part is the most useful thing here for anyone building in this space.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why GBA only
&lt;/h2&gt;

&lt;p&gt;I grew up on a GBA — Super Robot Wars, Fire Emblem, Pokémon, Castlevania, Zelda. Later NDS/3DS/PSP/Vita/Switch arrived and the GBA did its job and retired. On PC the emulator I remember is VisualBoyAdvance.&lt;/p&gt;

&lt;p&gt;I've used GBA, NDS and PSP emulators on phones. I kept coming back to GBA, for four reasons that are all practical rather than nostalgic:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pixel art holds up.&lt;/strong&gt; Personal taste, no defense offered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Battery.&lt;/strong&gt; A GBA game survives a long-haul flight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single screen.&lt;/strong&gt; The remaining screen space is exactly where virtual buttons want to go. NDS dual-screen on a phone is always a compromise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ROM hacks.&lt;/strong&gt; The GBA hack scene is the richest of any handheld.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Point 3 is the one that made me build something: &lt;strong&gt;GBA is the only handheld whose form factor natively fits a phone.&lt;/strong&gt; That's a product observation, not sentiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  What existing emulators get wrong (for me)
&lt;/h2&gt;

&lt;p&gt;I used the main ones on both platforms: Delta and Linkboy on iOS; Pizzaboy, Linkboy and Lemuroid on Android. Lemuroid is open source and a lot of shipped emulators are built on it.&lt;/p&gt;

&lt;p&gt;They're all good. Every one of them had small things that annoyed me.&lt;/p&gt;

&lt;p&gt;The only genuinely cross-platform one is Linkboy (formerly MyBoy), but its configuration surface is extremely deep — second only to RetroArch in complexity.&lt;/p&gt;

&lt;p&gt;That's the gap. Everyone was solving "can it run" and "can it be tuned perfectly." Nobody was solving &lt;strong&gt;"pick it up and play."&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The methodology was just dogfooding
&lt;/h2&gt;

&lt;p&gt;I'm a Flutter GDE and tech lead for a 40-person cross-platform team; GoGBA was a solo test of that experience. The only rule: &lt;strong&gt;if nothing annoys me during real play, users will like it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That sounds lazy. It's actually a harsh standard, because as a heavy user my tolerance is lower than the average player's.&lt;/p&gt;

&lt;p&gt;Two features nobody requested, that came out of actually playing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-orientation button opacity.&lt;/strong&gt; Most emulators give you one global opacity slider. But in portrait the buttons sit in dead space below the screen and I want them &lt;em&gt;clear&lt;/em&gt;; in landscape they overlay the game and I want them &lt;em&gt;faint&lt;/em&gt;. So opacity is persisted separately per orientation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;D-Pad haptics as its own toggle.&lt;/strong&gt; Most emulators have one vibration switch. But haptics belong on &lt;em&gt;discrete&lt;/em&gt; actions (A/B), not &lt;em&gt;high-frequency continuous&lt;/em&gt; input — a constantly buzzing D-Pad just makes your thumb numb. So it's a separate switch.&lt;/p&gt;

&lt;p&gt;You only find these by playing daily.&lt;/p&gt;

&lt;h2&gt;
  
  
  "It's just an AI-written frontend on a finished core"
&lt;/h2&gt;

&lt;p&gt;This is the objection I want to answer properly, because I think it's wrong in an interesting way.&lt;/p&gt;

&lt;p&gt;AI made this dramatically easier. &lt;strong&gt;Dramatically easier is not the same as low barrier.&lt;/strong&gt; Three concrete examples of where the barrier actually sits.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The real barrier is telling whether the AI is hallucinating
&lt;/h3&gt;

&lt;p&gt;I fixed three bugs in my own mGBA fork:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MBC6 save null-pointer crash&lt;/strong&gt; — some MBC6 cartridge games crashed outright&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GB MBC RTC state loss&lt;/strong&gt; — broke real-time-clock games like Pokémon Prism&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pokémon ROM identification off-by-one&lt;/strong&gt; — boundary error causing misidentification&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Those patches were written by AI.&lt;/strong&gt; I'm saying so plainly, because leaving it out would repeat the exact mistake I describe further down.&lt;/p&gt;

&lt;p&gt;But that's the point I actually want to make:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When you point an AI at a twenty-year-old C codebase, the risk isn't that it can't produce a fix. The risk is that it produces one that looks right.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You get a patch that compiles and makes the symptom disappear. That patch may also be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a null check at the wrong layer, converting a crash into &lt;em&gt;silent save corruption&lt;/em&gt; — which the user discovers forty hours later&lt;/li&gt;
&lt;li&gt;a hallucinated struct field or function that doesn't exist in mGBA, but compiles anyway because of macro expansion&lt;/li&gt;
&lt;li&gt;correct only for the one ROM on my desk, and broken for every other MBC6 cartridge&lt;/li&gt;
&lt;li&gt;an off-by-one "fixed" into an off-by-one in the other direction, where my test case happened not to cover the new boundary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the actual work wasn't writing the patch — it was &lt;strong&gt;reviewing&lt;/strong&gt; it. At which layer does that null check belong? Is the RTC state lost on the save path or the restore path? What does that field mean in mGBA's original design, and who owns its lifetime? The correct off-by-one boundary comes from reading the GBA cartridge header spec, not from asking the model again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI drove the cost of writing code to nearly zero, which makes "judging whether the code is correct" the only remaining barrier. And that judgment can't be outsourced back to the AI — you can't ask the thing that hallucinates to verify its own hallucinations.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's why I'd argue the barrier didn't drop, it &lt;strong&gt;moved&lt;/strong&gt;: from "can you produce it" to "can you recognize the answer that looks right and isn't." The second requires no less knowledge than the first, and unlike the first it's &lt;strong&gt;not fakeable&lt;/strong&gt; — you either know the cartridge header layout or you don't.&lt;/p&gt;

&lt;p&gt;One necessary clarification: &lt;strong&gt;these three fixes live only in GoGBA's fork. They are not upstreamed into mGBA.&lt;/strong&gt; So they're GoGBA-specific behavioral differences, not a contribution to the community — your mGBA doesn't have them, and the same MBC6 cartridge will still crash.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Where the native/Flutter boundary goes
&lt;/h3&gt;

&lt;p&gt;People say Flutter can't match native performance. True and not true.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Flutter (Dart)   ← all UI, state, config, navigation
   ↓ MethodChannel / EventChannel
Kotlin / Swift   ← rendering, audio, input, save IO
   ↓ JNI / C FFI
libretro mGBA    ← the emulation itself
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rendering is native&lt;/strong&gt; — OpenGL ES 3.0 on Android, Metal on iOS, framebuffer blitted straight into a Texture, zero copy. &lt;strong&gt;Flutter runs zero frames of emulation logic.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything else — 24 locales, settings hierarchy, button layout editor, paywall, cloud saves — is Flutter, pixel-identical across both platforms.&lt;/p&gt;

&lt;p&gt;There are no good or bad stacks, only correct or incorrect boundaries. Performance-sensitive work goes native; consistency-sensitive work goes Flutter. &lt;em&gt;Choosing where that line falls&lt;/em&gt; is the actual engineering.&lt;/p&gt;

&lt;p&gt;The native side still bit me plenty. Two that reached production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;EGL teardown race&lt;/strong&gt; — intermittent &lt;code&gt;SIGABRT&lt;/code&gt; (destroyed mutex) on Mali GPUs when exiting a game. Root cause: calling &lt;code&gt;eglTerminate&lt;/code&gt; on &lt;code&gt;EGL_DEFAULT_DISPLAY&lt;/code&gt;, which is &lt;strong&gt;process-wide shared state&lt;/strong&gt;. You don't tear that down from one instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Release-only empty-stack crash&lt;/strong&gt; — double-tap-to-exit fired twice, popped the navigation stack empty, and &lt;code&gt;matches.last&lt;/code&gt; crashed. Not reproducible in debug.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These two differ in kind from the mGBA patches above, and are hard for a related but distinct reason: &lt;strong&gt;they don't exist in any environment the AI can observe.&lt;/strong&gt; One reproduces intermittently and only on Mali GPUs; the other only in release builds. No stack trace, no reliable reproduction — just a &lt;code&gt;SIGABRT&lt;/code&gt; line in production Crashlytics. You can't paste that at a model and wait, because &lt;em&gt;working out what the problem even is&lt;/em&gt; is the step that has to happen first.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Adding features is easy; keeping them from becoming clutter is not
&lt;/h3&gt;

&lt;p&gt;Since launch: 1,499 commits — 276 features, 224 fixes.&lt;/p&gt;

&lt;p&gt;Shipped in that window: a Pokémon toolkit (Pokédex / type chart / natures / moves, offline SQLite, 24 languages), offline achievement caching with pending-sync states, a button layout editor with per-button hiding, gesture quick-save (slide-to-unlock interaction), adaptive orientation (portrait menus / landscape gameplay), cloud saves (iCloud + Google Drive), cheats, AI screen translation, and a daily tips card.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every single one had to answer the same question: does this make "pick it up and play" worse?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which is why GoGBA is one of the very few emulators with &lt;strong&gt;no ads at all.&lt;/strong&gt; Not on principle — ads structurally conflict with the core experience, so there's nothing to discuss.&lt;/p&gt;

&lt;p&gt;The hard part was never adding things. It's keeping a first-time user able to find what they need in three seconds &lt;em&gt;while&lt;/em&gt; the feature count grows.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mistake: I conflated "emulator" with "emulator frontend"
&lt;/h2&gt;

&lt;p&gt;I promoted GoGBA with the line &lt;strong&gt;"built an emulator in three days."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That was wrong, and not as a matter of phrasing — as a matter of concept.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;emulator&lt;/strong&gt; is mGBA. Twenty years of work by many people, cycle-accurate hardware behavior.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;emulator frontend&lt;/strong&gt; is GoGBA. UI, input, render pipeline, save management, platform integration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I built in three days was a prototype of the second thing. Calling it the first thing disrespected every mGBA contributor.&lt;/p&gt;

&lt;p&gt;The RetroAchievements community was justifiably angry, and GoGBA's hardcore certification was revoked.&lt;/p&gt;

&lt;p&gt;Two things I want to state honestly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, on the technical requirements, GoGBA does comply.&lt;/strong&gt; Hardcore forbids cheats, save states, rewind/fast-forward and memory editing. GoGBA gates all of them behind one shared predicate (&lt;code&gt;RA enabled ∧ authenticated session ∧ effective hardcore&lt;/code&gt;), checked independently at every entry point: cheats are not dispatched, the in-game save-state menu entry isn't rendered at all, and the manual slots, the auto-save/auto-resume automation and the free gesture quick-save slot each re-check the gate on their own path — save &lt;em&gt;and&lt;/em&gt; load, in both directions. Being free of the paywall doesn't exempt a slot. Rewind and frame advance aren't shipped features. The client User-Agent reports core name and version per RA's spec. The "publicly available for at least six months" eligibility requirement is now also satisfied.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, that doesn't matter much.&lt;/strong&gt; Certification isn't only a technical checklist — it includes community trust. One inaccurate marketing line damaged the second thing. Technical compliance can be fixed by writing code. Trust can only be fixed by time.&lt;/p&gt;

&lt;p&gt;I don't know when I'll be allowed to reapply. That call isn't mine to make.&lt;/p&gt;

&lt;p&gt;I'm writing it down because if you're building an emulator frontend: &lt;strong&gt;get these two words straight on day one.&lt;/strong&gt; It's professional courtesy, not semantics.&lt;/p&gt;




&lt;h2&gt;
  
  
  The reviews that got raised back to five stars
&lt;/h2&gt;

&lt;p&gt;Five-star reviews are the best part of this, and I built a page that collects them.&lt;/p&gt;

&lt;p&gt;But the ones I care about more are different: &lt;strong&gt;reviews that started below five stars and were raised after I fixed that specific user's problem.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first kind says "you did well." The second says "you're listening."&lt;/p&gt;

&lt;p&gt;For a one-person product, the second one is the moat. People didn't stay because GoGBA has the most features. They stayed because &lt;strong&gt;reporting a problem results in the problem being fixed.&lt;/strong&gt; How fast one person can turn that around is the only structural advantage independent development has — and the only one that costs nothing but attention.&lt;/p&gt;




&lt;p&gt;GoGBA is on the App Store and Google Play.&lt;/p&gt;

&lt;p&gt;There's not much to conclude at the six-month mark. The 7.4k moves every day, the reviews bring new problems every day, and whether RetroAchievements ever takes another look isn't mine to decide. The only thing I know is what's getting fixed in the next build.&lt;/p&gt;

&lt;p&gt;If you mostly play late at night or on a plane and don't want to chase anyone's progress, GoGBA was probably built for you. Because it was built for me.&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>gamedev</category>
      <category>mobile</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Modern Flutter Best Practices for 2026</title>
      <dc:creator>Hamber</dc:creator>
      <pubDate>Sat, 04 Jul 2026 14:35:55 +0000</pubDate>
      <link>https://dev.to/hamberluo/modern-flutter-best-practices-for-2026-56o3</link>
      <guid>https://dev.to/hamberluo/modern-flutter-best-practices-for-2026-56o3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;If you're starting a new Flutter project in 2026, this article covers two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What a modern Flutter project should look like (tech stack, architecture, engineering);&lt;/li&gt;
&lt;li&gt;The trade-offs between an OPC (One Person Company) project and an enterprise-grade production project.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything below is grounded in two real projects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;go_gba&lt;/strong&gt; — a GBA emulator App built by a single developer (single package, shipped on the App Store / Google Play, now at v3.12). A textbook OPC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AppX&lt;/strong&gt; (pseudonym) — a high-traffic consumer App maintained by a large cross-platform team (melos monorepo, multiple flavors, multiple environments). A textbook enterprise-grade project.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both projects share the same modern Flutter foundation, but make completely opposite bets on "complexity investment." Comparing them tells you far more about &lt;em&gt;when to reach for the heavy machinery&lt;/em&gt; than looking at either one alone.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  0. The Bottom Line (TL;DR)
&lt;/h2&gt;

&lt;p&gt;In 2026, several choices have moved from "optional" to "default":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;2026 default&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;State management&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Riverpod 3&lt;/strong&gt; (+ code generation)&lt;/td&gt;
&lt;td&gt;Compile-time safety, testable, no BuildContext dependency — the de facto community standard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Routing&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;go_router&lt;/strong&gt; (declarative + type-safe routes)&lt;/td&gt;
&lt;td&gt;Officially recommended; deep links / nested navigation are first-class&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data models&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;freezed 3&lt;/strong&gt; + json_serializable&lt;/td&gt;
&lt;td&gt;Immutable, pattern matching, &lt;code&gt;copyWith&lt;/code&gt; — kills hand-written boilerplate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;i18n&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;slang&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Type-safe, compile-time checked, an order of magnitude nicer than the official &lt;code&gt;intl&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version pinning&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;fvm&lt;/strong&gt; (pin the Flutter SDK version)&lt;/td&gt;
&lt;td&gt;The end of "works on my machine"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code generation&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;build_runner&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;freezed / riverpod / slang / routes / assets all depend on it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lint&lt;/td&gt;
&lt;td&gt;Strict lint + &lt;strong&gt;custom lint rules&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Turns team conventions into machine-enforceable rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crash / analytics&lt;/td&gt;
&lt;td&gt;Firebase Crashlytics + Analytics (or equivalent)&lt;/td&gt;
&lt;td&gt;A production App without observability is flying blind&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Details below.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Tech Stack: The 2026 "Standard Recipe"
&lt;/h2&gt;

&lt;p&gt;The two projects agree closely on their core libraries, which itself is evidence that consensus has formed.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.1 State Management — Riverpod 3
&lt;/h3&gt;

&lt;p&gt;Stop agonizing over Provider / Bloc / GetX. &lt;strong&gt;Riverpod 3 is already the default answer for new projects.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;go_gba: &lt;code&gt;flutter_riverpod: 3.3.2&lt;/code&gt;, hand-written providers.&lt;/li&gt;
&lt;li&gt;AppX: &lt;code&gt;riverpod_generator&lt;/code&gt; + &lt;code&gt;riverpod_annotation&lt;/code&gt;, full code generation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The difference is &lt;strong&gt;whether you adopt code generation&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Hand-written (good enough for OPC, directly readable)&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;gameLibraryProvider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FutureProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Game&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;((&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gameRepositoryProvider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;loadAll&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Generated (recommended for enterprise; stricter types, safer refactors)&lt;/span&gt;
&lt;span class="nd"&gt;@riverpod&lt;/span&gt;
&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Game&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;gameLibrary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GameLibraryRef&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gameRepositoryProvider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;loadAll&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;strong&gt;Recommendation&lt;/strong&gt;: Go straight to Riverpod 3 on new projects. Team projects should use the generator (with &lt;code&gt;riverpod_lint&lt;/code&gt; enforcing conventions); solo projects are perfectly fine hand-writing — don't add the mental overhead of code generation just to look "advanced."&lt;/p&gt;

&lt;h3&gt;
  
  
  1.2 Routing — go_router
&lt;/h3&gt;

&lt;p&gt;Both projects use &lt;code&gt;go_router: 17.x&lt;/code&gt;. AppX additionally adopts &lt;code&gt;go_router_builder&lt;/code&gt; (type-safe routes: route parameters go from &lt;code&gt;String&lt;/code&gt; to compile-time-checked objects).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// go_router_builder: routes ARE types, no more string concatenation for navigation&lt;/span&gt;
&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;go&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GameDetailRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;gameId:&lt;/span&gt; &lt;span class="n"&gt;game&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;location&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Recommendation&lt;/strong&gt;: go_router is the only no-brainer. Deep links, Web support, and nested Shell navigation all rely on it. The builder is worth it for team projects; optional for solo.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.3 Data Models — freezed 3
&lt;/h3&gt;

&lt;p&gt;AppX uses freezed across the board to define immutable models, paired with json_serializable for automatic serialization. This is the default way to handle data classes in 2026: immutability, &lt;code&gt;copyWith&lt;/code&gt;, &lt;code&gt;when&lt;/code&gt;/&lt;code&gt;map&lt;/code&gt; pattern matching, and value equality — all generated for you.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note the naming convention for generated files (worth copying from AppX):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;*.f.freezed.dart&lt;/code&gt; — freezed-generated copyWith/when/map&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;*.f.g.dart&lt;/code&gt; — freezed + JSON serialization&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;*.g.dart&lt;/code&gt; — riverpod / standalone json_serializable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Never hand-edit generated files&lt;/strong&gt;, and &lt;code&gt;exclude&lt;/code&gt; them in &lt;code&gt;analysis_options.yaml&lt;/code&gt; to avoid lint noise.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  1.4 i18n — slang
&lt;/h3&gt;

&lt;p&gt;Both projects abandoned the official &lt;code&gt;intl&lt;/code&gt; arb workflow in favor of &lt;strong&gt;slang&lt;/strong&gt;. Reasons: type safety (&lt;code&gt;t.home.title&lt;/code&gt; instead of &lt;code&gt;AppLocalizations.of(context)!.homeTitle&lt;/code&gt;), compile-time missing-key checks, and support for plurals / parameters / namespaces.&lt;/p&gt;

&lt;p&gt;An enterprise-grade detail: AppX's slang output is &lt;strong&gt;not committed&lt;/strong&gt; (&lt;code&gt;strings*.g.dart&lt;/code&gt; stays out of the repo) and is generated on demand locally / in CI. This reduces merge conflicts but requires CI to run i18n generation before the build. OPC projects can just commit the output for simplicity.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.5 Networking
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;go_gba: plain &lt;code&gt;dio&lt;/code&gt;, simple and direct.&lt;/li&gt;
&lt;li&gt;AppX: &lt;code&gt;dio&lt;/code&gt; + &lt;code&gt;dio_http2_adapter&lt;/code&gt; (HTTP/2) + &lt;code&gt;native_dio_adapter&lt;/code&gt; (uses the native network stack) + in-house networking / WebSocket infrastructure packages (unified team wrappers).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;This is the first classic fork between OPC and enterprise&lt;/strong&gt;: solo projects use bare dio; teams extract the network layer into standalone packages to unify interceptors, retries, auth, telemetry, and certificate pinning.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Architecture: Different "Investment Budgets" for Layering
&lt;/h2&gt;

&lt;p&gt;This is where the two projects diverge the most, and where "complexity is an investment" shows most clearly.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.1 OPC: feature-by-layer (go_gba)
&lt;/h3&gt;

&lt;p&gt;go_gba uses the classic &lt;strong&gt;slice-by-technical-layer&lt;/strong&gt; directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lib/
├── core/          # emulator / services / analytics / errors / theme
├── data/          # datasources / repositories / adapters / platform
├── domain/        # business entities and use cases
├── providers/     # Riverpod providers
├── pages/         # organized by feature page: home / play / settings / toolkit ...
├── widgets/       # shared components
├── router/        # go_router config
└── i18n/          # slang
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Characteristics: &lt;strong&gt;one package does it all&lt;/strong&gt;. &lt;code&gt;pages/&lt;/code&gt; has sub-directories per feature; &lt;code&gt;data&lt;/code&gt;/&lt;code&gt;domain&lt;/code&gt; are shared globally. For a project maintained by one person, this structure carries the lowest mental load — you can fit the whole dependency graph in your head and don't need package boundaries to force decoupling.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.2 Enterprise: feature-first + Clean Architecture inside each feature (AppX)
&lt;/h3&gt;

&lt;p&gt;AppX is a &lt;strong&gt;melos monorepo&lt;/strong&gt;, split at the top level into three kinds of packages by responsibility:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apps/
└── app/                     # app shell: main_dev / main_prod / main_test (multiple entrypoints)
packages/
├── features/                # business feature packages, each self-contained:
│   ├── home/  feature_a/  feature_b/  auth/  ai/ ...
├── design_system/           # design system: colors/typography/spacing/components/chart
├── data_hub/  router/  routes/  env/  foundation/  push/  flavor_config/
└── lints/                   # team's custom lint rules package
plugins/                     # native plugins: various platform-capability wrappers
third_party/                 # third-party wrappers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;strong&gt;each feature package internally&lt;/strong&gt; is a full Clean Architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;packages/features/home/lib/src/
├── application/    # coordinators / services (use-case orchestration)
├── domain/         # entities / repositories (interfaces)
├── data/           # datasources (local/remote) / dtos / mappers / repositories_impl
└── presentation/   # pages / widgets / controllers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why go this heavy? Because on a team of dozens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Package boundaries = enforced decoupling.&lt;/strong&gt; Feature A cannot directly import Feature B's internal implementation — only its public API. You can't achieve this with directory conventions; you need packages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compilation isolation = faster incremental builds.&lt;/strong&gt; Changing one feature doesn't recompile the world.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No collisions in parallel development.&lt;/strong&gt; Different squads own different packages, so the conflict surface is small.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An independent design system&lt;/strong&gt; = a single source of truth for UI consistency (the &lt;code&gt;design_system&lt;/code&gt; package), instead of every page hard-coding its own colors.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  2.3 The Key Judgment: When Should You Upgrade From an OPC Structure to a Monorepo?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Don't start with a monorepo.&lt;/strong&gt; This is a hotspot for over-engineering. Upgrade only when the signals appear:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The codebase grows beyond what one person can "hold in their head";&lt;/li&gt;
&lt;li&gt;2+ developers need to work on different modules in parallel;&lt;/li&gt;
&lt;li&gt;Incremental compilation starts getting noticeably slow;&lt;/li&gt;
&lt;li&gt;Arguments over "which module does this logic belong to" start happening;&lt;/li&gt;
&lt;li&gt;You need to reuse a single design system / network layer across multiple Apps or flavor outputs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Until then, go_gba's single-package-with-clear-layering is the &lt;strong&gt;most cost-effective&lt;/strong&gt; structure.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Engineering: Turning Conventions Into Machine Enforcement
&lt;/h2&gt;

&lt;p&gt;The maturity of a modern Flutter project is largely reflected in &lt;em&gt;how many conventions are machine-enforced rather than left to human discipline&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.1 Strict Lint (both projects do it, at different intensities)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;go_gba: based on &lt;code&gt;leancode_lint&lt;/code&gt;, and it &lt;strong&gt;built its own &lt;code&gt;gogba_custom_lint&lt;/code&gt; package&lt;/strong&gt; — encoding project-specific conventions (e.g. "must go through a certain service instead of calling the API directly") as custom lint rules. Worth doing even solo, because it's a guardrail for "future you."&lt;/li&gt;
&lt;li&gt;AppX: an extremely strict &lt;code&gt;analysis_options.yaml&lt;/code&gt;, promoting many rules from &lt;code&gt;warning&lt;/code&gt; to &lt;strong&gt;&lt;code&gt;error&lt;/code&gt; level&lt;/strong&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;analyzer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;strict-casts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;       &lt;span class="c1"&gt;# forbid implicit type casts&lt;/span&gt;
    &lt;span class="na"&gt;strict-inference&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;# forbid inference falling back to dynamic&lt;/span&gt;
  &lt;span class="na"&gt;errors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;always_declare_return_types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;error&lt;/span&gt;
    &lt;span class="na"&gt;avoid_void_async&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;error&lt;/span&gt;
    &lt;span class="na"&gt;prefer_single_quotes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;error&lt;/span&gt;
    &lt;span class="na"&gt;require_trailing_commas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;error&lt;/span&gt;   &lt;span class="c1"&gt;# enforce trailing commas → better diffs and formatting&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Recommendation&lt;/strong&gt;: Max out lint on day one of a new project. &lt;code&gt;strict-casts&lt;/code&gt; + &lt;code&gt;strict-inference&lt;/code&gt; are two low-cost, high-reward switches. Team projects must have a shared &lt;code&gt;lints&lt;/code&gt; package to unify rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.2 Custom Lint — the 2026 Power Move
&lt;/h3&gt;

&lt;p&gt;Both projects do this, and many people overlook it: &lt;strong&gt;use &lt;code&gt;custom_lint&lt;/code&gt; to compile your team's/project's verbal conventions into static checks.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A convention written in a doc goes unread; written as a lint rule, a violation lights up red. go_gba did this even as a one-person project — proof that the ROI is high enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.3 Version Pinning — fvm Is a Necessity
&lt;/h3&gt;

&lt;p&gt;AppX uses &lt;code&gt;fvm&lt;/code&gt; to pin the Flutter SDK to an exact version, committed in &lt;code&gt;.fvmrc&lt;/code&gt;. Everyone on the team, CI, and local runs all use the same SDK.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Team projects without fvm periodically bleed time to "it won't compile on my end."&lt;/strong&gt; Solo projects should use it too, mainly to prevent a global upgrade from breaking the project.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.4 The Code-Generation Pipeline Must Be "Ordered"
&lt;/h3&gt;

&lt;p&gt;A hard-won lesson from AppX (written in its AGENTS.md): &lt;strong&gt;codegen cannot run in parallel.&lt;/strong&gt; Because packages have dependencies (design_system's generated output is consumed by features), running in parallel causes &lt;code&gt;AssetNotFoundException&lt;/code&gt;. So it wrote &lt;code&gt;codegen.sh&lt;/code&gt; to generate in dependency-graph order, leaf packages first.&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="c"&gt;# Correct: in dependency order&lt;/span&gt;
melos run codegen   &lt;span class="c"&gt;# → bash script/codegen.sh&lt;/span&gt;

&lt;span class="c"&gt;# Wrong: parallel melos exec build_runner → random failures&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway&lt;/strong&gt;: monorepo codegen must explicitly manage ordering. Single-package projects don't have this problem — one &lt;code&gt;dart run build_runner build -d&lt;/code&gt; does it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.5 Git Hooks — Move Discipline Left, Before Push
&lt;/h3&gt;

&lt;p&gt;After &lt;code&gt;melos bootstrap&lt;/code&gt;, AppX &lt;strong&gt;automatically installs versioned git hooks&lt;/strong&gt; (&lt;code&gt;pre-push&lt;/code&gt; runs a lint attribution check). This way "you must pass validation before committing" isn't something people have to remember — it's intercepted automatically at push time.&lt;/p&gt;

&lt;p&gt;Paired with this is a unified &lt;code&gt;verify.sh&lt;/code&gt; (format check + per-package analyze across the whole workspace), and &lt;strong&gt;local and CI run the exact same script&lt;/strong&gt; — this is crucial: passing locally = passing CI, eliminating the "green locally, red in CI" finger-pointing.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.6 Multi-Environment / Multi-Entrypoint (an enterprise trait)
&lt;/h3&gt;

&lt;p&gt;AppX has three entrypoints: &lt;code&gt;main_dev.dart&lt;/code&gt; / &lt;code&gt;main_prod.dart&lt;/code&gt; / &lt;code&gt;main_test.dart&lt;/code&gt;, paired with generated environment variables (&lt;code&gt;env.impl.dart&lt;/code&gt;) and per-flavor config (&lt;code&gt;flavor_env.impl.dart&lt;/code&gt;). One codebase produces dev/prod, plus different flavor outputs, via build parameters.&lt;/p&gt;

&lt;p&gt;go_gba, as a single product, doesn't need any of this. &lt;strong&gt;Multi-flavor / multi-channel packaging is a textbook "tax you only pay at enterprise scale."&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Observability: The Eyes of a Production App
&lt;/h2&gt;

&lt;p&gt;go_gba, despite being a one-person project, does observability thoroughly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;firebase_crashlytics&lt;/code&gt; — crash collection&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;firebase_analytics&lt;/code&gt; — user behavior&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;firebase_remote_config&lt;/code&gt; — remote config / gradual rollout switches&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;firebase_ai&lt;/code&gt; — AI capabilities&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;This is worth emphasizing: observability isn't an enterprise privilege — an OPC needs it more.&lt;/strong&gt; A solo developer has no QA team and no support agents relaying issues; when something breaks in production, all you have is the Crashlytics stack trace and the Analytics funnel to diagnose it yourself. remote_config lets you kill a broken feature without shipping a release — a lifesaver for indie developers.&lt;/p&gt;

&lt;p&gt;Enterprise projects typically use an in-house or heavier APM (AppX uses &lt;code&gt;sentry_flutter&lt;/code&gt;), but the &lt;strong&gt;core idea is identical&lt;/strong&gt;: crashes, performance, and behavior — three data streams, none optional.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. OPC vs Enterprise: A Decision Matrix
&lt;/h2&gt;

&lt;p&gt;Condensing the differences into one table. On the left, "the baseline both should do"; on the right, where they fork.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.1 The Shared Baseline (do this regardless of scale)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Riverpod 3 + go_router + freezed + slang&lt;/td&gt;
&lt;td&gt;Modern foundation, no compromise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strict lint (strict-casts / strict-inference)&lt;/td&gt;
&lt;td&gt;Max it out on day one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;build_runner code generation&lt;/td&gt;
&lt;td&gt;Kill boilerplate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability (crash + analytics + remote config)&lt;/td&gt;
&lt;td&gt;Mandatory in production; OPC needs it especially&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clear layering (data / domain / presentation)&lt;/td&gt;
&lt;td&gt;Layer even in a single package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom lint rules&lt;/td&gt;
&lt;td&gt;High ROI, worth it even solo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  5.2 The Fork Points
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;OPC (e.g. go_gba)&lt;/th&gt;
&lt;th&gt;Enterprise (e.g. AppX)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Repo structure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Single package, layered directories&lt;/td&gt;
&lt;td&gt;melos monorepo, packages as boundaries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Architecture depth&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;feature-by-layer, globally shared domain&lt;/td&gt;
&lt;td&gt;Independent Clean Architecture per feature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;State management&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hand-written Riverpod is enough&lt;/td&gt;
&lt;td&gt;Riverpod generator + riverpod_lint enforcement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Network layer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Bare dio&lt;/td&gt;
&lt;td&gt;Extracted into standalone packages; unified interceptors/auth/HTTP2/WS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Design system&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A single theme directory&lt;/td&gt;
&lt;td&gt;Standalone &lt;code&gt;design_system&lt;/code&gt; package (single UI source)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Multi-environment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Single entrypoint&lt;/td&gt;
&lt;td&gt;Multiple flavor entrypoints + multi-channel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;codegen&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One command&lt;/td&gt;
&lt;td&gt;Ordered generation script by dependency graph&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CI / discipline&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Local scripts + store release&lt;/td&gt;
&lt;td&gt;git hook + verify script (local = CI) + full pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dependency versions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Exact pinning is enough&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dependency_overrides&lt;/code&gt; unifies versions across the workspace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Optimization goal&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Iteration speed, lowest mental load&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Maintainability, parallelism, consistency&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  5.3 The One-Line Principle
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;OPC optimizes for "speed of change"; enterprise optimizes for "safety of change."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On a solo project, your biggest cost is your own attention, so cut every bit of complexity that needs "maintaining" (monorepo, extra abstraction layers, multi-channel packaging).&lt;br&gt;
On a team project, your biggest cost is communication and incidents, so pay for "boundaries" and "enforcement" (package isolation, git hooks, unified versions).&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  6. The Two Most Common Mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;OPC over-engineering&lt;/strong&gt;: one person puts a to-do App on a melos monorepo with four-layer Clean Architecture per feature. The result: 80% of the time spent maintaining scaffolding instead of building features. &lt;strong&gt;Complexity is an investment; below the scale threshold, it's pure loss.&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Enterprise under-investment&lt;/strong&gt;: five people cram into one &lt;code&gt;lib/&lt;/code&gt;, with no package boundaries, no unified lint, codegen run by hand, and local vs CI scripts out of sync. The result: every merge is a disaster and "it works" is down to luck. &lt;strong&gt;Skip the tax you owe, and you'll pay it back later with interest.&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To judge which tier you're in, don't look at "how professional I want to seem" — look at your &lt;strong&gt;actual current headcount and codebase size.&lt;/strong&gt; Upgrade when the scale arrives. That's the only correct sequence.&lt;/p&gt;




&lt;h2&gt;
  
  
  Appendix: 2026 Starter Checklist
&lt;/h2&gt;

&lt;p&gt;Day one of a new project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;fvm use &amp;lt;version&amp;gt;&lt;/code&gt; to pin the Flutter SDK, commit &lt;code&gt;.fvmrc&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Riverpod 3 + go_router + freezed + slang as the foundation&lt;/li&gt;
&lt;li&gt;[ ] Max out strict lint in &lt;code&gt;analysis_options.yaml&lt;/code&gt; (&lt;code&gt;strict-casts&lt;/code&gt;, &lt;code&gt;strict-inference&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] Wire up crash + analytics + remote config (Firebase or equivalent)&lt;/li&gt;
&lt;li&gt;[ ] Layered directories (even in a single package): &lt;code&gt;data&lt;/code&gt; / &lt;code&gt;domain&lt;/code&gt; / &lt;code&gt;presentation&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Generated-file naming convention + &lt;code&gt;.gitignore&lt;/code&gt;/&lt;code&gt;exclude&lt;/code&gt; handling&lt;/li&gt;
&lt;li&gt;[ ] A single &lt;code&gt;verify&lt;/code&gt; script, run identically locally and in CI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Additionally for team projects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] melos workspace + package boundaries (features / design_system / foundation)&lt;/li&gt;
&lt;li&gt;[ ] A &lt;code&gt;lints&lt;/code&gt; package to unify rules; consider custom lint&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;dependency_overrides&lt;/code&gt; to unify versions across the workspace&lt;/li&gt;
&lt;li&gt;[ ] git hook (pre-push validation)&lt;/li&gt;
&lt;li&gt;[ ] Multiple flavor entrypoints, ordered codegen script&lt;/li&gt;
&lt;li&gt;[ ] AGENTS.md / CONTRIBUTING spelling out conventions (especially traps like codegen ordering)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>flutter</category>
      <category>opc</category>
    </item>
    <item>
      <title>Building a Handheld Console with Flutter</title>
      <dc:creator>Hamber</dc:creator>
      <pubDate>Tue, 28 Apr 2026 13:11:16 +0000</pubDate>
      <link>https://dev.to/hamberluo/building-a-handheld-console-with-flutter-3idh</link>
      <guid>https://dev.to/hamberluo/building-a-handheld-console-with-flutter-3idh</guid>
      <description>&lt;h2&gt;
  
  
  The Afternoon I Got Stuck on a Japanese Dialogue
&lt;/h2&gt;

&lt;p&gt;One weekend last year, I was playing a classic GBA RPG and hit an NPC conversation I couldn't read — all in Japanese. I screenshotted it, switched to a translation app, looked it up, switched back. The game had already moved on.&lt;/p&gt;

&lt;p&gt;That context switch was deeply annoying.&lt;/p&gt;

&lt;p&gt;I'm a Flutter GDE, and I happened to have a GBA emulator project called &lt;strong&gt;GoGBA&lt;/strong&gt; sitting on my machine. I thought: what if I could press one button, without ever leaving the game, and have AI read the screen and translate it for me?&lt;/p&gt;

&lt;p&gt;This article is the complete story of going from that idea to a shipped feature. The stack: &lt;strong&gt;Flutter + mGBA + Firebase AI (Gemini) + Riverpod + Clean Architecture&lt;/strong&gt;. All real production code.&lt;/p&gt;

&lt;p&gt;GoGBA is live on the App Store and Google Play — search &lt;strong&gt;GoGBA&lt;/strong&gt; to download it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: Architecture — Can Flutter Actually Run an Emulator?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why Not Go Native
&lt;/h3&gt;

&lt;p&gt;Emulators are performance-sensitive, so the instinct is "Flutter isn't fast enough." But GoGBA's emulation core is &lt;strong&gt;libretro/mGBA&lt;/strong&gt; — a battle-tested C/C++ engine. Flutter only handles UI and event dispatch; it never touches the emulation logic.&lt;/p&gt;

&lt;p&gt;That's what makes cross-platform viable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Flutter UI (Dart)
      ↓  MethodChannel / EventChannel
Kotlin (Android) / Swift (iOS)
      ↓  JNI / C FFI
libretro mGBA (C/C++)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flutter renders the game screen using the &lt;strong&gt;Texture widget&lt;/strong&gt; — the native layer writes mGBA's framebuffer into a &lt;code&gt;SurfaceTexture&lt;/code&gt; (Android) or &lt;code&gt;CVPixelBuffer&lt;/code&gt; (iOS), and Flutter composites it directly. Zero-copy. 60fps with no issues.&lt;/p&gt;

&lt;h3&gt;
  
  
  Designing the Channel Boundaries
&lt;/h3&gt;

&lt;p&gt;GoGBA uses three channels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;MethodChannel&lt;/span&gt; &lt;span class="n"&gt;_channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;MethodChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'go_gba/emulator'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;MethodChannel&lt;/span&gt; &lt;span class="n"&gt;_audioChannel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;MethodChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'go_gba/audio'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;EventChannel&lt;/span&gt; &lt;span class="n"&gt;_eventChannel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;EventChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'go_gba/emulator_events'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;_channel&lt;/code&gt;: command traffic — load ROM, save state, cheats&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;_audioChannel&lt;/code&gt;: separated to prevent audio calls from blocking the game loop&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;_eventChannel&lt;/code&gt;: native-initiated events — RetroAchievements unlocks, leaderboard updates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;EventChannel&lt;/code&gt; is the design decision most people miss.&lt;/strong&gt; Emulator events happen asynchronously on the native side. Polling with MethodChannel is wasteful. Surfacing them as a Dart &lt;code&gt;Stream&lt;/code&gt; via EventChannel means a Riverpod provider can just &lt;code&gt;watch&lt;/code&gt; it — fully reactive, no polling, no glue code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: Can Clean Architecture Actually Work in Flutter?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why Bother With Layers
&lt;/h3&gt;

&lt;p&gt;GoGBA's early code was all crammed into &lt;code&gt;PlayPage&lt;/code&gt; — emulator calls, save logic, and UI state tangled together. When cloud saves, cheats, and AI translation needed to be added, every change rippled unpredictably.&lt;/p&gt;

&lt;p&gt;Clean Architecture's real value isn't aesthetics. It's &lt;strong&gt;letting features evolve independently&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;GoGBA's layer structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pages / widgets / providers   ← Presentation
        ↓
domain/usecases               ← Application (business rules)
        ↓
domain/entities, ports,       ← Domain (pure Dart, no Flutter/dart:io)
repositories (interfaces)
        ↑ implements
data/repositories, core/emulator  ← Data / Infra
        ↓ MethodChannel
Kotlin / Swift / mGBA (native)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The hard rule: &lt;code&gt;domain/&lt;/code&gt; cannot import `package:flutter/&lt;/strong&gt;&lt;code&gt;, &lt;/code&gt;dart:io&lt;code&gt;, or anything from &lt;/code&gt;data/`.** Not a suggestion — a rule.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enforcing Architecture with custom_lint
&lt;/h3&gt;

&lt;p&gt;Code review alone will eventually miss things. GoGBA uses &lt;strong&gt;custom_lint&lt;/strong&gt; to turn these constraints into compile-time errors:&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="c1"&gt;# analysis_options.yaml&lt;/span&gt;
&lt;span class="na"&gt;analyzer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;custom_lint&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two custom rules enforce the boundaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gogba_domain_layer_dependencies&lt;/code&gt;: blocks flutter / dart:io / data imports in domain&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gogba_presentation_no_data_imports&lt;/code&gt;: blocks presentation from reaching into data directly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now if anyone writes &lt;code&gt;import 'package:flutter/material.dart'&lt;/code&gt; inside &lt;code&gt;domain/&lt;/code&gt;, &lt;code&gt;flutter analyze&lt;/code&gt; fails and CI catches it. &lt;strong&gt;The rule lives in the toolchain, not in someone's memory.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the single most effective architecture enforcement technique I've used in a real Flutter project.&lt;/p&gt;

&lt;h3&gt;
  
  
  Port/Adapter for Cross-Layer Dependencies
&lt;/h3&gt;

&lt;p&gt;Riverpod providers need to read and write app config — but they shouldn't import &lt;code&gt;ConfigDatasource&lt;/code&gt; directly (that's a data-layer type). GoGBA's solution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// domain/ports/app_config_storage_port.dart (interface, pure Dart)&lt;/span&gt;
&lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppConfigStoragePort&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppConfig&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;updateConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AppConfig&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// data/adapters/ (implementation)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConfigDatasourceAppConfigStorageAdapter&lt;/span&gt;
    &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="n"&gt;AppConfigStoragePort&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// providers/ (composition root)&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;appConfigStoragePortProvider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppConfigStoragePort&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;((&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ConfigDatasourceAppConfigStorageAdapter&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;Presentation depends only on the port interface. Tests swap in a fake. Widget tests don't need to touch the filesystem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: AI Real-Time Translation — One Button, Three Technical Layers
&lt;/h2&gt;

&lt;p&gt;This is my favorite feature in GoGBA, and the most interesting engineering problem in the project.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: Capturing the Game Screen
&lt;/h3&gt;

&lt;p&gt;The GBA screen is a native texture — not a regular Flutter widget. You can't screenshot it the normal way.&lt;/p&gt;

&lt;p&gt;GoGBA wraps the game view in a &lt;code&gt;RepaintBoundary&lt;/code&gt;, then uses &lt;code&gt;RenderRepaintBoundary.toImage()&lt;/code&gt; to capture the current frame. The expensive encoding work runs in a separate isolate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/core/utils/game_texture_capture.dart&lt;/span&gt;
&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Uint8List&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;captureGameTextureAsJpeg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;GlobalKey&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;targetWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;targetHeight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;boundary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentContext&lt;/span&gt;
      &lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;findRenderObject&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;RenderRepaintBoundary&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;boundary&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;boundary&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;pixelRatio:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;byteData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toByteData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;format:&lt;/span&gt; &lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ImageByteFormat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;png&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;byteData&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;pngBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;byteData&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asUint8List&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// PNG → resize → JPEG runs in an isolate — main thread stays unblocked&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_encodePngBytesToJpeg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;pngBytes:&lt;/span&gt; &lt;span class="n"&gt;pngBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;targetWidth:&lt;/span&gt; &lt;span class="n"&gt;targetWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;targetHeight:&lt;/span&gt; &lt;span class="n"&gt;targetHeight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;compute()&lt;/code&gt; call is the key detail. Image encoding and resizing happen in a dedicated isolate — the main thread stays responsive and the game keeps running without a hitch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: Gemini Multimodal Translation
&lt;/h3&gt;

&lt;p&gt;GoGBA uses &lt;strong&gt;Firebase AI Logic&lt;/strong&gt; (Vertex AI on Firebase) with the &lt;code&gt;firebase_ai&lt;/code&gt; package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/core/services/game_screen_translation_service.dart&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;GenerativeModel&lt;/span&gt; &lt;span class="n"&gt;_model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FirebaseAI&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;vertexAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;location:&lt;/span&gt; &lt;span class="s"&gt;'global'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;generativeModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;model:&lt;/span&gt; &lt;span class="s"&gt;'gemini-3.1-flash-lite-preview'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;generationConfig:&lt;/span&gt; &lt;span class="n"&gt;GenerationConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;maxOutputTokens:&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;temperature:&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;topP:&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;// Translation doesn't need reasoning — disable to save latency and tokens&lt;/span&gt;
        &lt;span class="nl"&gt;thinkingConfig:&lt;/span&gt; &lt;span class="n"&gt;ThinkingConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withThinkingBudget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;translateJpeg&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;jpegBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;targetLanguageTag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="s"&gt;'GBA screenshot: pixel UI. Transcribe all visible on-screen text, '&lt;/span&gt;
      &lt;span class="s"&gt;'then translate it into "&lt;/span&gt;&lt;span class="si"&gt;$targetLanguageTag&lt;/span&gt;&lt;span class="s"&gt;". '&lt;/span&gt;
      &lt;span class="s"&gt;'Use natural RPG/menu phrasing. '&lt;/span&gt;
      &lt;span class="s"&gt;'Output only the translation text, no scene summary or extra commentary. '&lt;/span&gt;
      &lt;span class="s"&gt;'If no readable text, reply exactly: No text detected.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;generateContent&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;multi&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="n"&gt;InlineDataPart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'image/jpeg'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Uint8List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jpegBytes&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
      &lt;span class="n"&gt;TextPart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prompt engineering choices are deliberate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Use natural RPG/menu phrasing&lt;/code&gt;&lt;/strong&gt;: keeps translations in-genre — "HP" won't become "Health Points"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Output only the translation text&lt;/code&gt;&lt;/strong&gt;: strips the model's boilerplate preamble&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;If no readable text, reply exactly: No text detected.&lt;/code&gt;&lt;/strong&gt;: structured fallback the client can match on&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;temperature: 0.1&lt;/code&gt;&lt;/strong&gt;: translation is a deterministic task; higher temperature just adds noise&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ThinkingConfig.withThinkingBudget(0)&lt;/code&gt;&lt;/strong&gt;: Gemini 2.x enables thinking by default; for translation it adds latency and tokens with no benefit — explicitly disable it&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Layer 3: Monthly Quota Management
&lt;/h3&gt;

&lt;p&gt;AI calls have real costs. GoGBA's AI translation is a separate subscription. Monthly usage limits are served from Firebase Remote Config, so they can be adjusted without a release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/domain/services/game_screen_translation_quota_service.dart&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GameScreenTranslationQuotaService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;_currentUtcYm&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toUtc&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="si"&gt;${u.year.toString().padLeft(4, '0')}&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;
        &lt;span class="s"&gt;'-&lt;/span&gt;&lt;span class="si"&gt;${u.month.toString().padLeft(2, '0')}&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;isExhausted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;monthlyLimit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&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;monthlyLimit&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;uses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;getUsesThisMonth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;uses&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;monthlyLimit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;recordSuccessfulTranslation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;prefs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;SharedPreferences&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_usesForCurrentUtcMonth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_keyCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;UTC month, not local time.&lt;/strong&gt; Users span time zones. Local time means the quota resets at different moments for different people. UTC is the only fair counting window.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Wiring It Together: the UI Layer
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/pages/play/widgets/game_translation_bottom_sheet.dart&lt;/span&gt;
&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Capture the frame&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;jpeg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;captureGameTextureAsJpeg&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="nl"&gt;targetWidth:&lt;/span&gt; &lt;span class="n"&gt;vw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;targetHeight:&lt;/span&gt; &lt;span class="n"&gt;vh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Translate with Gemini (follows system language)&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="n"&gt;LocaleSettings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentLocale&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;flutterLocale&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toLanguageTag&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;GameScreenTranslationService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;translateJpeg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;jpegBytes:&lt;/span&gt; &lt;span class="n"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;targetLanguageTag:&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Show result and record quota&lt;/span&gt;
  &lt;span class="n"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_phase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_TranslationPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;_resultText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;recordUsageOnSuccess&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;GameScreenTranslationQuotaService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;recordSuccessfulTranslation&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;What the user experiences: press the translate button → bottom sheet slides up → spinner for a second or two → translation appears. Behind that: frame capture, isolate encoding, multimodal AI call, quota write. All async. Game never pauses.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 4: AI-Assisted Development — What It Actually Feels Like
&lt;/h2&gt;

&lt;p&gt;GoGBA's development workflow is deeply integrated with &lt;strong&gt;Claude Code&lt;/strong&gt; (Anthropic's AI coding assistant). As a solo developer, it lets me maintain the kind of engineering discipline that normally takes a team.&lt;/p&gt;

&lt;p&gt;A few real examples:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Architecture enforcement&lt;/strong&gt;: The project's &lt;code&gt;SKILL.md&lt;/code&gt; documents the domain layer rules and forbidden patterns. Claude Code reads this before every change and won't suggest code that violates the layering — the constraints stay consistent without manual review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;i18n automation&lt;/strong&gt;: GoGBA ships in 24 languages. When a new feature adds UI strings, Claude Code fills in all language files in &lt;code&gt;l10n/*.i18n.json&lt;/code&gt;, then triggers &lt;code&gt;dart run slang&lt;/code&gt; to regenerate. What used to take 20 minutes of copy-paste takes seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fastlane releases&lt;/strong&gt;: Build number bumps, changelog generation, App Store submission — all scripted. Claude Code runs the sequence and catches problems.&lt;/p&gt;

&lt;p&gt;This isn't "AI replacing the developer." It's &lt;strong&gt;AI reducing the cost of following your own rules to near zero&lt;/strong&gt;. Write the standards once; the tool enforces them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 5: Bugs That Taught Me Things
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bug 1: &lt;code&gt;ref.read()&lt;/code&gt; inside &lt;code&gt;dispose()&lt;/code&gt; crashes&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Riverpod's &lt;code&gt;ref.read()&lt;/code&gt; and &lt;code&gt;ref.watch()&lt;/code&gt; cannot be called after &lt;code&gt;dispose()&lt;/code&gt;. The widget is gone; the provider may have already been released. GoGBA had a handful of early crashes from this. The rule is now in &lt;code&gt;SKILL.md&lt;/code&gt; and detected by custom_lint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 2: &lt;code&gt;invalidate(provider)&lt;/code&gt; causes an AsyncLoading flash&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Calling &lt;code&gt;invalidate&lt;/code&gt; after updating config forces the provider to rebuild from scratch, briefly putting the UI into a loading state. Users see a flicker. The fix: update state directly with &lt;code&gt;state = newValue&lt;/code&gt; inside the notifier and let Riverpod diff it. No invalidation needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 3: Gemini's thinking mode is on by default&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;firebase_ai&lt;/code&gt; Gemini 2.x models enable extended thinking by default. For translation — a deterministic task — this means longer latency, more tokens, and less predictable output. You have to explicitly disable it with &lt;code&gt;ThinkingConfig.withThinkingBudget(0)&lt;/code&gt;. The default bit me in early testing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;GoGBA is my testbed for engineering ideas: where Flutter's cross-platform ceiling actually sits, whether Clean Architecture can hold up in a real project without becoming an interview-question abstraction, how to design AI features that are genuinely useful rather than just impressive in a demo.&lt;/p&gt;

&lt;p&gt;My conclusions: &lt;strong&gt;Flutter is mature enough for this. AI tooling is raising the ceiling for individual developers in ways that weren't possible two years ago.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every specific choice in this codebase — custom_lint guarding domain boundaries, &lt;code&gt;compute()&lt;/code&gt; keeping the main thread clean, UTC-based quota windows — is a scar from a real mistake. I hope some of it saves you the same trouble.&lt;/p&gt;

&lt;p&gt;Search &lt;strong&gt;GoGBA&lt;/strong&gt; on the App Store or Google Play. If you play GBA games in Japanese or English and hit a text wall, the AI translation feature is there for exactly that.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Questions about Flutter cross-platform development, Firebase AI Logic integration, or shipping a solo app with an AI workflow? Drop them in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>ai</category>
      <category>vibecoding</category>
    </item>
  </channel>
</rss>
