```html How to Build a Sudoku Game App in SwiftUI (2026)

How to Build a Sudoku Game App in SwiftUI

A Sudoku game app lets players solve randomly generated logic puzzles on their iPhone, with difficulty tiers, error highlighting, and a timer — perfect for indie developers targeting the casual puzzle audience on the App Store.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Intermediate Estimated time: 1 week Updated: May 12, 2026

Prerequisites

Architecture overview

The app uses a thin @Observable GameViewModel that owns a 9×9 matrix of SudokuCell value types, a reference to the solved board (for error-checking), and a timer publisher. PuzzleGenerator is a pure stateless struct that fills a board using backtracking and then removes clues to match the chosen difficulty. SudokuPuzzle is a SwiftData @Model class that snapshots the in-progress board to disk so the player can resume mid-puzzle. Views are kept dumb: GridView, CellView, and NumberPadView receive data and call closures back into the view model.

SudokuApp/
├── App/
│   └── SudokuApp.swift          # @main, ModelContainer setup
├── Models/
│   ├── SudokuCell.swift         # struct: value, isGiven, isError, notes
│   ├── SudokuPuzzle.swift       # @Model (SwiftData) — saved game snapshot
│   └── Difficulty.swift         # enum: easy / medium / hard
├── Engine/
│   ├── PuzzleGenerator.swift    # backtracking fill + clue removal
│   └── SudokuSolver.swift       # validator + unique-solution check
├── ViewModels/
│   └── GameViewModel.swift      # @Observable — grid state, timer, selection
├── Views/
│   ├── ContentView.swift        # nav root
│   ├── MenuView.swift           # new game / resume
│   ├── GameView.swift           # main game screen
│   ├── GridView.swift           # 9×9 grid layout
│   ├── CellView.swift           # single cell with animation
│   ├── NumberPadView.swift      # digit entry bar
│   └── CompletionOverlay.swift  # win celebration
└── PrivacyInfo.xcprivacy

Step-by-step

1. Project setup

Create a new Xcode project using the iOS App template, set the minimum deployment to iOS 17.0, and enable SwiftData in the project capabilities. Delete the default ContentView placeholder code and add the folder structure above.

// SudokuApp.swift
import SwiftUI
import SwiftData

@main
struct SudokuApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: SudokuPuzzle.self)
    }
}

2. Data model

Define SudokuCell as a value type (struct) so that mutations on the grid trigger SwiftUI diff updates automatically. Persist the active game with a SwiftData @Model that stores the grid as a flat [Int] array and a Date for timing.

// Models/SudokuCell.swift
struct SudokuCell: Equatable {
    var value: Int        // 0 = empty
    var isGiven: Bool
    var isError: Bool = false
    var notes: Set<Int> = []
}

// Models/SudokuPuzzle.swift
import SwiftData
import Foundation

@Model
final class SudokuPuzzle {
    var id: UUID
    var flatGrid: [Int]          // 81 values, row-major
    var flatGivens: [Bool]       // which cells are locked
    var flatErrors: [Bool]
    var difficulty: String
    var elapsedSeconds: Int
    var startedAt: Date

    init(
        flatGrid: [Int],
        flatGivens: [Bool],
        difficulty: String
    ) {
        self.id = UUID()
        self.flatGrid = flatGrid
        self.flatGivens = flatGivens
        self.flatErrors = Array(repeating: false, count: 81)
        self.difficulty = difficulty
        self.elapsedSeconds = 0
        self.startedAt = Date()
    }
}

3. Core grid UI

Build the 9×9 grid with LazyVGrid and a custom CellView. Bold lines every third cell (box borders) are drawn using an overlay on grouped sub-grids, not individual cells — this keeps the cell count clean.

// Views/GridView.swift
import SwiftUI

struct GridView: View {
    let grid: [[SudokuCell]]
    let selected: (row: Int, col: Int)?
    let onSelect: (Int, Int) -> Void

    private let columns = Array(
        repeating: GridItem(.flexible(), spacing: 1),
        count: 9
    )

    var body: some View {
        LazyVGrid(columns: columns, spacing: 1) {
            ForEach(0..<9, id: \.self) { row in
                ForEach(0..<9, id: \.self) { col in
                    CellView(
                        cell: grid[row][col],
                        isSelected: selected.map { $0.row == row && $0.col == col } ?? false,
                        isHighlighted: isHighlighted(row: row, col: col)
                    )
                    .aspectRatio(1, contentMode: .fit)
                    .onTapGesture { onSelect(row, col) }
                    .border(boxBorderColor(row: row, col: col), width: boxBorderWidth(row: row, col: col))
                }
            }
        }
        .padding(2)
        .background(Color(uiColor: .separator))
        .clipShape(RoundedRectangle(cornerRadius: 8))
    }

    private func isHighlighted(row: Int, col: Int) -> Bool {
        guard let sel = selected else { return false }
        return sel.row == row || sel.col == col
            || (row / 3 == sel.row / 3 && col / 3 == sel.col / 3)
    }

    private func boxBorderColor(row: Int, col: Int) -> Color {
        (col % 3 == 2 && col != 8) || (row % 3 == 2 && row != 8)
            ? Color(uiColor: .separator).opacity(0.8)
            : Color.clear
    }

    private func boxBorderWidth(row: Int, col: Int) -> CGFloat {
        (col % 3 == 2 && col != 8) || (row % 3 == 2 && row != 8) ? 2 : 0
    }
}

// Views/CellView.swift
struct CellView: View {
    let cell: SudokuCell
    let isSelected: Bool
    let isHighlighted: Bool

    var body: some View {
        ZStack {
            backgroundColor
            if cell.value != 0 {
                Text("\(cell.value)")
                    .font(.system(size: 20, weight: cell.isGiven ? .bold : .regular, design: .rounded))
                    .foregroundStyle(foregroundColor)
                    .contentTransition(.numericText())
                    .animation(.spring(duration: 0.2), value: cell.value)
            }
        }
    }

    private var backgroundColor: Color {
        if isSelected { return Color.accentColor.opacity(0.3) }
        if isHighlighted { return Color.accentColor.opacity(0.1) }
        return Color(uiColor: .secondarySystemBackground)
    }

    private var foregroundColor: Color {
        if cell.isError { return .red }
        if cell.isGiven { return .primary }
        return .accentColor
    }
}

#Preview {
    GridView(
        grid: Array(repeating: Array(repeating: SudokuCell(value: 0, isGiven: false), count: 9), count: 9),
        selected: nil,
        onSelect: { _, _ in }
    )
    .padding()
}

4. Puzzle generation and backtracking solver

This is the heart of the app. Fill a blank 9×9 board using randomised backtracking to get a valid completed grid, then remove clues until the board has exactly one solution. The difficulty enum controls how many clues remain (easy: 36+, medium: 28–35, hard: 22–27).

// Engine/PuzzleGenerator.swift
import Foundation

enum Difficulty: String, CaseIterable {
    case easy, medium, hard

    var cluesToRemove: Int {
        switch self {
        case .easy:   return 45
        case .medium: return 53
        case .hard:   return 59
        }
    }
}

struct PuzzleGenerator {
    /// Returns (puzzle grid with 0s for blanks, solved grid)
    static func generate(difficulty: Difficulty) -> (puzzle: [[Int]], solution: [[Int]]) {
        var board = Array(repeating: Array(repeating: 0, count: 9), count: 9)
        _ = fill(&board)
        let solution = board

        var puzzle = board
        var removed = 0
        var positions = (0..<81).map { ($0 / 9, $0 % 9) }.shuffled()

        for (row, col) in positions {
            guard removed < difficulty.cluesToRemove else { break }
            let backup = puzzle[row][col]
            puzzle[row][col] = 0
            var copy = puzzle
            if countSolutions(&copy) == 1 {
                removed += 1
            } else {
                puzzle[row][col] = backup
            }
        }
        return (puzzle, solution)
    }

    // MARK: - Private helpers

    private static func fill(_ board: inout [[Int]]) -> Bool {
        guard let (row, col) = nextEmpty(board) else { return true }
        for num in (1...9).shuffled() {
            if isValid(board, row: row, col: col, num: num) {
                board[row][col] = num
                if fill(&board) { return true }
                board[row][col] = 0
            }
        }
        return false
    }

    private static func countSolutions(_ board: inout [[Int]], limit: Int = 2) -> Int {
        guard let (row, col) = nextEmpty(board) else { return 1 }
        var count = 0
        for num in 1...9 {
            if isValid(board, row: row, col: col, num: num) {
                board[row][col] = num
                count += countSolutions(&board, limit: limit)
                board[row][col] = 0
                if count >= limit { return count }
            }
        }
        return count
    }

    private static func nextEmpty(_ board: [[Int]]) -> (Int, Int)? {
        for r in 0..<9 {
            for c in 0..<9 {
                if board[r][c] == 0 { return (r, c) }
            }
        }
        return nil
    }

    private static func isValid(_ board: [[Int]], row: Int, col: Int, num: Int) -> Bool {
        // Row
        if board[row].contains(num) { return false }
        // Column
        if (0..<9).contains(where: { board[$0][col] == num }) { return false }
        // Box
        let br = (row / 3) * 3, bc = (col / 3) * 3
        for r in br..<br+3 {
            for c in bc..<bc+3 {
                if board[r][c] == num { return false }
            }
        }
        return true
    }
}

5. GameViewModel and in-progress persistence

The @Observable GameViewModel bridges the engine output to the view layer and syncs game state to a SwiftData SudokuPuzzle record every time the player enters a digit, so resuming after backgrounding the app is seamless.

// ViewModels/GameViewModel.swift
import Observation
import SwiftUI
import Combine

@Observable
final class GameViewModel {
    var grid: [[SudokuCell]] = []
    var selected: (row: Int, col: Int)?
    var isComplete = false
    var elapsedSeconds = 0

    private var solution: [[Int]] = []
    private var timerCancellable: AnyCancellable?

    // MARK: - Game lifecycle

    func startNewGame(difficulty: Difficulty) {
        stopTimer()
        let (puzzle, sol) = PuzzleGenerator.generate(difficulty: difficulty)
        solution = sol
        grid = puzzle.enumerated().map { r, row in
            row.enumerated().map { c, val in
                SudokuCell(value: val, isGiven: val != 0)
            }
        }
        selected = nil
        isComplete = false
        elapsedSeconds = 0
        startTimer()
    }

    func restore(from saved: SudokuPuzzle) {
        stopTimer()
        solution = []   // re-solve if needed — omitted for brevity
        grid = (0..<9).map { r in
            (0..<9).map { c in
                let idx = r * 9 + c
                return SudokuCell(
                    value: saved.flatGrid[idx],
                    isGiven: saved.flatGivens[idx],
                    isError: saved.flatErrors[idx]
                )
            }
        }
        elapsedSeconds = saved.elapsedSeconds
        startTimer()
    }

    // MARK: - Interaction

    func selectCell(row: Int, col: Int) {
        withAnimation(.easeInOut(duration: 0.1)) {
            selected = (row, col)
        }
    }

    func enterNumber(_ number: Int) {
        guard let sel = selected,
              !grid[sel.row][sel.col].isGiven else { return }
        let current = grid[sel.row][sel.col].value
        let newVal = (number == current) ? 0 : number
        grid[sel.row][sel.col].value = newVal
        grid[sel.row][sel.col].isError = newVal != 0
            && newVal != solution[sel.row][sel.col]
        checkCompletion()
    }

    func clearCell() {
        guard let sel = selected,
              !grid[sel.row][sel.col].isGiven else { return }
        grid[sel.row][sel.col].value = 0
        grid[sel.row][sel.col].isError = false
    }

    // MARK: - Helpers

    private func checkCompletion() {
        isComplete = grid.allSatisfy { row in
            row.allSatisfy { !$0.isError && $0.value != 0 }
        }
        if isComplete { stopTimer() }
    }

    private func startTimer() {
        timerCancellable = Timer.publish(every: 1, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in self?.elapsedSeconds += 1 }
    }

    private func stopTimer() {
        timerCancellable?.cancel()
    }
}

// Views/GameView.swift (abbreviated)
struct GameView: View {
    @State private var vm = GameViewModel()

    var body: some View {
        VStack(spacing: 16) {
            TimerView(seconds: vm.elapsedSeconds)
            GridView(grid: vm.grid, selected: vm.selected, onSelect: vm.selectCell)
            NumberPadView(onNumber: vm.enterNumber, onClear: vm.clearCell)
        }
        .padding()
        .onAppear { vm.startNewGame(difficulty: .medium) }
        .overlay {
            if vm.isComplete {
                CompletionOverlay()
                    .transition(.scale.combined(with: .opacity))
            }
        }
        .animation(.spring(bounce: 0.3), value: vm.isComplete)
    }
}

#Preview {
    GameView()
}

6. Animations and visual polish

SwiftUI's .contentTransition(.numericText()) gives digit entry a satisfying flip effect for free. The completion overlay uses a Canvas particle burst — no third-party library required, keeping your binary lean and App Store review smooth.

// Views/CompletionOverlay.swift
import SwiftUI

struct CompletionOverlay: View {
    @State private var particles: [Particle] = (0..<60).map { _ in Particle() }
    @State private var animating = false

    var body: some View {
        ZStack {
            Color.black.opacity(0.45).ignoresSafeArea()
            TimelineView(.animation) { tl in
                Canvas { ctx, size in
                    for p in particles {
                        let age = tl.date.timeIntervalSinceReferenceDate - p.birth
                        let progress = min(age / p.lifetime, 1.0)
                        let x = p.origin.x + p.velocity.x * CGFloat(age)
                        let y = p.origin.y + p.velocity.y * CGFloat(age) + 200 * CGFloat(age * age)
                        let opacity = 1 - progress
                        var circle = Path()
                        circle.addEllipse(in: CGRect(x: x - 5, y: y - 5, width: 10, height: 10))
                        ctx.fill(circle, with: .color(p.color.opacity(opacity)))
                    }
                }
            }
            VStack(spacing: 12) {
                Text("Puzzle Complete!")
                    .font(.largeTitle.bold())
                    .foregroundStyle(.white)
                Text("Great job!")
                    .foregroundStyle(.white.opacity(0.8))
            }
        }
        .onAppear {
            let now = Date.timeIntervalSinceReferenceDate
            particles = particles.map { p in
                var new = p; new.birth = now; return new
            }
        }
    }
}

private struct Particle {
    var origin: CGPoint = CGPoint(x: CGFloat.random(in: 60...320), y: CGFloat.random(in: 100...400))
    var velocity: CGPoint = CGPoint(
        x: CGFloat.random(in: -80...80),
        y: CGFloat.random(in: -200...(-60))
    )
    var color: Color = [Color.red, .orange, .yellow, .green, .blue, .purple].randomElement()!
    var lifetime: Double = Double.random(in: 1.0...2.0)
    var birth: Double = 0
}

#Preview {
    CompletionOverlay()
}

7. Privacy Manifest (required for App Store)

Apple requires a PrivacyInfo.xcprivacy file for any app using required reason APIs. Even a local puzzle game typically accesses UserDefaults (settings) and NSFileManager (save files), both of which need declared reasons. Add the file to your app target, not just to a framework.

<!-- PrivacyInfo.xcprivacy -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSPrivacyTracking</key>
  <false/>
  <key>NSPrivacyTrackingDomains</key>
  <array/>
  <key>NSPrivacyCollectedDataTypes</key>
  <array/>
  <key>NSPrivacyAccessedAPITypes</key>
  <array>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <string>CA92.1</string>
      </array>
    </dict>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <string>C617.1</string>
      </array>
    </dict>
  </array>
</dict>
</plist>

Common pitfalls

Adding monetization: Ad-supported

The lowest-friction monetization path for a free Sudoku game is banner and interstitial ads via Google AdMob (or Apple's own SKAdNetwork-compatible alternative). Add the AdMob Swift Package from https://github.com/googleads/swift-package-manager-google-mobile-ads, initialise GADMobileAds.sharedInstance().start() in your App init, and drop a GADBannerView wrapped in a UIViewRepresentable at the bottom of GameView. Show an interstitial ad between puzzles (not mid-solve — App Store reviewers flag it as disruptive) by calling GADInterstitialAd.load in GameViewModel.startNewGame and presenting it in the completion handler of isComplete. Pair the ad tier with a one-time StoreKit 2 Product.purchase IAP to remove ads permanently — players who see the game's quality often convert, and this combination consistently outperforms subscriptions for puzzle apps.

Shipping this faster with Soarias

Soarias automates the boilerplate steps that eat a full day on an intermediate project like this one. After you describe your app, it scaffolds the complete folder structure above — PuzzleGenerator, GameViewModel, SwiftData model, and all view files — in under two minutes. More importantly, it generates the PrivacyInfo.xcprivacy file with correct reason codes based on the APIs your code actually calls, wires up fastlane with an Appfile and Fastfile pre-configured for TestFlight, and pushes your first build to App Store Connect without you touching the Organizer.

For an intermediate project like this Sudoku app, most developers spend 3–4 hours just on App Store Connect setup (bundle ID, certificates, provisioning profiles, metadata, screenshots for six device sizes). Soarias collapses that to a single guided flow that takes under 30 minutes the first time. The puzzle logic and animations are yours to own; Soarias handles everything from xcode-select to "Waiting for Review."

Related guides

FAQ

Does this work on iOS 16?

The @Observable macro and .contentTransition(.numericText()) both require iOS 17. If you need iOS 16 support, swap @Observable for @ObservableObject / @Published and remove the .contentTransition modifier — the app will compile and run but without the number flip animation. SwiftData also requires iOS 17, so you'd need to fall back to Codable + UserDefaults for persistence.

Do I need a paid Apple Developer account to test?

No — you can build and run on a personal device with a free Apple ID through Xcode's automatic signing. However, a free account limits you to three installed apps and a 7-day certificate that must be renewed manually. A paid $99/year Developer Program membership is required for TestFlight distribution and any App Store submission.

How do I add this to the App Store?

Register your bundle ID in the Apple Developer portal, create an app record in App Store Connect, set your pricing and availability, upload at least one screenshot per supported device size (you can use the Simulator), fill in the description and keywords, and submit for review. First-time reviews for a simple game typically take 1–3 business days. Soarias handles most of this flow end-to-end with a guided setup.

How do I implement multiple difficulty levels without regenerating on every launch?

Pre-generate a pool of 5–10 puzzles per difficulty level in a background Task at first launch and cache them in SwiftData. When the player taps "New Game," pop the next puzzle off the cached pool and kick off generation of a replacement in the background. This way puzzle delivery is instant and the generation cost is amortised. For a free app you can also bundle a small JSON file of 50–100 pre-generated puzzles per difficulty as a fallback before the live generator runs.

Last reviewed: 2026-05-12 by the Soarias team.

```