Why Android 14 Changes Ludo Game Development

Android 14 (API level 34) introduces several platform changes that directly impact multiplayer Ludo game development. The most significant is the expansion of foreground service types — your game must now declare the connectedDevice type if the app maintains persistent WebSocket connections for multiplayer while a player receives push notifications. Without this declaration on Android 14+, the system may kill your game's network connection when it moves to the background, dropping players from live matches.

The partial screen sharing API in Android 14 also opens new possibilities: players can share their live Ludo board with spectators without exposing the entire device screen. For tournament-mode games, this enables spectator feeds with minimal additional code. Jetpack Compose's Activity-based capture APIs integrate cleanly with Compose's state model, allowing you to toggle spectator mode with a single composable function call.

Photo picker improvements in Android 14 also matter for avatar selection in Ludo games. The updated PhotoPicker API provides a consistent, privacy-preserving way for players to select profile pictures without granting broad READ_EXTERNAL_STORAGE permissions. Finally, Android 14's ultra-wideband (UWB) APIs are relevant for local multiplayer Ludo games that use device-to-device proximity for matchmaking — a feature increasingly common in party-game scenarios.

Project Setup with Jetpack Compose and Hilt

Start by creating an Android Studio Hedgehog (2023.1.1) or newer project. Use Kotlin 1.9.x as the language level and enable the Kotlin Symbol Processing (KSP) plugin instead of KAPT for annotation processors — KSP is 2–4x faster for Room and Hilt compilation, which matters during iterative Compose rebuilds. The project template uses a single-module architecture with Compose as the UI layer and Hilt managing all dependency injection across the WebSocket client, Room database, and WorkManager workers.

build.gradle.kts (project level)
plugins {
    id("com.android.application") version "8.2.0" apply false
    id("org.jetbrains.kotlin.android") version "1.9.21" apply false
    id("com.google.dagger.hilt.android") version "2.48.1" apply false
    id("com.google.devtools.ksp") version "1.9.21-1.0.15" apply false
}
build.gradle.kts (app level)
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("com.google.dagger.hilt.android")
    id("com.google.devtools.ksp")
}

android {
    namespace = "com.ludoking.game"
    compileSdk = 34

    defaultConfig {
        applicationId = "com.ludoking.game"
        minSdk = 24
        targetSdk = 34
        versionCode = 1
        versionName = "1.0.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        vectorDrawables {
            useSupportLibrary = true
        }
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    kotlinOptions {
        jvmTarget = "17"
    }

    buildFeatures {
        compose = true
        buildConfig = true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.6"
    }

    packaging {
        resources {
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
        }
    }
}

dependencies {
    // Jetpack Compose BOM — use latest stable
    val composeBom = platform("androidx.compose:compose-bom:2024.01.00")
    implementation(composeBom)

    // Compose UI
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.compose.material3:material3")
    implementation("androidx.compose.foundation:foundation")

    // Activity & ViewModel Compose
    implementation("androidx.activity:activity-compose:1.8.2")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")

    // Hilt — dependency injection
    implementation("com.google.dagger:hilt-android:2.48.1")
    ksp("com.google.dagger:hilt-android-compiler:2.48.1")
    implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
    implementation("androidx.hilt:hilt-work:1.1.0")
    ksp("androidx.hilt:hilt-compiler:1.1.0")

    // Room — local game state persistence
    implementation("androidx.room:room-runtime:2.6.1")
    implementation("androidx.room:room-ktx:2.6.1")
    ksp("androidx.room:room-compiler:2.6.1")

    // Retrofit — REST API calls
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.retrofit2:converter-gson:2.9.0")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")

    // Socket.IO — real-time multiplayer WebSocket
    implementation("io.socket:socket.io-client:2.1.0") {
        exclude(group = "org.json", module = "json")
    }

    // WorkManager — background sync
    implementation("androidx.work:work-runtime-ktx:2.9.0")

    // Firebase Cloud Messaging
    implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
    implementation("com.google.firebase:firebase-messaging-ktx")

    // In-App Review API
    implementation("com.google.android.play:review-ktx:2.0.1")

    // Coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")

    // Gson
    implementation("com.google.code.gson:gson:2.10.1")

    // Core KTX
    implementation("androidx.core:core-ktx:1.12.0")

    // DataStore — preferences
    implementation("androidx.datastore:datastore-preferences:1.0.0")

    debugImplementation("androidx.compose.ui:ui-tooling")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}
AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
    <uses-permission android:name="android.permission.VIBRATE" />

    <application
        android:name=".LudoGameApplication"
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/Theme.LudoGame"
        android:usesCleartextTraffic="false"
        tools:targetApi="34">

        <!-- Main game activity -->
        <activity
            android:name=".ui.MainActivity"
            android:exported="true"
            android:theme="@style/Theme.LudoGame"
            android:windowSoftInputMode="adjustResize">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <!-- FCM push notification service -->
        <service
            android:name=".notifications.LudoFCMService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
        </service>

        <!-- WorkManager initialization -->
        <provider
            android:name="androidx.startup.InitializationProvider"
            android:authorities="${applicationId}.androidx-startup"
            android:exported="false"
            tools:node="merge">
            <meta-data
                android:name="androidx.work.WorkManagerInitializer"
                android:value="androidx.startup"
                tools:node="remove" />
        </provider>

        <!-- Boot receiver to reschedule sync workers -->
        <receiver
            android:name=".workers.BootReceiver"
            android:exported="false">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED" />
            </intent-filter>
        </receiver>
    </application>
</manifest>

Hilt Dependency Injection Module for WebSocket

Hilt replaces manual dependency management with annotated entry points that Android's lifecycle respects automatically. For a Ludo game, the most critical injected component is the WebSocket client — it needs to survive configuration changes, reconnect intelligently after network loss, and emit state updates to multiple Compose observers simultaneously. A properly scoped Hilt module ensures the Socket.IO client is shared across ViewModels within an activity lifecycle but recreated if the application process is killed.

The module below provides a singleton SocketClient that wraps the raw Socket.IO client with game-specific event handling, token-based authentication headers, and automatic reconnection with exponential backoff. It also injects the OkHttpClient used by Retrofit, sharing the connection pool so HTTP and WebSocket traffic reuse the same TCP connections to the game server.

di/LudoModules.kt
package com.ludoking.game.di

