Android · Kotlin

Android Documentation

A progressive tutorial for embedding and customising every PluzzleSDK game in your Jetpack Compose app.

Overview

Every PluzzleSDK game follows the same pattern: create a model, instantiate the composable, and pass slot lambdas to customise visuals and react to events.

// 1. Create a model
val model = SudokuGameModel.example

// 2. Instantiate the composable
SudokuGameView(model = model)

// 3. Use slot lambdas to customise visuals and react to events
SudokuGameView(
    model = model,
    cellSlot = { isSelected, text, isFixed, notes, index -> MyCell(isSelected, text, isFixed, notes, index) },
    onInput = { row, col, value, model -> /* handle input */ },
    onCompletion = { isCorrect -> /* handle completion */ }
)

Requirements

Android API 26+ · AGP 8+

Language

Kotlin 2.0

UI Framework

Jetpack Compose


Number Puzzle

Sudoku

1

Embed with one line

Pass the built-in example model to render a fully playable Sudoku puzzle immediately.

import com.pluzzle.sdk.sudoku.SudokuGameView
import com.pluzzle.sdk.sudoku.SudokuGameModel

SudokuGameView(model = SudokuGameModel.example)
2

Build a model

Construct your own puzzle by supplying the starting grid and the complete solution. Use null for cells the player must fill in.

val model = SudokuGameModel(
    grid = listOf(
        listOf(null, 3, 4, 6, 7, 8, 9, 1, 2),
        listOf(6, 7, 2, 1, 9, 5, 3, 4, 8),
        // ... 7 more rows
    ),
    solution = listOf(
        listOf(5, 3, 4, 6, 7, 8, 9, 1, 2),
        listOf(6, 7, 2, 1, 9, 5, 3, 4, 8),
        // ...
    )
)
3

Customise grid cells

Pass a cellSlot lambda to replace the default cell. Your composable receives isSelected, the digit string to display, whether it is pre-filled and locked, optional pencil-mark notes, and the flat cell index (row * 9 + col).

SudokuGameView(
    model = model,
    cellSlot = { isSelected, text, isFixed, notes, index ->
        MyCell(isSelected = isSelected, text = text, isFixed = isFixed, notes = notes, index = index)
    }
)
Slot type
typealias SudokuCellSlot =
    @Composable (
        isSelected: Boolean,
        text: String,
        isFixed: Boolean,
        notes: Set<Int>?,   // pencil-mark candidates, null until first note
        index: Int          // flat cell index (row * 9 + col)
    ) -> Unit
@Composable
fun MyCell(isSelected: Boolean, text: String, isFixed: Boolean) {
    val background = when {
        isFixed    -> MaterialTheme.colorScheme.primaryContainer
        isSelected -> MaterialTheme.colorScheme.primary
        else       -> Color.Transparent
    }
    val textColor = if (isSelected && !isFixed) {
        MaterialTheme.colorScheme.onPrimary
    } else {
        MaterialTheme.colorScheme.onSurface
    }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(background),
        contentAlignment = Alignment.Center
    ) {
        if (text.isNotEmpty()) {
            Text(text, style = MaterialTheme.typography.titleMedium,
                 fontWeight = FontWeight.Bold, color = textColor)
        }
    }
}
4

Customise the input pad

Replace the default number pad by providing an inputPadCellSlot lambda. Your composable receives the digit label and a tap callback.

SudokuGameView(
    model = model,
    cellSlot = { isSelected, text, isFixed, notes, index -> MyCell(isSelected, text, isFixed, notes, index) },
    inputPadCellSlot = { label, onTap -> MyPadButton(label = label, onTap = onTap) }
)
Slot type
typealias InputPadCellSlot =
    @Composable (label: String, onTap: () -> Unit) -> Unit
@Composable
fun MyPadButton(label: String, onTap: () -> Unit) {
    FilledTonalButton(
        onClick = onTap,
        modifier = Modifier.fillMaxWidth()
    ) {
        Text(label, style = MaterialTheme.typography.titleMedium,
             fontWeight = FontWeight.Bold)
    }
}
5

Add callbacks

onInput fires on every placement or clear — the 4th parameter is the already-updated SudokuGameModel, so there is no stale closure. onCompletion fires when every cell is filled, reporting whether the grid matches the solution.

