Chain Reaction Architecture

How the Android game is put together: a pure Kotlin rules engine, one ViewModel that owns all state, a Bluetooth RFCOMM link that sends only taps, and a Compose Canvas that replays each explosion wave by wave.

Kotlin + Jetpack Compose ~1,450 lines · 10 files minSdk 26 · target 35 No server, AndroidX only v1.2
Overview

Four layers, one direction of data

The UI only reads Compose state from the ViewModel and calls back into it. The ViewModel is the only thing that talks to the engine and to Bluetooth. The engine has no Android dependencies, so it is unit-tested on the JVM.

UI layer ui/*.kt · Jetpack Compose MainActivity MenuScreen LobbyScreen GameScreen BoardView Sounds Screen switch by enum · Canvas drawing · wave animation · SoundPool pop observes Compose state tap · goTo playAgain · playbackDone GameViewModel screen · mode · state · playback · notice turn rules · remote-move queue · BT protocol Engine.play(state, pos) send · incoming · status Game engine game/GameEngine.kt Board · GameState · Wave · MoveResult Pure Kotlin, immutable state, unit-tested BtLink bt/BtLink.kt RFCOMM socket · text lines Reader + writer coroutines on Dispatchers.IO
Coloured outlines mark the four pieces described below. Arrows show who calls whom.
FileLinesJob
game/GameEngine.kt142Rules. Returns every explosion wave so the UI can animate it.
GameViewModel.kt169Screens, turns, Bluetooth protocol, queue of remote moves.
bt/BtLink.kt154Bluetooth Classic host/join, ordered line messages, session guard.
ui/BoardView.kt245Grid and orb drawing, tumbling clusters, wave animation and its timing.
ui/LobbyScreen.kt307Permissions, paired list, scanning (phones only), host and join.
ui/GameScreen.kt161Player chips, turn label, winner card, disconnect notice.
ui/MenuScreens.kt134Title, spinning orbs, local setup for 2–8 players.
MainActivity.kt47One when (vm.screen) switch with back handlers. No navigation library.
Sounds.kt59SoundPool pop and the saved mute setting.
ui/Theme.kt32Eight player colours, navy background.
game/GameEngine.kt

The engine returns waves, not just a result

A cell bursts when its orb count reaches its number of neighbours: 2 in a corner, 3 on an edge, 4 in the middle. Engine.play(state, pos) keeps going until no cell is full, and records each step as a Wave with two boards: mid, with the bursting orbs in flight, and after, once they have landed and captured the neighbours.

A real two-wave move on the top-left 3×3 corner. Red taps (0,0). Dashed cells are full and will burst. Blue loses both cells and is eliminated.
GameViewModel.kt

One tap, end to end

The rules finish instantly; the screen catches up. state jumps straight to the result, while playback holds the move until the board has animated it. Taps are ignored while a playback is running.

  1. BoardViewpointerInput turns the touch into a row and column and calls vm.tap(r, c).
  2. GameViewModelChecks it is your turn and the cell is yours or empty. In Bluetooth mode it sends MOVE|r|c first.
  3. EngineEngine.play returns a MoveResult: the board right after placing, the list of waves, and the next GameState.
  4. GameViewModelSets state = result.next and playback = Playback(id, result).
  5. BoardViewA LaunchedEffect(playback.id) pops the new orb in, then plays each wave: show mid, fly orbs, show after.
  6. GameViewModelplaybackDone() clears the playback and applies the next queued remote move, if one arrived meanwhile.
bt/BtLink.kt

Bluetooth sends taps, never boards

Both phones run the same deterministic engine on the same moves, so their boards can't drift apart. The link is Bluetooth Classic RFCOMM with a fixed service UUID. Messages are plain text, one line each, and a single writer coroutine keeps them in order.

Host phone player 0 · Red Joiner phone player 1 · Green listen…() → accept() createRfcommSocket…().connect() RFCOMM socket opens HELLO|13|6|0 New game, host moves first Takes rows/cols, starts game Tap → Engine.play MOVE|2|3 Same Engine.play → same board Tap → Engine.play MOVE|5|1 Queued if still animating RESTART|1 BYE
Either phone can send RESTART or BYE. The round number decides who starts, so the first move alternates each round.
MessageDirectionMeaning
HELLO|rows|cols|roundhost → joinerStarts the game. Host is player 0.
MOVE|r|ceither wayA tap by the sender. Rejected if it isn't their turn or the cell is illegal.
RESTART|roundeither wayNew round. Only applied if the round number is higher than the current one.
BYEeither waySender left. The other phone shows a notice and closes the link.
Idle Hosting Connecting Connected Lost host() connect() accept() connect() EOF/error stop() from any state returns to Idle
Status is a StateFlow the ViewModel collects. A Lost status during a game shows the disconnect notice.

Session counter

Every host, connect or stop increments session and closes the old sockets. Background threads from an older session see the number changed and quietly give up, so a cancelled connection can never take over a new one.

Lobby and permissions

Android 12+ asks for BLUETOOTH_SCAN, CONNECT and ADVERTISE. Android 11 and older use the legacy permissions plus location. The join list shows paired devices and scan results, filtered to phones.

ui/BoardView.kt · waveDurations()

Long chains stay around six seconds

The first 8 waves always play at a calm 270 ms each. After that the pace eases toward whatever fits the rest of the chain into a 6 s budget, never faster than 40 ms. If that still isn't fast enough, only every n-th wave is animated and the others are applied instantly.

Bars show milliseconds per wave, computed with the app's own formula. Grey ticks are waves applied without animation.
The rest

Sound, build and tests

One synthesized pop

tools/make_sounds.py generates res/raw/pop.wav. SoundPool plays it on every placement and wave, with a small random pitch change and at most one pop every 110 ms. Mute is saved in SharedPreferences.

Build

Gradle Kotlin DSL, Java 17, Compose BOM 2024.12. ./gradlew assembleDebug builds the APK; ./gradlew testDebugUnitTest runs the rule and timing tests in EngineTest.kt.