Export for Mobile
File ▸ Export for Mobile (React Native)… writes a bare React Native project with your game already inside it, which you then build and submit to the App Store or Play Store yourself. No Expo, no Awaken service in the loop: the output is an ordinary RN project and it is yours to change.
What you get
The export writes a directory, not a zip:
game/ your exported game (game.html + scene.fg). Both platforms read this one directory.
App.tsx a full-screen WebView pointed at game/game.html
shellBridge.ts what the game's page may do in the shell: the pages it may load, the links it may open,
the "can't run here" screen's words, Android's Back button
pageWatch.ts when the shell starts the game's page again (see Coming back from the background)
saveKey.ts the key the app signs its save file with (see Game saves)
storeBridge.ts in-app purchases through react-native-iap (see In-app purchases)
storeProducts.ts the products the game sells, from App Settings (Store), written by every export
outbox.ts what the game's scripts send to their own server (api.outbox), kept in a file until it is sent
(see Outbox & App Events)
appChannel.ts the release channel (api.app.channel) the shell hands the page: release, beta or dev, decided at
launch by AppDelegate.swift and AppChannelModule.kt (see Outbox & App Events, Release channel)
scripts/release-check.mjs npm run release-check, before every store build (see Before a release)
android/ Android project. build.gradle folds game/ into the app's assets.
app/upload.p12, keystore.properties
the Android release key, when App Settings has one (see Android release key)
ios/ iOS project. The Podfile copies game/ into the app bundle during the build.
Products.storekit the products, for testing purchases in Xcode, written by every export
awaken.config.json the identity this export was stamped withThe template ships the orientation from App Settings (every orientation on iPad), keep-awake, haptics (api.app.haptic, through a native module on each phone), and Android's Back button handed to the game already wired. The game page inside is the same game.html a browser runs, so everything in The Player about phones (no zoom, no browser gestures, touch input) holds in the app too.
App identity
Identity is derived from the saved project name, so a project saved as Mall Rats.awaken becomes:
| Field | Value |
|---|---|
displayName | Mall Rats |
bundleId | com.awaken.mall-rats |
packageName | same as bundleId, so the two stores agree |
orientation | landscape (default) |
A project with no usable name falls back to Awaken Game. To set the name, id, version, orientation, icon or launch screen yourself, use App Settings. A project with App Settings has those fields rewritten on every export; a project without them keeps the behaviour described below.
Display names are escaped per platform, and the two rules deliberately differ. Info.plist takes standard XML entities. Android's values/strings.xml looks like XML but is not plain XML text: aapt2 resolves entities first and then applies its own rules, so ' resolves to a bare ' that aapt2 rejects, and " resolves to a bare " that aapt2 silently treats as Android quote syntax and strips - shipping the name without its quotes. Android strings therefore get backslash escapes instead. This is verified against the real aapt2 binary rather than against something that merely looks like valid XML.
Re-exporting into the same directory
Awaken remembers the directory you exported to, so the second export does not ask again. What it writes depends on what it finds there:
An update writes only the three game payload files (game/game.html, game/scene.fg and game/launch-logo.png) and saveKey.ts, plus the two Android release key files when App Settings has a key, unless the project has App Settings: then it also rewrites the fields App Settings owns. Other native config is left alone, so a rename or any other change you made in Xcode or Android Studio survives every future export. Every update reads the folder's ios/AwakenGame/Info.plist, App.tsx and saveKey.ts, and says in the log when the folder was made before the iOS scene lifecycle or before signed saves (see Mobile folders made by an older editor).
"This project" is checked on bundleId and packageName in awaken.config.json, not on the display name - precisely so that renaming the app natively does not make a legitimate re-export start refusing itself. A config that cannot be read or parsed counts as a mismatch: a directory the exporter cannot positively confirm as home for the current project is never treated as home for it.
Game saves
In the mobile app, api.save writes to a file, awaken-save.json in the app's Documents folder, rather than to the WebView's localStorage. iOS moves an app to a new folder on every install and every update, App Store updates included, and the WebView keys localStorage by the page's location, so a save kept there starts empty after each one. Documents is kept across updates on iOS and Android.
The shell (App.tsx and nativeSave.ts) reads the file before the WebView starts and sets it on the page before any game script runs. The player sends the whole save back each time it changes; the shell writes it within half a second, or at once when the app leaves the foreground, through a temporary file so a stopped app never leaves half a save. The player also keeps writing localStorage. A save over 5 MB is refused. Games need no change: api.save and api.load work as before, and web exports still use localStorage alone.
The first run of a shell with this change finds no file, so the player moves in the save it has in localStorage, and progress made on that install carries over. A save that an older shell already lost cannot be recovered: on iOS, any install or update made with it, a reinstall from Xcode included, started with empty localStorage. A re-export updates only game/ and saveKey.ts, so a folder exported before this change needs App.tsx, nativeSave.ts and saveSign.ts copied from a new export, and the @noble/hashes dependency added (see Mobile folders made by an older editor).
Signed saves
The save file is signed, so a player who edits it (more coins, every level unlocked) gets the save from before the edit instead. The file holds the save, a format number and a signature:
json
{ "v": 1, "data": { "awaken:mygame:coins": "120" }, "sig": "3f9a...64 hex characters" }sig is the HMAC-SHA256 of the save under the game's key, computed over its [key, value] pairs sorted by key and written as JSON. The shell signs and checks it (nativeSave.ts, using the @noble/hashes package in saveSign.ts). The page never sees the key or the signature, and games need no change.
Where the key lives. Each game has its own key: 32 random bytes, written as 64 hex characters. The first mobile export makes it and stores it in the project's App Settings (under Advanced), so save the project after that export. Generate key, next to the field, puts a new random key in it, from the browser's crypto.getRandomValues. When the field already holds a key it asks first: "A new key makes every existing save file invalid. Players of a published game would lose their progress. Continue?" Every mobile export writes it into the app's saveKey.ts. An update never changes the key in a folder: a project without a key takes the folder's, and a project whose key differs from the folder's is refused, with the fix in the log (copy the folder's key into App Settings, or export to a new folder).
Never change the key once the app has shipped. Every player's save is signed with it, and under a new key the app would reject them all.
What happens to an edited file. When the signature does not match, the shell moves the file to awaken-save.rejected.json (replacing any older one) and reads awaken-save.prev.json, the save before the last write, which every write keeps. If that one fails too, or is not there, the game starts with an empty save.
Updating from an app without signed saves. The first start of the new app reads the old, unsigned file as it is, so players keep their progress. Its first write signs the save and creates a marker file, awaken-save.signed; from then on an unsigned file is rejected like an edited one.
The limits. The key is inside the app, and an app can be taken apart: a determined player can extract the key and sign a file of their own, or delete the marker so an unsigned file is read once more. Signing deters casual editing of the save file. It does not stop a determined player, and nothing that runs only on the device can. Do not use the save to decide anything that costs you money: verify in-app purchases with the App Store or Google Play, through the store's receipt checks or a server you run.
Android release key
Google Play knows an app by the key its uploads are signed with, its upload key, and every update must be signed with the same key. The template's release build is signed with the debug key (android/app/debug.keystore, password android, the same in every project made from the template), which Play rejects.
Create it
In App Settings, open Advanced and press Create key under Android release key. The editor makes, in the browser:
- an RSA 2048 key pair (Web Crypto, so the page does not freeze; the button shows a spinner while it works);
- a self-signed X.509 certificate, signed with SHA256withRSA, valid for 30 years from today (Google Play requires validity past 22 October 2033), with the subject
CN=<app name>, O=<app name>; - a PKCS#12 keystore (
upload.p12, about 2.4 KB) holding both, under the aliasupload, with a random 24-character password of letters and digits (look-alikes such as0,O,1andlleft out). A PKCS#12 keystore has one password, so the store and key passwords are the same.
The section then shows the certificate's SHA-256 fingerprint and the date it was made. Press Save, then save the project. The key is stored in the .awaken project file, as base64 text inside App Settings, so do not share the project publicly.

