Android & iOS
Android and iOS package your project as a real native app: the engine’s C++ core compiled to arm64, rendering through an embedded Dawn (WebGPU → Metal on iOS, Vulkan on Android), with your game scripts running on an embedded JS engine — V8, the JIT Chrome runs, on Android, and QuickJS-ng on iOS, where Apple allows no JIT. It is not a WebView and not a wrapper around the web build — there is no browser in the process.
The authoring model is identical to every other target. The same scenes, the same components, the same TypeScript. Nothing in a project is mobile-specific.
Two platforms, one runtime
Section titled “Two platforms, one runtime”Android and iOS are offered as two targets, not one “Mobile”, because they
are assembled into entirely different things — a signed .apk here, an Xcode
project there — and only one of them can be finished on any OS. One row could not
tell you whether this machine can finish the job, or what comes out.
They do share one runtime and one export payload. The engine, the SDK and the
game runtime all live in the app binary, so what an export writes is app
content — your scenes, scripts and cooked assets — rather than a runnable
package the way dist-web/ is.
| Android | iOS | |
|---|---|---|
| Graphics | Vulkan (via Dawn) | Metal (via Dawn) |
| Export output | dist-android/ |
dist-ios/ |
| Assembled by | the editor (no Android SDK) | the editor, then Xcode |
| Assembly host OS | any desktop OS | macOS only |
| Result | a signed .apk, or an Android Studio project |
an Xcode project you open, sign and run |
Android offers both because it can: the editor assembles a package outright, and it can also write the Gradle project that package would have been built from. Pick the project under Output in the Android section when the game needs to add an SDK, a permission or an Activity of its own — that is the same shape iOS has always had, where Xcode does the assembling.
What a packaged game runs on
Section titled “What a packaged game runs on”| Android | iOS | |
|---|---|---|
| Minimum OS | Android 10 (API 29) | iOS 17.0 |
| Declared target | API 33 | — |
| CPU | arm64-v8a (every real device); x86_64 (the emulator) only when asked |
arm64 device + simulator slice |
| GPU | Vulkan 1.0.3 required — declared required="true", so a device without it does not see the app on Play |
Metal |
An Android package carries the phone ABI only, which is about half of what it used to
weigh. To try a build in an emulator, tick Include the emulator (x86_64) in the Android
section (or pass --emulator-abi on the command line) and install that one; leave it off
for anything you ship.
Android’s floor is 29 because the engine’s font path calls AFontMatcher_create,
which is API 29 — below that there is nothing to fall back to. It was declared as
26 through v0.37.0, which was a claim rather than a capability: those builds install
on Android 10 and 11 and then fail to start, because the NDK was told to build against
a higher API and turned every guarded symbol into a load-time requirement. Nothing
that ran on v0.37.0 stops running on v0.38.0 — the number now says what was always
true.
Prerequisites
Section titled “Prerequisites”The Package dialog distinguishes two kinds of missing prerequisite, and they are not the same severity:
- A missing engine runtime — for a mobile target, its runtime template — means the package cannot be produced at all.
- A missing native toolchain does not. The export still writes the app’s content; only the final assembly needs the toolchain, and that step can run on another machine — an iOS build always does when the editor is on Windows.
-
Get the runtime for your target. Install the runtime template: the engine, prebuilt for arm64 and published with each release. The target’s row offers Download — or Install from file… if you already have the archive (offline, a mirror, a company share). It is matched exactly against the editor’s version, because the SDK is compiled into the app binary, so it is one download per editor upgrade — and none of the engine’s own build dependencies come with it.
Android needs nothing else: the APK is compiled, aligned and signed by the editor, on whatever OS it is running on.
-
Install the platform toolchain — iOS only.
- Android — none. There is no Android SDK, NDK or JDK in this path.
- iOS — macOS with Xcode, and nothing else: no CMake, no Ninja, no xcodegen, no Dawn checkout. Apple ships no toolchain for other operating systems, so the dialog says so rather than pretending otherwise — but the export still writes a complete project you can carry to a Mac.
-
Check the dialog. The target’s row carries a warning triangle until the probe passes, and the panel names what is missing.
Exporting
Section titled “Exporting”- Build → Package Project…, then pick Android or iOS under MOBILE.
- Choose a build config and an output folder (
dist-android/dist-iosby default). Source maps are not offered — there is no browser devtools to consume them. - Check Scenes in build as you would for any target.
- Package. The export cooks assets, bundles scripts, and writes the app’s
content plus an
app.config.jsoncarrying the app’s identity.
That is the whole export. With the runtime template installed, the output folder holds the finished app:
-
Android — a signed
.apk, ready foradb install -ror a copy to the device. It is signed with a development key the editor generates on first use (the same standing as Android Studio’s debug key: fine for a device, refused by every store). To sign with your own, pass--key/--certtonode build-tools/cli.js native --package --content dist-android.For Google Play, tick Google Play App Bundle (.aab) in the Android section: the export writes one beside the APK. Play has required a bundle rather than an APK for new apps since 2021 — it is the upload format, and cannot be installed on a device, which is why the APK is still what you test with.
-
Android, with Output set to Android Studio project — an ordinary Gradle project instead of a package: your content under
app/src/main/assets, the engine underapp/src/main/jniLibs, the host’s Java shim as source, and anapp/build.gradle.ktscarrying the app’s identity. Open the folder in Android Studio and Run; add your SDKs tobuild.gradle.ktsas you would in any Android app. Exporting again rewrites the game and leaves the build script alone, so a project that has grown an SDK survives its game being rebuilt. -
iOS — an Xcode project: the
.xcframework, the app shell and a generated.pbxproj, written around your content. Open it, pick your signing team under Signing & Capabilities, and Run.

