iOS Documentation
A progressive tutorial for embedding and customising every PluzzleSDK game in your SwiftUI app.
Overview
Every PluzzleSDK game follows the same three-step pattern: create a model, instantiate the view, and chain modifiers to customise visuals and react to events.
// 1. Create a model (hold as @State in your parent view)
@State private var model = SudokuGameModel.example
// 2. Instantiate the view — pass model as a Binding
SudokuGameView(model: $model)
// 3. Add modifiers to customise and react to events
SudokuGameView(model: $model)
.grid(spacing: 1, cell: MyCell.self)
.input(cell: MyPadButton.self)
.onInput { row, col, value in ... }
.onCompletion { isCorrect in ... }Requirements
iOS 17+ · Xcode 15+
Language
Swift 6.2
UI Framework
SwiftUI
Sudoku
Embed with one line
Pass the built-in example model to render a fully playable Sudoku puzzle immediately.
import PluzzleSDK
SudokuGameView(model: .example)
.padding()Build a model
Construct your own puzzle by supplying the starting grid and the complete solution. Use nil for cells the player must fill in.
let model = SudokuGameModel(
grid: [
[nil, 3, 4, 6, 7, 8, 9, 1, 2],
[6, 7, 2, 1, 9, 5, 3, 4, 8],
// ... 7 more rows
],
solution: [
[5, 3, 4, 6, 7, 8, 9, 1, 2],
[6, 7, 2, 1, 9, 5, 3, 4, 8],
// ...
]
)Customise grid cells
Conform your cell view to SudokuCellProtocol and inject it with .grid(). Your cell receives whether it is selected, the digit string to display, and whether it is pre-filled and locked.
SudokuGameView(model: $model)
.grid(spacing: 1, cell: MyCell.self, cornerRadius: 12)public protocol SudokuCellProtocol: View {
// Primary init — must implement
init(isSelected: Bool, text: String, isFixed: Bool, notes: Set<Int>?)
// Extended init with cell index — default implementation forwards to above
init(isSelected: Bool, text: String, isFixed: Bool, notes: Set<Int>?, index: Int)
// Convenience (notes defaults to nil) — provided via extension
}struct MyCell: View, SudokuCellProtocol {
var isSelected: Bool
var text: String
var isFixed: Bool
var notes: Set<Int>?
init(isSelected: Bool, text: String, isFixed: Bool, notes: Set<Int>? = nil) {
self.isSelected = isSelected
self.text = text
self.isFixed = isFixed
self.notes = notes
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(isFixed ? Color.primary.opacity(0.1)
: (isSelected ? Color.accentColor : .clear))
.overlay(
RoundedRectangle(cornerRadius: 4)
.strokeBorder(Color.secondary.opacity(0.3), lineWidth: 0.5)
)
Text(text)
.font(.title2.bold())
.foregroundStyle(isSelected && !isFixed ? .white : .primary)
}
}
}Customise the input pad
Replace the default number pad by conforming to InputPadCellProtocol and passing it to .input(). Your button receives the digit label and a tap closure.
SudokuGameView(model: $model)
.grid(spacing: 1, cell: MyCell.self)
.input(cell: MyPadButton.self)public protocol InputPadCellProtocol: View {
init(label: String, onTap: @escaping () -> Void)
}struct MyPadButton: View, InputPadCellProtocol {
var label: String
var onTap: () -> Void
init(label: String, onTap: @escaping () -> Void) {
self.label = label
self.onTap = onTap
}
var body: some View {
Button(action: onTap) {
Text(label)
.font(.title3.bold())
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
}Add callbacks
onSelect fires whenever the player taps a new non-fixed cell, passing its row and column. onInput fires on every placement or clear. onCompletion fires when every cell is filled, reporting whether the grid matches the solution.
SudokuGameView(model: $model)
.onSelect { row, col in
selectedCell = (row, col)
}
.onInput { row, col, value in
// value is nil when the player clears a cell
print("Placed \(value.map(String.init) ?? "cleared") at (\(row), \(col))")
}
.onCompletion { isCorrect in
alertMessage = isCorrect ? "Puzzle solved!" : "Grid full but incorrect."
showAlert = true
}Grid-only mode
Pass gridOnly: true to render only the Sudoku grid — the accessory view and input pad are hidden. Useful for previews, thumbnails, or custom layouts where you supply your own controls.
// Render just the grid — no number pad or accessory view
SudokuGameView(model: $model, isNotesMode: $isNotesMode, gridOnly: true)
.grid(spacing: 1, cell: MyCell.self, cornerRadius: 8)Add hints
Attach .hint(trigger:) and pass a counter held in @State. Each increment activates hint mode — all empty editable cells are highlighted. Tap any highlighted cell to fill it with the correct solution value. Hint mode ends after one tap.
@State private var hintCount = 0
SudokuGameView(model: $model)
.hint(trigger: $hintCount)
// Each increment activates hint mode — all empty editable cells glow orange.
// The player taps the cell they want filled with its correct solution value.
// Hint mode ends after one tap. Has no effect when every editable cell is already filled.
Button("Hint") { hintCount += 1 }Putting it all together
Custom cells, input pad, hint trigger, and callbacks in one block.
SudokuGameView(model: $model)
.grid(spacing: 1, cell: MyCell.self, cornerRadius: 8)
.input(cell: MyPadButton.self)
.hint(trigger: $hintCount)
.onSelect { row, col in
selectedCell = (row, col)
}
.onInput { row, col, value in
print("Placed \(value.map(String.init) ?? "nil") at (\(row), \(col))")
}
.onCompletion { isCorrect in
alertMessage = isCorrect ? "Well done!" : "Not quite right."
showAlert = true
}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 PluzzleSDK
// 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
Conform to MinesweeperCellProtocol. Your cell receives row, column, and a MinesweeperCellState — hidden, revealed(adjacentMines), flagged, exploded, or mineRevealed.
MinesweeperGameView(model: model)
.grid(spacing: 4, cell: MyCell.self)public protocol MinesweeperCellProtocol: View {
init(row: Int, column: Int, state: MinesweeperCellState)
}struct MyCell: View, MinesweeperCellProtocol {
let row: Int
let column: Int
let state: MinesweeperCellState
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 8).fill(background)
label
}
.aspectRatio(1, contentMode: .fit)
}
private var background: Color {
switch state {
case .hidden, .flagged: return Color(.systemGray5)
case .revealed: return Color(.systemGray6)
case .exploded: return .red.opacity(0.8)
case .mineRevealed: return Color(.systemGray4)
}
}
@ViewBuilder
private var label: some View {
switch state {
case .revealed(let n) where n > 0:
Text("\(n)").font(.caption.bold())
case .flagged:
Image(systemName: "flag.fill").foregroundStyle(.orange)
case .exploded, .mineRevealed:
Image(systemName: "xmark.circle.fill").foregroundStyle(.white)
default:
EmptyView()
}
}
}Add callbacks
onInput fires once per safely revealed cell — including every cell uncovered during flood-fill, in BFS order. onCompletion fires when the game ends; at that point all mine positions are automatically revealed and remaining flags are cleared.
MinesweeperGameView(model: model)
.onInput { coord, score in
// Fires once per revealed cell — including every cell in a flood-fill
print("Revealed (\(coord.row), \(coord.col)) — score: \(score)")
}
.onCompletion { didWin in
// At game end, all mines are revealed and flags are cleared automatically
resultMessage = didWin ? "Board cleared!" : "Better luck next time."
showResult = true
}Enable flagging mode
Use .flaggingMode(_:) to make taps plant or remove flags instead of revealing cells. Wire it to a button or toolbar toggle so the player can switch modes without long-pressing.
@State private var flagging = false
// Toggle from a toolbar button or segmented control
Button { flagging.toggle() } label: {
Image(systemName: flagging ? "flag.fill" : "flag")
}MinesweeperGameView(model: $model)
.flaggingMode(flagging) // taps plant/remove flags when trueAdd hints
Attach .hint(trigger:) and pass a counter held in @State. Each increment activates hint mode — the player can tap any cell freely without losing the game if a mine is hit. Hint mode ends after one tap.
@State private var hintCount = 0
MinesweeperGameView(model: $model)
.hint(trigger: $hintCount)
// Each increment activates hint mode — the player can tap any cell freely.
// Tapping a mine in hint mode does NOT end the game. Hint mode ends after one tap.
Button("Hint") { hintCount += 1 }Putting it all together
MinesweeperGameView(model: $model)
.grid(spacing: 4, cell: MyCell.self)
.flaggingMode(flagging)
.hint(trigger: $hintCount)
.onInput { coord, score in
triggerSelectionHaptic()
}
.onCompletion { didWin in
resultMessage = didWin ? "Board cleared!" : "Better luck next time."
showResult = true
}
.padding()
.aspectRatio(1, contentMode: .fit)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 PluzzleSDK
StreaksGameView(model: StreaksModel(rows: 5, columns: 5))
.padding()
.aspectRatio(1, contentMode: .fit)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
let open = StreaksModel(rows: 5, columns: 5)
// Grid with blocked obstacle cells
let model = StreaksModel(
rows: 5, columns: 5,
blockedCells: [
StreaksCoord(row: 1, col: 1),
StreaksCoord(row: 1, col: 3),
StreaksCoord(row: 3, col: 1),
StreaksCoord(row: 3, col: 3),
]
)Customise grid cells
Conform to StreaksCellProtocol. The selected(order:) case gives you the 1-based position of the cell in the current path — use it to display the trail sequence.
StreaksGameView(model: model)
.grid(spacing: 8, cell: MyCell.self)public protocol StreaksCellProtocol: View {
init(row: Int, column: Int, state: StreaksCellState)
}
public enum StreaksCellState: Equatable, Hashable {
case unselected
case selected(order: Int)
case blocked
}struct MyCell: View, StreaksCellProtocol {
var row: Int
var column: Int
var state: StreaksCellState
var body: some View {
RoundedRectangle(cornerRadius: 10)
.fill(background)
.overlay {
if case .selected(let order) = state {
Text("\(order)")
.font(.caption.bold())
.foregroundStyle(.white)
}
}
}
private var background: Color {
switch state {
case .unselected: return Color.gray.opacity(0.2)
case .selected: return Color.indigo
case .blocked: return Color.gray.opacity(0.05)
}
}
}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))
.grid(spacing: 10, cell: MyCell.self)
.onInput { path in
// path is [(row: Int, col: Int)], newest cell appended last
triggerHaptic()
}
.onCompletion { _ in
// Always fires with true — only triggers on a successful completion
showSuccessBanner = true
}
.padding()
.aspectRatio(CGFloat(6) / CGFloat(4), contentMode: .fit)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 PluzzleSDK
KelvinGridView(model: KelvinGridModel(targetWord: "SWIFT", maxAttempts: 6))
.padding()Restore a saved session by passing previously submitted guesses — all rows are evaluated and coloured automatically on load.
// Restore an in-progress session
let model = KelvinGridModel(
targetWord: "SWIFT",
maxAttempts: 6,
currentGuesses: ["STORM", "SHIFT"] // evaluated and coloured on load
)Customise grid cells
Conform to KelvinGridCellProtocol. Each cell gets a KelvinCellState: .empty, .pending, .correct (green), .misplaced (orange), or .wrong(Int) (gray). The Int in .wrong is the signed alphabetical distance from the correct letter — a unique hint mechanic that nudges the player toward the answer.
KelvinGridView(model: model)
.grid(spacing: 8, cell: MyCell.self)public protocol KelvinGridCellProtocol: View {
init(letter: String, state: KelvinCellState, isActiveRow: Bool)
}
public enum KelvinCellState: Equatable, Hashable {
case empty // No letter typed
case pending // Letter typed, row not yet submitted
case correct // Right letter, right position (green)
case misplaced // Right letter, wrong position (orange)
case wrong(Int) // Not in word (gray); Int is signed alphabetical distance
}Use isActiveRow to render a highlighted border on the row currently being typed.
struct MyCell: View, KelvinGridCellProtocol {
var letter: String
var state: KelvinCellState // .empty | .pending | .correct | .misplaced | .wrong(Int)
var isActiveRow: Bool
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(background)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(borderColor, lineWidth: 1.5)
)
if case .wrong(let offset) = state {
VStack(spacing: 1) {
Text(letter).font(.title2.bold()).foregroundStyle(.white)
// Signed distance: +3 means guess comes 3 letters after correct
Text(offset >= 0 ? "+\(offset)" : "\(offset)")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
}
} else {
Text(letter)
.font(.title2.bold())
.foregroundStyle(letter.isEmpty ? .clear : .white)
}
}
}
private var background: Color {
switch state {
case .empty, .pending: return Color(.systemGray5)
case .correct: return .green
case .misplaced: return .orange
case .wrong: return Color(.systemGray2)
}
}
private var borderColor: Color {
switch state {
case .empty, .pending: return isActiveRow ? .accentColor : Color(.systemGray3)
default: return .clear
}
}
}Customise keyboard keys
Replace the QWERTY keyboard by conforming to KelvinKeyProtocol. The label is a single letter or '⌫' for the delete key.
KelvinGridView(model: model)
.input(cell: MyKey.self)public protocol KelvinKeyProtocol: View {
init(label: String, onTap: @escaping () -> Void)
}struct MyKey: View, KelvinKeyProtocol {
var label: String // single letter, or "⌫" for delete
var onTap: () -> Void
var body: some View {
Button(action: onTap) {
Text(label)
.font(.subheadline.bold())
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(Color.accentColor.opacity(0.15))
.foregroundStyle(.primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}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)
.grid(spacing: 8, cell: MyCell.self)
.input(cell: MyKey.self)
.onInput { guess, states in
let correct = states.filter { $0 == .correct }.count
print("\(guess): \(correct)/\(model.columns) correct positions")
}
.onCompletion { didWin in
alertMessage = didWin ? "Well done!" : "The word was \(model.targetWord)."
showAlert = 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 PluzzleSDK
let model = WordWheelModel(
mainLetter: "E",
letters: ["R", "A", "T", "H", "N", "G", "S"],
acceptableAnswers: ["earth", "heart", "hate", "rate", "grate", "great", "stare"]
)
WordWheelView(model: model)
.padding()Restore an in-progress session by passing already-found words in currentAnswers.
// Restore a session with already-found words
let model = WordWheelModel(
mainLetter: "E",
letters: ["R", "A", "T", "H", "N", "G", "S"],
currentAnswers: ["earth", "heart"],
acceptableAnswers: ["earth", "heart", "hate", "rate", "grate", "great", "stare"]
)Customise letter tiles
Conform to WordWheelInputProtocol. 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)
.input(cell: MyTile.self)public protocol WordWheelInputProtocol: View {
init(letter: String, isMain: Bool, isUsed: Bool, onTap: @escaping () -> Void)
}struct MyTile: View, WordWheelInputProtocol {
var letter: String
var isMain: Bool // true for the centre tile — every valid word must include it
var isUsed: Bool // true when already tapped in the current attempt
var onTap: () -> Void
var body: some View {
ZStack {
Circle()
.fill(isUsed ? Color.gray.opacity(0.3)
: (isMain ? Color.purple : Color.accentColor))
Text(letter)
.font(isMain ? .title2.bold() : .headline)
.foregroundStyle(.white)
}
.onTapGesture {
guard !isUsed else { return }
onTap()
}
}
}Customise action buttons
Three buttons — Submit, Delete, and Clear — share WordWheelActionButtonProtocol. Use the label property to differentiate their appearance.
WordWheelView(model: model)
.input(cell: MyTile.self)
.actionButton(cell: MyButton.self)public protocol WordWheelActionButtonProtocol: View {
init(label: String, onTap: @escaping () -> Void)
}struct MyButton: View, WordWheelActionButtonProtocol {
var label: String // "Submit", "Delete", or "Clear"
var onTap: () -> Void
var body: some View {
Button(action: onTap) {
Text(label)
.font(.subheadline.bold())
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
}Customise found-word chips
Replace the found-words list chips by conforming to WordWheelOutputProtocol. The word is passed lowercased.
WordWheelView(model: model)
.input(cell: MyTile.self)
.actionButton(cell: MyButton.self)
.output(cell: MySolutionCell.self)public protocol WordWheelOutputProtocol: View {
init(word: String)
}struct MySolutionCell: View, WordWheelOutputProtocol {
var word: String // the found word, lowercased
var body: some View {
Text(word.uppercased())
.font(.caption.bold())
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.accentColor.opacity(0.15))
.clipShape(Capsule())
}
}Putting it all together
onWordSubmitted fires on every submission (valid or not). onCompletion fires when every acceptable answer has been found.
WordWheelView(model: model)
.input(cell: MyTile.self)
.actionButton(cell: MyButton.self)
.output(cell: MySolutionCell.self)
.onWordSubmitted { word, isValid in
feedbackMessage = isValid ? "✓ \(word.capitalized)" : "Not a valid word."
}
.onCompletion {
showCompletionAlert = true
}Takuzu
Embed with one line
Pass the built-in example model to render a fully playable 6×6 Takuzu puzzle. Tap any editable cell to cycle it through empty → 1 → 0 → empty.
import PluzzleSDK
@State private var model = TakuzuModel.example
TakuzuGameView(model: $model)
.padding()
.aspectRatio(1, contentMode: .fit)Build a model
Supply a cells grid with nil for editable cells and true/false for fixed givens, plus a solution grid for answer validation. TakuzuModel.example provides a ready-made 6×6 puzzle.
// Supply a cells grid (nil = editable, non-nil = fixed given) and the complete solution.
let model = TakuzuModel(
size: 6,
cells: [
[true, nil, nil, false, nil, nil ],
[nil, nil, true, nil, nil, false],
[nil, false, nil, nil, true, nil ],
[false, nil, nil, true, nil, nil ],
[nil, nil, false, nil, true, nil ],
[nil, true, nil, nil, false, nil ],
],
solution: [
[true, true, false, false, true, false],
[true, false, true, true, false, false],
[false, false, true, false, true, true ],
[false, true, false, true, false, true ],
[true, false, false, true, true, false],
[false, true, true, false, false, true ],
]
)Customise grid cells
Conform to TakuzuCellProtocol and inject it with .grid(). Each cell receives value (nil/true/false), isFixed, and isViolation — giving you everything needed to drive any visual design.
struct MyTakuzuCell: TakuzuCellProtocol {
let row: Int
let column: Int
let value: Bool? // nil = empty, true = "1", false = "0"
let isFixed: Bool // given cell — cannot be edited
let isViolation: Bool // cell is part of a rule violation
init(row: Int, column: Int, value: Bool?, isFixed: Bool, isViolation: Bool) {
self.row = row; self.column = column
self.value = value; self.isFixed = isFixed; self.isViolation = isViolation
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(background)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(isViolation ? Color.red : Color.clear, lineWidth: 2)
)
if let v = value {
Text(v ? "1" : "0")
.font(.system(size: 18, weight: isFixed ? .heavy : .semibold, design: .rounded))
.foregroundStyle(.white)
}
}
.aspectRatio(1, contentMode: .fit)
}
private var background: Color {
switch value {
case nil: return Color(.systemGray5)
case true: return .accentColor
case false: return Color(.label)
}
}
}public protocol TakuzuCellProtocol: View {
init(
row: Int,
column: Int,
value: Bool?, // nil = empty, true = "1", false = "0"
isFixed: Bool, // given cell that cannot be edited
isViolation: Bool // cell is part of a rule violation
)
}TakuzuGameView(model: $model)
.grid(spacing: 4, cell: MyTakuzuCell.self)Violation highlighting
showViolations(true) is the default. Cells that break balance, no-triples, or uniqueness rules receive isViolation: true so your cell can render error feedback. Disable with showViolations(false), or read model.violations directly.
TakuzuGameView(model: $model)
.showViolations(true) // default — highlight balance, no-triples, uniqueness violations
.showViolations(false) // disable all real-time violation feedback
// Access violations directly from the model (Set<TakuzuCoord>)
let violations = model.violations
print("\(violations.count) cells in violation")Add callbacks
onCellTap fires on every editable cell tap with the new value after the cycle. onGameComplete fires once when every cell is filled — the Bool argument is true when the board matches the solution exactly.
TakuzuGameView(model: $model)
.onCellTap { row, col, newValue in
// newValue: nil = cleared, true = "1", false = "0"
print("(\(row),\(col)) → \(newValue.map { $0 ? "1" : "0" } ?? "empty")")
}
.onGameComplete { isCorrect in
print(isCorrect ? "Puzzle solved!" : "Board filled — incorrect.")
}Putting it all together
Custom cells, violation highlighting, hint trigger, and completion callback in one block.
@State private var model = TakuzuModel.example
@State private var hintCount = 0
TakuzuGameView(model: $model)
.grid(spacing: 4, cell: MyTakuzuCell.self)
.showViolations(true)
.hint(trigger: $hintCount)
.onCellTap { row, col, value in
print("(\(row),\(col)) → \(value.map { $0 ? "1" : "0" } ?? "empty")")
}
.onGameComplete { isCorrect in
showAlert = true
alertMessage = isCorrect ? "Solved!" : "Board filled — try again."
}
// Each increment activates hint mode — tap any highlighted empty cell to fill it with
// its correct value. Hint mode ends after one tap.
Button("Hint") { hintCount += 1 }Voxel(beta)
Embed with one line
VoxelGameView needs no model by default. Two players alternate tapping ghost cubes to build a 3D structure. Drag anywhere to rotate and inspect it.
import PluzzleSDK
VoxelGameView()
.padding()
.aspectRatio(1, contentMode: .fit)Choose a node shape
Control the geometry of every cube in the scene with .node(shape:size:). Four shapes are available.
// Choose the geometry for every node in the scene
VoxelGameView()
.node(shape: .box(chamfer: 0.0), size: 0.85) // sharp cube
.node(shape: .box(chamfer: 0.15), size: 0.85) // rounded cube (default)
.node(shape: .sphere, size: 0.85) // ball
.node(shape: .capsule, size: 0.85) // pillApply a colour theme
Customise Player One, Player Two, the neutral seed cube, the ghost placement hints, and the win-highlight colour independently.
VoxelGameView()
.theme(VoxelTheme(
playerOne: .indigo, // Player One cube colour
playerTwo: .pink, // Player Two cube colour
seed: .gray, // Neutral starting cube
ghost: .gray.opacity(0.3), // Placement hint cubes
win: .yellow // Highlight on the winning line
))Configure the model
Use VoxelModel to change the win-line length or cap the game with a turn limit. maxTurns: 0 means no limit.
// Set the win length and an optional turn limit
VoxelGameView(model: VoxelModel(winLength: 4, maxTurns: 60))Putting it all together
onInput fires on every cube placement. onCompletion fires with the winning player, or nil on a draw.
VoxelGameView(model: VoxelModel(winLength: 3, maxTurns: 0))
.node(shape: .box(chamfer: 0.15), size: 0.85)
.theme(VoxelTheme(
playerOne: .indigo, playerTwo: .pink,
seed: .gray, ghost: .gray, win: .yellow
))
.onInput { coord, player in
let label = player == .one ? "Indigo" : "Pink"
statusText = "\(label) placed at (\(coord.x), \(coord.y), \(coord.z))"
}
.onCompletion { winner in
if let winner {
alertMessage = "\(winner == .one ? "Indigo" : "Pink") wins!"
} else {
alertMessage = "It's a draw!"
}
showAlert = true
}
.padding()
.aspectRatio(1, contentMode: .fit)Shikaku
Embed with one line
Pass the built-in example model to render a fully playable 9×6 Shikaku puzzle. Drag from any cell to an opposite corner to draw a rectangle — release to commit it. Tap any covered cell to remove its rectangle.
import PluzzleSDK
@State private var model = ShikakuModel.example
ShikakuGameView(model: $model)
.padding()Build a model
Construct a puzzle by supplying the grid dimensions and the sparse clue dictionary that maps each clue coordinate to its required rectangle area. Provide a solution array to enable the hint system.
let model = ShikakuModel(
rows: 5,
columns: 5,
clues: [
ShikakuCoord(row: 0, col: 1): 6,
ShikakuCoord(row: 0, col: 4): 4,
ShikakuCoord(row: 2, col: 2): 6,
ShikakuCoord(row: 4, col: 0): 4,
ShikakuCoord(row: 4, col: 4): 5,
],
solution: [
ShikakuRect(row: 0, col: 0, rowSpan: 2, colSpan: 3), // area 6 — covers clue at (0,1)
ShikakuRect(row: 0, col: 3, rowSpan: 2, colSpan: 2), // area 4 — covers clue at (0,4)
// … remaining rectangles that partition every cell
]
)Customise grid cells
Conform to ShikakuCellProtocol and inject it with .grid(). ShikakuCellState carries everything your cell needs: the clue number, which rectangle covers it, drag-preview state, violation state, and a stable colour index for tinting rectangles consistently.
ShikakuGameView(model: $model)
.grid(spacing: 2, cell: MyCell.self)public protocol ShikakuCellProtocol: View {
init(row: Int, column: Int, state: ShikakuCellState)
}
public struct ShikakuCellState {
let clue: Int? // Clue number, or nil for blank cells
let rect: ShikakuRect? // Rectangle this cell belongs to, or nil if uncovered
let isRectOrigin: Bool // true when this cell is the top-left corner of its rectangle
let isViolation: Bool // true when the covering rectangle breaks a rule
let isPreview: Bool // true when inside the current drag-preview rectangle
let isOverlap: Bool // true when covered AND inside drag preview (will be overwritten)
let colorIndex: Int? // Stable palette index for the covering rectangle; nil when uncovered
}struct MyCell: View, ShikakuCellProtocol {
let row: Int
let column: Int
let state: ShikakuCellState
init(row: Int, column: Int, state: ShikakuCellState) {
self.row = row
self.column = column
self.state = state
}
// A stable palette of tints — colorIndex cycles through them
private static let palette: [Color] = [
.blue.opacity(0.25), .green.opacity(0.25), .orange.opacity(0.25),
.purple.opacity(0.25), .pink.opacity(0.25), .teal.opacity(0.25),
]
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(background)
.overlay(
RoundedRectangle(cornerRadius: 4)
.strokeBorder(borderColor, lineWidth: state.isViolation ? 2 : 0.5)
)
if let clue = state.clue {
Text("\(clue)")
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundStyle(.primary)
}
}
}
private var background: Color {
if state.isOverlap { return .red.opacity(0.2) }
if state.isPreview { return .accentColor.opacity(0.2) }
if state.isViolation { return .red.opacity(0.12) }
if let idx = state.colorIndex {
return Self.palette[idx % Self.palette.count]
}
return Color(.systemGray6)
}
private var borderColor: Color {
state.isViolation ? .red : Color(.systemGray4)
}
}Violation highlighting
showViolations(true) is the default. Any rectangle that contains zero or more than one clue, or whose area does not match the clue number, causes isViolation: true on each of its cells. Pass false to disable this feedback entirely.
ShikakuGameView(model: $model)
.showViolations(true) // default — highlight rectangles with wrong area or multiple clues
.showViolations(false) // disable all violation feedbackAdd callbacks
onMove fires each time the player commits a rectangle, passing the placed ShikakuRect. onComplete fires once when every cell is covered and every rectangle satisfies the rules.
ShikakuGameView(model: $model)
.onMove { rect in
// Fires each time the player commits a new rectangle
print("Placed \(rect.rowSpan)×\(rect.colSpan) at (\(rect.row), \(rect.col)), area = \(rect.area)")
}
.onComplete {
// Fires once when every cell is covered and all rectangles satisfy the rules
showSuccessBanner = true
}Add hints
Attach .hint(trigger:) and pass a counter held in @State. Each increment activates hint mode — all uncovered cells are highlighted. Tap any highlighted cell to reveal its solution rectangle. Hint mode ends after one tap. Requires the model to be initialised with a solution array.
@State private var hintCount = 0
ShikakuGameView(model: $model)
.hint(trigger: $hintCount)
// Each increment activates hint mode — all uncovered cells are highlighted.
// Tap any highlighted cell to reveal its solution rectangle. Hint mode ends after one tap.
// Requires a solution to be provided in ShikakuModel — has no effect without one.
Button("Hint") { hintCount += 1 }Putting it all together
Custom cells, violation highlighting, hint trigger, and both callbacks in one block.
@State private var model = ShikakuModel.example
@State private var hintCount = 0
ShikakuGameView(model: $model)
.grid(spacing: 2, cell: MyCell.self)
.showViolations(true)
.hint(trigger: $hintCount)
.onMove { rect in
print("Placed \(rect.rowSpan)×\(rect.colSpan) at (\(rect.row), \(rect.col))")
}
.onComplete {
alertMessage = "Puzzle solved!"
showAlert = true
}
.padding()Explore the examples
Explore a fully working iOS and Android sample project you can run immediately.