SudokuGameView(
    model = model,
    onInput = { row, col, value, model ->
        // value is null when the player clears a cell
        // model is the already-updated SudokuGameModel — no stale closure
        println("Placed ${value ?: "cleared"} at ($row, $col)")
    },
    onCompletion = { isCorrect ->
        message = if (isCorrect) "Puzzle solved!" else "Grid full but incorrect."
        showDialog = true
    }
)
6

Track selection and wrong cells

onSelectionChange fires whenever the selected cell changes (null when cleared). SudokuHighlightState is a convenience class in the SDK with selectedIndex and wrongCells fields. Wire it to onSelectionChange and onInput, then read wrongCellIndices from the fresh model to avoid stale state.

val highlight = remember { SudokuHighlightState() }

SudokuGameView(
    model = gameModel,
    onModelChange = { gameModel = it },
    onSelectionChange = { highlight.selectedIndex = it },
    onInput = { _, _, _, newModel ->
        highlight.wrongCells = newModel.wrongCellIndices
    },
    cellSlot = { isSelected, text, isFixed, notes, index ->
        MyCell(isSelected, text, isFixed, notes, index, highlight)
    },
    inputPadCellSlot = { label, onTap -> MyPadButton(label, onTap) }
)
7

Putting it all together

Custom cells, input pad, and callbacks in one block. Reset the puzzle by mutating the model binding directly.

SudokuGameView(
    model = model,
    cellSlot = { isSelected, text, isFixed, notes, index ->
        MyCell(isSelected = isSelected, text = text, isFixed = isFixed, notes = notes, index = index)
    },
    inputPadCellSlot = { label, onTap ->
        MyPadButton(label = label, onTap = onTap)
    },
    onInput = { row, col, value, model ->
        println("Placed ${value ?: "nil"} at ($row, $col)")
    },
    onCompletion = { isCorrect ->
        message = if (isCorrect) "Well done!" else "Not quite right."
        showDialog = true
    }
)

// Reset by mutating the model binding directly
// e.g. model = model.copy(state = model.grid)

Grid Game

Minesweeper

1

Embed with a difficulty

Choose from beginner, intermediate, or expert by setting rows, columns, and mine count. Mines are generated on the first tap with a safe-tap guarantee.

import com.pluzzle.sdk.minesweeper.MinesweeperGameView
import com.pluzzle.sdk.minesweeper.MinesweeperModel

// Beginner
MinesweeperGameView(model = MinesweeperModel(rows = 9, columns = 9, mineCount = 10))

// Intermediate
MinesweeperGameView(model = MinesweeperModel(rows = 16, columns = 16, mineCount = 40))

// Expert
MinesweeperGameView(model = MinesweeperModel(rows = 16, columns = 30, mineCount = 99))
2

Customise cells

Provide a cellSlot lambda. Your composable receives row, column, and a MinesweeperCellState — Hidden, Revealed(adjacentMines), Flagged, Exploded, or MineRevealed.

MinesweeperGameView(
    model = model,
    cellSlot = { row, col, state -> MyCell(row = row, col = col, state = state) }
)
Slot type
typealias MinesweeperCellSlot =
    @Composable (row: Int, col: Int, state: MinesweeperCellState) -> Unit

sealed class MinesweeperCellState {
    object Hidden       : MinesweeperCellState()
    data class Revealed(val adjacentMines: Int) : MinesweeperCellState()
    object Flagged      : MinesweeperCellState()
    object Exploded     : MinesweeperCellState()
    object MineRevealed : MinesweeperCellState()
}
@Composable
fun MyCell(row: Int, col: Int, state: MinesweeperCellState) {
    val background = when (state) {
        is MinesweeperCellState.Hidden,
        is MinesweeperCellState.Flagged  -> Color(0xFFE0E0E0)
        is MinesweeperCellState.Revealed -> Color(0xFFF5F5F5)
        is MinesweeperCellState.Exploded -> Color.Red.copy(alpha = 0.8f)
        is MinesweeperCellState.MineRevealed -> Color(0xFFBDBDBD)
    }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .clip(RoundedCornerShape(8.dp))
            .background(background),
        contentAlignment = Alignment.Center
    ) {
        when (state) {
            is MinesweeperCellState.Revealed ->
                if (state.adjacentMines > 0)
                    Text(state.adjacentMines.toString(),
                         style = MaterialTheme.typography.labelMedium,
                         fontWeight = FontWeight.Bold)
            is MinesweeperCellState.Flagged ->
                Text("⚑", style = MaterialTheme.typography.labelMedium)
            is MinesweeperCellState.Exploded ->
                Text("✕", style = MaterialTheme.typography.labelMedium, color = Color.White)
            else -> {}
        }
    }
}
3

