Skip to content

Repository files navigation

CyanBat

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.

🚀 Architecture

  • 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: Vector2 is 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.

📁 Project structure

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.

▶️ Running it

Desktop, no emulator needed:

./gradlew :desktop:run

Android:

./gradlew :app:installDebug

There 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.

🎮 Controls

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.

🧪 Building and testing

./gradlew build

Assembles 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.

📦 Cutting a release

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.0

That 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.

Signing secrets

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 10000

Then 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.jks

Without 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.

🛠 Tech stack

  • Language: Kotlin 2.x, Kotlin Multiplatform
  • UI: Compose Multiplatform
  • Persistence: Jetpack DataStore (Android), java.util.prefs (desktop)
  • Desktop audio: javax.sound.sampled plus the mp3spi/jlayer service providers
  • CI/CD: GitHub Actions

📜 History

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.

About

Compose Multiplatform 2D Game

Topics

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages