How to Build a Chess App in SwiftUI

A Chess app lets players match wits against an AI opponent on a fully rule-complete 8×8 board — castling, en passant, promotion, and all. It's aimed at indie developers who want a technically ambitious iOS project that showcases custom game logic, Swift concurrency, and CoreML integration.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Advanced Estimated time: 2–4 weeks Updated: May 12, 2026

Prerequisites

Architecture overview

The app splits into three layers. The Models layer contains a SwiftData-persisted ChessGame record for game history and a value-type ChessBoard struct for in-memory move generation — keeping the AI able to copy the board cheaply without hitting the database. The Views layer renders the 8×8 grid, piece highlights, and game controls via a single ChessGameViewModel. The AI layer is an actor housing minimax with alpha-beta pruning; CoreML can replace or augment the hand-tuned material score for stronger play at the same search depth.

ChessApp/
├── Models/
│   ├── ChessGame.swift        # SwiftData @Model — persisted game record
│   ├── ChessBoard.swift       # value type: move gen, validation, evaluate()
│   └── ChessPiece.swift       # PieceType, PieceColor, Unicode symbols
├── Views/
│   ├── ChessBoardView.swift   # 8×8 grid + tap handling
│   └── GameControlsView.swift # new game, undo, difficulty picker
├── AI/
│   └── ChessAI.swift          # minimax + α-β pruning (background actor)
└── PrivacyInfo.xcprivacy

Step-by-step

1. Data model

Use SwiftData to persist game records and a plain Codable struct for the live board so the AI can copy board state without touching the store.

import SwiftData
import Foundation

enum PieceType: String, Codable {
    case king, queen, rook, bishop, knight, pawn
}
enum PieceColor: String, Codable { case white, black }

struct ChessPiece: Codable, Identifiable {
    var id       = UUID()
    var type:    PieceType
    var color:   PieceColor
    var file:    Int        // 0 (a-file) – 7 (h-file)
    var rank:    Int        // 0 – 7
    var hasMoved = false

    var symbol: String {
        let w = [PieceType.king:"♔",.queen:"♕",.rook:"♖",
                 .bishop:"♗",.knight:"♘",.pawn:"♙"]
        let b = [PieceType.king:"♚",.queen:"♛",.rook:"♜",
                 .bishop:"♝",.knight:"♞",.pawn:"♟"]
        return (color == .white ? w : b)[type] ?? "?"
    }
}

@Model final class ChessGame {
    var id          = UUID()
    var boardData   = Data()        // JSON-encoded [ChessPiece]
    var currentTurn = "white"
    var moveHistory = [String]()    // algebraic notation
    var status      = "active"      // active | checkmate | stalemate | draw
    var createdAt   = Date()
}

2. Chessboard UI

Render the board as nested ForEach loops iterating rank (reversed) then file, overlaying selection and valid-move indicators on each square.

struct ChessBoardView: View {
    @ObservedObject var vm: ChessGameViewModel

    var body: some View {
        VStack(spacing: 0) {
            ForEach((0..<8).reversed(), id: \.self) { rank in
                HStack(spacing: 0) {
                    ForEach(0..<8, id: \.self) { file in
                        SquareView(
                            file: file, rank: rank,
                            piece: vm.piece(at: file, rank: rank),
                            isSelected:  vm.selectedSquare == [file, rank],
                            isValidMove: vm.validMoves.contains([file, rank])
                        )
                        .onTapGesture { vm.handleTap(file: file, rank: rank) }
                    }
                }
            }
        }
        .aspectRatio(1, contentMode: .fit)
        .clipShape(RoundedRectangle(cornerRadius: 6))
        .shadow(radius: 8)
    }
}

struct SquareView: View {
    let file, rank: Int;  let piece: ChessPiece?
    let isSelected, isValidMove: Bool

    var bg: Color { (file + rank) % 2 == 0
        ? Color(red: 0.71, green: 0.53, blue: 0.39)
        : Color(red: 0.94, green: 0.85, blue: 0.71) }

