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.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Solid Swift/SwiftUI knowledge; familiarity with Swift concurrency (
async/await,actor) - Working knowledge of chess rules — castling, en passant, and promotion all need explicit move-generation code
- Optional: CoreML Tools (Python) if you plan to export a neural network for position evaluation
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
- Running minimax on the main thread. Even depth-3 search blocks the UI on older devices. Always use a background
actororTask.detachedand dispatch results back withMainActor.run. - Skipping special moves. Castling, en passant, and pawn promotion require non-trivial extra logic. App Store reviewers do play test chess apps — broken or missing rules are flagged under Guideline 2.1 (App Completeness).
- Infinite repetition loops. Without threefold-repetition or fifty-move-rule detection, two AIs (or a stubborn player) can cycle a position forever and hang the game.
- Missing Privacy Manifest causes auto-rejection. If you use SwiftData or
UserDefaultswithout aPrivacyInfo.xcprivacydeclaring the accessed API types, Apple sends an automated rejection email before a human ever reviews the binary. - Unicode chess glyph inconsistency. The built-in Unicode chess symbols (♔–♟) render at different sizes across iOS versions. Ship a consistent custom font or use SF Symbols with a chess symbol pack to avoid pieces looking mismatched.
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.