<?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.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>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>