Add callbacks

onInput fires once per safely revealed cell — including every cell uncovered during flood-fill. onCompletion fires when the game ends.

MinesweeperGameView(
    model = model,
    onInput = { coord, score ->
        // Fires once per revealed cell — including every cell in a flood-fill
        println("Revealed (${coord.row}, ${coord.col}) — score: $score")
    },
    onCompletion = { didWin ->
        message = if (didWin) "Board cleared!" else "Better luck next time."
        showDialog = true
    }
)
4

Putting it all together

MinesweeperGameView(
    model = MinesweeperModel(rows = 9, columns = 9, mineCount = 10),
    cellSlot = { row, col, state -> MyCell(row, col, state) },
    onInput = { coord, score ->
        triggerSelectionHaptic()
    },
    onCompletion = { didWin ->
        message = if (didWin) "Board cleared!" else "Better luck next time."
        showDialog = true
    }
)

Path Drawing

Streaks(beta)

1

Embed with a grid size

Specify rows and columns. The player drags a single continuous path through every non-blocked cell. Lifting mid-path resets it silently.

import com.pluzzle.sdk.streaks.StreaksGameView
import com.pluzzle.sdk.streaks.StreaksModel

StreaksGameView(model = StreaksModel(rows = 5, columns = 5))
2

Add blocked cells

Blocked cells are impassable obstacles that don't count toward completion. You are responsible for ensuring the remaining cells form a fully connectable path.

// Plain open grid
val open = StreaksModel(rows = 5, columns = 5)

// Grid with blocked obstacle cells
val model = StreaksModel(
    rows = 5,
    columns = 5,
    blockedCells = listOf(
        StreaksCoord(row = 1, col = 1),
        StreaksCoord(row = 1, col = 3),
        StreaksCoord(row = 3, col = 1),
        StreaksCoord(row = 3, col = 3),
    )
)
3

Customise grid cells

Pass a cellSlot lambda. The Selected state gives you the 1-based order of the cell in the current path — use it to display the trail sequence.

StreaksGameView(
    model = model,
    cellSlot = { row, col, state -> MyCell(row = row, col = col, state = state) }
)
Slot type
typealias StreaksCellSlot =
    @Composable (row: Int, col: Int, state: StreaksCellState) -> Unit

sealed class StreaksCellState {
    object Unselected               : StreaksCellState()
    data class Selected(val order: Int) : StreaksCellState()
    object Blocked                  : StreaksCellState()
}
@Composable
fun MyCell(row: Int, col: Int, state: StreaksCellState) {
    val background = when (state) {
        is StreaksCellState.Unselected -> Color.Gray.copy(alpha = 0.2f)
        is StreaksCellState.Selected   -> Color(0xFF5C6BC0)
        is StreaksCellState.Blocked    -> Color.Gray.copy(alpha = 0.05f)
    }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .clip(RoundedCornerShape(10.dp))
            .background(background),
        contentAlignment = Alignment.Center
    ) {
        if (state is StreaksCellState.Selected) {
            Text(
                text = "${state.order}",
                style = MaterialTheme.typography.labelMedium,
                fontWeight = FontWeight.Bold,
                color = Color.White
            )
        }
    }
}
4

Putting it all together

onInput fires with the full ordered path on every new cell visited. onCompletion fires only on a successful completion — always with true.

StreaksGameView(
    model = StreaksModel(rows = 4, columns = 6),
    cellSlot = { row, col, state -> MyCell(row, col, state) },
    onInput = { path ->
        // path is List<StreaksCoord>, newest cell appended last
        triggerHaptic()
    },
    onCompletion = { _ ->
        // Always fires with true — only triggers on successful completion
        showSuccessBanner = true
    }
)

