A minimal Bevy (Rust) starter game that already ships to Linux, Windows, macOS (Apple Silicon), Android (APK), and the browser (WASM) from a single Linux dev machine. The build methodology was extracted from the Julian2 train game project.
▶ Play the demo in your browser — deployed automatically to GitHub Pages by the release workflow.
The base game is a sphere you drive around a 3D plane:
- WASD / arrow keys — move on the ground (relative to camera)
- Space / Ctrl — fly up / down
- Mouse drag — orbit the camera (one-finger drag on Android)
- Scroll wheel — zoom
The pillars and arena walls are solid: bumping into them nudges the sphere out and plays a knock sound (assets/bounce.wav, pitch-varied per hit) — a tiny demo of collision response plus the audio pipeline working on every platform.
One Rust crate produces every platform:
src/lib.rs -> ALL game code lives here (run_game()).
crate-type = ["staticlib", "cdylib", "rlib"]
#[bevy_main] fn main() is the Android entry point.
src/main.rs -> tiny desktop wrapper: fn main() { game_base::run_game() }
| Platform | Mechanism | Entry artifact |
|---|---|---|
| Linux | native cargo run (x11 feature) |
target/release/game_base |
| Windows | cross-compile from Linux with MinGW (x86_64-pc-windows-gnu) |
target/windows_dist/ (exe + assets, zip and ship) |
| macOS (arm64) | cross-compile from Linux in a podman container: osxcross + Apple SDK → .app bundle → .dmg |
target/macos_dist/ (<Game>.app + <game>-macos-arm64-v<version>.dmg) |
| Android A (primary) | cargo apk builds the cdylib into an APK per ABI; NativeActivity, no Java code at all |
target/{debug,release}/apk/*.apk |
| Android B (alternative) | cargo ndk drops the cdylib into app/src/main/jniLibs/, then Gradle wraps it with a Java GameActivity into one universal APK |
app/build/outputs/apk/debug/app-debug.apk |
| Browser (WASM) | compile the bin to wasm32-unknown-unknown (webgl2 feature), wasm-bindgen generates the JS glue, web/index.html hosts the canvas |
target/web_dist/ (static site — serve anywhere) |
Key load-bearing details (easy to lose, hard to rediscover):
bevy_embedded_assetsembedsassets/into the binary. On Android there is no loose filesystem for Bevy'sAssetServer, so this is what makes assets work in the APK. It must be added beforeDefaultPlugins.cpalwithoboe-shared-stdcxxmakes audio work on Android; it links againstlibc++_shared.so, which the Gradle path copies out of the NDK explicitly (Step 2 ofbuildanddeploy.sh).- Bevy is built with
default-features = false; theandroid-native-activityfeature is what Path A needs,x11is what native Linux needs. If you use Path B (Gradle/GameActivity), switch the bevy featureandroid-native-activity→android-game-activityso it matches the JavaGameActivitywrapper (Path A was the proven/primary path in the source project). - Two APKs are built on purpose in Path A: x86_64 for the desktop emulator, arm64-v8a for real phones. Path B builds one fat APK containing both.
- Emulator GPU emulation matters for wgpu/Vulkan:
swangle_indirect(run_emulator.sh) is the most stable;start_new_emulator.shis the SwiftShader/CPU fallback for hosts whose GPU driver crashes the emulator. - macOS is built in a container, not on a Mac.
build_macos.sh→Containerfile.macos→ osxcross + Apple's SDK. Two details are load-bearing: Apple Silicon refuses to exec an arm64 binary with no code signature at all, so the build ad-hoc signs the bundle withrcodesign(this is not the same as Developer ID signing); andbindgen(pulled in bycoreaudio-sys→cpal→bevy_audio) ignoresCC_*and needs the sysroot passed viaBINDGEN_EXTRA_CLANG_ARGS_*, which the image bakes in. See macOS notes. game.envcentralizes the game identity + Android SDK paths; every script sources it.- Browser build details:
bevy_embedded_assetsmeans the.wasmis fully self-contained (no asset fetch issues on static hosts);wasm-bindgen-climust exactly match thewasm-bindgencrate version inCargo.lock(build_web.shauto-installs the right one);getrandom0.3 (pulled via ahash/bevy) needs thewasm_jsfeature andRUSTFLAGS=--cfg getrandom_backend="wasm_js"— both are wired in already; the window is bound to the#game-canvaselement inweb/index.html. - Browser build size: the web build uses the dedicated
[profile.wasm-release](opt-levelz, fat LTO — native platforms keep the fast default release profile) and thenwasm-opt -Ozfrom binaryen if installed. Expect roughly half the size of a plain release wasm.
./build_macos.sh builds target/macos_dist/<Game>.app and a matching .dmg without a Mac anywhere in the loop. Everything happens inside the image defined by Containerfile.macos:
| Piece | Source | Why |
|---|---|---|
| osxcross | pinned commit, built from source | clang/cctools/ld64 wrappers that target Apple's SDK |
| macOS SDK 14.5 | joseluisq/macosx-sdks (public mirror), sha256-pinned |
headers + framework stubs for AppKit/Metal/CoreAudio |
rcodesign |
prebuilt release, sha256-verified | ad-hoc code signing from Linux |
xorriso |
Ubuntu package | writes the .dmg disk image |
The first run compiles osxcross and is slow (~15–20 minutes, several GB of image). After that the image is cached and a build is just cargo build + packaging. Crates are cached in a podman volume (gamebase-macos-cargo-registry) across runs.
In CI the macos job runs the exact same image. Building it on every push would be wasteful, so the job pushes it to this repo's container registry (ghcr.io/<owner>/<repo>/macos-builder:<sdk-version>, private by default) and pulls it on later runs. Measured: the first run took 18m30s (12m building and pushing the image, 6m for the cargo build); the next run, pulling the cached image, took 6m49s — the pull step itself dropped to 41s. A fork whose token cannot push to GHCR just rebuilds the image each time and logs a warning; the build still succeeds.
Things worth knowing before you ship the .dmg:
- Ad-hoc signing is mandatory, not cosmetic. arm64 macOS will not execute a binary with no signature at all, so
build_macos_bundle.shalways runsrcodesign sign. That is not Developer ID signing. - Gatekeeper will still block it, because the app is neither Developer-ID-signed nor notarized — those steps need a paid Apple Developer account and Apple's notary service. Your users open it once via right-click → Open, or you run
xattr -dr com.apple.quarantine "/Applications/Game Base.app". To do it properly, sign and notarize with your own certificate (rcodesigncan do both from Linux). - The
.dmgis an ISO9660+Rock Ridge image, which macOS mounts as a read-only disk with the usual drag-to-/Applicationslayout. A compressed UDBZ/HFS+ image needs Apple'shdiutil(or a from-sourcelibdmg-hfsplus); if you want one, runhdiutil convert in.dmg -format UDBZ -o out.dmgon any Mac. - Intel Macs are not covered. The same image can build
x86_64-apple-darwin— swap the target inbuild_macos_bundle.shandlipothe two binaries into a universal one — but the default output is arm64-only. - Licensing: the macOS SDK is Apple's property and its licence only permits use on Apple-branded hardware. The mirror is publicly reachable; that is not the same as a licence. Decide for yourself whether this route fits your situation, and don't redistribute the built image.
- App icon:
png2icnsbuildsAppIcon.icnsfromassets/macos-icon.pngif you add one (1024x1024 works best). Without it the build falls back to the 48x48 Android launcher icon, or ships with no icon at all.
| Script | What it does |
|---|---|
setup_env.sh --check |
Verify every requirement of every build script (no sudo, no installs) — prints [ OK ]/[MISS] per item |
setup_env.sh |
Check, then install only what's missing: system packages (apt or dnf detected automatically), Rust + cross targets, MinGW-w64, podman, cargo-apk/cargo-ndk, Android SDK/NDK 26, debug keystore |
run_linux.sh [debug] |
Build + run natively on Linux |
build_windows.sh |
Cross-compile Windows release, package exe + assets into target/windows_dist/ |
build_macos.sh |
Cross-compile the macOS arm64 .app + .dmg in a podman container into target/macos_dist/ (--rebuild to refresh the toolchain image, --shell to poke around inside it) |
build_web.sh |
Build the browser version into target/web_dist/ (compile to wasm, run wasm-bindgen, add web/index.html) |
build_cargo_apk.sh / _debug.sh |
Path A: build emulator (x86_64) + phone (ARM64) APKs |
deploy_cargo_apk.sh |
Install Path A APK to running emulator, launch, follow logcat |
deploy_phone.sh |
Install Path A ARM64 APK to USB phone, launch, follow logcat |
run_emulator.sh |
Start a clean emulator (Swangle GPU — most stable for Bevy) |
start_new_emulator.sh |
Fallback emulator (SwiftShader CPU rendering) |
buildanddeploy.sh |
Path B: cargo-ndk → jniLibs → Gradle universal APK → install + launch + logs |
deploy_apk_to_emulator.sh |
Re-install Path B APK without rebuilding |
read_logs.sh / read_logs_phone.sh |
Dump last 50 relevant log lines (emulator / phone) |
kill_phone_app.sh |
Force-stop the game on the phone |
increment_version.sh [major|minor|patch] |
Bump version.txt (default: patch) and sync the version into Cargo.toml and app/build.gradle |
version.txt at the repo root is the single source of truth for the game version.
./increment_version.sh(orminor/major) bumps it and syncsCargo.toml+ GradleversionName/versionCode.- Commit and push to
main. .github/workflows/release.ymlbuilds every platform on GitHub Actions and publishes a GitHub Release taggedv<version>with:<game>-linux-x86_64-v<version>.tar.gz(binary + assets)<game>-windows-x86_64-v<version>.zip(exe + assets, MinGW cross-compiled)<game>-macos-arm64-v<version>.dmg(Apple Silicon.appin a disk image)<game>-android-arm64-v<version>.apk(phones) and<game>-android-x86_64-v<version>.apk(emulator)<game>-web-v<version>.zip(static site) — the same build is also deployed to GitHub Pages as the live demo (first run: if thepagesjob fails, enable Pages once under repo Settings → Pages → Source: GitHub Actions)
Pushing again without bumping the version updates the existing release for that tag rather than creating a new one. CI signs APKs with a freshly generated debug keystore — replace that step with a real keystore (repo secret) before shipping to a store.
./setup_env.sh --check # see what your machine is missing
./setup_env.sh # once per machine; installs only the missing pieces (apt or dnf)
./run_linux.sh # play on Linux
./build_windows.sh # produce target/windows_dist/ for Windows
./build_macos.sh # produce target/macos_dist/ for Apple Silicon (podman; slow first run)
./build_web.sh # produce target/web_dist/ for the browser
python3 -m http.server -d target/web_dist 8080 # ...then play at localhost:8080
./run_emulator.sh # boot the Android emulator...
./build_cargo_apk_debug.sh && ./deploy_cargo_apk.sh # ...and play in it
./build_cargo_apk.sh && ./deploy_phone.sh # play on a USB phoneThe base identity is game_base / "Game Base". To turn this into your game, change these — they must stay consistent with each other:
1. Cargo.toml — the source of truth:
[package] name = "my_game" # crate name
[lib] name = "my_game" # native library name -> libmy_game.so
[[bin]] name = "my_game" # exe name -> my_game / my_game.exe
[package.metadata.android]
package = "com.yourstudio.mygame" # Android application id (Path A)
label = "My Game" # app name shown under the iconAlso update src/main.rs (my_game::run_game();) and the window title in src/lib.rs.
The
[package.metadata.android.signing.release]block points at a debug keystore — fine for sideloading; generate a real keystore (keytool -genkey ...) before any store release.
2. game.env — the scripts read the same names from here:
GAME_NAME="my_game" # = Cargo.toml lib/bin name
GAME_LABEL="My Game" # = [package.metadata.android] label; macOS .app / .dmg name
MACOS_BUNDLE_ID="com.yourstudio.mygame" # macOS bundle identifier
ANDROID_PACKAGE="com.yourstudio.mygame" # = [package.metadata.android] package
GRADLE_PACKAGE="org.yourstudio.my_game" # = app/build.gradle applicationId3. App icon — replace assets/android-res/mipmap-mdpi/ic_launcher.png (used by both Android paths). Optionally add assets/macos-icon.png at 1024x1024 for the macOS .app icon.
3b. Demo link — the browser demo deploys to https://<your-user>.github.io/<your-repo>/; update the link at the top of this README. (web/index.html needs no changes — build_web.sh fills in the game name.)
4. Only if you use Path B (Gradle):
app/build.gradle—namespaceandapplicationIdapp/src/main/AndroidManifest.xml—android:label(app name) and theandroid.app.lib_namemeta-data value (=GAME_NAME)app/src/main/java/.../MainActivity.java— thepackageline andSystem.loadLibrary("my_game"); move the file to a directory matching the new package (app/src/main/java/com/yourstudio/mygame/)settings.gradle—rootProject.nameCargo.toml— swap bevy featureandroid-native-activity→android-game-activity
One-shot rename (Path A pieces, from the repo root — review the diff after):
NEW=my_game; NEWPKG=com.yourstudio.mygame; NEWLABEL="My Game"
sed -i "s/game_base/$NEW/g" Cargo.toml src/main.rs game.env
sed -i "s/com\.gamebase\.game/$NEWPKG/g" Cargo.toml game.env
sed -i "s/Game Base/$NEWLABEL/g" Cargo.toml src/lib.rs game.env5. Grow the game — everything lives in src/lib.rs. The pattern that scales (used by the parent project): split features into modules, each exposing a Bevy Plugin, and add_plugins(...) them in run_game(). Put new assets in assets/ — they are embedded automatically on every platform.
| Thing | Version | Why pinned |
|---|---|---|
| bevy | 0.15.x | input/render APIs used here |
| bevy_embedded_assets | 0.12 | matches bevy 0.15 |
| Android NDK | 26.1.10909125 | referenced by every script + setup |
| AGP / Gradle | 8.4.0 / 8.6 | Path B only |
| games-activity | 4.4.0 | must stay compatible with bevy's android-activity crate |
| macOS SDK | 14.5 | pinned by version and sha256 in Containerfile.macos; bump both together |
| osxcross | pinned commit | its build script and wrapper naming change over time |
| rcodesign (apple-codesign) | 0.29.0 | prebuilt binary, sha256-verified |
| wasm-bindgen-cli | = wasm-bindgen in Cargo.lock |
hard requirement; build_web.sh/CI resolve it automatically |
| minSdk / target/compileSdk | 30 / 33 / 34 | Path B only; cargo-apk defaults handle Path A |