import android.content.Context
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.ludoking.game.data.local.LudoDatabase
import com.ludoking.game.data.remote.LudoApiService
import com.ludoking.game.data.remote.SocketClient
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    private const val BASE_URL = "https://api.ludokingapi.site/"
    private const val SOCKET_URL = "wss://api.ludokingapi.site/"
    private const val CONNECT_TIMEOUT = 10L
    private const val READ_TIMEOUT = 30L
    private const val WRITE_TIMEOUT = 30L

    @Provides
    @Singleton
    fun provideGson(): Gson = GsonBuilder()
        .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
        .create()

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient {
        val loggingInterceptor = HttpLoggingInterceptor().apply {
            level = HttpLoggingInterceptor.Level.BODY
        }

        return OkHttpClient.Builder()
            .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
            .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
            .writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS)
            .pingInterval(10, TimeUnit.SECONDS)
            .addInterceptor(loggingInterceptor)
            .addInterceptor { chain ->
                val original = chain.request()
                val token = getStoredAuthToken() // fetch from DataStore
                val request = original.newBuilder()
                    .apply {
                        if (token != null) {
                            header("Authorization", "Bearer $token")
                        }
                    }
                    .build()
                chain.proceed(request)
            }
            .retryOnConnectionFailure(true)
            .build()
    }

    @Provides
    @Singleton
    fun provideRetrofit(okHttpClient: OkHttpClient, gson: Gson): Retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .client(okHttpClient)
        .addConverterFactory(GsonConverterFactory.create(gson))
        .build()

    @Provides
    @Singleton
    fun provideLudoApiService(retrofit: Retrofit): LudoApiService =
        retrofit.create(LudoApiService::class.java)

    @Provides
    @Singleton
    fun provideSocketClient(
        okHttpClient: OkHttpClient,
        gson: Gson,
        @ApplicationContext context: Context
    ): SocketClient = SocketClient(
        serverUrl = SOCKET_URL,
        okHttpClient = okHttpClient,
        gson = gson,
        applicationContext = context
    )
}

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    @Provides
    @Singleton
    fun provideLudoDatabase(@ApplicationContext context: Context): LudoDatabase =
        LudoDatabase.getInstance(context)

    @Provides
    @Singleton
    fun provideGameDao(database: LudoDatabase) = database.gameDao()

    @Provides
    @Singleton
    fun providePlayerDao(database: LudoDatabase) = database.playerDao()
}

@Module
@InstallIn(SingletonComponent::class)
object CoroutineModule {

    @Provides
    @Singleton
    fun provideApplicationScope(): CoroutineScope =
        CoroutineScope(SupervisorJob() + Dispatchers.IO)
}
data/remote/SocketClient.kt
package com.ludoking.game.data.remote

import android.content.Context
import android.util.Log
import com.google.gson.Gson
import io.socket.client.IO
import io.socket.client.Socket
import io.socket.emitter.Emitter
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
import java.net.URI
import javax.inject.Inject
import javax.inject.Singleton

data class GameConnectionState(
    val isConnected: Boolean = false,
    val isConnecting: Boolean = false,
    val latencyMs: Long = 0L,
    val error: String? = null
)

@Singleton
class SocketClient @Inject constructor(
    private val serverUrl: String,
    private val okHttpClient: okhttp3.OkHttpClient,
    private val gson: Gson,
    private val applicationContext: Context
) {
    private val tag = "LudoSocketClient"

    private var socket: Socket? = null
    private var pingStart: Long = 0L

    private val _connectionState = MutableStateFlow(GameConnectionState())
    val connectionState: StateFlow = _connectionState.asStateFlow()

    private val _moveEvents = MutableSharedFlow(extraBufferCapacity = 64)
    val moveEvents: SharedFlow<MoveResult> = _moveEvents.asSharedFlow()

    private val _turnEvents = MutableSharedFlow<TurnEvent>(extraBufferCapacity = 16)
    val turnEvents: SharedFlow<TurnEvent> = _turnEvents.asSharedFlow()

    private val _gameStateEvents = MutableSharedFlow<GameState>(extraBufferCapacity = 8)
    val gameStateEvents: SharedFlow<GameState> = _gameStateEvents.asSharedFlow()

    fun connect(gameId: String, authToken: String) {
        if (socket?.connected() == true) return

        _connectionState.value = GameConnectionState(isConnecting = true)

        val options = IO.Options().apply {
            transports = arrayOf("websocket")
            reconnection = true
            reconnectionAttempts = 10
            reconnectionDelay = 1_000L
            reconnectionDelayMax = 8_000L
            timeout = 5_000L
            extraHeaders = mapOf("Authorization" to listOf("Bearer $authToken"))
        }

        socket = IO.socket(URI.create(serverUrl), options).apply {
            on(Socket.EVENT_CONNECT) {
                Log.d(tag, "Connected: ${id()}")
                _connectionState.value = GameConnectionState(isConnected = true)
                emit("join_game", JSONObject().put("gameId", gameId))
            }

            on(Socket.EVENT_DISCONNECT) {
                Log.d(tag, "Disconnected: $it")
                _connectionState.value = GameConnectionState(isConnected = false)
            }

            on(Socket.EVENT_CONNECT_ERROR) { args ->
                val error = args.firstOrNull()?.toString() ?: "Unknown"
                Log.e(tag, "Connection error: $error")
                _connectionState.value = GameConnectionState(error = error)
            }

            on("pong") {
                val rtt = System.currentTimeMillis() - pingStart
                _connectionState.value = _connectionState.value.copy(latencyMs = rtt)
                Log.d(tag, "Latency: ${rtt}ms")
            }

            on("move_result") { args ->
                val moveResult = gson.fromJson(args.firstOrNull()?.toString(), MoveResult::class.java)
                _moveEvents.tryEmit(moveResult)
            }

            on("turn_changed") { args ->
                val turnEvent = gson.fromJson(args.firstOrNull()?.toString(), TurnEvent::class.java)
                _turnEvents.tryEmit(turnEvent)
            }

            on("game_state") { args ->
                val state = gson.fromJson(args.firstOrNull()?.toString(), GameState::class.java)
                _gameStateEvents.tryEmit(state)
            }
        }

        socket?.connect()
    }

    fun sendMove(movePayload: MovePayload) {
        socket?.emit("make_move", gson.toJsonTree(movePayload))
    }

    fun sendDiceRoll() {
        socket?.emit("roll_dice")
    }

    fun ping() {
        pingStart = System.currentTimeMillis()
        socket?.emit("ping")
    }

    fun disconnect() {
        socket?.disconnect()
        socket?.off()
        socket = null
        _connectionState.value = GameConnectionState()
    }
}

Jetpack Compose Ludo Board Rendering

The Ludo board is a 15×15 grid with a cross-shaped home column for each color. Rendering this in Compose requires a custom Canvas composable that maps board coordinates to pixel positions using the grid's symmetry properties. The board has four distinct zones: the starting areas where tokens spawn, the four colored home columns, the center home area, and the outer track that all tokens traverse. Compose's recomposition model ensures that token animations trigger only the affected tokens to redraw, not the entire board — a critical performance advantage over imperative View rendering.

The LudoBoard composable below is structured as a single Canvas backed by a remember-cached BoardRenderer that precomputes cell positions. Token movement animations use animateFloatAsState with spring physics for a tactile, responsive feel that matches the physical dice-and-token sensation of the real board game.

ui/board/LudoBoard.kt
package com.ludoking.game.ui.board

import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import com.ludoking.game.data.model.GameState
import com.ludoking.game.data.model.TokenPosition

private val RED = Color(0xFFE53935)
private val BLUE = Color(0xFF1E88E5)
private val GREEN = Color(0xFF43A047)
private val YELLOW = Color(0xFFFFB300)
private val BOARD_BG = Color(0xFFFFF8E1)
private val GRID_LINE = Color(0xFF5D4037)

data class BoardCoordinate(
    val gridX: Int,
    val gridY: Int,
    val pixelX: Float,
    val pixelY: Float,
    val cellSize: Float
)

@Composable
fun LudoBoard(
    gameState: GameState,
    onTokenTapped: (playerIndex: Int, tokenIndex: Int) -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true
) {
    val renderer = remember { BoardRenderer() }

    Canvas(
        modifier = modifier
            .fillMaxWidth()
            .aspectRatio(1f)
            .pointerInput(enabled) {
                if (!enabled) return@pointerInput
                detectTapGestures { offset ->
                    val tappedCoord = renderer.findCellAt(offset.x, offset.y, size.width.toFloat())
                    if (tappedCoord != null) {
                        val (playerIdx, tokenIdx) = renderer.findTokenAt(tappedCoord, gameState)
                        if (playerIdx != null && tokenIdx != null) {
                            onTokenTapped(playerIdx, tokenIdx)
                        }
                    }
                }
            }
    ) {
        val cellSize = size.width / 15f
        renderer.updateBoardMetrics(size.width, size.height, cellSize)

        // Draw base board
        drawRect(color = BOARD_BG, size = size)

        // Draw colored home areas
        drawHomeArea(renderer.getHomeArea(0), RED)      // Top-left
        drawHomeArea(renderer.getHomeArea(1), BLUE)     // Top-right
        drawHomeArea(renderer.getHomeArea(2), GREEN)    // Bottom-right
        drawHomeArea(renderer.getHomeArea(3), YELLOW)    // Bottom-left

        // Draw center home
        drawCenterHome()

        // Draw grid lines
        drawGrid(cellSize)

        // Draw colored path for each home column
        drawHomeColumn(0, RED, renderer.getHomeColumn(0), cellSize)
        drawHomeColumn(1, BLUE, renderer.getHomeColumn(1), cellSize)
        drawHomeColumn(2, GREEN, renderer.getHomeColumn(2), cellSize)
        drawHomeColumn(3, YELLOW, renderer.getHomeColumn(3), cellSize)

        // Draw all tokens with animated positions
        gameState.players.forEachIndexed { playerIdx, player ->
            val color = when (playerIdx) {
                0 -> RED; 1 -> BLUE; 2 -> GREEN; 3 -> YELLOW
                else -> Color.Gray
            }
            player.tokens.forEachIndexed { tokenIdx, token ->
                if (!token.isFinished) {
                    val animatedPos = remember(token.position) {
                        token.position
                    }
                    val targetPixel = renderer.gridToPixel(
                        animatedPos.x, animatedPos.y, cellSize
                    )
                    val animatedOffset by animateFloatAsState(
                        targetValue = targetPixel,
                        animationSpec = spring(
                            dampingRatio = Spring.DampingRatioMediumBouncy,
                            stiffness = Spring.StiffnessLow
                        ),
                        label = "token_$playerIdx$tokenIdx"
                    )

                    val tokenCenter = Offset(
                        targetPixel + cellSize / 2,
                        renderer.getTokenYOffset(playerIdx, tokenIdx) * cellSize
                    )
                    val radius = cellSize * 0.28f

                    // Token shadow
                    drawCircle(
                        color = Color.Black.copy(alpha = 0.2f),
                        radius = radius,
                        center = tokenCenter.copy(y = tokenCenter.y + 2.dp.toPx())
                    )
                    // Token body
                    drawCircle(
                        color = color,
                        radius = radius,
                        center = tokenCenter
                    )
                    // Token highlight
                    drawCircle(
                        color = Color.White.copy(alpha = 0.3f),
                        radius = radius * 0.4f,
                        center = tokenCenter.copy(
                            x = tokenCenter.x - radius * 0.3f,
                            y = tokenCenter.y - radius * 0.3f
                        )
                    )
                }
            }
        }

        // Highlight movable tokens for current player
        val currentPlayer = gameState.players.getOrNull(gameState.currentPlayerIdx)
        currentPlayer?.movableTokens?.forEach { movableIdx ->
            val token = currentPlayer.tokens.getOrNull(movableIdx) ?: return@forEach
            val pixel = renderer.gridToPixel(token.position.x, token.position.y, cellSize)
            val center = Offset(pixel + cellSize / 2, renderer.getTokenYOffset(gameState.currentPlayerIdx, movableIdx) * cellSize)
            drawCircle(
                color = Color.White.copy(alpha = 0.5f),
                radius = cellSize * 0.34f,
                center = center,
                style = Stroke(width = 3.dp.toPx())
            )
        }
    }
}

private fun DrawScope.drawHomeArea(area: BoardCoordinate, color: Color) {
    drawRect(
        color = color.copy(alpha = 0.3f),
        topLeft = Offset(area.pixelX, area.pixelY),
        size = Size(area.cellSize * 6, area.cellSize * 6)
    )
}

private fun DrawScope.drawCenterHome() {
    val centerX = size.width / 2 - size.width / 15f * 3
    val centerY = size.height / 2 - size.width / 15f * 3
    val homeSize = size.width / 15f * 6

    // Draw star pattern for home center
    drawRect(
        color = Color(0xFF795548),
        topLeft = Offset(centerX, centerY),
        size = Size(homeSize, homeSize)
    )
    // Inner decoration
    val starRadius = homeSize / 3
    drawCircle(
        color = Color(0xFFFFD700),
        radius = starRadius,
        center = Offset(centerX + homeSize / 2, centerY + homeSize / 2),
        style = Stroke(width = 2.dp.toPx())
    )
}

