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
Sudoku
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)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),
// ...
)
)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)
}
)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)
}
}
}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) }
)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)
}
}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
}
)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) }
)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)Minesweeper
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))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) }
)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 -> {}
}
}
}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
}
)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
}
)Streaks(beta)
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))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),
)
)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) }
)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
)
}
}
}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
}
)Kelvin Grid(beta)
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
)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)
}
)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)
}
}
}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) }
)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)
}
}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
}
)Word Wheel(beta)
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")
)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)
}
)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
)
}
}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) }
)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)
}
}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) }
)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
)
}
}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
}
)Voxel(beta)
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))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
)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)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")
}
)Explore the examples
Explore a fully working iOS and Android sample project you can run immediately.