Android

By the end of this guide your Lua project runs on an Android phone as an app you can install from the launcher.

Android is the easier of the two mobile platforms. Nothing has to be approved by anyone: the packer makes a signing key for you on first use, and a device in developer mode installs what you hand it. You need the Android SDK command-line tools and a phone or an emulator.

How an Android build is split in two

We cross-compile the engine and ship it inside a ready APK. Your project goes in beside it, and the result is signed and installed:

TEXT
we build      →  natiny.apk        the engine and Lua, already inside an APK
you pack      →  my_game.apk       the same engine, running your scripts

The step in the middle is android/pack.py, which ships in the bundle. It never compiles anything, so repacking after an edit takes a second.

Install the tools

You need the Android SDK build-tools, one platform, and a JDK 17 or newer for the signer. If you have Android Studio, you have all three. Otherwise install the command-line tools and run:

Shell
sdkmanager "build-tools;35.0.0" "platforms;android-35"
sdkmanager --licenses
export ANDROID_HOME="$HOME/Android/Sdk"

Set ANDROID_HOME to the real SDK directory - the path above is the usual one on Linux, and macOS puts it in ~/Library/Android/sdk. The packer also takes --sdk DIR if you would rather not export anything.

Then connect a phone with USB debugging turned on, or start an emulator, and check that it is visible:

Shell
adb devices

One device listed as device means you are ready. unauthorized means the phone is waiting for you to accept the debugging prompt on its screen.

Unzip the bundle and run the example

Download the Lua wrapper bundle - android-arm64 for a physical phone, android-amd64 for an x86_64 emulator:

TEXT
natiny-android_arm64-apk/
├── natiny.apk              the engine and Lua, ready to install
├── android/
│   ├── pack.py             puts your project into an APK
│   ├── AndroidManifest.xml.in
│   └── res/                the launcher icon
└── data/
    └── main.lua            the script inside natiny.apk, to read and to edit

Install it before touching anything else:

Shell
adb install -r natiny.apk

Open natiny from the launcher. The Natiny logo appears on a dark background. That is data/main.lua running, and it tells you the device, the architecture and the driver are all fine before any of your own code is involved.

If the install itself fails, the phone usually needs Install unknown apps allowed for whatever is handing the APK over - this one is signed with a debug key, not by Google Play.

Put your own scripts in an APK

Make a project folder with main.lua at its root:

TEXT
my_game/
└── main.lua
Shell
python android/pack.py --project ./my_game --apk natiny.apk \
    --package com.example.my_game --name "My Game"

adb install -r my_game.apk

--apk natiny.apk is where the engine comes from: the packer reuses the downloaded APK, so nothing is compiled. --package is the application id, and two apps cannot share one - give your own before you hand the APK to anybody.

Open My Game. It runs your main.lua. When it closes during startup instead, the reason is in the log:

Shell
adb logcat -s natiny

Why not just edit natiny.apk? Signature schemes v2 and v3 sign the archive itself, not its entries. Replacing a file inside a signed APK invalidates the signature and the device refuses to install it. Repacking is the only way, and that is what pack.py does.

Add assets and keep your paths

The whole --project folder becomes data/ inside the app:

TEXT
my_game/
├── main.lua
├── player.lua
└── fonts/
    └── roboto.ttf
Lua
local font_data = natiny.resource.load("data/fonts/roboto.ttf")

That is the same string a desktop build uses, so a project moves between platforms without touching its paths.

Assets inside an APK are read-only. Files written at run time go to the app's private directory instead, and the resource API looks there when loading, so a save and the load after it agree about where the file went:

Lua
natiny.resource.save(save_resource, "save.json")
local loaded = natiny.resource.load("save.json")

Set the name, the icon and the orientation

Shell
python android/pack.py --project ./my_game --apk natiny.apk \
    --package com.example.my_game --name "My Game" \
    --icon icon.png --orientation sensorLandscape \
    --version-name 1.2.0 --version-code 7
  • --icon takes a square PNG, 192×192 or larger.
  • --orientation is what the manifest asks for: portrait, landscape, sensorLandscape, locked and a few more.
  • --version-name is what users see. --version-code is the integer a device compares when deciding whether an APK is an update, and it has to increase with every release you publish.

python android/pack.py --help lists the rest.

Sign it with your own key before you publish

Without --keystore, the packer creates a debug key at ~/.natiny/debug.keystore on first use and reuses it afterwards. That is what keeps adb install -r working between builds - a key made fresh each time would make every build look like a different app.

A debug key is fine for testing and useless for distribution. For a release, sign with a key you keep:

Shell
python android/pack.py --project ./my_game --apk natiny.apk \
    --package com.example.my_game --name "My Game" \
    --keystore release.jks --key-alias mygame --key-pass "$KEYSTORE_PASSWORD"

Keep that keystore safe and backed up. Google Play ties an application id to the key that first signed it, and losing the key means you cannot publish an update to that app again.

Typical problems

  • cannot find the Android SDK - ANDROID_HOME does not point at the SDK directory. Set it, or pass --sdk.
  • A note about no main.lua at the root - --project points at the wrong folder. The packer says it and carries on, and the app then starts with nothing to run.
  • INSTALL_FAILED_UPDATE_INCOMPATIBLE - an app with the same application id is installed and was signed with a different key. Uninstall it, which also removes its saved data, or sign with the original key.
  • INSTALL_FAILED_NO_MATCHING_ABIS - the bundle's architecture does not match the device. Use android-arm64 for phones and android-amd64 for an x86_64 emulator.
  • The app installs and closes immediately - read adb logcat -s natiny.
  • A device older than Android 7.0 cannot run it at all. Natiny draws with Vulkan, and API 24 is the release where a device is required to support it.

What you have now

Your scripts run on Android, and the loop is short: edit, repack, adb install -r. The engine is never rebuilt, because it comes out of the APK you downloaded.

A touch arrives as the left mouse button at the finger's position, so a project written for a desktop works on a phone as it stands. For more than one finger, natiny.input reports every touch.