One Flutter codebase produces two apps, and that part is genuinely real: the Dart, the game loop, the widgets, the assets, the business logic are one thing, and we mean it when we say so. But “one codebase” is a claim about your source tree, not about your release day. The moment a build exists, the road splits into two pipelines that share almost nothing — different signing models, different reviewers, different metadata systems, different purchase plumbing, different privacy paperwork. This is a first-hand map of exactly where that fork is, drawn by a studio that pushes the same games to both the App Store and Google Play and has felt every seam.
What actually stays shared
Start with the good news, because it is the reason to build
this way at all. The expensive half of a game — the part that takes months and
defines whether the thing is any fun — is fully shared. The game logic,
the Flame components, the state model, the animations, the tuning, the assets, and the
overwhelming majority of the tests are a single Dart tree that compiles to both platforms
from the same source. When we fix a bug, we fix it once; when we tune a mechanic, both
stores get the tuned version. Even a surprising amount of the release scaffolding is
shared: version numbers live in one pubspec.yaml, and much of our store
automation is common tooling that only branches at the last step. If you have read
why we build in Flutter
and Flame, this is the payoff that post promised, made concrete. One codebase is a
promise about your source tree, not about your release day — and the release day is
where this post lives.
Everything below the built binary is where the two roads part. Here is the whole fork on one page before we walk each stage.
Code signing: two completely different trust models
The first fork is signing, and it is not a difference of detail — the two platforms answer “how do we know this build is really you?” with two philosophies. On Apple, identity is a web of artifacts you assemble and keep in sync: a distribution certificate that proves who you are, an app ID (bundle identifier) that names the app, the set of capabilities it is entitled to (push, in-app purchase, associated domains), and a provisioning profile that ties all of those together for a given build. Get one of them stale or mismatched and the build is refused, often with an error that names the symptom rather than the cause. It is more moving parts than a newcomer expects, and the parts expire.
On Android, the model front-loads a single decision and then mostly leaves you alone. You sign your upload with a keystore — a key file you generate once — and with Play App Signing, Google holds the actual app signing key that end devices verify, while you keep an upload key to authenticate submissions. The practical upshot is a different failure mode: fewer day-to-day surprises, but one irreplaceable artifact. Lose the upload key without a recovery path and you are in a genuinely bad spot. Apple punishes drift; Android punishes losing the one thing. Neither is harder in the abstract — they just fail differently, and the muscle memory does not transfer.
Store review: a submission gate, a track ladder
Both stores review what you upload, but the shape of the gate differs enough to reorganise how you plan a release. Apple reviews each submission before it can reach the public store — a mix of automated checks and human reviewers who can, and do, ask questions about anything from a login flow to a piece of marketing copy. One rule we live with every release is Guideline 2.3.10: iOS metadata must not reference other platforms, so a description that mentions Android or Google Play is a hard submission-blocker. Google enforces the mirror of that rule against Apple references. It is a small thing that catches teams constantly, because the source text is often shared and only the store copy is meant to diverge.
Google Play adds a dimension Apple does not expose the same way: release tracks. A build climbs a ladder — internal testing, then closed, then open, then production — and you promote it rung by rung, controlling exactly who sees it at each stage and rolling out to a percentage of users on the final rung. Apple has TestFlight for beta distribution, which plays a similar role for pre-release testing, but the App Store itself is closer to a single public switch you flip after review clears. Our own workflow leans hard on this difference: we require a human to verify a build on TestFlight or Play internal before anything is promoted to production, precisely because the two stores make “staging” mean slightly different things. We will not quote review times or rejection rates here — they vary by app, category, and week, and any number we invented would be worse than useless.
Metadata and screenshots: same story, two content models
Your store listing is written twice, and not because anyone enjoys it. The fields differ. App Store Connect wants an app name, a subtitle, a keywords field, promotional text, a description, and screenshots sized to specific device classes. The Play Console wants a title, a short description, a full description, a feature graphic, and its own screenshot set. The character limits differ, the keyword handling differs (Apple has a dedicated keywords field; Play leans on the description), and the screenshot device matrices differ enough that a single export rarely satisfies both. Localisation multiplies all of it: each locale is a full copy of the listing on each store. The honest version is that metadata is where “one codebase” quietly stops helping you at all — there is no shared source of truth the stores agree on, so you maintain two, and you keep them from contradicting each other by hand or by tooling you build yourself.
In-app purchases: the same product, configured twice
Our games are free and ad-supported, with a single optional Pro purchase that removes the ads — we are not claiming an ad-free product, because that would be false. That one product still has to be created, priced, and reviewed independently in each store, through two unrelated billing systems. On iOS it is a StoreKit product defined in App Store Connect; on Android it is a Play Billing product defined in the Play Console. The product-identifier conventions differ, the pricing models differ (each store has its own price-tier and tax handling), and even sandbox testing works differently — Apple uses sandbox tester accounts, Google uses licensed test accounts and its own test tracks. If the purchase were a subscription the divergence would be wider still, with each store owning renewal, grace periods, and billing retry on its own terms. Our shared Dart calls one purchase abstraction; underneath, it is talking to two completely separate economies. We wrote about the philosophy of that Pro tier in free-to-play, done honestly; this is the plumbing side of the same coin.
Versioning: the one shared rule that bites
Here is the exception that proves the rule — a place where the two
pipelines are forced back into agreement. A version has two halves: a marketing version a human
reads (1.9.1) and a build number a store uses to order uploads. On iOS that build number is
CFBundleVersion; on Android it is versionCode. Both are driven from the single
version: line in pubspec.yaml, which is exactly why they are so easy to let
drift — and drift is a trap. Our rule is blunt: the two build numbers must match for any
cut. If one platform forces a bump — Play rejects a versionCode as already
used, or an App Store upload is rejected and re-cut — the other platform is re-cut at the same
bumped number and re-uploaded, even if its earlier upload succeeded. It feels wasteful in the
moment and it is the correct discipline: the alternative is two stores carrying the “same”
release at different build numbers, and every future comparison becomes a guessing game. Parity is cheap
to keep and expensive to reconstruct.
Privacy declarations: two questionnaires, one truth
The last fork is the paperwork, and it has teeth because it is a public, binding claim about your app’s behaviour. Apple requires an App Privacy declaration — the “nutrition label” you fill out in App Store Connect describing what data the app and its third-party SDKs collect and how it is used — plus App Tracking Transparency consent if you track users across apps, and an export-compliance answer about encryption. Google Play requires its own Data safety form, covering similar ground with different categories and its own wording. The two forms ask overlapping questions in incompatible shapes, and the only safe way through is to answer both from a single honest account of what the binary actually does — the same ad SDK, the same analytics, the same purchase flow — and then translate that one truth into each store’s form. Answer them independently and you will eventually contradict yourself, which is the one outcome guaranteed to draw scrutiny. The declarations diverge; the underlying data behaviour must not.
What one codebase actually buys you
So the pitch is true and incomplete at the same time. One codebase collapses the expensive half of shipping — building the game — into a single artifact you maintain once. It does nothing for the release half, where a binary meets two stores with two trust models, two review cultures, two content systems, two billing economies, and two privacy forms, joined by exactly one shared constraint that you have to actively defend. The mistake we see teams make is budgeting for the shared part and treating the fork as an afterthought, then losing a release week to a signing profile or a mismatched build number. Plan for two pipelines. Automate the parts that are mechanical, keep a human on the parts that are judgement, and never let the “one codebase” slogan convince you that release day is one thing. It is two — and knowing precisely where they diverge is most of what makes it boring, which on release day is the highest compliment there is. If you want the stack-level reasoning that sits under all of this, it is in why we build in Flutter and Flame; and the browser adds a whole third road, which we walk in shipping web games in 2026.
Notes
This is a first-hand engineering account of shipping our own Flutter games to both the App Store and Google Play, not a benchmark or a survey. It deliberately contains no review-time estimates, rejection rates, approval percentages, or timing figures — those vary by app, category, and week, we did not run a controlled study to measure them, and inventing them would undercut the point of the piece. Every characterisation of how a step behaves is a practitioner’s directional judgement drawn from our own release experience. Store rules and console workflows change over time; the official documentation linked in Sources is the current authority, and specifics should be verified there before you rely on them. Our own build-number-parity practice is a studio convention described as we run it, not a platform requirement.
Sources
The platform mechanics referenced above — the App Store review rules (including the cross-platform-reference guideline), App Store Connect metadata and App Privacy details, Play App Signing, Play release tracks, and the Data safety form — are documented by the platform owners:
- Apple — App Store Review Guidelines (submission review; Guideline 2.3.10 on platform references).
- Apple — App privacy details on the App Store (the App Privacy declaration) and App Tracking Transparency.
- Google Play Console Help — Play App Signing.
- Google Play Console Help — Prepare & roll out a release (test tracks).
- Google Play Console Help — Provide information for Google Play’s Data safety section.
Keep reading: all posts on the WizusLabs Engineering blog.