private fun DrawScope.drawGrid(cellSize: Float) {
    for (i in 0..15) {
        // Vertical lines
        drawLine(
            color = GRID_LINE,
            start = Offset(i * cellSize, 0f),
            end = Offset(i * cellSize, size.height),
            strokeWidth = 1.dp.toPx()
        )
        // Horizontal lines
        drawLine(
            color = GRID_LINE,
            start = Offset(0f, i * cellSize),
            end = Offset(size.width, i * cellSize),
            strokeWidth = 1.dp.toPx()
        )
    }
}

private fun DrawScope.drawHomeColumn(
    playerIndex: Int,
    color: Color,
    path: List<BoardCoordinate>,
    cellSize: Float
) {
    path.forEach { coord ->
        drawRect(
            color = color.copy(alpha = 0.2f),
            topLeft = Offset(coord.pixelX, coord.pixelY),
            size = Size(cellSize, cellSize)
        )
    }
}
ui/board/BoardRenderer.kt
package com.ludoking.game.ui.board

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.dp

class BoardRenderer {
    private var boardWidth = 0f
    private var boardHeight = 0f
    private var cellSize = 0f

    fun updateBoardMetrics(width: Float, height: Float, cellSizePx: Float) {
        boardWidth = width
        boardHeight = height
        cellSize = cellSizePx
    }

    fun gridToPixel(gridX: Int, gridY: Int, cellSize: Float): Float {
        return gridX * cellSize + cellSize / 6
    }

    fun findCellAt(px: Float, py: Float, totalWidth: Float): Pair<Int, Int>? {
        val cs = totalWidth / 15f
        val gx = (px / cs).toInt()
        val gy = (py / cs).toInt()
        return if (gx in 0..14 && gy in 0..14) gx to gy else null
    }

    fun findTokenAt(
        cell: Pair<Int, Int>,
        state: com.ludoking.game.data.model.GameState
    ): Pair<Int, Int>? {
        state.players.forEachIndexed { pIdx, player ->
            player.tokens.forEachIndexed { tIdx, token ->
                if (token.position.x == cell.first && token.position.y == cell.second && !token.isFinished) {
                    return pIdx to tIdx
                }
            }
        }
        return null
    }

    fun getHomeArea(playerIndex: Int): BoardCoordinate {
        val offsets = listOf(
            0 to 0,   // Red: top-left
            9 to 0,   // Blue: top-right
            9 to 9,   // Green: bottom-right
            0 to 9    // Yellow: bottom-left
        )
        val (gx, gy) = offsets.getOrElse(playerIndex) { 0 to 0 }
        return BoardCoordinate(gx, gy, gx * cellSize, gy * cellSize, cellSize)
    }

    fun getHomeColumn(playerIndex: Int): List<BoardCoordinate> {
        // Home columns are the 5-cell colored paths leading to center
        return buildList {
            val (startX, startY, dx, dy) = when (playerIndex) {
                0 -> listOf(1, 6, 1, 0)    // Red: moves right
                1 -> listOf(8, 1, 0, 1)    // Blue: moves down
                2 -> listOf(13, 8, -1, 0)  // Green: moves left
                3 -> listOf(6, 13, 0, -1) // Yellow: moves up
                else -> listOf(0, 0, 0, 0)
            }
            val coords = playerIndex to Triple(startX, startY, dx to dy)
        }
    }

    fun getTokenYOffset(playerIndex: Int, tokenIndex: Int): Float {
        // Stack tokens vertically within the same cell
        val baseOffsets = mapOf(
            0 to listOf(0.1f, 0.3f, 0.5f, 0.7f),
            1 to listOf(0.2f, 0.4f, 0.6f, 0.8f),
            2 to listOf(0.1f, 0.3f, 0.5f, 0.7f),
            3 to listOf(0.2f, 0.4f, 0.6f, 0.8f)
        )
        return baseOffsets[playerIndex]?.getOrElse(tokenIndex) { 0.3f } ?: 0.3f
    }
}

WorkManager Background Sync

WorkManager is the Android-recommended solution for deferrable background work that must execute reliably — even if the app is closed or the device restarts. For Ludo games, WorkManager handles three critical background tasks: match result sync (uploading completed game results to the server when connectivity returns), pending move uploads (offline moves made during a network outage are queued and flushed when online), and leaderboard cache refresh (fetching updated rankings periodically without waking the app).

WorkManager's constraint system ensures these workers only run when the device has a non-metered network connection and is not in low battery mode. The ExistingWorkPolicy prevents duplicate workers from stacking when multiple conditions trigger simultaneously. Because WorkManager respects the Android application lifecycle, workers are automatically paused when the system is under memory pressure, ensuring game performance is never degraded by background sync operations.

workers/GameSyncWorker.kt
package com.ludoking.game.workers

import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.ludoking.game.data.local.GameDao
import com.ludoking.game.data.local.MatchResultEntity
import com.ludoking.game.data.remote.LudoApiService
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit

@HiltWorker
class GameSyncWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted workerParams: WorkerParameters,
    private val apiService: LudoApiService,
    private val gameDao: GameDao
) : CoroutineWorker(context, workerParams) {

    override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
        try {
            // 1. Upload pending match results
            val pendingResults = gameDao.getPendingSyncResults()
            var allSynced = true

            for (result in pendingResults) {
                val response = apiService.syncMatchResult(result)
                if (response.isSuccessful) {
                    gameDao.markResultSynced(result.sessionId)
                } else {
                    allSynced = false
                }
            }

            // 2. Fetch latest leaderboard if full sync succeeded
            if (allSynced) {
                val leaderboardResponse = apiService.getLeaderboard(limit = 100)
                if (leaderboardResponse.isSuccessful) {
                    leaderboardResponse.body()?.let { leaderboard ->
                        gameDao.updateLeaderboard(leaderboard)
                    }
                }
            }

            Result.success()
        } catch (e: Exception) {
            if (runAttemptCount < 3) {
                Result.retry()
            } else {
                Result.failure()
            }
        }
    }

    companion object {
        private const val PERIODIC_WORK_NAME = "game_sync_periodic"
        private const val ONE_TIME_WORK_NAME = "game_sync_immediate"

        fun schedulePeriodicSync(context: Context) {
            val constraints = Constraints.Builder()
                .setRequiredNetworkType(NetworkType.CONNECTED)
                .setRequiresBatteryNotLow(true)
                .build()

            val periodicSync = PeriodicWorkRequestBuilder<GameSyncWorker>(
                repeatInterval = 15,
                repeatIntervalTimeUnit = TimeUnit.MINUTES,
                flexTimeWindow = 5,
                flexTimeWindowUnit = TimeUnit.MINUTES
            )
                .setConstraints(constraints)
                .addTag("ludo_game_sync")
                .build()

            WorkManager.getInstance(context).enqueueUniquePeriodicWork(
                PERIODIC_WORK_NAME,
                ExistingPeriodicWorkPolicy.KEEP,
                periodicSync
            )
        }

        fun triggerImmediateSync(context: Context) {
            val constraints = Constraints.Builder()
                .setRequiredNetworkType(NetworkType.CONNECTED)
                .build()

            val immediateSync = OneTimeWorkRequestBuilder<GameSyncWorker>()
                .setConstraints(constraints)
                .addTag("ludo_game_sync_immediate")
                .build()

            WorkManager.getInstance(context).enqueueUniqueWork(
                ONE_TIME_WORK_NAME,
                ExistingWorkPolicy.REPLACE,
                immediateSync
            )
        }
    }
}

class BootReceiver : android.content.BroadcastReceiver() {
    override fun onReceive(context: android.content.Context, intent: android.content.Intent) {
        if (intent.action == android.content.Intent.ACTION_BOOT_COMPLETED) {
            GameSyncWorker.schedulePeriodicSync(context)
        }
    }
}

FCM Push Notifications for Turn Reminders

Firebase Cloud Messaging delivers turn reminder notifications that re-engage players who have left the app mid-game. The FCM service below handles three notification types: turn reminders (your turn in a live game), game invitations (friend wants to play), and tournament updates (bracket changes, prize announcements). Each notification type maps to a different notification channel with distinct importance levels — turn reminders use high importance to break through Do Not Disturb, while tournament updates use default importance.

The service extracts game metadata from the FCM payload and uses Android's NotificationManager .notify() with a per-game notification tag to prevent stacking notifications for the same game. Deep links embedded in the notification payload navigate directly to the specific game board when the player taps the notification, bypassing the home screen entirely.

notifications/LudoFCMService.kt
package com.ludoking.game.notifications

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.ludoking.game.R
import com.ludoking.game.ui.MainActivity
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class LudoFCMService : FirebaseMessagingService() {

    companion object {
        private const val CHANNEL_TURN_REMINDER = "turn_reminder"
        private const val CHANNEL_GAME_INVITE = "game_invite"
        private const val CHANNEL_TOURNAMENT = "tournament_updates"
        private const val NOTIFICATION_ID_BASE = 1000
    }

    override fun onCreate() {
        super.onCreate()
        createNotificationChannels()
    }

    override fun onNewToken(token: String) {
        super.onNewToken(token)
        // Send token to backend for targeted push delivery
        sendTokenToServer(token)
    }

    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        super.onMessageReceived(remoteMessage)

        val data = remoteMessage.data
        val notificationType = data["type"] ?: return

        when (notificationType) {
            "turn_reminder" -> showTurnReminder(data)
            "game_invite" -> showGameInvite(data)
            "tournament_update" -> showTournamentUpdate(data)
        }
    }

    private fun showTurnReminder(data: Map<String, String>) {
        val gameId = data["gameId"] ?: return
        val gameName = data["gameName"] ?: "Ludo Game"
        val currentPlayer = data["currentPlayer"] ?: "Player"
        val turnNumber = data["turnNumber"]?.toIntOrNull() ?: 0

        val notification = NotificationCompat.Builder(this, CHANNEL_TURN_REMINDER)
            .setSmallIcon(R.drawable.ic_dice)
            .setContentTitle("Your turn in $gameName")
            .setContentText("$currentPlayer rolled. Tap to take your turn!")
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setCategory(NotificationCompat.CATEGORY_REMINDER)
            .setAutoCancel(true)
            .setContentIntent(createDeepLinkIntent("ludoking://game/$gameId"))
            .addAction(
                R.drawable.ic_dice,
                "Take Turn",
                createDeepLinkIntent("ludoking://game/$gameId/action/take_turn")
            )
            .addAction(
                R.drawable.ic_close,
                "Forfeit",
                createDeepLinkIntent("ludoking://game/$gameId/action/forfeit")
            )
            .build()

        val manager = getSystemService(NotificationManager::class.java)
        manager?.notify(
            "game_$gameId",
            NOTIFICATION_ID_BASE + turnNumber,
            notification
        )
    }

    private fun showGameInvite(data: Map<String, String>) {
        val inviterName = data["inviterName"] ?: "A friend"
        val gameId = data["gameId"] ?: return
        val inviterId = data["inviterId"] ?: ""

        val notification = NotificationCompat.Builder(this, CHANNEL_GAME_INVITE)
            .setSmallIcon(R.drawable.ic_dice)
            .setContentTitle("$inviterName invited you to play Ludo")
            .setContentText("Join now and compete for the top spot!")
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
            .setCategory(NotificationCompat.CATEGORY_SOCIAL)
            .setAutoCancel(true)
            .setContentIntent(createDeepLinkIntent("ludoking://game/$gameId/accept"))
            .addAction(R.drawable.ic_check, "Accept", createDeepLinkIntent("ludoking://game/$gameId/accept"))
            .addAction(R.drawable.ic_close, "Decline", createDeepLinkIntent("ludoking://game/$gameId/decline"))
            .build()

        getSystemService(NotificationManager::class.java)
            ?.notify("invite_$inviterId", NOTIFICATION_ID_BASE + inviterId.hashCode(), notification)
    }

    private fun showTournamentUpdate(data: Map<String, String>) {
        val title = data["title"] ?: "Tournament Update"
        val message = data["message"] ?: "Check the latest tournament status."
        val tournamentId = data["tournamentId"] ?: return

        val notification = NotificationCompat.Builder(this, CHANNEL_TOURNAMENT)
            .setSmallIcon(R.drawable.ic_trophy)
            .setContentTitle(title)
            .setContentText(message)
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
            .setAutoCancel(true)
            .setContentIntent(createDeepLinkIntent("ludoking://tournament/$tournamentId"))
            .build()

        getSystemService(NotificationManager::class.java)
            ?.notify("tournament_$tournamentId", NOTIFICATION_ID_BASE + 9000, notification)
    }

    private fun createNotificationChannels() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val turnChannel = NotificationChannel(
                CHANNEL_TURN_REMINDER,
                "Turn Reminders",
                NotificationManager.IMPORTANCE_HIGH
            ).apply {
                description = "Notifications when it's your turn in a Ludo game"
                enableVibration(true)
                enableLights(true)
            }

            val inviteChannel = NotificationChannel(
                CHANNEL_GAME_INVITE,
                "Game Invitations",
                NotificationManager.IMPORTANCE_DEFAULT
            ).apply {
                description = "Invitations from friends to play Ludo"
            }

            val tournamentChannel = NotificationChannel(
                CHANNEL_TOURNAMENT,
                "Tournament Updates",
                NotificationManager.IMPORTANCE_LOW
            ).apply {
                description = "Updates about Ludo tournaments and prizes"
            }

            val manager = getSystemService(NotificationManager::class.java)
            manager?.createNotificationChannels(listOf(turnChannel, inviteChannel, tournamentChannel))
        }
    }

    private fun createDeepLinkIntent(uri: String): PendingIntent {
        val intent = Intent(this, MainActivity::class.java).apply {
            data = android.net.Uri.parse(uri)
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
        }
        return PendingIntent.getActivity(
            this,
            uri.hashCode(),
            intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )
    }

    private fun sendTokenToServer(token: String) {
        // Use WorkManager to send token asynchronously to avoid ANR
        val workRequest = androidx.work.OneTimeWorkRequestBuilder<TokenSyncWorker>()
            .setInputData(androidx.work.Data.Builder().putString("fcm_token", token).build())
            .build()
        androidx.work.WorkManager.getInstance(this).enqueue(workRequest)
    }
}