The Android target. Until the runtime template is installed, the dialog says so and offers Download or Install from file.
App identity
Section titled “App identity”A native app has OS-level properties the engine never reads — the runtime cannot rotate a phone, and a store keeps a bundle id forever — so they are declared for whoever assembles the app, in Settings → Project → Build and modules → Packaging:
| Setting | What it becomes |
|---|---|
| App Icon | The launcher icon on both platforms — one project-relative square PNG (1024×1024 is ideal). Android takes it as the launcher mipmap, iOS as the asset catalog Xcode derives every size from, so nothing is resized and one image is all you keep. Left empty, the app ships Estella’s mark rather than the platform’s placeholder. |
| Application ID | The Android manifest package and the iOS bundle id (reverse-DNS). Left empty it is derived from the project name; a published app should set its own. |
| Android Version Code | The integer Google Play orders builds by — it must increase with every upload. The version users see comes from the project version. |
| Orientation | Settings → Project → Game → Display → Orientation, one project-wide setting every target honors. Left unset it follows the design resolution’s aspect. |
The export writes these into app.config.json beside the content — deliberately
not in game.config.json, which is what the runtime reads.
What runs on a device
Section titled “What runs on a device”Everything. The mobile targets compile the whole engine source list, and the
three subsystems that ship as separate WebAssembly side modules on the web
(Box2D, the Spine runtime, the MPEG-1 video decoder) are compiled into the app
binary instead — a device has no dynamic-linking story and no reason for one.
app.sideModules still answers for them, so the runtime’s own feature gating
behaves exactly as it does in a browser.
| Subsystem | On device |
|---|---|
| Sprites, tilemaps, particles, post-processing | Native (Dawn) |
| Text, bitmap text, rich text | Same atlas + layout as the web; glyphs rasterized from the OS font |
| Physics (Box2D) | Compiled in; solved across worker threads |
| Spine | Compiled in — every version, each skeleton read by its own; see Spine versions |
| DragonBones | Compiled in — one runtime, since the format is frozen |
| Video | Compiled in (cooked at build, decoded on device) |
| Audio | Native mixer (miniaudio) — CoreAudio / AAudio |
| Text input | The platform’s soft keyboard is the field’s editing surface |
| Networking | The OS network stack (NSURLSession / HttpURLConnection) |
| Terrain and streamed worlds | Run on the native runtime. A terrain’s layer textures go to the device as bytes, as every other texture does (a device has no wasm heap to copy them through), and a chunked world’s maker uses a cancellation handle of its own, because the native runtime has no AbortController |
| Compressed textures | KTX2 transcoded to the device’s format (ASTC → ETC2 → BC), with mip chains |
| Lifecycle | onShow / onHide and OS memory warnings reach the app |
Spine versions
Section titled “Spine versions”A native app reads every Spine version the editor does: 2.1, 3.8, 4.1, 4.2 and 4.3. As in a browser build, each skeleton is read by the runtime of the version it was exported from, so a project can mix versions. The Spine version in Project Settings is only a preference.
The runtimes define the same C names, so the host compiles each one under its own prefix and links them side by side. On Android each version is a separate library (0.2 to 1 MB per ABI). The export reads the version of every skeleton it packages and includes only the libraries those skeletons need. On iOS and the desktop all versions are linked into the app, which adds about 1 MB.
Which runtimes a package carries is decided once for every target: the web, playables, mini-games and Android. Project Settings → Spine has a row per runtime, and each row says what the build will do with it:
- Auto (the default) carries the runtime when a packaged skeleton was exported from it.
- Include carries it even when no packaged skeleton uses it. Use it for skeletons that only code loads, such as a path built at run time or content fetched later.
- Exclude leaves it out. If a packaged skeleton needs that runtime, the export fails and names the skeleton, so it never ships a skeleton that cannot play.
The export result lists every runtime with the reason it was carried or left out.
A runtime template from an older release carries a single Spine version. If you package with one, the export lists every skeleton the template cannot play. Install this editor version’s template from Package Project to fix it.
If a future build ever does drop a subsystem, the export names it — with the scenes that use it — instead of quietly shipping a package missing half a scene.
What only bites on a device
Section titled “What only bites on a device”These are the differences worth knowing before your first device build. Each one shipped broken at least once because the editor serves a project straight off disk and never notices.
Assets only your code names get culled
Section titled “Assets only your code names get culled”A build ships what it can reach from the entry scenes. That is right for anything a scene names — and blind to anything named only in code: a texture in rich-text markup, a clip played by url, a prefab spawned by path. Those get culled, and the first you hear of it is a silent button or a missing image on the phone.
Mark the folder in the Content Browser: right-click → Delivery → Always
include in builds. It writes alwaysInclude into the same
.esengine/asset-groups.json that already decides local / subpackage / remote
delivery:
{ "version": "1.0", "groups": { "markup-images": { "folder": "assets/textures", "mode": "local", "alwaysInclude": true } }}Off by default, deliberately — reachability is what keeps a build from shipping everything. See Assets.
Tune textures for the platform where memory is tightest
Section titled “Tune textures for the platform where memory is tightest”Per-asset Import Settings have a tab per platform, mobile targets included — so a texture can be compressed harder for a phone than for the web without touching the source PNG. See Assets.
How fast your game script runs
Section titled “How fast your game script runs”The engine runs native at full speed everywhere. Your game script does too on
Android: it runs on V8 with its JIT, the engine Chrome runs — the same script
is compiled to machine code as it would be in a browser, with nothing to change in
the project. On a Snapdragon 8 Elite phone, a frame of 5,000 scripted entities that
took 64 ms on the interpreter takes 14 ms on V8 (on a desktop CPU: 76 ms → 8 ms).
V8 is libv8android.so per ABI, with the Unicode data Intl and Unicode regular
expressions (/\p{L}/u) need, so scripts see the same language a browser runs;
TextEncoder / TextDecoder are provided by the host.
The APK deflates its native libraries (Project Settings → Android → Compress native libraries, on by default): an empty project downloads as about 20 MB rather than 54, and Android unpacks the libraries once at install. Turn it off to store them for the system to map in place — half the installed size, nearly three times the download. The App Bundle always stores them; Google Play compresses what it delivers.
On iOS your game script is interpreted, by QuickJS: Apple’s rules allow no JIT outside its own browser. A per-entity loop written in TypeScript is the one thing that costs meaningfully more there than in a browser — prefer the engine’s own systems and bulk APIs over hand-written per-entity iteration in the hot path.
On Android, a system marked @compiled runs as machine code before the JIT has
seen it. The
export cross-compiles every /** @compiled */ system with the Android NDK, once per
64-bit ABI the package carries (arm64-v8a, and x86_64 for the emulator), and the
APK carries each build in lib/<abi>/. Where the interpreter spends microseconds
per entity, the compiled system spends tens of nanoseconds — and it still takes
a large share off what the JIT leaves of a frame (14 ms → 4.2 ms on that phone,
8.3 ms → 1.4 ms on the desktop). The NDK must be
installed on the exporting machine (Android Studio’s SDK Manager, or
ANDROID_NDK_HOME); the Package dialog says so when a project promises compiled
systems and it is missing. A system the host cannot bind falls back to the
interpreter for that frame, so a missing module costs speed, never correctness. iOS
still runs every system interpreted.
The first launch after an install
Section titled “The first launch after an install”On Android, V8 compiles the SDK bundle at launch — a fraction of a second — and keeps the result in the app’s cache, so later launches skip even that.
On iOS, parsing the SDK bundle on the interpreter takes ~14 s, and a compile cache does not exist until one launch has paid for it — which would be the launch right after an install. So the bytecode is built with the app and rides along in its assets: fresh-install-to-first-frame is ~0.1 s rather than ~10 s.
A build machine without a C compiler cannot produce it, and the app then compiles
on first run instead: it still works, but that one launch spends ten seconds on a
screen that a player reads as a hang. The boot record says which happened —
SDK bundle: loaded from the cache shipped with the app, or bundle: no compile cache — so a slow first launch is a question with an answer rather than a mystery.
The record’s first lines also name the engine (engine: V8 … or engine: QuickJS-ng …).
When something goes wrong on a device
Section titled “When something goes wrong on a device”Every launch writes a record next to the game, so a failure that only happens on someone else’s phone leaves something behind:
Android Android/data/<your.package.id>/files/estella-boot.logiOS the app's Documents directoryThe run before it is kept as estella-boot.prev.log — an app that dies and is
opened again would otherwise replace the record of the death with a record of the
retry.
estella boot record — 2026-07-29 19:07:38device: 24129PN74C, Android 16, arm64-v8aphase: gpu devicegpu: qualcomm Adreno (TM) 830 (adreno-8xx) — Adreno Vulkan Driver 512.800.71phase: surfacephase: engine contextphase: js runtime SDK bundle: loaded from bytecode shipped with the appphase: game scriptready in 105 msThe phases are the useful part: the last one in the file is where a launch
that never finished stopped. A launch that finished says ready in. A native
crash appends the signal, the phase it happened in, and the return addresses:
FATAL SIGSEGV (bad memory access) during phase: js runtimebacktrace (symbolize against this version's unstripped libestella_js_host.so): 0x7b41e2632c /data/app/.../libestella_js_host.so+0x1e32cThose addresses resolve against the unstripped libestella_js_host.so for
that engine version (llvm-addr2line -e libestella_js_host.so 0x1e32c); the copy
inside the APK is stripped.
To read it off an Android device:
adb shell cat /sdcard/Android/data/<your.package.id>/files/estella-boot.logadb pull /sdcard/Android/data/<your.package.id>/files/estella-boot.logWhen a game crashes, the next launch publishes the record where a player can
get at it — Android/media/<your.package.id>/, which a file manager lists,
falling back to Download/. The file is named estella-crash-<date>.log, and
the new run’s own record says where it went:
previous run CRASHED; its record was copied to /storage/emulated/0/Android/media/com.example.game/estella-crash-20260729-190738.logSo the instruction to give someone who reports a crash is: open the game once
more, then send the newest estella-crash-*.log from that folder. Only crashes
are published — a launch that finished leaves nothing there.
While a device is attached, the same lines are in logcat:
adb logcat --pid=$(adb shell pidof <your.package.id>)See also
Section titled “See also”- Building & Exporting — every target, the cook options, and the Package dialog.
- Assets — reachability culling, delivery groups, and import settings.
- Screen & Design Resolution — designing for a phone’s aspect.
native/README.md— the host’s architecture and the full build recipe.