Game Dev calendar_today February 10, 2025 schedule 6 min read

Developing Luma Luma: Building a Strategy Board Game in Kotlin and Compose

Luma Luma is a fast-paced digital strategy game that reimagines the "roll-and-write" genre through the lens of modern mobile engineering. Featuring dynamic, procedurally-generated 15x7 grids and a competitive dice-drafting engine, the project showcases how Jetpack Compose and Kotlin can be used to build high-performance, responsive gaming experiences.

Luma Luma Board Game Menu Screenshot

In traditional board game design, roll-and-write games rely on sheets of paper, dice, and markers. Digitizing this experience opens up incredible design possibilities: procedurally-generated map grids, dynamic tile placements, automatic score tallies, and instant cross-platform multiplayer.

However, translating tactile, mechanical board gaming elements into a digital screen introduces significant frontend engineering hurdles. We needed high-framerate rendering, precise tap coordinates on rectangular grids, and atomic state updates. To solve this, we went all-in on Kotlin and Jetpack Compose.

The Mechanics: Roll, Combine, Conquer

At its core, Luma Luma is a game of tactical optimization. Every turn, six dice are rolled—three representing colors and three representing numbers. Players must select one color and one number to mark cells on their personal 15x7 grid.

Strategic Placement

All marks must connect to the central "Column H" or an existing mark. To maximize efficiency, marks of the same color must stay adjacent to each other, forcing players to plan their paths carefully across the grid.

Race for Bonuses

Players compete to finish columns and color groups. The first to claim a column earns the maximum reward, while others receive a reduced value. The game ends as soon as two full color groups are completed.

To add further depth, players can utilize a limited number of **Jokers** to bypass dice restrictions at the cost of final points. Conversely, "Moon" cells scattered across the board act as traps: failing to mark them by the end of the game results in heavy penalties.

Declarative UI Meets High-Performance Game Loops

Jetpack Compose is highly optimized for business forms and lists, but complex game boards with thousands of interactive cells can cause performance bottlenecks due to excessive recomposition.

To overcome this, we bypassed nested composables for the main board. Instead, we used a single **`Canvas`** component and performed low-level 2D math to calculate grid coordinate matrices.

BoardCanvas.kt
Canvas(
    modifier = Modifier
        .fillMaxSize()
        .pointerInput(Unit) {
            detectTapGestures { offset ->
                val (row, col) = pixelToGrid(offset, cellSize)
                viewModel.onCellClicked(row, col)
            }
        }
) {
    // Render the 15x7 grid efficiently
    boardState.cells.forEach { cell ->
        drawRect(
            color = cell.color,
            topLeft = Offset(cell.x, cell.y),
            size = Size(cellSize, cellSize)
        )
        if (cell.isMarked) {
            drawMarkOverlay(cell, accentColor)
        }
    }
}

By confining all visual changes to inside the `DrawScope` of the `Canvas`, we can update colors, animations, and icons in real-time without triggering a full recomposition of the surrounding layout. This allows Luma Luma to maintain a solid **60 FPS** even on budget Android devices.

State Management with Unidirectional Data Flow

Strategy board games are highly stateful. Players roll dice, draft tokens, mark grid points, trigger combo points, and submit scores. To prevent race conditions or UI desyncs, Luma Luma employs a strict **Unidirectional Data Flow (UDF)** using Kotlin Coroutines and StateFlows:

autorenew The UDF Life-Cycle in Luma Luma

  1. Intent: User taps a cell (UI emits a GameIntent.SelectCell sealed class).
  2. Process: The GameViewModel receives the intent and validates the move coordinates against the current rule-set.
  3. Mutate: If valid, the ViewModel updates the immutable state block inside a thread-safe mutex block.
  4. Emit: A fresh immutable GameState is emitted to the UI, which seamlessly renders the updated marks.

Infinite Procedural Grids: From Go to Kotlin

To ensure replayability, Luma Luma generates a brand-new, balanced board for every match. This algorithm actually started as a prototype in Go before being ported to Kotlin to run natively on mobile.

The generator uses a loss function that penalizes configurations that don't meet specific design constraints: every color requires a strict cluster distribution (exactly one cluster of 1, 2, 3, 4, 5, and 6 cells), and every color must appear at least once per row. We use local search to optimize the grid layout, with a "kick" mechanism that flips multiple cells to random states when the algorithm hits a local minimum.

Smart AI Opponents: The Bot Arena

Training AI that "feels" like a human player required building a dedicated Bot Arena. We simulated over 500 games where different bot versions competed against each other to determine win rates. Starting with a basic baseline version, we iteratively developed new versions, testing specific hypotheses in each cycle to see what improved performance.

The smartest AIs don't just optimize their own score; they actively observe the opponent's state and try to block their progress. Since the actual rewards for completing columns or colors are often far away in the game loop, the bots utilize heuristics—calculating real-time metrics for column, color, and board completeness—to determine if their strategy is working.

Seamless Multiplayer with Firebase

Multiplayer in Luma Luma is designed to be frictionless. No accounts are needed; players simply share a 6-digit room code or QR code.

The real-time synchronization is powered by Firebase. We leverage Kotlin's ReactiveStateFlow to create a reactive bridge with the database. Every critical event—dice rolls, switching between active and passive player phases, and player "ready" confirmations—is perfectly synchronized. This reactive flow makes it easy to coordinate multi-player logic, such as waiting for all players to mark themselves as ready before proceeding. To maintain game stability, the host always controls bot automation turns, and if the host disconnects, the system automatically promotes the next player to the host role.

Polish: Dark Mode & Landscape Optimization

Jetpack Compose's theming engine made it incredibly easy to implement a Dark Mode that respects system preferences, making it easy on the eyes for late-night strategy sessions. Additionally, we built a dedicated Landscape View. Rotating the device gives players a full-detail view of the 15x7 grid, making it much easier to mark cells and plan long-term strategies on both phones and tablets.

Jeroen van Hoof

Jeroen van Hoof

MLOps Platform Architect

Jeroen designs, scales, and operates production ML infrastructure. He specializes in distributed containers orchestration, GitOps automation, and system reliability frameworks.