What the export writes
While the project has a key, every mobile export writes two files whole, on the first export and on every update:
| File | What it holds |
|---|---|
android/app/upload.p12 | The keystore. |
android/keystore.properties | storeFile=upload.p12, storePassword, keyAlias and keyPassword. |
Both are listed in the template's .gitignore. An update adds the two lines to the folder's .gitignore when they are missing and says so in the log; a folder with no .gitignore is named in the log instead. Never commit either file: with both, anyone can sign an update of your app.
The template's android/app/build.gradle reads keystore.properties and signs the release build with that key (storeType 'pkcs12'). Without the file a release build stops (assembleRelease, bundleRelease, installRelease), before anything is built, with a message saying the key is missing: a checkReleaseKey task that preReleaseBuild and validateSigningRelease depend on. The template used to fall back to the debug key, which Play rejects and which was easy to upload by mistake. Debug builds need no key and are unchanged. build.gradle belongs to you once exported, so an update never rewrites it. With a release key in App Settings, an update reads it and says in the log when it predates release keys ("...was made before Android release keys, so a release build is still signed with the debug key...") or still falls back to the debug key ("This mobile folder's android/app/build.gradle signs a release build with the debug key when keystore.properties is missing, which Google Play rejects: copy the release signing blocks shown under "Android release key" in a new folder's README.md into it, so a release build without the key stops instead."). The mobile project's README.md shows the four pieces of that block.
Build what Play takes with:
sh
cd android && ./gradlew bundleReleaseIt writes android/app/build/outputs/bundle/release/app-release.aab. ./gradlew assembleRelease makes an APK signed the same way. apksigner verify --print-certs (in the Android SDK's build-tools) prints the signing certificate's SHA-256, which is the fingerprint App Settings shows, in lower case and without colons.
This was checked on a copy of a real exported project (Crownquest), updated by the editor's own mobile export with the project's release key: ./gradlew assembleRelease built in 49 seconds and apksigner reported the APK signed with the key App Settings shows (SHA-256 e1d60d5b...). Without keystore.properties the same command stopped after 14 seconds at checkReleaseKey, with the message above, and ./gradlew assembleDebug still built (37 seconds).
Back it up
Download backup saves a zip with upload.p12 and a README.txt listing the alias, both passwords, the fingerprint and the date. Keep it somewhere safe and private, away from the project and its repository. The project file also holds the key, so you lose it only if you lose both the project and the backup.
Play App Signing
New apps on Google Play use Play App Signing: Google keeps the key that signs what players install (the app signing key), and you sign each upload with your upload key.
- In Play Console, create the app and accept Play App Signing with a key Google generates.
- Upload the first
app-release.aabbuilt with this key. That upload makes it the app's upload key. - Every later upload must be signed with the same key. Play Console shows its SHA-256 fingerprint under App integrity; it matches the one in App Settings.
If the key is lost
With Play App Signing, the account owner can ask Google to reset the upload key, in Play Console under App integrity. Remove the old key in App Settings, create a new one, download its backup, and export its certificate for Google from the backup's upload.p12:
sh
keytool -export -rfc -storetype pkcs12 -keystore upload.p12 -alias upload -file upload_certificate.pemGoogle emails the account owner when the new key can be used. Without Play App Signing a lost key cannot be replaced, and the app cannot be updated on Google Play.
Changing the key
An export never puts a new key over the one in a folder. When the folder's android/app/upload.p12 holds a key whose fingerprint differs from the project's (or one the editor cannot open with either password), the export is refused before anything is written, and the log names both fingerprints. Export to a new folder, or, if no app signed with the folder's key is published, delete android/app/upload.p12 and export again. Remove in App Settings asks first, and takes effect when you press Save.
To use a keystore of your own instead, put it at android/app/upload.p12, write android/keystore.properties by hand, and leave App Settings without a key: the export then leaves both files alone.
Before a release
The mobile project has a release check. Run it before every Archive in Xcode and every ./gradlew bundleRelease:
sh
npm run release-check # both platforms
npm run release-check -- --ios # iOS alone (no Android key needed)
npm run release-check -- --android # Android alone
npm run release-check -- --record # after an upload: write down the build numbers shippedIt exits with an error, listing each reason, when:
game/game.htmlloads a script from anywhere, or has anything after its closing</html>(the game must carry all its own code: App Store rule 2.5.2 and the privacy labels). The export refuses such a template too (see A template changed on the way is refused);game/game.htmlhas no player template stamp, so there is no telling which engine it runs. It prints the stamp's build time and engine commit;- the developer readout is on (
PERF_READOUTinApp.tsx); saveKey.tsholds no save key;- the Android release key is missing (
android/keystore.properties, or the keystore it names); - a build number (iOS
CURRENT_PROJECT_VERSION, AndroidversionCode) is not above the last one shipped.--recordwrites the shipped numbers to.release-state.jsonin the project; keep that file in git.
It also notes, without failing, an App.tsx that does not send what the game's scripts post with api.outbox, and Sentry left in a folder made while the template had it (an editor from 20 to 21 October 2026; the template's README, "Error reports", says how to take it out).
On the updated Crownquest copy above it printed "The game runs the player template built 2026-10-20T05:11:55.312Z from engine commit 4fc3808c36c0." and "Ready to release (ios and android): ios build 1, android build 1."
Links and the game's view
The WebView shows the game and nothing else (shellBridge.ts):
originWhitelistlets onlyfile://addresses into the view, andonShouldStartLoadWithRequestonly those inside the game folder (game/in the iOS app bundle, the asset root on Android). react-native-webview hands any other address the page goes to over to the phone, never into the game's view.onMessagedrops a message whose page address is not in the game folder, before anything reads it. iOS names the app's folder with and without/privatein front of/var; both count as the same place. Android's WebView gives every message the address"null"(it readsWebView.getUrl(), which answers null for the game's page), so on Android a message without an address is taken (fromGamePage), and the rule that nothing but the game folder loads is the lock there.- A game opens its privacy policy, its support page or an email with
api.openUrl(url). The page posts{"type": "awaken:open", "url"}, and the shell checks the link again (anhttpspage or amailtoaddress, nothing else) and opens it withLinking.openURL: the phone's browser or mail app.
When the game cannot run
When the game cannot start (see The Player for the codes), the page posts {"type": "awaken:fatal", "code"} and the shell shows its own screen over it: the launch colour, the launch logo, plain words in a colour that reads on the launch colour, and Try again, which starts the page again. On Android, for a graphics failure, the words add "Updating Android System WebView may fix this." and an Update Android System WebView button opens its Play Store page (market://details?id=com.google.android.webview, or the web page without the Play Store). The screen is laid out inside the safe area.
The iOS 27 simulator has WebGPU but no GPU adapter, so it shows this screen: "This device can't run the game" and "It needs a newer graphics system." over the launch colour, with the logo and Try again.

An error that does not stop the game is not painted over it in the app: the page sends it as {"type": "awaken:report"}, and the shell logs it with [awaken report] in report() in App.tsx. The game's scripts hear every such error as awaken:error and can send it to the game's own server through the outbox (see Outbox & App Events); native crashes are in Xcode's Organizer and Play Console's Android vitals. A save write that fails (a full disk, most often) is reported there too, tried once more a second later, and kept for the next save if that fails as well.
Android: Back, text size, backup and devices
Back goes to the game first. The shell raises an awaken:back event in the page and the game's scripts answer through api.onBack. The page replies {"type": "awaken:back-handled", "id", "handled"}. When nothing handled the press, or no answer comes within 300 ms (a page still loading), the app goes to the background, as Back does from an app's home screen on Android 12 and later: BackHandler.exitApp, which MainActivity.kt turns into moveTaskToBack. React Native's own fallback finishes the activity, and the next open loaded the whole game again from the launch screen.
On an Android 15 emulator (Android System WebView 124, which offers no WebGPU adapter there) the app showed its own "can't run here" screen with Update Android System WebView; Try again started the page again (its address ending in &awaken-restart); Back sent the app to the background and opening it again brought back the same activity, page and all; and Update Android System WebView, with no Play Store in that emulator image, opened the Play Store's web page in Chrome. The same run found two faults, fixed in the template: the app crashed as it started when dataDetectorTypes was the bare string "none" (the new architecture's prop parser on Android expects an array, which react-native-webview builds on iOS alone), and the address check above dropped every message until it took Android's "null".
Text and display size. textZoom={100} keeps the phone's font size setting from resizing the game's text, and fontScale|density in android:configChanges keeps a change to either from restarting the activity, and the game with it.
Manifest. android:appCategory="game". react-native-fs asks for WRITE_EXTERNAL_STORAGE, and the manifest merger adds READ_EXTERNAL_STORAGE as implied by it; the game never touches shared storage, so tools:node="remove" takes out both. Crownquest's release APK asks for INTERNET alone, apart from AndroidX's own signature-only receiver permission.
Save backup. App Settings' Back up saves on Android (on unless you turn it off) is written as android:allowBackup: on, the player's Google account backup keeps the save. res/xml/data_extraction_rules.xml lists the only files Android may back up or carry to a new phone: awaken-save.json, awaken-save.prev.json and awaken-save.signed. The WebView's own data is never included. iOS backs the save up with the app's Documents either way.
Devices. The manifest requires Vulkan 1.1 (<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true"/>, where 0x401000 is Vulkan 1.1.0). WebGPU on Android runs on Vulkan 1.1 or newer, so a phone without it can never run the game, and Google Play no longer offers it the app. Vulkan 1.1 is needed, not enough: a phone that has it can still lack WebGPU, with an Android System WebView too old to have it on, or a GPU driver Chrome keeps WebGPU off for. Those phones still install the app and see the "can't run here" screen. Test on real devices, and exclude models that fail in the Play Console device catalog.
iPad: every orientation
The app is universal. UISupportedInterfaceOrientations~ipad always lists all four orientations, whatever the orientation in App Settings (which still sets the iPhone's): App Store upload validation refuses an iPad app that takes part in multitasking without all four (ITMS-90474), and iPadOS 26 and later resize every app's window anyway (Apple's TN3192, which retires UIRequiresFullScreen). So on an iPad the game gets landscape-shaped windows as well as portrait ones, and Split View and Stage Manager sizes in between. Lay the game out for any window shape (next section).
Laying the game out for every screen
Phones and tablets come in every shape, and an iPad window can be any of them. You set this up in the editor rather than in a script that reads api.input.viewWidth and viewHeight every frame:
- Layout variants. Give the HUD, each menu and each card one UI screen per shape (phone portrait, phone landscape, tablet), all with the same name and different window-size limits. Exactly one variant shows at any window size, switching by itself as the window changes, and the
api.uiverbs reach whichever one shows. Cover every aspect: an iPad in Split View is neither a phone nor a tablet shape. - Design size. Give each variant the window size it was laid out for (Design width and Design height), and its UI scales with the window: a layout made for an 844 x 390 phone fills a 1440 x 900 window in proportion and shrinks to fit a 667 x 375 one.
- Safe area. Edge-anchored UI keeps clear of the notch, the Dynamic Island and the home indicator by itself. The safe area comes from the shell (
#awaken-safe-area=andwindow.__awakenSetSafeArea), so it is right in the app; the editor has none, so test notches on a device. - The board. A 2D camera with Fit
containkeeps a playfield whole and clear of the safe area and of the edges the HUD reserves (the screens' Inset fields). With Turnportrait, a landscape board fills the width of an upright phone. Pair that with orientationanyorportraitin App Settings: an iPhone app locked tolandscapenever gets a portrait window.
Scripts that place things themselves still have api.input.viewWidth, viewHeight, api.input.safeArea and api.ui.rect(name), which says where an element is drawn after all of the above.
What the App Store reads
ITSAppUsesNonExemptEncryptionisfalseinInfo.plist, so App Store Connect does not ask about encryption on every upload. The save file's HMAC signature is exempt.- No permission purpose strings: the template had an empty
NSLocationWhenInUseUsageDescription, which is gone. PrivacyInfo.xcprivacydeclares the required-reason APIs React Native's core uses: file timestamps (C617.1), the app's own user defaults (CA92.1) and time since boot (35F9.1). ThePodfilepasses:privacy_file_aggregation_enabled => falsetouse_react_native!; with aggregation on, everypod installmerged each pod's reasons into the app's file, which is how it came to declare react-native-fs's0A2A.1,85F4.1andE174.1. Each pod still ships its own manifest in its own bundle. Apod installon the Crownquest copy left the file as written.
Coming back from the background
The page in the WebView can go away while the app runs: iOS ends a background app's web content process, Android its render process, and the engine asks to start again when its GPU device is lost. The shell starts it again behind the launch cover, from the save file. Three rules keep that from costing the player:
- It waits for the app to be active. A page that goes away in the background is started again when the app is next in the foreground, not in the background, where it would load unseen with no GPU to start. On the iPad simulator a page killed in the background stayed gone until the app came back, and loaded within a second of it.
- The heartbeat asks twice. Back in the foreground the shell asks the page whether it is there, and asks again 1.5 seconds later; only after 3 seconds of silence does it start the page again.
- A restart skips the Awaken splash's minimum. The restarted page's address carries
awaken-restartin its fragment, and the loading screen fades at the first frame instead of holding the Awaken splash for 3 seconds the player already saw at launch.
Building
Node 22.11 or newer.
The exporter cannot set the executable bit on android/gradlew, because the File System Access API it writes through has no call for that. Set it once by hand:
sh
chmod +x android/gradlew
npm install
cd ios && pod install && cd ..
npx react-native run-android
npx react-native run-ios --deviceDevice support, stated plainly
- iOS needs a Mac with Xcode, and iOS 26 or newer on the device. The game needs WebGPU, which
WKWebViewhas on by default from iOS 26, so the app's minimum iOS is 26.0 (IPHONEOS_DEPLOYMENT_TARGETon the app target, andplatform :iosin the Podfile), and the App Store offers it only to iOS 26 and later. To run it in the Simulator you need an iOS 26 or later simulator runtime. The app builds against the iOS 27 SDK and launches on the iOS 27.0 simulator, where the WebGPU API is present but no GPU adapter is available, so the game reports "No suitable GPU adapter found." WebGPU inWKWebViewhas not been confirmed on a physical device. - Android needs Android Studio and Android 12 or newer (
minSdkVersion) with Vulkan 1.1 (see Devices). That is the install floor, not a rendering guarantee. Android WebGPU support is unverified: Chromium's WebGPU rollout for Android has excluded Android System WebView, so the game may not render on Android even on a recent OS and WebView version. Where it does not, the app shows the "can't run here" screen with a way to update Android System WebView.
Treat mobile as the least-proven target of the three. Export to game.html on a desktop browser is the path with the most mileage on it.
See also
- Export to game.html - the single-file export the mobile project wraps
- Export to a folder - the multi-file web export
- The Player - what actually runs inside the WebView
- App Settings - name, id, version, icon, launch screen and the two keys
- In-app purchases - products, the stores, and testing purchases
- Outbox & App Events - sending to the game's own server, kept in the app until sent
- Projects & Saving - where the app's name comes from
- In-Game UI - UI screens, layout variants and design sizes for every screen shape
- Camera - Fit contain and the portrait turn