Proposal: Mobile platform services
Milestone: M13 · Status: ⬜ planned · Depends on: M12 mobile core (heads, natives CI, lifecycle, editor link) · Decision: ADR 0100
Facts were checked on 2026-10-05 (linked inline). ⚠ marks the ones that are uncertain or changing.
Problem
Section titled “Problem”A shippable mobile game needs store and platform services that have no desktop equivalent in the engine today:
- in-app purchases;
- achievements, leaderboards and cloud saves;
- ads with legally required consent;
- local and push notifications;
- analytics and crash reports.
The engine only has inert Steam wrappers (Steamworks). The native APIs are hostile to .NET
bindings. StoreKit 2 is Swift-only async API, which an Objective-C binding cannot call. Play Billing, Play Games v2,
UMP and the ad SDKs are Gradle/Maven artefacts with large dependency trees.
- Engine-level APIs as servers (consistent with
RenderServer/AudioServer, reached throughTree.Servers), with the same game code on iOS, Android and desktop. - Thin engine-owned native shims for the platform side: Swift on iOS and Kotlin on Android, each behind one flat,
versioned C ABI (
mfplatform), built and tested in CI. The same pattern asmfrmlui(ADR 0002, Native libraries). - Desktop backends:
- Steam maps achievements, leaderboards and cloud saves when Steam runs;
- a scriptable fake backend for every service (editor, tests, desktop play);
- null backends otherwise.
- Consent first. No SDK that collects data initialises before the consent state allows it. The engine owns the ATT and UMP/TCF flow and the privacy-manifest and data-safety bookkeeping.
- Optional per game. A game that uses no ads pays no ad-SDK size and declares no tracking. Each service is opted into in the export preset.
Non-goals
Section titled “Non-goals”- Server-side receipt validation, a backend, or player accounts. The engine exposes a validation hook and the signed payloads; the game’s server does the rest.
- Cross-platform entitlement sync. Social features: friends, invites, multiplayer matchmaking via Game Center or Play.
- Subscriptions: the API leaves room for them (product type, renewal info) but v1 ships consumables and non-consumables only (⚠ see open questions).
- Building our own ad mediation, analytics backend or crash symbolication server.
Facts this design rests on
Section titled “Facts this design rests on”| Area | Fact | Source |
|---|---|---|
| StoreKit 2 | Swift-only async API. Transaction.updates must be observed from launch. AppTransaction needs iOS 16. JWS-signed transactions can be verified with the App Store Server Library. Local testing uses a .storekit file (Xcode 14+ can sync it with App Store Connect); sandbox testers live in App Store Connect. | Transaction.updates, VerificationResult, StoreKit testing |
| Apple server | verifyReceipt and Server Notifications v1 are deprecated; use the App Store Server API and Notifications v2 (JWS). | forums 731550 |
| Play Billing | Current 9.1.0 (Jun 2026). PBL 8+ required for new apps and updates from 31 Aug 2026 (extension to 1 Nov 2026). PBL 8 is deprecated 31 Aug 2027 (2-year cycle). Purchases must be acknowledged within 3 days (3 min for license testers). License testers and the Play Billing Lab app exist for testing. | release notes, deprecation FAQ, integrate, test |
| Google server | Real-time developer notifications arrive via Pub/Sub. Look purchases up with purchases.subscriptionsv2.get / productsv2. | RTDN |
| Play Games v2 | Automatic sign-in at launch. Achievements, leaderboards, Saved Games (snapshots). v1 is closed to new titles since Sep 2025, its APIs are removed in play-services-games 25.0.0, and v1 traffic shuts down May 2027. | migrate to v2, deprecation |
| Game Center / iCloud | GKLocalPlayer.authenticateHandler; GKLeaderboard (iOS 14+ APIs); GKAccessPoint. GKSavedGame needs an iCloud container. NSUbiquitousKeyValueStore holds 1 MB / 1024 keys and works for App Store builds only. Game Center, IAP, iCloud and push all need the paid Apple program. | GKSavedGame, KVS, capabilities |
| Consent | Since 16 Jan 2024, personalised Google ads in the EEA/UK (and now Switzerland) need a Google-certified, TCF-integrated CMP; UMP is one. requestConsentInfoUpdate runs on every launch. ATT only when tracking across apps; a missing NSUserTrackingUsageDescription crashes the app. | AdMob CMP, UMP Android, UMP iOS, ATT |
| Ads | AdMob iOS: SPM/CocoaPods, GADApplicationIdentifier, about 40 SKAdNetworkItems. New AdMob apps need verified app-ads.txt or get limited serving. AppLovin MAX (≥ 12.0) can run UMP itself and pass TCF/AC strings to networks. Unity LevelPlay 9.5 reads consent from UMP/AC CMPs and ships a privacy manifest. ⚠ No vendor publishes SDK size figures. | AdMob quick start, app-ads.txt, MAX, LevelPlay |
| Privacy manifests | Signed SDK manifests are required for SDKs on Apple’s “commonly used” list. That includes Firebase (Core, Crashlytics, Messaging, …) but not GoogleMobileAds, UMP, AppLovin or ironSource, which ship manifests voluntarily. | third-party SDK requirements |
| Push | FCM HTTP v1 only (the legacy API closed in June 2024); Android needs Google Play services. POST_NOTIFICATIONS is a runtime permission from Android 13. SCHEDULE_EXACT_ALARM is denied by default on Android 14, so games use inexact alarms or WorkManager. APNs uses token auth with a .p8 key and an ES256 JWT, refreshed at least hourly. | FCM v1, notification permission, exact alarms, APNs tokens |
| Crash reporting | Crashlytics NDK supports native crashes but needs symbol upload. The Sentry .NET SDK reports managed exceptions plus native crashes through its bundled Cocoa/Android SDKs, and supports AOT/trimming (NativeAOT on iOS). ⚠ Sentry with NativeAOT on Android is unconfirmed; iOS NativeAOT stack-trace issues are open. | Crashlytics NDK, Sentry Android, Sentry Apple, sentry-dotnet#3280 |
| Interop | Swift exports C symbols with @_cdecl (underscored), or with @c (SE-0495, accepted Oct 2025, “Implemented (Swift 6.3)”, ⚠ check the shipping Xcode). .NET for iOS references an .xcframework through NativeReference. AndroidMavenLibrary (.NET 9+) pulls Maven artefacts with dependency verification. Kotlin/JVM cannot export C symbols. | SE-0495, iOS build items, AndroidMavenLibrary |
Proposed design
Section titled “Proposed design”Engine API
Section titled “Engine API”Each service is a server registered by GameHost when the project enables it (project.mfproj services). Games
reach it through Tree.Servers, or through the static facades for the common calls. All results are delivered on the
game thread (IFrameServer.Process), as Task-returning methods and as [Signal]-style C# events for unsolicited
updates.
classDiagram
class PlatformServices {
+ConsentServer Consent
+StoreServer Store
+GameServicesServer GameServices
+CloudSaveServer CloudSave
+NotificationServer Notifications
+AdsServer Ads
+AnalyticsServer Analytics
+CrashReporter Crashes
}
class StoreServer {
+ProductsAsync(ids) Task~ProductInfoList~
+PurchaseAsync(id, accountToken) Task~PurchaseResult~
+RestoreAsync() Task
+Entitlements IReadOnlyList~Entitlement~
+event TransactionUpdated
+IReceiptValidator Validator
}
class GameServicesServer {
+SignInState State
+UnlockAchievementAsync(id)
+SetAchievementProgressAsync(id, pct)
+SubmitScoreAsync(board, score)
+LoadScoresAsync(board, scope, range)
+ShowAchievementsUI()
+ShowLeaderboardUI(board)
}
class CloudSaveServer {
+SaveAsync(slot, bytes, meta) Task~SaveResult~
+LoadAsync(slot) Task~CloudSave~
+ListAsync() Task~SaveMetaList~
+ConflictPolicy Policy
}
class IStoreBackend { <<interface>> }
class IGameServicesBackend { <<interface>> }
StoreServer --> IStoreBackend
GameServicesServer --> IGameServicesBackend
IStoreBackend <|.. AppleStoreBackend
IStoreBackend <|.. PlayStoreBackend
IStoreBackend <|.. FakeStoreBackend
IGameServicesBackend <|.. GameCenterBackend
IGameServicesBackend <|.. PlayGamesBackend
IGameServicesBackend <|.. SteamGameServicesBackend
IGameServicesBackend <|.. FakeGameServicesBackend
-
Ids are logical.
project.mfprojmaps them per platform, so game code never branches:"services": {"store": { "products": { "remove_ads": { "type": "nonConsumable", "ios": "com.ex.removeads", "android": "remove_ads" },"gems_100": { "type": "consumable", "ios": "com.ex.gems100", "android": "gems_100" } } },"gameServices": { "achievements": { "first_win": { "ios": "first_win", "android": "CgkI…AQ", "steam": "ACH_WIN" } },"leaderboards": { "high_score": { "ios": "hs", "android": "CgkI…Ag", "steam": "HighScore" } } },"cloudSave": { "backend": "auto" }, // auto = iCloud / Play Saved Games / Steam Cloud / local"consent": { "umpOnLaunch": true, "attPrompt": "beforeAds" },"ads": { "provider": "admob", "android": { "appId": "ca-app-pub-…" }, "ios": { "appId": "ca-app-pub-…" } },"notifications": { "push": false, "local": true },"analytics": { "provider": "none" }, "crashes": { "provider": "sentry", "dsn": "https://…@…/…" } // or "local"}The cooker validates the map (every logical id has an id for each exported platform).
-
Backend selection happens at start-up from the platform and the services section:
- iOS → Apple backends;
- Android → Google backends;
- desktop with Steam running → Steam for game services and cloud saves;
- editor /
--fake-services <file.json>→ fake; - otherwise null (calls complete with
ServiceUnavailable, never throw).
Shim architecture
Section titled “Shim architecture”flowchart LR
subgraph Managed["C# (MainframeEngine + .Android/.iOS)"]
S["StoreServer, GameServicesServer, …"] --> B["Apple*/Play* backends<br/>(request ids, JSON payloads)"]
B --> P["MfPlatform (LibraryImport 'mfplatform')<br/>abi check, poll loop"]
end
subgraph iOS["iOS: mfplatform.xcframework (static)"]
PI["C header mfplatform.h<br/>@c / @_cdecl exports"] --> SW["Swift modules<br/>StoreKit 2 · GameKit · CloudKit · UserNotifications · ATT · UMP/ads"]
end
subgraph Android["Android: libmfplatform.so + mfplatform.aar"]
PJ["C ABI in C++ (JNI bridge)<br/>caches JavaVM, attaches threads"] --> KT["Kotlin MfPlatform objects<br/>Billing · PGS v2 · Saved Games · FCM · UMP/ads"]
end
P --> PI
P --> PJ
SW -- "events (queue)" --> PI
KT -- "external fun onEvent()" --> PJ
- One ABI on both platforms.
Native/Platform/include/mfplatform.his the contract, built under the rules of ADR 0002: cdecl, fixed-width types,mfplatform_boolasint32_t,struct_size+user_datafirst, no exceptions across, null-checked, andmfplatform_abi_version()=(MAJOR<<16)|MINOR. A MINOR may only append functions or struct fields. - Asynchronous and request based. Every operation is
mfplatform_<service>_<op>(const char* json_args, uint64_t request_id)and returns at once. Results and unsolicited events (transaction updates, sign-in changes, push tokens, consent changes) are pushed into a lock-free MPSC queue inside the shim.MfPlatform.Poll()(mfplatform_poll(buf, cap)) drains it once per frame on the game thread:- each event is
{ type, request_id, status, payload_utf8 }, payload as JSON; - payloads are parsed with a
JsonSerializerContext(AOT-safe); - none of this is on the hot path, and allocations happen only when a service event arrives;
- JSON keeps the C ABI small and stable: new fields need no ABI bump.
- each event is
- No callbacks into managed code from platform threads. The poll model avoids
[UnmanagedCallersOnly]reentrancy from StoreKit’s or Play’s threads. When the game is paused and the frame loop stops, events wait in the queue: a purchase completing in the background is delivered on resume, and is still inTransaction.updates/ the Play purchase list anyway. - UI-presenting calls (purchase sheet, Game Center UI, consent form, ads) need the platform’s top view controller
/ activity. The shim gets it from the host:
MainframeEngine.iOSpasses the rootUIViewControllerandMainframeEngine.Androidthe currentActivitythroughmfplatform_set_host(…)at start-up, and again after the activity is recreated. - iOS build.
- A Swift package in
Native/Platform/ios/with one target per service (MfStore,MfGameKit,MfCloud,MfNotifications,MfConsent,MfAds) and a coreMfPlatformCore(queue, JSON, ABI). - C entry points use
@cwhen the CI Xcode’s Swift supports it,@_cdeclbefore that (same symbols for C callers). - Built by
xcodebuild -create-xcframework(device + simulator) innatives.yml, then linked by the iOS head viaNativeReference(Kind=Static,Frameworks="StoreKit GameKit …",ForceLoad). Only the targets the export preset enables are linked, so an unused service’s framework does not pull in its system framework. - Third-party iOS SDKs (UMP, Google Mobile Ads, sentry-cocoa) are vendored as pinned xcframeworks fetched by checksum in CI from the vendor’s official release URLs, never CocoaPods at build time. Their privacy manifests are merged into the app’s at export.
- A Swift package in
- Android build.
- A Gradle library module in
Native/Platform/android/(Kotlin,minSdk 29) →mfplatform.aar. A small C++ JNI bridge (libmfplatform.so, NDK r28+, 16 KB aligned) implements the C ABI:- it caches
JavaVM*inJNI_OnLoad; - it calls
@JvmStaticKotlin entry points; - Kotlin posts events back through
external fun nativeOnEvent(...)into the same queue.
- it caches
- Kotlin/JVM cannot export C symbols, so the bridge is the price of one ABI on both platforms. The alternative,
managed JNI calls (
JNIEnv/Java.Interop) implementing the same backend interface, would split the pattern and is kept only as the fallback if the bridge proves troublesome. - Maven dependencies (Play Billing 9.x,
play-services-games-v2, Play Core asset delivery from M12, UMP,play-services-ads(AdMob),firebase-messaging,sentry-android+ NDK) are declared in the.aar’s POM. The Android head consumes them throughAndroidMavenLibrary(.NET 9+, dependency-verified) with versions pinned centrally next toDirectory.Packages.props(aNative/Platform/android/versions.tomlthe build reads), so the dependency list is reviewed like NuGet packages.
- A Gradle library module in
- Why not .NET bindings (generated Java binding libraries, Objective-C binding projects, community plugins)?
- StoreKit 2 cannot be bound at all.
- Generated Java bindings of Billing/PGS/ads pull large API surfaces into trimming and AOT analysis and break on every library update.
- Community plugins add third-party NuGet dependencies (needs discussion, CLAUDE.md) with their own release cadence.
- A shim keeps each service at ~200–600 lines of Swift/Kotlin written against the vendor’s documented API, which is easy to update when Play Billing’s two-year deprecation cycle forces a bump.
- Tests per layer:
- C ABI CTest (export list from the header, as for
mfrmlui); - Swift XCTest on the simulator, with StoreKit Test (
SKTestSession+.storekitfile) for purchase flows; - Kotlin unit tests + Robolectric for the event plumbing and the billing state machine against a fake
BillingClient; - C# unit tests of every server against the fake backends.
- C ABI CTest (export list from the header, as for
Privacy and consent
Section titled “Privacy and consent”ConsentServer runs before any other service at launch, and every data-collecting service asks it first.
sequenceDiagram
participant G as GameHost start
participant C as ConsentServer
participant U as UMP (Google)
participant A as ATT (iOS)
participant S as Ads / Analytics / Crashes
G->>C: Initialize (before first scene)
C->>U: requestConsentInfoUpdate (every launch)
U-->>C: status (required / not required / obtained)
alt form required (EEA/UK/CH)
C->>U: loadAndShowConsentFormIfRequired
U-->>C: TCF string + Additional Consent
end
opt iOS and services need tracking (ads personalisation, attribution)
C->>A: requestTrackingAuthorization (after UMP, at the configured moment)
A-->>C: authorized / denied / restricted
end
C-->>S: ConsentState { ads: personalised | limited | none, analytics: on | off, crashes: on | off }
S->>S: initialise with that state (or not at all)
- State.
ConsentState(AdsPersonalisation,AnalyticsAllowed,CrashReportsAllowed,AttStatus,TcfString) is persisted by the platform SDKs and mirrored in user data.ConsentServer.ShowPrivacyOptionsAsync()backs the mandatory “privacy settings” entry point in the game’s options menu (UMP’sprivacyOptionsRequirementStatussays when it is required). - Defaults are conservative. With no consent answer, ads are non-personalised (or limited ads) and analytics are off. Crash reports are on only if the game declares them as necessary for app functionality and lists them in the data-safety form and privacy nutrition labels; otherwise they are gated like analytics.
- ATT. Requested only when a service declares tracking (
NSPrivacyTracking = true, plusNSUserTrackingUsageDescriptionadded by the export). Never at launch before the player has seen the game: the defaultattPrompt: "beforeAds"asks right before the first ad request. Denied ATT means no IDFA; SKAdNetwork / AdAttributionKit still works. - Privacy manifest aggregation (export step):
- start from the engine template’s
PrivacyInfo.xcprivacy(the M12 .NET required-reason entries); - add each enabled service’s declared collected data types and required-reason APIs from
Native/Platform/ios/<Service>/PrivacyInfo.fragment.xcprivacy; - add vendor SDK manifests (which stay inside their xcframeworks and are also listed in the export report);
- the result lists every data type for the App Store privacy label.
- start from the engine template’s
- Google data safety. The same per-service declarations produce a data-safety worksheet
(
artifacts/export/data-safety.md: data types, purpose, shared / not shared, optional / required, encrypted in transit, deletion) to copy into Play Console; there is no API for the form. - Children. If
services.audience: "children":- ads switch to the provider’s child-directed / COPPA flags (
tagForChildDirectedTreatment, TFUA); - analytics and ATT are disabled;
- the export warns about Play Families policy and Apple Kids Category rules.
- ads switch to the provider’s child-directed / COPPA flags (
In-app purchases
Section titled “In-app purchases”sequenceDiagram
participant Game
participant St as StoreServer
participant Sh as mfplatform shim
participant OS as StoreKit 2 / Play Billing
Game->>St: PurchaseAsync("gems_100")
St->>Sh: store_purchase({"id":"com.ex.gems100"}, req 42)
Sh->>OS: Product.purchase() / launchBillingFlow
OS-->>Sh: success (JWS) / purchaseToken · pending · cancelled
Sh-->>St: event {req 42, status, payload}
St->>St: Validator?.ValidateAsync(payload) (optional, game server)
St-->>Game: PurchaseResult (Purchased · Pending · Cancelled · Failed(reason))
Game->>St: grant goods, then Finish(transaction)
St->>Sh: finish (Transaction.finish / consume or acknowledge)
-
Products are
ProductInfo { Id, Type (Consumable, NonConsumable), DisplayName, Description, DisplayPrice, PriceMicros, CurrencyCode }, fetched withProductsAsync. The products map lists the ids. The display price is always the store’s localised string, never formatted by the engine. -
Transactions must be finished explicitly after the game has granted the goods, because both stores re-deliver unfinished transactions:
- StoreKit 2
Transaction.finish(); - Play:
consumeAsyncfor consumables,acknowledgePurchasefor others, within 3 days or Play refunds.
StoreServer.Finishmaps to the right call. Unfinished transactions are re-raised throughTransactionUpdatedat every launch until finished. - StoreKit 2
-
Unsolicited transactions:
- The iOS shim observes
Transaction.updatesfrommfplatform_init(at launch, as Apple requires). Ask to Buy, refunds, offer codes and purchases on another device all arrive there. - Android:
PurchasesUpdatedListener, plusqueryPurchasesAsyncon every resume (PBL 8 removedqueryPurchaseHistory). enableAutoServiceReconnection()(PBL 8+) handles disconnects.
- The iOS shim observes
-
Entitlements.
StoreServer.Entitlements(non-consumables currently owned) comes fromTransaction.currentEntitlements/queryPurchasesAsyncand is cached in user data for offline start.RestoreAsync()maps toAppStore.sync()(iOS; required by App Review as a visible “Restore purchases” button) and a re-query on Android. -
Validation.
- The engine verifies what can be verified on-device without secrets: StoreKit 2’s
VerificationResult(JWS signature checked by StoreKit) and Play’s purchase signature against the app’s public key (optional, in the preset). IReceiptValidatoris the hook for server validation (App Store Server API / Play Developer APIproductsv2), where a backend exists. The engine sends the raw JWS / purchase token and an optionalappAccountToken/ obfuscated account id that the game supplies.
- The engine verifies what can be verified on-device without secrets: StoreKit 2’s
-
Pending purchases (Ask to Buy, cash payments in some countries) return
Pendingand complete later throughTransactionUpdated. -
Testing:
-
iOS: a
.storekitconfiguration file generated from the products map (mf-cook services --storekit), used by XCTestSKTestSessionin CI (simulator; purchase, cancel, Ask to Buy, refund, interrupted purchase) and by Xcode runs. Sandbox testers in App Store Connect for TestFlight builds. -
Android: license testers (test cards; 3-minute acknowledge window, so the 3-day logic is tested quickly), internal track builds and the Play Billing Lab app for response-code simulation. CI covers the Kotlin billing state machine against a fake
BillingClient. -
Desktop/editor:
FakeStoreBackendwith scripted outcomes:{ "gems_100": ["purchased"], "remove_ads": ["pending", "purchased"] }plus an editor Services panel to trigger refunds and updates.
-
Achievements, leaderboards and cloud saves
Section titled “Achievements, leaderboards and cloud saves”| Concept | iOS | Android | Steam (desktop) | Fake/local |
|---|---|---|---|---|
| Sign-in | GKLocalPlayer.authenticateHandler (shows the Game Center sheet if needed); GKAccessPoint optional | PGS v2 automatic sign-in at launch; GamesSignInClient.isAuthenticated, manual sign-in button if it fails | always signed in when Steam runs | always |
| Unlock / progress | GKAchievement (percentComplete) | AchievementsClient.unlock / setSteps (incremental) | SetAchievement + StoreStats (existing Steam wrappers) | JSON in user data |
| Leaderboards | GKLeaderboard.submitScore / loadEntries (iOS 14+ API, recurring boards) | LeaderboardsClient.submitScore / loadTopScores | FindLeaderboard / UploadLeaderboardScore / DownloadLeaderboardEntries | local list |
| Native UI | GKGameCenterViewController | getAchievementsIntent / getLeaderboardIntent | Steam overlay | RML debug panel |
| Cloud saves | iCloud: CloudKit private database (records ≤ 1 MB per asset; quota is the user’s iCloud) behind the engine API. GKSavedGame as an option when the game wants Game Center-bound saves. NSUbiquitousKeyValueStore (1 MB total) only for small settings. | PGS Saved Games (snapshots: data + cover image + description + played time; conflict callbacks) | Steam Cloud (ISteamRemoteStorage file API, or Auto-Cloud configured in the Steamworks partner site) | files in user data |
- API.
GameServicesServer.UnlockAchievementAsync,SetAchievementProgressAsyncandSubmitScoreAsyncare queued and retried. Offline unlocks are stored in user data and flushed on the next sign-in, because the native SDKs only partly do this themselves. - Cloud saves are
ICloudSaveBackends (updated after G4 shipped). There is no separateCloudSaveServer: G4’sSaveServer(Projects → Cloud saves) already writes slots locally first, uploads the same.msavebytes best effort with retries, runsSyncAsyncwith the three conflict policies below and keeps the losing copy as.conflict-<utc>. M13.3 adds oneICloudSaveBackendper store (CloudKit, Play Saved Games, Steam Cloud through a newISteamRemoteStoragewrapper) and the conflict dialog widget; the file format does not change. The.msaveheader carriesSaveMeta’s fields one to one (slot,description,playTimeSeconds,savedUtc,device,progress;CloudSaveMeta). The class diagram above keeps the original sketch.- Conflicts (two devices) follow
ConflictPolicy:MostRecent;HighestProgress(usesProgressValue);Manual: raisesConflictDetected(local, remote)and the game resolves it, with an RML dialog in the widget library.
- The local copy is always written first (user data). The cloud write is best effort and retried.
- Saves are also the M12
OnApplicationPausepersistence path, so the two are one call for games.
- Conflicts (two devices) follow
- Mapping desktop ↔ Steam. Logical ids map to Steam API names in the same
services.gameServicestable. When Steam is not running (or its natives are not shipped, ADR 0044), the local backend keeps unlocks so the game logic is identical.
Notifications
Section titled “Notifications”- Local notifications (
NotificationServer.ScheduleLocal(id, title, body, fireAt | interval, data),Cancel(id); title/body are already translated by the game viaTr):- iOS:
UNUserNotificationCenter(UNTimeIntervalNotificationTrigger/ calendar trigger), authorisation requested withRequestPermissionAsync()at a moment the game chooses. - Android: a notification channel per category (
project.mfprojservices.notifications.channels) andPOST_NOTIFICATIONSat runtime on 13+. Scheduling uses inexactAlarmManager.setAndAllowWhileIdleor WorkManager (exact alarms are denied by default on 14+ and reserved for alarm/calendar apps). - Typical uses: energy refilled, daily reward. Tapping a notification launches the game with
data, delivered asNotificationOpenedafter start-up.
- iOS:
- Push.
- iOS: APNs directly; no Firebase needed. Shim:
registerForRemoteNotifications→PushTokenChanged(token). Entitlementaps-environment. The game’s server sends with a.p8token key; that server is the game’s. - Android: FCM is required (Google Play services). The Android shim adds
firebase-messagingand the head needs the project’sgoogle-services.jsonvalues, which the export injects as resources (no Gradle plugin needed: the values are written as string resources).FirebaseMessagingService.onNewToken→PushTokenChanged. - Firebase Messaging on iOS is not used: one less SDK on Apple’s commonly-used list.
- Foreground pushes raise
PushReceived(data); background pushes show as system notifications, and tapping one raisesNotificationOpened.
- iOS: APNs directly; no Firebase needed. Shim:
- Remote content (silent pushes to refresh data) is out of scope for v1.
Ads are the heaviest and most regulated service, so they are opt-in at export (services.ads.provider) and
always behind ConsentServer.
| Option | Pros | Cons |
|---|---|---|
| Google AdMob (+ mediation, + UMP) | one vendor for ads and the certified CMP; widest docs; mediation adapters for other networks; free | app-ads.txt verification required for new apps; Google-centric demand unless mediation is configured |
| AppLovin MAX | strong game demand and in-house bidding; its consent flow can show UMP and forwards TCF/AC to mediated networks | separate dashboard, heavier SDK; its consent flow must cover every mediated network |
| Unity LevelPlay (ironSource) | strong rewarded-video demand for games; reads UMP/AC consent; ships a privacy manifest | SDK and dashboard churn (ironSource → Unity), Unity-centric docs |
- Decided (2026-10-05): AdMob + UMP, recorded in ADR 0100.
- It is the implementation M13.5 ships.
MfAdstargets anAdProvidersub-interface inside the shim. - The same vendor solves the certified-CMP requirement, and AdMob mediation can add MAX/LevelPlay demand without engine changes.
- A direct MAX or LevelPlay module would be a second shim module behind the same C ABI calls (
ads_load,ads_show, events). It is not planned.
- It is the implementation M13.5 ships.
- Engine API.
AdsServer:LoadAsync(AdFormat.Interstitial | Rewarded | Banner, placement),ShowAsync(placement)→AdResult { Shown, Rewarded(amount, type), Failed, Skipped };ShowBanner(placement, BannerPosition)/HideBanner; banners respectEngine.SafeArea, and the ad view is a native view over the game surface.- The engine pauses the tree and audio while a fullscreen ad shows (lifecycle focus loss) and resumes after.
- Rewarded callbacks are delivered only through the poll loop on the game thread.
- Optional server-side verification (SSV) is configured in the ad network; the engine passes the custom data.
- Compliance on export:
- Info.plist
GADApplicationIdentifierandSKAdNetworkItems(the provider’s list, pinned in the shim version); Android manifestcom.google.android.gms.ads.APPLICATION_ID; - a reminder to host app-ads.txt on the developer website listed in the stores;
- ATT usage string;
- the data-safety worksheet updated (device identifiers, advertising data);
- privacy manifest aggregated;
- a size report that shows the ad SDK’s share (⚠ no vendor publishes sizes, so they are measured in M13.5).
- Info.plist
- Testing: the providers’ test ad unit ids in debug builds, always (live ids in debug get accounts banned); a fake backend on desktop that draws a placeholder RML ad and simulates fill/no-fill/reward.
Analytics and crash reporting
Section titled “Analytics and crash reporting”- Crash reporting, built in layers:
-
Engine-level, always available, no SDK.
AppDomain.UnhandledException,TaskScheduler.UnobservedTaskExceptionand the audio thread’s captured fault write a crash file to user data on the way down: exception chain, last 200 log entries from aMemoryLogSink, device, OS, engine and game version, tier, thermal state.- On next launch,
CrashReporter.PendingReportsoffers them to the game (upload to its own endpoint, or attach to a support mail), and the editor link uploads them in dev builds. - NativeAOT stack traces need symbols: the export keeps the
.dSYM(iOS) and unstripped.so/.dbg(Android) as build artefacts, andmf-symbolicatemaps addresses offline.
-
Native crashes (signals in native code, out-of-memory kills) need a native SDK. The options considered:
Option Managed exceptions Native crashes Notes Sentry ( SentryNuGet, .NET SDK with bundled Cocoa/Android SDKs)yes, with .NET stack traces yes AOT/trimming supported; ⚠ NativeAOT stack traces on iOS have open issues; NativeAOT on Android unconfirmed; a NuGet dependency. Not chosen: the decided variant uses Sentry’s native SDKs through the shim (below) Firebase Crashlytics (via the shim) only as “non-fatal” custom reports the engine forwards yes (NDK) needs symbol upload for each build, adds Firebase core (on Apple’s commonly-used list → signed manifest); free Engine-only (layer 1) yes no (Play Console / App Store Connect still show OS crash reports with symbolication from uploaded dSYM / native debug symbols) zero dependencies Decided (2026-10-05): Sentry native SDKs behind the services shim (an approved M13 dependency):
- sentry-cocoa in the iOS Swift package and sentry-android (with the NDK integration) in the Kotlin
module. Both are pinned like the other vendor SDKs: an xcframework by checksum, a Maven version in
versions.toml. - No
SentryNuGet. The managed side stays dependency-free and runtime-agnostic. - The C ABI gains
crash_init(json)(DSN, environment, release, consent),crash_capture_managed(json),crash_breadcrumb(json)andcrash_set_context(json). - The engine’s layer-1 handler forwards unhandled managed exceptions as Sentry events (exception chain, .NET stack frames as text plus native addresses, last log entries as breadcrumbs) before writing its local crash file.
- The native SDKs catch signals, Mach exceptions, ANRs and NDK crashes themselves.
- Symbols:
mobile-publish.ymluploads dSYMs and Android native debug symbols to Sentry with the Sentry CLI (official release binary, checksum-pinned; no third-party action) next to the store uploads. - Initialised only after
ConsentServerallows crash reports, unless the game declares them as required for app functionality. - Sentry is on no Apple required-signature list, but sentry-cocoa ships a privacy manifest, which is aggregated.
- Layer 1 + the stores’ own dashboards (Play vitals, Xcode Organizer) stay as the zero-config fallback when no DSN is configured.
- sentry-cocoa in the iOS Swift package and sentry-android (with the NDK integration) in the Kotlin
module. Both are pinned like the other vendor SDKs: an xcframework by checksum, a Maven version in
-
- Analytics.
AnalyticsServer.LogEvent(name, params)with a fixed small schema (string/number params, ≤ 25 per event) and backends: none (default), Firebase Analytics via the shim (if push already brought Firebase in on Android), or the game’s own HTTP endpoint (batched, offline-queued, consent-gated). The engine records nothing on its own: no engine telemetry.
Desktop and Steam mapping
Section titled “Desktop and Steam mapping”| Service | iOS | Android | Desktop + Steam | Desktop (no Steam) / editor |
|---|---|---|---|---|
| Consent | UMP + ATT | UMP | none needed (no ads) → ConsentState all-allowed except ads | fake (scriptable) |
| Store | StoreKit 2 | Play Billing 9.x | null (Steam microtransactions out of scope) | fake |
| Achievements / leaderboards | Game Center | Play Games v2 | Steam user stats / leaderboards | local |
| Cloud saves | CloudKit (or GKSavedGame) | PGS Saved Games | Steam Cloud | local files |
| Notifications | UNUserNotificationCenter / APNs | NotificationManager / FCM | null | fake (shows a toast in the editor) |
| Ads | AdMob (+ mediation) | AdMob (+ mediation) | null | fake placeholder |
| Analytics / crashes | engine + Sentry (sentry-cocoa via shim) | engine + Sentry (sentry-android/NDK via shim) | engine (crash files; Sentry desktop later) | engine |
Editor integration
Section titled “Editor integration”- A Services section in the export dialog (enable per service, ids map editor with validation) and in project settings.
- A Services panel during desktop play (fake backends): product list with outcome buttons, achievement and leaderboard state, cloud-save slots with “simulate conflict”, consent state switcher (EEA / non-EEA, ATT denied/authorised), push/notification injection.
- The editor link forwards the same fake-backend commands to a device dev build (
--fake-serviceson device), so purchase flows can be QA’d without store accounts.
M13 phases
Section titled “M13 phases”| Phase | Deliverable | Acceptance |
|---|---|---|
| M13.0 Shim skeleton | mfplatform.h + ABI check; Swift package and Kotlin AAR + JNI bridge with the poll queue; natives.yml legs; PlatformServices + fake/null backends; services section + id map validation | CTest/XCTest/JUnit green in CI; a ping round trip on simulator and emulator; fake backends drive every server in unit tests |
| M13.1 Consent & privacy | ConsentServer (UMP, ATT), manifest aggregation, data-safety worksheet | EEA/non-EEA flows verified with UMP debug geography on device; the export report lists data types |
| M13.2 IAP | StoreServer on StoreKit 2 + PBL 9.x; entitlements, restore, finish/acknowledge, pending, validator hook; .storekit generation | XCTest StoreKit suite in CI; a license-tester purchase on the internal track; sandbox purchase via TestFlight |
| M13.3 Game services | Game Center, PGS v2, Steam mapping, offline queue; ICloudSaveBackends for CloudKit / Saved Games / Steam Cloud under G4’s SaveServer (conflict policies shipped in G4) | unlock + score + save/load/conflict on both stores’ test accounts; Steam path tested with the existing wrappers |
| M13.4 Notifications | local (both), push (APNs, FCM), permission flows, open-from-notification | scheduled local notification fires and opens the game with data; push token reaches a test sender |
| M13.5 Ads | AdMob + UMP (decided); pause/resume around fullscreen ads; export compliance (SKAdNetwork, app id, app-ads.txt reminder); size measurement | test ads (all formats) on both; consent-denied → non-personalised verified; size delta recorded |
| M13.6 Analytics & crashes | engine crash files + PendingReports, symbol artefacts + upload in mobile-publish.yml, AnalyticsServer with HTTP backend; Sentry native SDKs in the shim (crash_* ABI) + symbol upload | a forced managed exception and a native crash (iOS + Android) appear in Sentry with symbolicated stacks; local crash file reported on next launch |
Task list
Section titled “Task list”-
Native/Platform/(header, Swift package, Kotlin module, JNI bridge, tests);natives.ymllegs; lock entries -
MfPlatformbinding (LibraryImport, ABI check, poll, JSON context);PlatformServices; null/fake backends -
servicessection inProjectSettings(+ migration), id-map validation inmf-cook -
ConsentServer; manifest aggregation; data-safety worksheet -
StoreServer+ Apple/Play backends;.storekitgenerator; XCTest StoreKit suite -
GameServicesServer, Apple/Play/SteamICloudSaveBackends (G4’sSaveServeris the local layer); offline queue; conflict dialog widget -
NotificationServer(local + push), channels, permission flows -
AdsServer+ AdMob/UMP module; export compliance; fake ads -
CrashReporter(engine files,PendingReports), Sentry native SDKs in the shim (crash_*ABI), symbol artefacts + Sentry CLI upload;AnalyticsServer(HTTP backend) - Editor: Services export section, Services play panel, device fake-services forwarding
- Docs: current-state
docs/design/mobile-services.mdwhen shipped (ads provider and crash backend already decided in ADR 0100)
Decisions
Section titled “Decisions”Decided on 2026-10-05 (ADR 0100):
- Ads: AdMob + UMP (Ads).
- Crash reporting: Sentry native SDKs (sentry-cocoa, sentry-android/NDK) behind the
mfplatformshim, with noSentryNuGet (Analytics and crash reporting). An approved M13 dependency.
Open questions
Section titled “Open questions”- Sentry hosting: sentry.io (SaaS) or self-hosted? The DSN is per game, so the engine is agnostic.
- Subscriptions (battle pass, VIP) in v1 or later? They add renewal states, grace periods and server notifications.
- Cloud saves on iOS: CloudKit (engine-defined records, full control) vs
GKSavedGame(simpler and Game Center-bound, but it needs an iCloud container anyway)? Default: CloudKit. - Firebase on Android only for FCM push, or also Analytics once it is there? Default: FCM only. Crashes go to Sentry.
- Swift
@cvs@_cdecl: require the Swift version with SE-0495 in CI (Xcode 26.x with Swift 6.3?), or keep@_cdecluntil the minimum Xcode guarantees it? - Steam microtransactions (
ISteamMicroTxnneeds a web API backend): out of scope, or aStoreServerbackend later?
Related
Section titled “Related”Mobile core (M12) · Milestones · Steamworks · Native libraries · Projects & GameHost · Game UI · ADR 0100