Skip to content

Android

The normal Android workflow starts with a standalone game created by the packaged Snowpulse SDK. From the extracted SDK directory, create the game first:

./snowpulse new \
  --name MyGame \
  --id com.example.mygame \
  --dir /path/to/projects

That command generates /path/to/projects/MyGame/android/ along with the game's CMake project, source, assets, and platform metadata. This guide assumes that generated project layout. If you have not created a game yet, start with Getting Started.

For GameAnalytics/ByteBrew selection, generated-project dependencies, R8 rules, and the optional redistribution-approved ByteBrew AAR path, see Analytics.

Snowpulse Android games use a native C++17 shared library hosted by AndroidX GameActivity. The shipping renderer is OpenGL ES 3 through EGL; Android Frame Pacing presents frames through Swappy when vsync is enabled. Games target touch-first phones and tablets on Android 9/API 28 or newer.

Vulkan, controllers/Android TV, Chromebook certification, soft-keyboard game text input, Play Asset Delivery, Play Games cloud saves, billing, ads, and Android video recording are not part of the v1 backend. The presentation and asset-provider boundaries are designed so Vulkan and Play Asset Delivery can be added later without changing game-facing APIs.

Pinned Toolchain

Android upgrades are validated as a complete set. Do not update one item in isolation in a release branch.

Component Version
JDK 17
Gradle wrapper 9.5.0
Android Gradle Plugin 9.3.0
compileSdk / targetSdk 36
minSdk 28
Build Tools 36.0.0
Android NDK 29.0.14206865 (r29)
CMake 3.22.1
GameActivity 4.4.2
Android Frame Pacing 2.1.3
Oboe 1.10.0
Media3 1.10.1
OkHttp 5.4.0

The pins follow the official AGP 9.3 compatibility matrix, NDK revision history, AndroidX Games releases, Oboe 1.10.0 release, and Media3 releases. API 36 also meets the Google Play target requirement taking effect on August 31, 2026; see Target API level requirements.

Developer Installation

Install the current stable Android Studio or Android command-line tools, then use SDK Manager to install:

  • SDK Platform 36 and Build Tools 36.0.0
  • Platform Tools (adb)
  • NDK 29.0.14206865
  • CMake 3.22.1, Ninja, and LLDB
  • Android Emulator
  • API 28 and API 36 emulator images

Install JDK 17 separately if Android Studio does not provide the required JDK. Set JAVA_HOME to that JDK and ANDROID_SDK_ROOT to the SDK. Accept SDK licenses with sdkmanager --licenses. Install bundletool for local AAB validation and split-APK testing.

Standalone SDK and game projects

From the extracted SDK directory, diagnose the environment without changing it. Keep using this SDK launcher when maintaining the generated game; there is no supported ./snowpulse launcher in the client project's root directory.

./snowpulse doctor android

Engine contributors

The repository tool is a separate maintainer alternative. Use it only from a full Snowpulse engine source checkout when working on engine-tree examples:

./snowpulse-engine doctor android

The doctor reports a non-17 active Java, missing or mismatched SDK packages, unaccepted licenses, missing emulator images, and missing bundletool. A system Java 25 or CMake 4.x does not replace the pinned JDK 17/CMake 3.22.1 pair used by Gradle.

Generated Project

snowpulse new creates a complete android/ project beside the CMake game:

project/
  .snowpulse/              # managed-file hashes and project metadata
  android/
    app/
      build.gradle.kts
      proguard-rules.pro
      src/main/AndroidManifest.xml
      src/main/res/
    gradle/libs.versions.toml
    gradle/verification-metadata.xml
    gradle/wrapper/
    build.gradle.kts
    settings.gradle.kts
    gradlew
    keystore.properties.example
    verify-artifact.sh
  assets/
  src/
  CMakeLists.txt

The app module owns the application ID, display name, version code/name, icons, orientation, assets, ABI filters, shrinking, and signing. Its externalNativeBuild points at the game root and requests only the game's shared-library target. :snowpulseRuntime is an engine-owned Gradle library containing the reusable GameActivity, OkHttp, and Media3 bridge.

