Skip to content

OTA updates

Ship a new version of a game — engine JS, game code, and assets — to players who already have it installed, without a store review. Modoki games run in a WebView, so an update can replace everything the app renders; it just can't touch anything native.

What it can and can't replace

Can: engine code, your game's TypeScript/JS, scenes, and every asset (textures, models, audio, fonts). This is the same content a normal build produces — OTA just ships it to an already-installed app instead of through the store.

Can't: anything native. A new Capacitor plugin, a changed native permission, an updated app icon, a bumped minimum OS version — all of those still need a real store release. If a change only touches what runs inside the WebView, OTA can carry it; if it touches the native shell around the WebView, it can't.

One-time setup

Before you can publish an update, the project needs a signing identity and a place to host bundles.

  1. Generate a signing keyBuild → OTA Keys…, pick a name (or leave it as default), and click Generate. This writes a private key to build/ota-keys/<name>.json on your machine and shows you the matching public key.

    Back this up — there is no "regenerate"

    The private key never leaves your machine and is never committed to git. Losing it means every app binary you've already shipped can never be updated again — they have the old public key baked in and will reject anything signed with a new one. The dialog (and the underlying tool) refuses to overwrite an existing key file for exactly this reason. If you need a second identity, generate one under a different name; you can't "reset" this one.

  2. Set up a bucket — OTA bundles are hosted as static files on a CDN-backed bucket (this guide assumes Google Cloud Storage, since that's what the built-in publish tooling targets). The bucket needs public read access and CORS enabled for GET/HEAD — without CORS, publishing still appears to succeed, but every device's fetch() for the update silently fails. Build → Publish OTA Update… checks and sets this for you as part of publishing, so you don't need to configure it by hand.

  3. Fill in Project Settings → OTA — turn Enabled on, set the Base URL to your bucket's public URL, and confirm the Bundle name (shell for the main app). The Public key field is read-only — it's filled in automatically once you generate a key in step 1, never hand-typed.

Publishing an update

Once setup is done, publishing is one dialog: Build → Publish OTA Update…. It shows what's live right now, pre-fills the next version string, and gives you a Mandatory checkbox. Click Publish, and it streams its progress: a fresh build from your current project settings, a check that the version string hasn't been published before, then the upload.

That's it — no separate "build" step first. The dialog always builds from what's on disk right now, so what you see in the editor is what ships.

What players experience

An update you publish doesn't apply immediately to someone already playing — it's staged in the background and takes effect on the next launch. This is deliberate: swapping code out from under a running session would be far riskier than asking for a relaunch.

  • Routine (the default): staged silently. The player sees nothing until they next open the app, at which point they're already on the new version.
  • Mandatory: the player sees a progress bar while the update downloads, then a "please restart to continue" screen. Apps can't restart themselves on iOS, so this is a deliberate dead end, not a bug — the player closes and reopens the app to continue.

Use mandatory sparingly — it interrupts whoever is mid-session when you publish. It exists for the update that must land now (a game-breaking bug, a security fix), not routine content.

When an update goes wrong

Every update has to boot successfully twice, on two separate app launches, before the device trusts it. If it fails to boot three times in a row, the device automatically reverts to the last known-good version and marks the broken one as permanently rejected — no player ever sees a crash loop, and no player is stuck on a bad update.

Recovery is fix-forward only

Once a version is rejected on a device, that device will never try it again — not after a republish under the same version string, not ever. The only way to recover is to publish a new version number. If you republish a fix under the same string you already shipped, every device that already rejected it stays stuck — publishing will look like it worked, and silently not reach the players who need the fix. Never reuse a version string once you've published it.

Store policy

Both Apple and Google allow this, within limits — but read their current guidelines yourself before relying on a summary written today, since policy text changes.

  • AppleApp Store Review Guideline 2.5.2 requires an app to be self-contained, with an explicit exception for content interpreted by WebKit/JavaScriptCore, provided it doesn't change the app's primary purpose. Shipping new levels, fixes, and assets for an existing game fits that exception; using OTA to bolt on an unrelated app or a general-purpose app store inside your app does not.
  • Google Play — has no equivalent "update frequency" restriction, but its policies on WebView/JavaScript content still apply: don't load arbitrary third-party JavaScript at runtime, and disclose what the update mechanism does.

In short: use this to ship your own game's content and fixes, not to work around app review for something that wouldn't otherwise be approved.

Troubleshooting

Most OTA failures are silent by design (an update check must never crash a game the player is already looking at), which makes the symptom the only clue:

SymptomLikely cause
Publish "succeeds," but no device ever reports an updateBucket is missing CORS. Re-run Publish OTA Update… — it verifies/sets CORS on every publish.
Updates always download the whole bundle, never a small deltaThe embedded manifest is missing from the app's own build (an older native build, or — on Android specifically — a stale Gradle asset-merge; rebuild with the OTA-aware pipeline, which runs a clean build for OTA-enabled projects). Not a correctness bug — delta is an optimization, so this just costs bandwidth, never breaks the update.
You republished a version and some devices still run the old codeYou reused a version string. See "Recovery is fix-forward only" above — publish a new version number.
A published update never reaches players who already have an OLD build installedCheck Base URL/Bundle name/Public key in Project Settings actually match what those installed builds were shipped with — a mismatch here fails the same way a missing update does: silently.

Built with Modoki.