iOS · Swift

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


Number Puzzle

Sudoku

1

Embed with one line

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

import PluzzleSDK

SudokuGameView(model: .example)
    .padding()
2

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],
        // ...
    ]
)
3

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)
Protocol
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)
        }
    }
}
4

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)
Protocol
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))
        }
    }
}
5

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
    }
6

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)
7

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 }
8

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
    }

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 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))
2

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)
Protocol
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()
        }
    }
}
3

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
    }
4

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 true
5

Add 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 }
6

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)

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 PluzzleSDK

StreaksGameView(model: StreaksModel(rows: 5, columns: 5))
    .padding()
    .aspectRatio(1, contentMode: .fit)
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
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),
    ]
)
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)
Protocol
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)
        }
    }
}
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))
    .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)

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 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
)
2

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)
Protocol
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
        }
    }
}
3

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)
Protocol
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))
        }
    }
}
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)
    .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
    }

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 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"]
)
2

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)
Protocol
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()
        }
    }
}
3

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)
Protocol
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))
        }
    }
}
4

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)
Protocol
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())
    }
}
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)
    .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
    }

Binary Puzzle

Takuzu

1

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)
2

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 ],
    ]
)
3

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)
        }
    }
}
Protocol
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)
4

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")
5

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.")
    }
6

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 }

3D Strategy

Voxel(beta)

1

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)
2

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)   // pill
3

Apply 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
    ))
4

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))
5

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)

Spatial Logic

Shikaku

1

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()
2

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
    ]
)
3

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)
Protocol
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)
    }
}
4

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 feedback
5

Add 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
    }
6

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 }
7

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()
Focus

Explore the examples

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