For a standalone project created by an older SDK, run the current SDK's updater from the SDK directory:

./snowpulse update --dir /path/to/project --dry-run
./snowpulse update --dir /path/to/project

No selector means --all, so a missing android/ project and a missing assets/asset-packs.json are added while the SDK and unchanged generated infrastructure are refreshed. Use --android, --asset-packs, or --sdk-only for a targeted update. The updater never replaces gameplay source or existing assets; it only migrates recognized generated AppConfig and stock portal setup to target-config defaults. Custom code is preserved with a warning. Customized generated files remain in place, with current candidates staged under .snowpulse/pending/; deliberate replacement requires --force-managed. For an already generated game with a vendored engine, use the default update or --sdk-only to receive runtime fixes such as system volume-key handling, then rebuild and reinstall the APK. --android alone refreshes scaffolding but does not replace the vendored engine source.

Open <project>/android in Android Studio. Do not open the repository root as an Android project. The default orientation is unspecified; change android:screenOrientation in the app manifest to portrait, landscape, or another Android-supported value without changing native code.

App Entry and CMake

Define the application once:

std::unique_ptr<snowpulse::App> createApplication() {
    return std::make_unique<Application>();
}

SNOWPULSE_DEFINE_APP(createApplication)

Create its target with:

snowpulse_add_game(application
    SOURCES ${APPLICATION_SOURCES}
    DISPLAY_NAME "Application"
    BUNDLE_ID "com.example.application"
    ASSETS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/assets"
    ASSIMP ON
    SPINE_VERSION "4.3"
    WEB_MODE GAME
    WEB_PLATFORM SNOWBLINK_PLAIN
)

snowpulse_add_game creates a shared library on Android, an app-bundle executable on iOS, and a normal executable elsewhere. It also selects the engine variant and owns web/iOS packaging; generated projects use this common declaration.

Android configuration defines SNOW_PLATFORM_ANDROID and excludes GLFW, GLAD, desktop tools/tests, desktop libcurl, and unrelated example targets. Engine-tree examples created or adopted with snowpulse-engine own an android/ project and one unconditional root CMake registration; see Engine-Tree Example Projects.

Build and Deploy

Run from the generated android/ directory:

./gradlew :app:assembleDebug
./gradlew :app:installDebug
./gradlew :app:connectedDebugAndroidTest

Debug builds contain arm64-v8a and x86_64, native debug symbols, and the normal Android debug signature. Use adb logcat for native and Java logs:

adb logcat -s Snowpulse GameActivity libc DEBUG

Release builds require explicit upload-key credentials:

export SNOWPULSE_ANDROID_KEYSTORE_FILE=/secure/path/upload.jks
export SNOWPULSE_ANDROID_KEYSTORE_PASSWORD='...'
export SNOWPULSE_ANDROID_KEY_ALIAS='upload'
export SNOWPULSE_ANDROID_KEY_PASSWORD='...'

./gradlew :app:assembleRelease
./gradlew :app:bundleRelease

The release APK and AAB are under app/build/outputs/apk/release/ and app/build/outputs/bundle/release/. Release contains only arm64-v8a and uses -O3, ThinLTO, hidden visibility, section garbage collection, R8, resource shrinking, and stripped shipped libraries. Full native debug symbols and the R8 mapping.txt remain separate build outputs for Play Console crash symbolication.

Release tasks fail if credentials are absent; they never fall back to the debug key. Instead of environment variables, copy keystore.properties.example to the ignored keystore.properties and fill the same four names. Never commit a keystore or real secrets.

Use Play App Signing with a separately protected upload key. An AAB is a Play publishing artifact, not an installable application; see Android App Bundles.

AAB and Release Verification

Validate and locally install an AAB:

bundletool validate --bundle app/build/outputs/bundle/release/app-release.aab
bundletool build-apks \
  --bundle app/build/outputs/bundle/release/app-release.aab \
  --output app-release.apks \
  --ks "$SNOWPULSE_ANDROID_KEYSTORE_FILE" \
  --ks-key-alias "$SNOWPULSE_ANDROID_KEY_ALIAS"
bundletool install-apks --apks app-release.apks

