CyanBat is a side-scrolling 2D action game, inspired by retro classics like Gradius and modern arcade staples like Flappy Bird. It runs on Android and on the desktop (Windows, macOS and Linux) from a single shared codebase.
- Kotlin Multiplatform: the engine and the whole game — rules, screens and menu UI — are common code. The platform modules only supply platform pieces: a framebuffer, input, audio, haptics and persistence.
- Compose Multiplatform: one set of menu, settings and credits screens renders on both Android and desktop, from shared string and drawable resources.
- Entity Component System:
:engine's ECS decouples game logic from data, so behavior is composed from components rather than an inheritance hierarchy. - Value class optimization:
Vector2is a bit-packed value class, so movement math allocates nothing in the game loop. - Sub-pixel precision: geometry is float-based, and the frame loop clamps its delta so a resume cannot fast-forward the simulation.
| Module | What it is |
|---|---|
:engine |
Platform-agnostic engine: Game/Screen/Graphics/Audio interfaces, the ECS (at.smiech.engine.ecs), math (at.smiech.engine.math), and the shared GameLoop. androidMain and jvmMain hold the platform implementations. |
:game |
CyanBat itself: game screens, entity factory, spawners, and the shared Compose UI. Android + JVM. |
:app |
Android application — activities, DataStore, and Android asset wiring. |
:desktop |
Compose Desktop application — window, JVM asset wiring, and preferences-backed storage. |
Game assets live once at the repo root in assets/, packaged as Android assets by :app and as
classpath resources by :desktop.
Desktop, no emulator needed:
./gradlew :desktop:runAndroid:
./gradlew :app:installDebugThere is also a /run-cyanbat skill that drives the Android build end to end on an emulator —
build, install, launch, screenshot, and check persistence. See
.claude/skills/run-cyanbat/SKILL.md.
| Move | Pause / resume | Quit to menu | |
|---|---|---|---|
| Touch | Drag the bat, or tap where you want it | — (tap resumes) | Back, twice |
| Keyboard | WASD or the arrow keys |
Esc (or P) |
Q |
| Controller | Left stick or d-pad | Start |
B |
Dragging pins the bat under your finger and keeps the offset you grabbed it by, so it never snaps out from under the fingertip; the hit box is padded well past the sprite so it is catchable without aiming. A touch landing away from the bat flies it over instead, which is how the game played before it was draggable. Holding a key or pushing a stick overrides a drag for as long as it lasts.
Backgrounding the app — or, on desktop, the window losing focus — pauses the run, and it stays paused until you resume it rather than dropping you straight back into a dodge.
Controller support is real on Android, where the platform reports pads as key codes and joystick
axes. On desktop the JDK has no gamepad API, so nothing feeds those events yet: the mapping seam
is ControlHandler.onAxis/onButton, and a backend only has to call them.
./gradlew buildAssembles every module, runs lint, and runs the unit tests — ECS, math, spawn pacing, and a check that the MP3 service provider desktop audio depends on is actually present.
Every artifact takes its version from cyanbat.version in gradle.properties. The release
workflow overrides it with the tag being built, so tagging is what sets the version — the property
is only what an untagged build stamps.
Pushing a version tag builds and publishes everything:
git tag 2.0 && git push origin 2.0That produces a signed Android APK plus Windows, macOS and Linux desktop installers, and attaches
them to a GitHub release. Tags work with or without a leading v; a suffixed tag such as 2.1-rc1
publishes as a pre-release. To rehearse without spending a tag, run the Release workflow
manually from the Actions tab — it builds and uploads the same artifacts to the run, and publishes
nothing.
The APK is signed with a keystore supplied through repository secrets, so nothing sensitive lives in the repository.
2.0 is signed with a new keystore. The one the 1.x releases used is gone, and Android identifies an app by its signature, so 2.0 is a fresh install rather than an update — anything still running a 1.x build has to be uninstalled first. That break is a one-off; from 2.0 onwards the same keystore has to keep being used, because losing it again would force the same break on whoever is running 2.x by then.
Create it once, and back it up somewhere you will still have in a few years:
keytool -genkeypair -v -keystore cyanbat-release.jks -storetype PKCS12 \
-alias cyanbat -keyalg RSA -keysize 4096 -validity 10000Then add four repository secrets, under Settings → Secrets and variables → Actions:
| Secret | What it is |
|---|---|
ANDROID_KEYSTORE_BASE64 |
The keystore file, base64-encoded |
ANDROID_KEYSTORE_PASSWORD |
Keystore password |
ANDROID_KEY_ALIAS |
Alias of the signing key inside the keystore — cyanbat above |
ANDROID_KEY_PASSWORD |
Password for the key. Optional on PKCS12, see below |
A PKCS12 keystore has only one password. keytool refuses to give the key its own — "Different
store and key passwords not supported for PKCS12 KeyStores" — and the store password is what
unlocks the key. So a keystore whose key appears to have no password is normal, not broken: leave
ANDROID_KEY_PASSWORD unset and the build uses the store password. Set it only for an older JKS
keystore that genuinely carries a separate one.
Encode the keystore with:
base64 -w0 cyanbat-release.jksWithout these the release job fails rather than publishing an APK nobody can install. Local builds
and pull requests need no keystore at all — assembleRelease simply produces an unsigned APK, as
it always has.
Desktop installers are unsigned, so Windows SmartScreen and macOS Gatekeeper will warn on first run. Fixing that needs a paid code-signing certificate and an Apple developer account.
- Language: Kotlin 2.x, Kotlin Multiplatform
- UI: Compose Multiplatform
- Persistence: Jetpack DataStore (Android),
java.util.prefs(desktop) - Desktop audio:
javax.sound.sampledplus the mp3spi/jlayer service providers - CI/CD: GitHub Actions
This project originated as an academic project in 2012, based on the principles from Beginning Android Games by Mario Zechner and Robert Green. The original framework was provided by DI Robert Grüneis. In 2026 it was refactored from legacy OOP to a data-driven ECS architecture, and then from an Android-only app to Kotlin Multiplatform with a desktop target.
Developed with ❤️ using Kotlin.