ProGuard and R8 Obfuscation Rules

R8 is enabled by default for release builds on AGP 8.0+, replacing the older ProGuard toolchain. For Ludo games, R8 optimization is critical for two reasons: reducing APK size (important for emerging markets where users are on limited data plans) and protecting game logic from reverse engineering. However, Socket.IO, Retrofit, and Gson all use reflection internally, requiring explicit keep rules to prevent R8 from stripping essential classes.

The rules below cover the essential ProGuard configuration for a production Ludo game. The Socket.IO rules preserve event listener registration, Retrofit preserves model classes used in API responses, and Room preserves database entities and DAOs. The Gson rules ensure field names are preserved in JSON serialization — a mismatch here causes silent data corruption where server responses deserialize into the wrong fields.

proguard-rules.pro
# =============================================
# Ludo Game ProGuard / R8 Rules
# =============================================

# ---- Socket.IO Client ----
# Socket.IO uses dynamic event listeners registered via on()
# R8 may incorrectly remove listeners that are only reachable via reflection
-keep class io.socket.** { *; }
-keep class io.socket.client.** { *; }
-keep interface io.socket.client.** { *; }
-keepclassmembers class io.socket.client.Socket {
    <fields>;
    <methods>;
}
-keepclassmembers class io.socket.emitter.Emitter {
    <fields>;
    <methods>;
}

# Preserve Socket.IO event names as strings (they are matched by string name)
-keepclassmembers class * {
    @io.socket.emitter.Emitter.Listener <methods>;
}

# ---- Retrofit & OkHttp ----
-keepattributes Signature, InnerClasses, EnclosingMethod
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
-keepattributes AnnotationDefault

-keepclassmembers,allowshrinking,allowobfuscation interface * {
    @retrofit2.http.* <methods>;
}

-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
-dontwarn javax.annotation.**
-dontwarn kotlin.Unit
-dontwarn retrofit2.KotlinExtensions
-dontwarn retrofit2.KotlinExtensions$*

-if interface * { @retrofit2.http.* <methods>; }
-keep,allowobfuscation interface <1>

-if interface * { @retrofit2.http.* <methods>; }
-keep,allowobfuscation interface <1> { *; }

# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn javax.annotation.**
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase

# ---- Gson ----
# CRITICAL: Preserve field names for JSON serialization.
# Gson uses field names directly — renaming breaks all API responses.
-keepattributes Signature
-keepattributes *Annotation*
-dontwarn sun.misc.**
-keep class com.google.gson.stream.** { *; }
-keep class * extends com.google.gson.TypeAdapter
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer

# Keep all data model classes used with Retrofit/Gson
# Add your model package here
-keep class com.ludoking.game.data.model.** { *; }
-keep class com.ludoking.game.data.remote.** { *; }
-keep class com.ludoking.game.data.local.** { *; }

# ---- Room Database ----
# Room generates implementation classes at compile time
# R8 must keep entity classes and DAO interfaces
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
-dontwarn androidx.room.paging.**

# ---- Hilt ----
-keep class dagger.hilt.** { *; }
-keep class javax.inject.** { *; }
-keep class * extends dagger.hilt.android.internal.managers.ComponentSupplier { *; }
-keep class * extends dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper { *; }

# ---- Firebase ----
-keep class com.google.firebase.** { *; }
-dontwarn com.google.firebase.**
-keep class com.google.android.gms.** { *; }
-dontwarn com.google.android.gms.**

# ---- Kotlin Coroutines ----
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembers class kotlinx.coroutines.** {
    volatile <fields>;
}
-keepclassmembers class kotlin.coroutines.SafeContinuation {
    volatile <fields>;
}

# ---- Data Classes used for WebSocket events ----
# These are serialized/deserialized at runtime via Gson
-keepclassmembers class com.ludoking.game.data.model.GameState {
    <fields>;
}
-keepclassmembers class com.ludoking.game.data.model.MovePayload {
    <fields>;
}
-keepclassmembers class com.ludoking.game.data.model.MoveResult {
    <fields>;
}
-keepclassmembers class com.ludoking.game.data.model.TurnEvent {
    <fields>;
}

# ---- Keep enum values ----
-keepclassmembers enum * {
    public static **[] values();
    public static ** valueOf(java.lang.String);
}

# ---- Compose ----
-dontwarn androidx.compose.**

# ---- WebSocket URL preservation ----
# Some ProGuard versions rename string literals used as URL patterns
-keep class com.ludoking.game.BuildConfig {
    String BASE_URL;
    String SOCKET_URL;
}

# =============================================
# Optimization settings for release builds
# =============================================
-allowaccessmodification
-repackageclasses
-renamesourcefileattribute SourceFile

In-App Review API for Play Store Ratings

The Google Play In-App Review API lets you prompt players to rate your Ludo game without leaving the app. Unlike the older MarketIntent approach that forced users out of the app, the in-app review flow displays a native bottom sheet where users can submit a star rating and optional review text — all within context. For Ludo games, the best time to trigger a review request is after a completed match, not on first launch, and only for engaged players (5+ games played, session longer than 3 minutes, no prior rating attempt).

The InAppReviewManager below implements these heuristics using DataStore to track player engagement milestones and uses a single ReviewManager instance shared across the app via Hilt injection. The manager respects API rate limits by checking ReviewInfo.isAvailable() before launching the flow — Google caps review prompts to 1 per user per 30 days regardless of app behavior, so calling the flow when unavailable is wasted.