Verify the universal APK or signed release APK directly:

apksigner verify --verbose --print-certs app/build/outputs/apk/release/app-release.apk
./verify-artifact.sh app/build/outputs/apk/release/app-release.apk --release
./verify-artifact.sh app/build/outputs/bundle/release/app-release.aab --release

The helper checks signatures, APK 16 KB ZIP alignment, each native ELF LOAD segment, and the arm64-only release policy. NDK r29 emits 16 KB-aligned shared libraries by default; the check prevents an incompatible prebuilt library from slipping into the package. See Android's 16 KB page-size guide.

Runtime Architecture

SnowpulseGameActivity extends GameActivity. The native glue owns the app factory, lifecycle/input poll loop, EGL context and window surface, and render loop. It handles window replacement, pause/resume, configuration and density changes, low-memory notifications, process/activity recreation, and destruction. Java network/video callbacks use token registries or atomic handoffs, so callbacks cannot call deleted C++ requests or application objects.

The EGL context is retained while only the window surface is unavailable. An EGL_CONTEXT_LOST event recreates EGL and restores registered shaders, asset-backed and dynamic textures, ImGui device objects, and Effekseer device objects. Renderer command execution remains separate from EGL/Swappy presentation, preserving the seam for a future Vulkan presenter.

The surface requests RGBA8, depth 24, stencil 8, and GLES 3. Vsync uses SwappyGL_swap; disabling vsync uses direct eglSwapBuffers.

Subscribe to Android lifecycle events through the normal event bus:

auto subscription = context()->events()->on<snowpulse::AppLifecycleEvent>(
    [](const snowpulse::AppLifecycleEvent& event) {
        // Suspended, Resumed, or LowMemory
    });

Override App::onBackRequested() and return true to remain in game. Return false to let Android finish the Activity through system/predictive Back.

Touch, Safe Areas, and Resizing

GameActivity pointer IDs remain stable for each touch. Input::touches() exposes all active pointers; the first active touch is mirrored to MouseLeft and mousePosition() for existing scene/UI code. Rotation, density changes, surface resize, Escape/Back, and standard hardware keys are handled. Volume Up, Volume Down, Volume Mute, and the camera button remain system-owned and are not exposed through Input; Android displays its normal volume UI and adjusts the media stream. Controllers, Android TV, and Chromebook-specific mappings are deferred.

The Activity suggests AudioManager.STREAM_MUSIC while it is in the foreground. Calls, accessibility services, and other higher-priority system audio states may still override the selected stream normally. Audio focus is separate: focus loss temporarily mutes/suspends engine output without changing the user's device volume.

Rendering is edge-to-edge and immersive. Insets are reported in logical game coordinates:

const auto insets = context()->safeAreaInsets();

Games may apply these values to UIRoot padding; world rendering is not automatically inset. The Activity restores immersive mode after transient system bars or focus changes and accepts tablet/multi-window surface changes.

Assets

All normal assets stay in the base app module and are read directly from the APK/AAB with AAssetManager; Snowpulse does not perform first-run extraction. Logical paths are UTF-8, case-sensitive, relative to assets/, and reject absolute paths, backslashes, drive prefixes, and .. segments.

IAssetStream provides read, seek, tell, and size; IAssetSource provides exists, open, readAll, and directory enumeration. AppContext::assetSource() exposes the active provider. Texture, font, JSON, atlas, particle, audio, Spine, Effekseer, Assimp, and Tiled paths use this boundary. The *Rooted methods remain filesystem-only compatibility APIs and should not be used for packaged Android assets.

Keep Tiled .tmj, .tsj, .tj, and referenced images in their authored relative directory layout. References are resolved relative to the declaring JSON file, remain case-sensitive, and load through AAssetManager; do not turn them into /assets/... filesystem paths. Native arrays and base64 tile data using zlib, gzip, or zstd are decoded inside the engine and do not need Android Java or Maven dependencies.

Base assets report Ready through the existing asset-pack API. Play Asset Delivery can later supply another IAssetSource, but is not implemented.

The Gradle app prevents compression for seek-sensitive audio, MP4, FBX, Spine binary, and Effekseer formats. Add a custom extension to noCompress if an application introduces another long-form seekable format.