Word Guessing

Kelvin Grid(beta)

1

Embed with a target word

Provide the word to guess and the maximum attempts. Column count is derived automatically from the word length.

import com.pluzzle.sdk.kelvin.KelvinGridView
import com.pluzzle.sdk.kelvin.KelvinGridModel

KelvinGridView(model = KelvinGridModel(targetWord = "SWIFT", maxAttempts = 6))

Restore a saved session by passing previously submitted guesses — all rows are evaluated and coloured automatically on load.

// Restore an in-progress session
val model = KelvinGridModel(
    targetWord = "SWIFT",
    maxAttempts = 6,
    currentGuesses = listOf("STORM", "SHIFT")  // evaluated and coloured on load
)
2

Customise grid cells

Pass a cellSlot lambda. Each cell receives a KelvinCellState: Empty, Pending, Correct (green), Misplaced (orange), or Wrong(offset: Int) (gray). The Int in Wrong is the signed alphabetical distance from the correct letter — a unique hint that nudges the player toward the answer.

KelvinGridView(
    model = model,
    cellSlot = { letter, state, isActiveRow ->
        MyCell(letter = letter, state = state, isActiveRow = isActiveRow)
    }
)
Slot type
typealias KelvinCellSlot =
    @Composable (letter: String, state: KelvinCellState, isActiveRow: Boolean) -> Unit

sealed class KelvinCellState {
    object Empty     : KelvinCellState()  // No letter typed
    object Pending   : KelvinCellState()  // Letter typed, row not yet submitted
    object Correct   : KelvinCellState()  // Right letter, right position (green)
    object Misplaced : KelvinCellState()  // Right letter, wrong position (orange)
    data class Wrong(val offset: Int) : KelvinCellState()
    // Not in word (gray); offset is signed alphabetical distance
}

Use isActiveRow to render a highlighted border on the row currently being typed.

@Composable
fun MyCell(letter: String, state: KelvinCellState, isActiveRow: Boolean) {
    val background = when (state) {
        is KelvinCellState.Empty,
        is KelvinCellState.Pending  -> Color(0xFFEEEEEE)
        is KelvinCellState.Correct  -> Color(0xFF4CAF50)
        is KelvinCellState.Misplaced -> Color(0xFFFF9800)
        is KelvinCellState.Wrong    -> Color(0xFF9E9E9E)
    }
    val borderColor = when (state) {
        is KelvinCellState.Empty,
        is KelvinCellState.Pending ->
            if (isActiveRow) MaterialTheme.colorScheme.primary
            else Color.LightGray
        else -> Color.Transparent
    }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .clip(RoundedCornerShape(8.dp))
            .background(background)
            .border(1.5.dp, borderColor, RoundedCornerShape(8.dp)),
        contentAlignment = Alignment.Center
    ) {
        if (state is KelvinCellState.Wrong) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                Text(letter, style = MaterialTheme.typography.titleMedium,
                     fontWeight = FontWeight.Bold, color = Color.White)
                val sign = if (state.offset >= 0) "+${state.offset}" else "${state.offset}"
                Text(sign, style = MaterialTheme.typography.labelSmall,
                     color = Color.White.copy(alpha = 0.85f))
            }
        } else {
            Text(letter, style = MaterialTheme.typography.titleMedium,
                 fontWeight = FontWeight.Bold, color = Color.White)
        }
    }
}
3

Customise keyboard keys

Replace the QWERTY keyboard by providing a keySlot lambda. The label is a single letter or '⌫' for the delete key.

KelvinGridView(
    model = model,
    keySlot = { label, onTap -> MyKey(label = label, onTap = onTap) }
)
Slot type
typealias KelvinKeyCellSlot =
    @Composable (label: String, onTap: () -> Unit) -> Unit
@Composable
fun MyKey(label: String, onTap: () -> Unit) {
    FilledTonalButton(
        onClick = onTap,
        modifier = Modifier.fillMaxWidth(),
        shape = RoundedCornerShape(8.dp)
    ) {
        Text(label, style = MaterialTheme.typography.labelLarge,
             textAlign = TextAlign.Center)
    }
}
4

Putting it all together

onInput fires on each row submission with the guess string and per-cell states. onCompletion fires when the game ends — either the word was found or attempts were exhausted.