ui/review/InAppReviewManager.kt
package com.ludoking.game.ui.review

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import androidx.lifecycle.lifecycleScope
import com.google.android.play.core.inappreview.InAppReviewManager
import com.google.android.play.core.inappreview.InAppReviewPriority
import com.google.android.play.core.inappreview.InAppReviewStatus
import com.google.android.play.core.review.ReviewInfo
import com.google.android.play.core.review.ReviewManagerFactory
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton

private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "ludo_review_prefs")

@Singleton
class InAppReviewManager @Inject constructor(
    @ApplicationContext private val context: Context
) {
    private val reviewManager: InAppReviewManager =
        ReviewManagerFactory.create(context)

    private object Prefs {
        private val GAMES_PLAYED = intPreferencesKey("games_played")
        private val SESSION_TIME_MS = longPreferencesKey("session_time_ms")
        private val REVIEW_ATTEMPTED = booleanPreferencesKey("review_attempted")
        private val LAST_SESSION_TIME = longPreferencesKey("last_session_time")
    }

    // Thresholds for triggering review prompt
    companion object {
        private const val MIN_GAMES_PLAYED = 5
        private const val MIN_SESSION_TIME_MS = 3 * 60 * 1000L // 3 minutes
        private const val MAX_SESSION_GAP_MS = 30 * 60 * 1000L // 30 minutes since last play
    }

    fun onGameCompleted() {
        val prefs = context.dataStore.data
        val current = prefs.first()
        val gamesPlayed = (current[Prefs.GAMES_PLAYED] ?: 0) + 1
        val sessionStart = current[Prefs.LAST_SESSION_TIME] ?: System.currentTimeMillis()
        val sessionDuration = System.currentTimeMillis() - sessionStart

        context.dataStore.edit { editor ->
            editor[Prefs.GAMES_PLAYED] = gamesPlayed
            editor[Prefs.SESSION_TIME_MS] = (current[Prefs.SESSION_TIME_MS] ?: 0L) + sessionDuration
            editor[Prefs.LAST_SESSION_TIME] = System.currentTimeMillis()
        }

        // Trigger review check after a completed game
        checkAndLaunchReview()
    }

    fun onSessionStart() {
        context.dataStore.edit { editor ->
            editor[Prefs.LAST_SESSION_TIME] = System.currentTimeMillis()
        }
    }

    private fun checkAndLaunchReview() {
        val prefs = context.dataStore.data
        val current = prefs.first()

        val gamesPlayed = current[Prefs.GAMES_PLAYED] ?: 0
        val totalSessionTime = current[Prefs.SESSION_TIME_MS] ?: 0L
        val reviewAttempted = current[Prefs.REVIEW_ATTEMPTED] ?: false
        val lastSessionTime = current[Prefs.LAST_SESSION_TIME] ?: 0L
        val timeSinceLastSession = System.currentTimeMillis() - lastSessionTime

        // Heuristic: engaged player who just finished a game
        val isEngagedPlayer = gamesPlayed >= MIN_GAMES_PLAYED &&
                (totalSessionTime >= MIN_SESSION_TIME_MS || timeSinceLastSession < MAX_SESSION_GAP_MS)

        if (!isEngagedPlayer || reviewAttempted) return

        reviewManager.requestInAppReview(context.mainExecutor)
            .addOnSuccessListener { reviewInfo: ReviewInfo ->
                if (reviewInfo.isAvailable) {
                    launchReview(reviewInfo)
                }
            }
            .addOnFailureListener { e ->
                // Log failure silently — never block gameplay for review issues
            }
    }

    private fun launchReview(reviewInfo: ReviewInfo) {
        reviewManager.launchReviewFlow(context as android.app.Activity, reviewInfo)
            .addOnCompleteListener { result ->
                context.dataStore.edit { editor ->
                    editor[Prefs.REVIEW_ATTEMPTED] = true
                }

                when (result.result) {
                    InAppReviewStatus.REVIEW_IN_PROGRESS -> {
                        // User is actively reviewing
                    }
                    InAppReviewStatus.REVIEW_APPROVED -> {
                        // User gave 5 stars — celebrate with a confetti animation
                    }
                    InAppReviewStatus.REVIEW_REFUSED,
                    InAppReviewStatus.REVIEW_NOT_LAUNCHED -> {
                        // User dismissed — don't ask again
                    }
                    InAppReviewStatus.PLAY_STORE_NOT_AVAILABLE -> {
                        // API not available on this device — fall back to link
                    }
                }
            }
    }
}

Architecture: MVVM with StateFlow

The game UI follows the MVVM pattern with StateFlow as the reactive backbone. The GameViewModel owns the game state, subscribes to Socket.IO events via the injected SocketClient, and exposes immutable StateFlow objects that the Compose UI collects reactively. This architecture cleanly separates the network layer from the UI layer — the Compose board composable never talks directly to the WebSocket client, only to the ViewModel's state.

Hilt's @AndroidEntryPoint annotation on the GameViewModel enables constructor injection for the SocketClient, LudoApiService, and GameDao. The ViewModel uses a viewModelScope-backed CoroutineScope to launch background tasks (API calls, database writes) that are automatically cancelled when the ViewModel is cleared, preventing memory leaks and orphaned coroutines.

ui/game/GameViewModel.kt
package com.ludoking.game.ui.game

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ludoking.game.data.local.GameDao
import com.ludoking.game.data.model.GameState
import com.ludoking.game.data.model.MovePayload
import com.ludoking.game.data.model.MoveResult
import com.ludoking.game.data.model.Player
import com.ludoking.game.data.model.TurnEvent
import com.ludoking.game.data.remote.LudoApiService
import com.ludoking.game.data.remote.SocketClient
import com.ludoking.game.ui.review.InAppReviewManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject

data class GameUiState(
    val gameState: GameState? = null,
    val isMyTurn: Boolean = false,
    val diceValue: Int? = null,
    val isRollingDice: Boolean = false,
    val lastMoveResult: MoveResult? = null,
    val connectionState: GameConnectionState = GameConnectionState(),
    val errorMessage: String? = null,
    val isGameOver: Boolean = false
)