    var body: some View {
        ZStack {
            bg
            if isSelected  { Color.yellow.opacity(0.4) }
            if isValidMove { Circle().fill(Color.black.opacity(0.18)).padding(12) }
            if let p = piece { Text(p.symbol).font(.system(size: 38)) }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

3. AI opponent — minimax with α-β pruning

Run the tree search on a background actor so the UI never blocks; depth 3 gives a solid challenge without perceptible lag on modern iPhones.

actor ChessAI {
    func bestMove(board: ChessBoard, depth: Int = 3) async -> Move? {
        var best: Move?
        var bestScore = Int.min
        for move in board.legalMoves(for: .black) {
            let score = minimax(board.applying(move), depth: depth - 1,
                                alpha: Int.min, beta: Int.max,
                                maximizing: false)
            if score > bestScore { bestScore = score; best = move }
        }
        return best
    }

    private func minimax(_ board: ChessBoard, depth: Int,
                         alpha: Int, beta: Int,
                         maximizing: Bool) -> Int {
        guard depth > 0, board.status == .active else { return board.evaluate() }
        var α = alpha, β = beta
        let color: PieceColor = maximizing ? .black : .white
        var value = maximizing ? Int.min : Int.max
        for move in board.legalMoves(for: color) {
            let s = minimax(board.applying(move), depth: depth - 1,
                            alpha: α, beta: β, maximizing: !maximizing)
            if maximizing { value = max(value, s); α = max(α, value) }
            else          { value = min(value, s); β = min(β, value) }
            if α >= β { break }   // α-β cut-off
        }
        return value
    }
}

// In your ViewModel, after the player's move:
Task {
    if let move = await ai.bestMove(board: currentBoard) {
        await MainActor.run { applyMove(move) }
    }
}

4. Privacy Manifest (PrivacyInfo.xcprivacy)

Add this file to your app target — SwiftData and UserDefaults both require declared reasons or Apple will auto-reject before a human reviewer sees the app.

<?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>NSPrivacyAccessedAPICategoryFileTimestamp</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array><string>C617.1</string></array>
    </dict>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array><string>CA92.1</string></array>
    </dict>
  </array>
</dict>
</plist>

Common pitfalls

Adding monetization: One-time purchase

Use StoreKit 2's Product.purchase() API to gate the full AI difficulty tiers behind a non-consumable in-app purchase — or price the app itself as a paid download in App Store Connect. Define the product in ASC, then call try await product.purchase() on a "Unlock Full Game" button. At app launch, iterate Transaction.updates and Transaction.currentEntitlement(for:) to restore the purchase without a separate Restore button. Store the entitlement in a SwiftData flag so it survives reinstalls. StoreKit 2 validates receipts server-side automatically — no custom backend needed for a one-time purchase model.

Shipping this faster with Soarias

Soarias scaffolds the full SwiftData model, PrivacyInfo.xcprivacy, and fastlane lanes (screenshot generation, metadata upload, ASC submission) from a single prompt — eliminating the 2–3 hours of Xcode project boilerplate that front-loads every new app. It also generates the StoreKit 2 purchase flow with Transaction.updates wired correctly, an area where subtle async ordering bugs reliably cause App Store rejections on first submission.

For an advanced project like this one — realistically 2–4 weeks of solo development — Soarias typically recovers 3–5 days: roughly a day on project setup and signing config, a day on the Privacy Manifest and App Store metadata, and 1–3 days on the submission pipeline itself. That time goes back to the chess logic and AI tuning: the parts that actually differentiate your app from everything else on the store.

Related guides

FAQ

Do I need a paid Apple Developer account?

Yes. The free tier lets you sideload onto your own device via Xcode, but TestFlight distribution and App Store submission both require the $99/year Apple Developer Program membership.

How do I submit this to the App Store?

Archive in Xcode (Product → Archive), upload via Xcode Organizer or xcrun altool, then complete the App Store Connect checklist: privacy nutrition labels, age rating, at least one screenshot per device class, and your PrivacyInfo.xcprivacy must all be in place before you can submit for review.

How do I tune AI difficulty without making it slower?

Expose search depth as a difficulty setting: depth 2 is beginner, 3 is intermediate, 4 is strong. Beyond depth 4, alpha-beta pruning still slows noticeably on complex positions — at that point, swap in a CoreML-exported evaluation network (e.g. a Lc0-style small net) to get stronger play at the same depth without increasing computation time.

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