KelvinGridView(
    model = model,
    cellSlot = { letter, state, isActiveRow ->
        MyCell(letter, state, isActiveRow)
    },
    keySlot = { label, onTap -> MyKey(label, onTap) },
    onInput = { guess, states ->
        val correct = states.count { it is KelvinCellState.Correct }
        println("$guess: $correct/${model.targetWord.length} correct positions")
    },
    onCompletion = { didWin ->
        message = if (didWin) "Well done!" else "The word was ${model.targetWord}."
        showDialog = true
    }
)

Letter Wheel

Word Wheel(beta)

1

Embed with a model

Provide the centre letter, the surrounding ring letters, and the full list of acceptable answers. The puzzle ends when every answer has been found.

import com.pluzzle.sdk.wordwheel.WordWheelView
import com.pluzzle.sdk.wordwheel.WordWheelModel

val model = WordWheelModel(
    mainLetter = "E",
    letters = listOf("R", "A", "T", "H", "N", "G", "S"),
    acceptableAnswers = listOf("earth", "heart", "hate", "rate", "grate", "great", "stare")
)

WordWheelView(model = model)

Restore an in-progress session by passing already-found words in currentAnswers.

// Restore a session with already-found words
val model = WordWheelModel(
    mainLetter = "E",
    letters = listOf("R", "A", "T", "H", "N", "G", "S"),
    currentAnswers = listOf("earth", "heart"),
    acceptableAnswers = listOf("earth", "heart", "hate", "rate", "grate", "great", "stare")
)
2

Customise letter tiles

Pass a inputSlot lambda. isMain identifies the centre tile — every valid word must contain it. isUsed is true when the tile has already been tapped in the current attempt.

WordWheelView(
    model = model,
    inputSlot = { letter, isMain, isUsed, onTap ->
        MyTile(letter = letter, isMain = isMain, isUsed = isUsed, onTap = onTap)
    }
)
Slot type
typealias WordWheelInputSlot =
    @Composable (letter: String, isMain: Boolean, isUsed: Boolean, onTap: () -> Unit) -> Unit
@Composable
fun MyTile(letter: String, isMain: Boolean, isUsed: Boolean, onTap: () -> Unit) {
    val background = when {
        isUsed -> Color.Gray.copy(alpha = 0.3f)
        isMain -> Color(0xFF9C27B0)
        else   -> MaterialTheme.colorScheme.primary
    }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .clip(CircleShape)
            .background(background)
            .clickable(enabled = !isUsed, onClick = onTap),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = letter,
            style = if (isMain) MaterialTheme.typography.titleLarge
                    else MaterialTheme.typography.titleMedium,
            fontWeight = FontWeight.Bold,
            color = Color.White
        )
    }
}
3

Customise action buttons

Three buttons — Submit, Delete, and Clear — are provided via actionButtonSlot. Use the label parameter to differentiate their appearance.

WordWheelView(
    model = model,
    inputSlot = { letter, isMain, isUsed, onTap ->
        MyTile(letter, isMain, isUsed, onTap)
    },
    actionButtonSlot = { label, onTap -> MyButton(label = label, onTap = onTap) }
)
Slot type
typealias WordWheelActionButtonSlot =
    @Composable (label: String, onTap: () -> Unit) -> Unit
@Composable
fun MyButton(label: String, onTap: () -> Unit) {
    val color = when (label) {
        "Submit" -> MaterialTheme.colorScheme.primary
        "Delete" -> MaterialTheme.colorScheme.secondary
        else     -> MaterialTheme.colorScheme.error
    }
    FilledTonalButton(
        onClick = onTap,
        modifier = Modifier.fillMaxWidth(),
        colors = ButtonDefaults.filledTonalButtonColors(
            containerColor = color.copy(alpha = 0.2f)
        )
    ) {
        Text(label, style = MaterialTheme.typography.labelLarge,
             fontWeight = FontWeight.Bold, color = color)
    }
}
4

Customise found-word chips

Replace the found-words list chips by providing a outputSlot lambda. The word is passed lowercased.

