This document explains how to build, install, and run Planet Blupi as an Android APK using SDL3, the Android NDK, and CMake.
| Tool | Recommended version |
|---|---|
| Android Studio | Ladybug (2024.2) or newer |
| Android SDK | API level 35 |
| Android NDK | 30.0.14904198 (installed via SDK Manager) |
| CMake (NDK bundle) | 3.21+ (installed via SDK Manager) |
| Java (JDK) | 17 (bundled with Android Studio) |
| Git | any recent version |
- Open Android Studio → Settings → SDK Manager → SDK Tools.
- Check NDK (Side by side) version 30.0.14904198.
- Check CMake (version 3.21 or higher).
- Click Apply and let Android Studio download and install.
git clone <repository-url> planetblupi
cd planetblupi
git submodule update --init --recursiveThe vendored SDL3 / SDL_image / SDL_mixer sources are required. They live under
third_party/.
Game assets must be present in the Android assets directory before building. Run these commands from the project root:
cd android/app/src/main/assets
# Symbolic links (recommended on Linux/macOS — saves disk space)
ln -s ../../../../../data data
ln -s ../../../../../image image
ln -s ../../../../../sound/english sound
ln -s ../../../../../movie movieIf your build system does not follow symbolic links, copy the directories instead:
cp -r ../../../../../data data
cp -r ../../../../../image image
cp -r ../../../../../sound/english sound
cp -r ../../../../../movie movieOther sound locales (deutsch, francais, us) can replace the
soundlink if you want to ship a different language.
Create the file android/local.properties (based on the provided template):
cp android/local.properties.template android/local.properties
# Edit the file and set sdk.dir to your Android SDK path, e.g.:
# sdk.dir=/home/user/Android/Sdkcd android
./gradlew assembleDebugThe first build downloads Gradle (≈ 150 MB) and compiles the NDK libraries, so it may take several minutes. Subsequent incremental builds are much faster.
The produced APK is at:
android/app/build/outputs/apk/debug/app-debug.apk
Enable USB debugging on your device (or start an AVD in Android Studio).
# Install
adb install app/build/outputs/apk/debug/app-debug.apk
# Launch
adb shell am start -n org.openeggbert.planetblupi/.PlanetBlupiActivity
# View logs (filter by the app's tag or by pid)
adb logcat -s SDL PlanetBlupiTo uninstall:
adb uninstall org.openeggbert.planetblupiRun this once to generate a keystore. Keep the keystore and passwords safe — you will need them for every future update.
cd android
keytool -genkeypair -v \
-keystore planet-blupi-release.keystore \
-alias planet-blupi \
-keyalg RSA \
-keysize 2048 \
-validity 10000
⚠️ Do not commit the keystore to version control.⚠️ Do not lose the keystore. If it is lost, you cannot publish future updates signed with the same key — users will have to uninstall and reinstall the app.
Create the file android/key.properties (one directory above app/) with your
real passwords:
storeFile=planet-blupi-release.keystore
storePassword=YOUR_STORE_PASSWORD
keyAlias=planet-blupi
keyPassword=YOUR_KEY_PASSWORD
⚠️ Do not commitkey.propertiesto version control. Bothkey.propertiesand*.keystoreare listed in.gitignore.
cd android
./gradlew clean assembleReleaseRelease APK output:
android/app/build/outputs/apk/release/app-release.apk
If key.properties is not present, the build will stop immediately with a clear
error message rather than a confusing Gradle failure.
apksigner verify --verbose app/build/outputs/apk/release/app-release.apkadb install -r app/build/outputs/apk/release/app-release.apkUsers who install the APK from outside the Play Store may need to enable Install unknown apps in their Android settings (Settings → Apps → Special app access → Install unknown apps).
The current Gradle configuration builds for arm64-v8a only. To add other
ABIs (e.g. x86_64 for the emulator) edit
android/app/build.gradle and extend the abiFilters list:
abiFilters 'arm64-v8a', 'x86_64'Game assets are packaged into the APK as Android assets. At first launch,
FreeApi automatically extracts them to the app's internal storage so that
the game code can access them via the standard C fopen() API (see
FreeApi Android startup below).
| Source directory | Path in APK |
|---|---|
data/ |
data/ |
image/ |
image/ |
sound/english/ |
sound/ |
movie/ |
movie/ |
Save data and configuration are written to the app's private internal storage
via SDL_GetPrefPath("org.openeggbert", "planetblupi"). This storage:
- persists across app restarts,
- is cleared when the app is uninstalled,
- is not accessible to other apps.
The launcher icon is generated from icon.bmp (or any square source image)
into res/mipmap-* PNG files. Android does not use BMP directly for launcher icons.
If the icon changes, regenerate the PNG density variants with ImageMagick:
# Flat icons
convert icon.bmp -resize 48x48 android/app/src/main/res/mipmap-mdpi/ic_launcher.png
convert icon.bmp -resize 72x72 android/app/src/main/res/mipmap-hdpi/ic_launcher.png
convert icon.bmp -resize 96x96 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
convert icon.bmp -resize 144x144 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
convert icon.bmp -resize 192x192 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
# Round icons (circular crop)
for density_size in "mdpi:48" "hdpi:72" "xhdpi:96" "xxhdpi:144" "xxxhdpi:192"; do \
density="${density_size%%:*}"; size="${density_size##*:}"; half=$((size/2)); \
convert icon.bmp -resize ${size}x${size} \
\( +clone -alpha extract -draw "fill white circle ${half},${half} ${half},0" \) \
-alpha off -compose CopyOpacity -composite \
android/app/src/main/res/mipmap-${density}/ic_launcher_round.png; \
donePlanet Blupi uses the FreeDirect / FreeApi abstraction layer to run the original WinAPI/DirectX game code on SDL3 without modification. On Android:
- The game is built as
libmain.so(shared library) instead of an executable. - SDL3 is built as
libSDL3.so(shared library) and loaded bySDLActivity. free-apiandfree-directare linked statically intolibmain.so.- SDL3's Java glue (
SDLActivity) handles lifecycle, input, audio, and surface management automatically. - Game assets are read via
SDL_IOFromFile, which transparently accesses the APKAAssetManageron Android. - Persistent data (saves, config) is written via
SDL_GetPrefPath. - No WinAPI, DirectX SDK, Wine, MonoGame, or .NET is required.
On Android, files bundled in an APK are only accessible through the Android
Asset Manager (or SDL_IOFromFile). Standard C fopen() with a relative path
looks in the process working directory, which is not the APK asset tree.
Because the original game code uses fopen() throughout, FreeApi extracts all
required assets to the app's internal storage on the first launch.
FreeApiAndroidSetup() runs at the start of SDL_main (before WinMain is
called) and performs the following steps:
- Reads
freeapi_android_assets.txtfrom the APK assets root. This manifest lists one top-level asset directory per line (e.g.data,image,sound,movie). - For each listed directory, recursively enumerates APK assets using
SDL_EnumerateDirectory(which falls back to the APK Asset Manager) and reads each file viaSDL_IOFromFile. - Writes every file into
SDL_GetAndroidInternalStoragePath()/<relpath>, creating parent directories as needed. - Writes a sentinel file
.freeapi_extractedso that extraction is skipped on subsequent launches. - Calls
chdir(SDL_GetAndroidInternalStoragePath())so that every laterfopen(relative)call resolves inside internal storage.
android/app/src/main/assets/freeapi_android_assets.txt must list every
top-level directory that the game needs to access via fopen():
data
image
sound
movie
Any FreeApi game targeting Android must ship this manifest in its APK assets root and list all directories required by the game.
The sentinel .freeapi_extracted prevents re-extraction on every launch.
If assets change between app versions, delete the sentinel file or clear the
app data on the device:
adb shell pm clear org.openeggbert.planetblupiMake sure the NDK version in android/app/build.gradle (ndkVersion)
matches what you installed in the SDK Manager.
- Enable Developer Options and USB debugging on your Android device.
- Try
adb devicesto confirm the device is listed.
If logcat shows Finished main function almost immediately after Running main function SDL_main, the game's WinMain is returning early. The most common
cause is that FreeApi's asset extraction failed and fopen("data/config.def")
returned NULL.
Check logcat for free-api: log lines:
adb logcat -s SDLLook for:
free-api: FreeApiAndroidSetup begin— confirms setup ran.free-api: internal storage path = …— confirms path was obtained.free-api: extracting APK assets to …— confirms extraction started.free-api: freeapi_android_assets.txt not found— manifest is missing from the APK assets; addandroid/app/src/main/assets/freeapi_android_assets.txt.free-api: SDL_main exiting ret=0— WinMain returned FALSE; check initialisation errors earlier in the log.
If logcat shows FREEAPI_ANDROID: GetMessage returning FALSE (WM_QUIT) shortly
after GetMessage entered, a quit message was posted before the game loop had a
chance to run. Check which path triggered it:
adb logcat -s SDL | grep FREEAPI_ANDROIDKey tags to look for:
| Log tag | Meaning |
|---|---|
FREEAPI_ANDROID: PostQuitMessage called |
PostQuitMessage() was called — note the nExitCode |
FREEAPI_ANDROID: SDL event SDL_EVENT_QUIT translated to WM_QUIT |
SDL itself sent a quit event |
FREEAPI_ANDROID: SDL event SDL_EVENT_WINDOW_CLOSE_REQUESTED … translated to WM_CLOSE |
SDL requested the window be closed; WM_CLOSE was forwarded to the WndProc |
FREEAPI_ANDROID: DefWindowProcA WM_CLOSE -> DestroyWindow + PostQuitMessage |
WM_CLOSE fell through to DefWindowProcA (game did not handle it) |
FREEAPI_ANDROID: DefWindowProcA WM_DESTROY -> PostQuitMessage |
WM_DESTROY fell through to DefWindowProcA |
What FreeApi does on Android (GetMessage / WaitMessage):
- Uses
SDL_WaitEventTimeout(NULL, 10)instead ofSDL_Delay(1)so the Android event system receives proper CPU time. GetMessagestill returnsFALSEonly onWM_QUIT; no other condition terminates the loop.- Focus-lost events (
SDL_EVENT_WINDOW_FOCUS_LOST) are suppressed and do not generateWM_ACTIVATEAPP(0), preventing premature deactivation.
Check adb logcat for native crash details. Common causes:
- Missing
libmain.so— rebuild with./gradlew assembleDebug. - Asset not found — verify that
data/,image/,sound/, andmovie/exist underandroid/app/src/main/assets/and are correctly linked or copied. git submodule update --init --recursivenot completed.
SDL_mixer must be built with OGG/WAV support. The vendored build enables WAV by default. If you see mixer errors in logcat, check that the sound files are present in the APK using:
aapt dump resources app/build/outputs/apk/debug/app-debug.apk | grep soundThe Android changes in CMakeLists.txt and cmake/ThirdPartySDL.cmake are
fully conditional on if(ANDROID). Desktop builds are unaffected.