WizusLabs Engineering · Frameworks

One codebase, two stores: what actually differs shipping iOS and Android

The cross-platform pitch is clean: write the game once, ship it everywhere. It is mostly true — and the word doing the quiet work is “mostly.” Everything up to the built binary is shared. Everything after it forks.

By WizusLabs Engineering · 2026-07-11 · ~9 min read

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.

A shared Flutter codebase fanning into two store release pipelines A single Flutter and Dart codebase in the centre fans out to two divergent release lanes. The App Store lane runs certificates and provisioning profiles, Apple review, App Store Connect listing and screenshots, StoreKit in-app-purchase products, and TestFlight to App Store release. The Google Play lane runs an upload key with Play App Signing, Play review, a Play Console listing and screenshots, Play Billing products, and staged internal, closed, open, and production tracks. A shared constraint links the two release ends: the iOS build number and the Android versionCode must stay in parity. Flutter / Dart codebase signing review metadata in-app purchase release track App Store lane · iOS Certs + provisioning Apple review ASC listing + screenshots StoreKit / ASC products TestFlight → App Store Google Play lane · Android Upload key + Play App Signing Play review Play listing + screenshots Play Billing products internal → closed → open → production shared: build-number parity CFBundleVersion = versionCode
One shared codebase, two release pipelines that share almost nothing — joined at the end by a single hard constraint: the build numbers must match.

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:

Keep reading: all posts on the WizusLabs Engineering blog.

← Back to the Blog