I shipped GoGBA (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.

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.


Why GBA only

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.

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:

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

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

What existing emulators get wrong (for me)

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.

They're all good. Every one of them had small things that annoyed me.

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

That's the gap. Everyone was solving "can it run" and "can it be tuned perfectly." Nobody was solving "pick it up and play."

The methodology was just dogfooding

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: if nothing annoys me during real play, users will like it.

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

Two features nobody requested, that came out of actually playing:

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

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

You only find these by playing daily.

"It's just an AI-written frontend on a finished core"

This is the objection I want to answer properly, because I think it's wrong in an interesting way.

AI made this dramatically easier. Dramatically easier is not the same as low barrier. Three concrete examples of where the barrier actually sits.

1. The real barrier is telling whether the AI is hallucinating

I fixed three bugs in my own mGBA fork:

  • MBC6 save null-pointer crash — some MBC6 cartridge games crashed outright
  • GB MBC RTC state loss — broke real-time-clock games like Pokémon Prism
  • Pokémon ROM identification off-by-one — boundary error causing misidentification

Those patches were written by AI. I'm saying so plainly, because leaving it out would repeat the exact mistake I describe further down.

But that's the point I actually want to make:

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.

You get a patch that compiles and makes the symptom disappear. That patch may also be:

  • a null check at the wrong layer, converting a crash into silent save corruption — which the user discovers forty hours later
  • a hallucinated struct field or function that doesn't exist in mGBA, but compiles anyway because of macro expansion
  • correct only for the one ROM on my desk, and broken for every other MBC6 cartridge
  • 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

So the actual work wasn't writing the patch — it was reviewing 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.

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.

That's why I'd argue the barrier didn't drop, it moved: 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 not fakeable — you either know the cartridge header layout or you don't.

One necessary clarification: these three fixes live only in GoGBA's fork. They are not upstreamed into mGBA. 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.

2. Where the native/Flutter boundary goes

People say Flutter can't match native performance. True and not true.

Flutter (Dart)   ← all UI, state, config, navigation
   ↓ MethodChannel / EventChannel
Kotlin / Swift   ← rendering, audio, input, save IO
   ↓ JNI / C FFI
libretro mGBA    ← the emulation itself

Enter fullscreen mode Exit fullscreen mode

Rendering is native — OpenGL ES 3.0 on Android, Metal on iOS, framebuffer blitted straight into a Texture, zero copy. Flutter runs zero frames of emulation logic.

Everything else — 24 locales, settings hierarchy, button layout editor, paywall, cloud saves — is Flutter, pixel-identical across both platforms.

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

The native side still bit me plenty. Two that reached production:

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

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

3. Adding features is easy; keeping them from becoming clutter is not

Since launch: 1,499 commits — 276 features, 224 fixes.

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.

Every single one had to answer the same question: does this make "pick it up and play" worse?

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

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


The mistake: I conflated "emulator" with "emulator frontend"

I promoted GoGBA with the line "built an emulator in three days."

That was wrong, and not as a matter of phrasing — as a matter of concept.

  • An emulator is mGBA. Twenty years of work by many people, cycle-accurate hardware behavior.
  • An emulator frontend is GoGBA. UI, input, render pipeline, save management, platform integration.

What I built in three days was a prototype of the second thing. Calling it the first thing disrespected every mGBA contributor.

The RetroAchievements community was justifiably angry, and GoGBA's hardcore certification was revoked.

Two things I want to state honestly:

First, on the technical requirements, GoGBA does comply. Hardcore forbids cheats, save states, rewind/fast-forward and memory editing. GoGBA gates all of them behind one shared predicate (RA enabled ∧ authenticated session ∧ effective hardcore), 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 and 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.

Second, that doesn't matter much. 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.

I don't know when I'll be allowed to reapply. That call isn't mine to make.

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


The reviews that got raised back to five stars

Five-star reviews are the best part of this, and I built a page in the app that collects them.

But the ones I care about more are different: reviews that started below five stars and were raised after I fixed that specific user's problem.

The first kind says "you did well." The second says "you're listening."

For a one-person product, the second one is the moat. People didn't stay because GoGBA has the most features. They stayed because reporting a problem results in the problem being fixed. 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.


GoGBA is on the App Store and Google Play.

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.

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.