Building Native Board Games in Jetpack Compose: Concurrency, 2.5D Physics, and Procedural Audio
Architectural learnings from building Ludo Supreme: resolving multi-coroutine state race conditions, procedural 2.5D tumbling dice, real-time PCM audio synthesis, and adaptive tablet layouts.
Overview
This guide compiles the technical insights, architectural patterns, and debugging solutions gained while building Ludo Supreme, a high-performance native Indian board game created entirely with Jetpack Compose, Kotlin Coroutines, and Android Canvas.
1. Concurrency & Race Conditions in Game State Machines
Problem: Multiple Tokens Moving Simultaneously
During gameplay testing, two tokens were occasionally seen moving across the board simultaneously. The issue was erratic, happening primarily when bots played or when automatic moves triggered.
Root Cause Analysis
- Unmanaged Coroutines & Overlapping Fallbacks:
In
rollDice(), a safety fallback coroutine was launched:// Fallback to advance turn if UI roll animation callback was delayed viewModelScope.launch { delay(550) if (_gameState.value.turnPhase == TurnPhase.ROLLING_DICE) { processRollResult(finalRoll) } } - Animation Delay Gap:
The 2.5D dice animation finished at ~400ms and called
onDiceRollComplete(dice) -> processRollResult(dice). - The Race Window:
When
processRollResult(dice)found only one eligible token to move (or a bot move), it launched a coroutine withdelay(450)so the player could see the rolled number before the token began hopping. Crucially,turnPhasewas not immediately transitioned away fromTurnPhase.ROLLING_DICEduring that 450ms pause. - Double Execution:
At $t = 550\text{ms}$ (while the 450ms pause was still active), the fallback timer woke up, checked
turnPhase == TurnPhase.ROLLING_DICE(evaluating totrue), and calledprocessRollResult(finalRoll)a second time. - Both coroutines entered
executeMove()in parallel, executing step-by-step loops concurrently and animating two separate tokens on the board at the exact same time.
Solution: Strict Job Tracking & Immediate Phase Locking
-
Explicit Coroutine Job Ownership: Track active jobs as nullable references rather than launching detached coroutines:
private var diceRollFallbackJob: Job? = null private var moveJob: Job? = null private var botJob: Job? = null private var turnJob: Job? = null -
Cancel Fallbacks on UI Callbacks: Cancel the fallback timer immediately when the UI event arrives:
fun onDiceRollComplete(dice: Int) { diceRollFallbackJob?.cancel() diceRollFallbackJob = null val state = _gameState.value if (state.turnPhase != TurnPhase.ROLLING_DICE) return processRollResult(dice) } -
Synchronous Phase Transition Before Suspension: Never delay without first changing the state phase away from the triggering phase:
} else if (movableTokens.size == 1 || allYardMoves) { // Lock UI immediately to prevent duplicate triggers or fallback race conditions _gameState.update { it.copy( turnPhase = TurnPhase.ANIMATING_MOVE, legalMoves = emptyList() ) } val moveToExecute = legalMoves.first() turnJob = viewModelScope.launch { delay(400) executeMove(moveToExecute) } } -
Single-Execution Guard in Move Execution: Ensure previous move jobs are cancelled before starting a new step animation:
private fun executeMove(move: MoveOption) { moveJob?.cancel() moveJob = viewModelScope.launch { // Step-by-step hop animation loop... } }
2. 2D Coordinate Stacking & Cell Clustering
Problem: Unintended Token Offsets in Home Stretches
When multiple tokens occupy the same square on the global track, they must be offset in a circle to remain visible. However, tokens of different colors in their private colored home stretches were also getting offset radially as if they were sharing the same square.
Root Cause
The position grouping map used -2 to token.homeStretchPosition as the dictionary key for home stretch tokens. Tokens of Player Red at index 2 and Player Green at index 2 shared the same (-2, 2) key despite being in completely different colored quadrants.
Solution: Color-Partitioned Spatial Keys
val key = if (token.state == TokenState.ON_TRACK) {
token.trackPosition to -1 // Global track shared across all colors
} else {
token.homeStretchPosition to token.color.ordinal // Private per-color track
}
positionMap.getOrPut(key) { mutableListOf() }.add(player to token)
3. Pseudo-3D (2.5D) Fast Tumbling Dice in Compose
Design Requirements
- Fixed container slot (62dp) without building an external 3D physics engine.
- Fast and snappy duration: 350ms–450ms total.
- Clear readability of the final rolled value on settle.
Implementation Pattern
-
Phase 1: Tumbling & Motion Blur (~270ms)
- Apply perspective transform:
Modifier.graphicsLayer { cameraDistance = 16f * density rotationX = rotX.value rotationY = rotY.value rotationZ = rotZ.value scaleX = scale.value scaleY = scale.value } - Rapidly randomize pip faces every 35ms in a lightweight coroutine loop to simulate high-speed rotation and motion blur.
- Animate
rotXto 720° androtYto 540° with a dynamic Z-axis wobble (-14° to +14°).
- Apply perspective transform:
-
Phase 2: Impact & Settle (~130ms)
- Abruptly cancel the face randomization and snap flat (
rotX=0,rotY=0,rotZ=0). - Instantly set the displayed face to the deterministic
targetValue. - Apply a physical impact bounce curve:
- Impact scale dip to 0.90x (35ms)
- Spring overshoot to 1.06x (55ms)
- Settle back to 1.0x (40ms)
- Emit
onRollComplete(targetValue).
- Abruptly cancel the face randomization and snap flat (
4. Zero-Asset Real-Time Procedural PCM Audio Synthesizer
Why Procedural Audio?
Loading multiple MP3/WAV files for repetitive game sound effects (dice rattling, pawn hops, captures) introduces file I/O overhead, memory bloat, and MediaPlayer release/allocation latency.
The Solution: Native AudioTrack Synthesizer
By generating raw 16-bit PCM waveforms at 44.1kHz and feeding them directly into Android’s AudioTrack, sound effects are synthesized mathematically in real-time with zero external files.
Key Synthesis Techniques
- Pawn Hop Step: 120ms sharp sine transient with rapid pitch-decay envelope ($650\text{ Hz} \to 200\text{ Hz}$) to simulate a tactile wooden tap.
- Dice Roll / Rattle: Pseudo-random white noise bursts shaped with an exponential decay envelope, modulated at 18Hz to simulate dice tumbling inside a cup.
- Capture: Descending frequency chirp ($880\text{ Hz} \to 180\text{ Hz}$) combined with a low-pass burst.
- Victory Fanfare: Arpeggiated sequence of harmonic frequencies (C5, E5, G5, C6) rendered with ADSR envelopes.
5. Responsive Tablet & Physical Rotation Handling
Configuration Changes vs. Activity Recreation
By default, rotating an Android tablet destroys and recreates the Activity, causing games to reset to the main menu if not properly preserved.
In AndroidManifest.xml:
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
android:exported="true">
In Compose UI:
- Use
BoxWithConstraintsto dynamically measure available screen dimensions. - Use
isLandscape = maxWidth > maxHeight. - Landscape: 4-corner arrangement placing Red & Blue pods on the left flank and Green & Yellow pods on the right flank, maximizing the center board to
constraints.maxHeight - 32.dp. - Portrait: Stacked layout with top and bottom player pod rows sandwiching the maximized square board.
6. Release Builds & Adaptive Launcher Icons
Key Takeaways
- Unsigned vs. Signed Release APKs:
Without a configured
signingConfig,./gradlew assembleReleaseoutputs an unsigned APK whichadb installrejects withINSTALL_PARSE_FAILED_NO_CERTIFICATES. - One-Command Release Setup:
Generate a 2048-bit RSA keystore via
keytooland configuresigningConfigs.create("release")directly inapp/build.gradle.kts. - Adaptive Icon Safe Zones: When designing game icons for Android, place the critical visual elements (crown, logo text, pawns) strictly within the inner 72dp circle of the 108dp adaptive icon canvas to prevent OEM masks (circles, squircles, rounded squares) from clipping icon content.