WordWheelView(
    model = model,
    inputSlot = { letter, isMain, isUsed, onTap ->
        MyTile(letter, isMain, isUsed, onTap)
    },
    actionButtonSlot = { label, onTap -> MyButton(label, onTap) },
    outputSlot = { word -> MySolutionChip(word = word) }
)
Slot type
typealias WordWheelOutputSlot =
    @Composable (word: String) -> Unit
@Composable
fun MySolutionChip(word: String) {
    Box(
        modifier = Modifier
            .clip(RoundedCornerShape(50))
            .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f))
            .padding(horizontal = 10.dp, vertical = 6.dp),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = word.uppercase(),
            style = MaterialTheme.typography.bodySmall,
            color = MaterialTheme.colorScheme.primary
        )
    }
}
5

Putting it all together

onWordSubmitted fires on every submission (valid or not). onCompletion fires when every acceptable answer has been found.

WordWheelView(
    model = model,
    inputSlot = { letter, isMain, isUsed, onTap ->
        MyTile(letter, isMain, isUsed, onTap)
    },
    actionButtonSlot = { label, onTap -> MyButton(label, onTap) },
    outputSlot = { word -> MySolutionChip(word) },
    onWordSubmitted = { word, isValid ->
        feedback = if (isValid) "✓ ${word.replaceFirstChar { it.uppercase() }}"
                   else "Not a valid word."
    },
    onCompletion = {
        showCompletionDialog = true
    }
)

3D Strategy

Voxel(beta)

1

Embed with a model

Pass a VoxelModel to render a fully playable 3D voxel game. The game starts with a neutral seed cube at the origin. Players alternate tapping ghost nodes — semi-transparent cubes that preview available positions — to place their cubes.

import com.pluzzle.sdk.voxel.VoxelGameView
import com.pluzzle.sdk.voxel.model.VoxelModel

// 3-in-a-row, no turn limit (default)
VoxelGameView(model = VoxelModel())

// 4-in-a-row with a 30-turn draw limit
VoxelGameView(model = VoxelModel(winLength = 4, maxTurns = 30))
2

Customise the theme

VoxelTheme controls five colours: playerOne and playerTwo colour each player's cubes, seed colours the neutral starting cube, ghost tints the placement-hint nodes, and win highlights the cubes that form the winning line.

val theme = VoxelTheme(
    playerOne = Color(0xFF2196F3),  // Player One cube colour
    playerTwo = Color(0xFFE53935),  // Player Two cube colour
    seed      = Color(0xFF9E9E9E),  // Neutral starting cube at origin
    ghost     = Color(0xFFBDBDBD),  // Semi-transparent placement hints
    win       = Color(0xFFFFEB3B)   // Winning-line highlight
)

VoxelGameView(
    model = VoxelModel(),
    theme = theme
)
3

Choose a node shape

Select the geometry used to render each cube. Box accepts a chamfer radius to round its edges. Sphere and Capsule offer softer alternatives. nodeSize controls how much of each grid cell the node fills (0–1).

// Box with chamfer rounding (default)
VoxelGameView(
    model = VoxelModel(),
    nodeShape = VoxelNodeShape.Box(chamfer = 0.1f),
    nodeSize = 0.9f
)

// Sphere
VoxelGameView(model = VoxelModel(), nodeShape = VoxelNodeShape.Sphere)

// Capsule
VoxelGameView(model = VoxelModel(), nodeShape = VoxelNodeShape.Capsule)
4

Putting it all together

onInput fires on every cube placement with the 3D coordinate and the placing player. onCompletion fires when the game ends — the winner is null on a draw.

VoxelGameView(
    model = VoxelModel(winLength = 3),
    nodeShape = VoxelNodeShape.Box(chamfer = 0.15f),
    nodeSize = 0.9f,
    theme = VoxelTheme(
        playerOne = Color(0xFF2196F3),
        playerTwo = Color(0xFFE53935),
        seed      = Color(0xFF9E9E9E),
        ghost     = Color(0xFFBDBDBD),
        win       = Color(0xFFFFEB3B)
    ),
    onInput = { coord, player ->
        println("Player $player placed at (${coord.x}, ${coord.y}, ${coord.z})")
    },
    onCompletion = { winner ->
        if (winner != null) println("Winner: $winner")
        else println("Draw")
    }
)
Focus

Explore the examples

Explore a fully working iOS and Android sample project you can run immediately.