Effekseer Effects

Android builds include the vendored Effekseer runtime and its OpenGL ES 3 renderer; no separate Java or Maven effect dependency is required. Effect assets use the same AssetManager-backed logical paths as other packaged assets, and the runtime restores its GLES device objects after EGL_CONTEXT_LOST.

#if SNOWPULSE_HAS_EFFEKSEER
// Include <runtime/components/effekseer_renderer.h> explicitly.
auto* effect = node->addComponent<snowpulse::EffekseerRenderer>();
effect->sortOrder = 10;

if (effect->load("effects/effect.efkefc")) {
    const auto handle = effect->playPrimary();
    if (handle != snowpulse::EffekseerRenderer::kInvalidHandle) {
        effect->setSpeed(handle, 0.9f);
    }
}
#endif

Keep referenced textures, models, and materials under assets/ using the relative layout expected by the authored effect. Effekseer sound modules and distortion/background capture are disabled. See Particles and Trails for handle controls and cross-platform usage.

Platform Services

  • Audio: statically linked vendored OpenAL Soft with the Maven/Prefab Oboe backend. It supports short buffers, seekable streamed music, focus and lifecycle muting; Oboe handles device/Bluetooth/headset route reopening.
  • HTTPS: asynchronous OkHttp over JNI, with verbs, query parameters, headers, redirects, per-call timeout, cancellation, response status/body/headers, and callbacks dispatched from the game thread. Android's trust store is used and cleartext HTTP is rejected by the manifest/network policy.
  • Saves: atomic slot writes below app-private filesDir; no storage permission is required. Saves survive updates and are included in Android backup rules; cache directories are excluded.
  • MP4: Media3 ExoPlayer renders asset:///... in a control-free overlay above GLES and follows Activity lifecycle cleanup.
  • ImGui: Snowpulse input plus imgui_impl_opengl3 GLES3.
  • Spine, Assimp, Box2D, and Effekseer: vendored native sources; Effekseer uses its GLES3 renderer.
  • MIDI, portal integrations, and cloud saves: existing null implementations.

Dependency Policy

Engine-owned C/C++ libraries remain source-vendored and compile as static PIC libraries: OpenAL Soft, Assimp, Box2D, Spine, Effekseer, stb, GLM, JSON, zlib, zstd, and the audio decoders. They link into the game .so with hidden visibility, section garbage collection, and ThinLTO. GameActivity and Swappy use their static Prefab variants. The official Oboe 1.10.0 Prefab release is a shared library and all three official native packages declare c++_shared, so the APK also contains liboboe.so and one matching libc++_shared.so; Snowpulse never mixes static and shared libc++ runtimes.

Do not vendor the Android SDK, NDK, JDK, CMake, Android Studio, Gradle distribution, general third-party AARs, or Maven caches. The explicitly redistribution-approved ByteBrew AAR is the documented exception. GameActivity, Frame Pacing, Oboe, Media3, OkHttp, and GameAnalytics are pinned Maven/Prefab dependencies because they track Android platform behavior, codecs, networking security, device compatibility, or provider releases. Generated settings configure google(), Maven Central, and GameAnalytics's pinned release repository (https://maven.gameanalytics.com/release). Dynamic versions are banned, and Gradle dependency-verification checksums are committed. Android does not use vcpkg or libcurl.

Profiling and Test Matrix

Use Android Studio CPU/Memory profilers, System Trace, GPU Inspector, and adb shell dumpsys gfxinfo <application-id> as appropriate. Profile Swappy presentation at 60, 90, and 120 Hz on representative Adreno and Mali devices.

Before a platform release, test API 28 and API 36 emulators, an API 28 physical phone, a current phone, a tablet, and a 16 KB-page device/emulator. Exercise asset loaders, every HTTP verb and failure mode, multi-touch identity, Back, rotation/resizing/insets, saves across process death and signed update, Home/resume, lock/unlock, focus loss, surface/context recreation, and repeated video show/hide. Rebuild representative desktop, web, and iOS targets after changes to common asset, input, audio, or GLES code.