@HiltViewModel
class GameViewModel @Inject constructor(
    private val socketClient: SocketClient,
    private val apiService: LudoApiService,
    private val gameDao: GameDao,
    private val reviewManager: InAppReviewManager
) : ViewModel() {

    private val _uiState = MutableStateFlow(GameUiState())
    val uiState: StateFlow<GameUiState> = _uiState.asStateFlow()

    val connectionState: StateFlow<GameConnectionState> = socketClient.connectionState
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), GameConnectionState())

    init {
        observeSocketEvents()
        reviewManager.onSessionStart()
    }

    fun joinGame(gameId: String, authToken: String) {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(errorMessage = null)
            socketClient.connect(gameId, authToken)
        }
    }

    fun rollDice() {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isRollingDice = true)
            socketClient.sendDiceRoll()
        }
    }

    fun movePiece(playerIndex: Int, tokenIndex: Int) {
        val state = _uiState.value.gameState ?: return
        val movePayload = MovePayload(
            gameId = state.sessionId,
            playerId = state.currentPlayerIdx,
            tokenId = tokenIndex,
            fromPosition = state.players[playerIndex].tokens[tokenIndex].position,
            timestamp = System.currentTimeMillis()
        )
        socketClient.sendMove(movePayload)
    }

    private fun observeSocketEvents() {
        viewModelScope.launch {
            socketClient.moveEvents.collect { result ->
                _uiState.value = _uiState.value.copy(
                    lastMoveResult = result,
                    isRollingDice = false
                )
                persistMoveResult(result)
            }
        }

        viewModelScope.launch {
            socketClient.turnEvents.collect { event ->
                val currentState = _uiState.value.gameState
                val isMyTurn = currentState?.let { event.nextPlayerIdx == it.myPlayerIdx } ?: false
                _uiState.value = _uiState.value.copy(
                    isMyTurn = isMyTurn,
                    diceValue = event.diceValue
                )
            }
        }

        viewModelScope.launch {
            socketClient.gameStateEvents.collect { state ->
                val wasGameOver = _uiState.value.isGameOver
                _uiState.value = _uiState.value.copy(
                    gameState = state,
                    isGameOver = state.status == "completed"
                )
                if (state.status == "completed" && !wasGameOver) {
                    reviewManager.onGameCompleted()
                }
            }
        }
    }

    private suspend fun persistMoveResult(result: MoveResult) {
        // Save to Room for offline replay capability
        gameDao.saveMove(
            MoveEntity(
                sessionId = result.sessionId,
                playerId = result.playerId,
                tokenId = result.tokenId,
                fromPos = result.fromPos,
                toPos = result.toPos,
                timestamp = result.timestamp,
                synced = false
            )
        )
    }

    override fun onCleared() {
        super.onCleared()
        socketClient.disconnect()
    }
}

Internal Links

React Native Tutorial

If you need cross-platform mobile support alongside Android, explore the React Native Ludo tutorial which shares API contracts and WebSocket event schemas with this Android implementation.

Real-Time API Reference

The real-time WebSocket API documents every socket event, payload structure, and room-joining flow used by the SocketClient in this guide.

Node.js Backend

Pair this Android client with the Node.js backend guide for the server-side WebSocket implementation that handles matchmaking and game state synchronization.

API Tutorial

The general API tutorial covers authentication, rate limiting, and error handling that apply to both REST calls and WebSocket connections.

Frequently Asked Questions

What Android SDK versions should I target for a Ludo game?
Target SDK 34 (Android 14) as the compile target with a minimum SDK of 24 (Android 7.0 Nougat). SDK 24 ensures compatibility with 98%+ of active Android devices globally. The only trade-off is that foreground service type declarations (connectedDevice) are ignored on older versions, so your WebSocket connections may be killed more aggressively on Android 7–13 when the app backgrounds during an active match. Use a foreground service with the appropriate type on Android 14+ to keep connections alive. For the absolute minimum APK size, enable R8 minification — the ProGuard rules in this guide reduce typical Ludo game APKs from 25MB to under 8MB.
How does Hilt compare to manual dependency injection for Ludo games?
Hilt eliminates boilerplate by generating the dependency graph at compile time via annotation processing. For a Ludo game with a WebSocket client, Room database, Retrofit service, and multiple ViewModels, manual DI requires writing a custom ApplicationComponent that manually instantiates and holds references to every dependency. Hilt's @Singleton scoping means the same Socket.IO instance is reused across all ViewModels in an activity lifecycle, preventing duplicate WebSocket connections when the configuration changes (e.g., screen rotation). The startup time cost is paid once at compilation; runtime performance is identical to manual DI because Hilt generates straightforward factory code.
Why use WorkManager instead of a simple background thread for game sync?
WorkManager provides three guarantees that simple threads cannot: guaranteed execution even if the app is killed, respect for system battery optimization policies, and built-in constraint checking (network availability, battery level, device idle). A coroutine launched from a Service dies when the system kills the Service under memory pressure. WorkManager persists the work request to disk and reschedules it automatically when conditions are met. For a Ludo game, this means a player who completes a match on the subway (no network) and then opens the app 2 hours later will have their game result synced to the server without any additional user action. The 15-minute periodic sync worker also keeps leaderboard data fresh without requiring the app to be open.
How do FCM push notifications work when the app is force-stopped?
Force-stopped apps on Android do not receive FCM messages. This is a platform restriction introduced in Android 3.1 and cannot be bypassed. The mitigation strategy is to use high importance notifications that work even when the app is backgrounded (but not force-stopped), and to design the game with a generous turn timeout (5 minutes recommended) so players have time to reopen the app before forfeit. For tournaments where force-stop cheating is a concern, implement server-side timeout enforcement — if the server does not receive a move within the timeout window, it auto-forfeits the player regardless of client-side notification delivery. See the Node.js backend guide for server-side timeout implementation.
What are the ProGuard performance implications for Socket.IO in release builds?
R8's optimization can actually improve Socket.IO performance by removing unused event listeners and inlining hot paths in the Socket.IO library. The main risk is class removal — if R8 renames or strips a class that Socket.IO loads via reflection (e.g., the Socket.IO protocol handler classes), the WebSocket connection silently fails at runtime. The keep rules in this guide preserve all Socket.IO public APIs and the Gson model classes that Socket.IO serializes. After enabling R8, test the release build on a physical device connected to a real multiplayer server — the Socket.IO debug logs (enabled via Socket.enableDebugLogs()) reveal any class-not-found issues immediately.
How does the In-App Review API integrate with Jetpack Compose?
The In-App Review API is not a composable — it requires an Activity reference to launch the system UI flow. The InAppReviewManager in this guide accepts the ApplicationContext from Hilt but checks context is Activity before calling launchReviewFlow(). In your MainActivity, pass this to the manager when triggering the review. The manager tracks engagement milestones (games played, session time) in DataStore using a Hilt-provided CoroutineScope, so the review eligibility check runs off the main thread. When the review flow completes, a callback fires in addOnCompleteListener — you can trigger a confetti composable overlay here to celebrate users who gave 5 stars.

Get the Complete Android Ludo Game Template

Download a fully configured Android Studio project with Jetpack Compose board, Hilt DI, WorkManager sync, FCM notifications, In-App Review, and ProGuard rules pre-integrated.

Get Android Project Template on WhatsApp