Most in-app-purchase code rots because it asks the wrong question. Gameplay ends up littered with "did they buy product com.game.full?" checks, so every new SKU touches gameplay, the free build and the paid build drift apart, and none of it is testable without a live store connection. Getting game IAP architecture right means starting from a different question entirely - one that gameplay code can answer without ever knowing which store, or which business model, is behind it.
In my React Native engine, gameplay only ever asks "is this player entitled to this content?" A declarative ProductCatalog maps store product IDs to entitlement keys, once. Everything else - which store, which business model - is a decision made at the app boundary, not in gameplay. The result: free, buy-once, and freemium games run the same code. This post shows the shape, the shipped code, and the seams that are still rough.
The trap: code that asks "did they pay?"
The naive shape looks reasonable at first:
if (hasPurchased('com.game.full')) {
unlockZones4to8();
}It works for exactly one product. Add a season pass, a cosmetic pack, and a freemium subscription tier, and this check metastasizes across the zone-select screen, the boss-gate check, the shop UI, the save/load path - anywhere content needs to be locked or unlocked. Three things go wrong as the SKU list grows:
- SKU sprawl into gameplay. Every new product wires a new check into a new place. The zone-select screen now knows about
com.game.full,com.game.season1,com.game.cosmetic_pack_3by name. - Free and paid builds drift apart. A free build shipped without a purchase SDK at all needs a hand-maintained stub for every one of those checks.
- Nothing is testable without a live store. You cannot unit-test "does the season-pass owner see zone 7" without mocking a store SDK response, because the check is the store SDK call.
The fix isn't a smarter purchase check. It's removing the purchase check from gameplay entirely.
Entitlements, not purchases: the game IAP architecture
The inversion at the core of this game IAP architecture: gameplay code never asks "did they buy X?" It asks isEntitled('zones.4-8'). Somewhere else, once, a catalog says which purchased product grants which entitlement keys. That indirection is the whole architecture.
// packages/iap/src/catalog.ts
export interface CatalogEntry {
readonly productId: ProductId;
/**
* Entitlement keys granted when this product is owned. A single product
* may unlock multiple entitlements (e.g., season pass).
*/
readonly grants: readonly EntitlementKey[];
/** Product type - hint for UI rendering; not used for entitlement logic. */
readonly type?: 'consumable' | 'non-consumable' | 'subscription';
/** Optional free-form label (localization key or display string). */
readonly label?: string;
}A CatalogEntry is data, not code. One product can grant several entitlement keys - a season pass might grant zones.4-8 plus a cosmetic palette in one row. And the file header documents the rule that makes the free model free: a missing catalog entry means "all free." No catalog row, no gate to pass.
The gate itself is ten lines:
// packages/iap/src/gate.ts
import type { EntitlementStore } from './entitlements.js';
export interface ContentSpec {
readonly requiresEntitlement?: string;
}
export function canAccessContent(spec: ContentSpec, entitlements: EntitlementStore): boolean {
if (!spec.requiresEntitlement) return true;
return entitlements.isEntitled(spec.requiresEntitlement);
}No requiresEntitlement on a ContentSpec means content passes unconditionally. That's the entire free-tier design: absence of a gate, not a special-cased "free mode" flag threaded through the game.
isEntitled itself resolves to a Set.has lookup - zero allocation on the hot path, the same discipline as the engine's zero-alloc game loop (see Related below). The store's onChange signal only fires on grant/revoke, so a component reading entitlements re-renders on purchase, not every frame.
Everything to the left of the gate is store plumbing. Everything to the right only ever reads entitlements.
One API, three business models
The payoff of the inversion: free, buy-once, and freemium are not three code paths. They're three catalogs.
- Free. Ship no catalog, or ship one with no gated content. Every
canAccessContentcall passes. - Buy-once. One non-consumable product grants one (or several) keys, permanently.
- Freemium. More catalog rows - a subscription-typed entry, cosmetic add-ons, consumables - layered on top of a free base game.
The shipped test fixture makes the shape concrete: a buy-once unlock, a cosmetic, and a consumable that grants nothing on its own (its value is spent elsewhere, not gated by entitlement):
// packages/iap/tests/entitlements.test.ts
const catalog = createProductCatalog([
{
productId: 'game.full',
grants: ['full_game', 'zones.4-8'],
type: 'non-consumable',
},
{
productId: 'game.cosmetic',
grants: ['palette.alt'],
type: 'non-consumable',
},
{ productId: 'game.shards_small', grants: [], type: 'consumable' },
]);game.full grants two keys from one purchase - the season-pass case from the catalog's own JSDoc, made real. game.cosmetic is a second, independent non-consumable. game.shards_small is a consumable with an empty grants array - a currency top-up the game spends its own way, not gated content. Free content simply never appears as a row. The gameplay code checking isEntitled('zones.4-8') doesn't know or care which of these three shapes produced the grant.
The provider is the only thing that changes
The store is a swap point, not a fork in the codebase. IAPProvider is a 4-method interface; every free build and every test uses the shipped no-op default:
// packages/iap/src/provider.ts
export interface IAPProvider {
getProducts(ids: readonly ProductId[]): Promise<readonly Product[]>;
purchase(id: ProductId): Promise<PurchaseResult>;
restore(): Promise<readonly PurchaseResult[]>;
onTransaction(cb: (tx: Transaction) => void): Unsubscribe;
}
/** Default no-op provider. Returns empty/cancelled results; never emits. */
export const NoopIAPProvider: IAPProvider = {
async getProducts(): Promise<readonly Product[]> {
return [];
},
async purchase(): Promise<PurchaseResult> {
return { status: 'cancelled' };
},
async restore(): Promise<readonly PurchaseResult[]> {
return [];
},
onTransaction(): Unsubscribe {
return () => {
// no-op
};
},
};Wiring an app for real money is two calls at the boundary, never inside gameplay: store.attachProvider(provider) subscribes to live transactions, and store.restoreOnBoot(provider) re-grants whatever the store already knows the player owns. Note what this is not: it is not createTier2Wiring. That file wires up analytics, leaderboards, and achievements - its own header says so explicitly - and IAP has never gone through it. The IAP wiring path is the entitlement store's own methods, subscribed directly to whatever provider the app boundary hands it.
@flare-engine/iap itself is a tier-1 package - it depends only on events, and only its optional useIAP React helper touches React at all. The catalog, gate, and entitlement store are pure TypeScript, RN-free and Skia-free. A game with no IAP pays zero cost for the package existing.
The one concrete store adapter shipped so far is RevenueCat. @flare-engine/iap-revenuecat lazy-imports react-native-purchases so the module never loads on platforms that don't need it, and an ensureConfigured helper memoizes the whole configure sequence behind a shared promise - not a boolean flag, because a boolean only flips after two awaits, and two concurrent first callers (a getProducts/restore Promise.all, say) can both slip past a boolean guard and double-call Purchases.configure, which RevenueCat's SDK rejects. StoreKit and Google Play Billing adapters are named as future work in the interface's own design, not written yet - swapping to either one, when they exist, means implementing IAPProvider again, not touching gameplay.
grantProduct on an unknown product ID is a silent no-op - the catalog stays the single source of truth for what's grantable, even if a store somehow reports a product the game never declared.
The shop UI is just another consumer
@flare-engine/ui-shop ships a ready-made ShopScreen, PaywallModal, and RestorePurchasesButton. None of them know anything the gameplay code doesn't. PaywallModal's purchase handler calls the provider, then grants through the same entitlement store gameplay reads:
// packages/ui-shop/src/PaywallModal.tsx
async function handlePurchase(): Promise<void> {
setPurchasing(true);
setPurchaseError(null);
try {
const result = await provider.purchase(productId);
if (result.status === 'purchased') {
entitlementStore.grant(entitlementKey);
onPurchased?.(result);
onDismiss();
} else if (result.status === 'failed') {
const e = result.error ?? new Error('Purchase failed');
setPurchaseError(e);
onError?.(e);
}
// 'cancelled' - stay open, no error shown
} catch (err: unknown) {
const e = err instanceof Error ? err : new Error(String(err));
setPurchaseError(e);
onError?.(e);
} finally {
setPurchasing(false);
}
}No RevenueCat types cross this boundary. The provider reports a PurchaseResult; the modal grants an entitlement key. That separation - adapter reports, app grants - is deliberate, and it's also the honest limit named below.
Where the money path actually breaks (shipped this week)
This is a restricted-registry package - version 0.2.1, access: restricted, MIT-licensed today, not on public npm, not yet a public dependency anyone outside this workspace can install. That framing matters for what follows: these are pre-public hardening fixes on code no external consumer has run in production, not bugs that shipped live to real customers.
The money path is where correctness bugs hide, because it's the code path exercised least often in normal development. One line, from this month, is the crispest example. restore() used to iterate every entitlement RevenueCat's SDK knew about - including ones that had already expired:
- for (const ent of Object.values(info.entitlements?.all ?? {})) {
+ for (const ent of Object.values(info.entitlements?.active ?? {})) {Restoring purchases on a fresh install used to re-grant a subscription that had lapsed months earlier. One word - all to active - fixed it (2026-07-07, commit b8bd067), alongside three siblings in the same pass: a faked transaction ID replaced with the store's real one, an onTransaction callback that only emitted the first entitlement per purchase instead of every one, and swallowed getProducts/restore errors that used to fail silently.
The next day (2026-07-08, 55d00a0) was the configure-once race described above, fixed by moving from a boolean flag to a memoized promise. Same day, ShopScreen picked up a refetch fix: swapping NoopIAPProvider for a real provider at runtime used to leave the shop showing stale, empty product data, because a "fetched once" ref never re-armed on provider identity change (7b43158). This week's OSS-M5 pass (8128b8b, 2026-07-10) shipped local in-memory providers for analytics, leaderboards, and achievements - but not for IAP, where the free default has always been NoopIAPProvider. Its only IAP-adjacent source change was a doc fix: Product.priceMicros was documented as "cents" when it's actually micros, a 10,000× unit error in a comment, not in the math.
Receipt validation stays out of this layer entirely - the interface never exposes receipt bytes; trust in what a purchase actually was is RevenueCat's backend's job. That boundary is also why the 54 tests across iap (20), iap-revenuecat (19), and ui-shop (15) - the IAP layer's own suite, not folded into the whole engine's count - lock invariants like "restore only re-grants active entitlements" instead of re-verifying RevenueCat's receipt logic.
Limits, and the ethics of a neutral gate
This is an entitlement architecture, not a payments backend. Named plainly, what it doesn't do:
- No server-side receipt validation, signature checking, or anti-tamper. A client that patches its own entitlement store can grant itself anything. This layer is a UX/architecture boundary, not an anti-piracy one.
- No subscription lifecycle logic.
CatalogEntry.type: 'subscription'is a UI hint; there's no renewal, expiry, or auto-revoke inside the store beyond whatrestore()reports as active. - No consumable balances. A
'consumable'catalog entry can exist, but spending, decrementing, and balance tracking are the game's problem, not this package's. - No multi-key gates.
canAccessContentchecks exactly onerequiresEntitlementstring - no AND/OR combinations, no quantity thresholds. - Only one concrete store adapter exists. RevenueCat. StoreKit and Google Play Billing adapters are named as future work in the design, not implementations you can import today.
And one thing worth stating without moralizing: the same isEntitled call that powers a clean "you own the full game" unlock (Ownership & Possession, one of Yu-kai Chou's Octalysis white-hat drives) is exactly the mechanism a scarcity-timer bundle or a "your streak-saver expires in an hour" prompt uses too (Scarcity & Impatience and Loss & Avoidance, both black-hat drives). The gate doesn't know or care which design it's serving. The engine ships the mechanism; the designer who writes the catalog rows owns the ethics of what those rows mean to a player.
The same pattern generalizes past games entirely. isEntitled('pro-export') gates a paid feature in a business React Native app the same way isEntitled('zones.4-8') gates a level - and RevenueCat is the standard RN IAP vendor outside games too. If you're building a "pro tier" into a SaaS RN app, the catalog-to-gate seam is the same shape, whether the entitlement unlocks a boss zone or a CSV export button.
Close
The lesson generalizes past this one engine: model entitlements, not purchases. Keep the store adapter at the app boundary, never in gameplay. Default to a no-op provider so free builds and tests never touch a store SDK at all. Free, buy-once, and freemium stop being three implementations and become three catalogs read by one gate.
If you're evaluating RevenueCat versus a raw StoreKit/Play Billing integration for your own React Native project, the actual decision that matters isn't which SDK to call - it's whether you've built the seam that lets you swap the answer later without touching gameplay.
Related
- Zero-alloc game loops in TypeScript - the same
Set.has/ zero-allocation hot-path disciplineisEntitledfollows. - Turborepo, Bun, and Biome across a 40-package monorepo - the monorepo
@flare-engine/iapand its siblings live in.