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.
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.
| File | Lines | Job |
|---|---|---|
game/GameEngine.kt | 142 | Rules. Returns every explosion wave so the UI can animate it. |
GameViewModel.kt | 169 | Screens, turns, Bluetooth protocol, queue of remote moves. |
bt/BtLink.kt | 154 | Bluetooth Classic host/join, ordered line messages, session guard. |
ui/BoardView.kt | 245 | Grid and orb drawing, tumbling clusters, wave animation and its timing. |
ui/LobbyScreen.kt | 307 | Permissions, paired list, scanning (phones only), host and join. |
ui/GameScreen.kt | 161 | Player chips, turn label, winner card, disconnect notice. |
ui/MenuScreens.kt | 134 | Title, spinning orbs, local setup for 2–8 players. |
MainActivity.kt | 47 | One when (vm.screen) switch with back handlers. No navigation library. |
Sounds.kt | 59 | SoundPool pop and the saved mute setting. |
ui/Theme.kt | 32 | Eight player colours, navy background. |
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.
- Board is two flat
IntArrays: owner per cell (-1= empty) and orb count. - GameState is immutable: board, current player, moves per player, eliminated flags, winner.
- A player can't be eliminated before their first move, which is why
moves[]is tracked. - Two guards stop endless chains: the loop ends once the mover owns every orb on the board, with a hard cap of 5,000 waves.
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.
- BoardView
pointerInputturns the touch into a row and column and callsvm.tap(r, c). - GameViewModelChecks it is your turn and the cell is yours or empty. In Bluetooth mode it sends
MOVE|r|cfirst. - Engine
Engine.playreturns aMoveResult: the board right after placing, the list of waves, and the nextGameState. - GameViewModelSets
state = result.nextandplayback = Playback(id, result). - BoardViewA
LaunchedEffect(playback.id)pops the new orb in, then plays each wave: showmid, fly orbs, showafter. - GameViewModel
playbackDone()clears the playback and applies the next queued remote move, if one arrived meanwhile.
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.
| Message | Direction | Meaning |
|---|---|---|
HELLO|rows|cols|round | host → joiner | Starts the game. Host is player 0. |
MOVE|r|c | either way | A tap by the sender. Rejected if it isn't their turn or the cell is illegal. |
RESTART|round | either way | New round. Only applied if the round number is higher than the current one. |
BYE | either way | Sender left. The other phone shows a notice and closes the link. |
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.
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.
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.