This tutorial introduces ChessUI, the SwiftUI module in SwiftChessTools.
ChessUI renders chess views and reports user intent. It builds on
ChessCore, but it does not take ownership of app policy, engine analysis,
clocks, online play, PGN browsing, or game records.
The working model:
ChessBoardModelstores display state for one board,ChessBoardViewrenders it, move callbacks reportChessBoardMoveAttemptvalues, and the app decides how to updateGame, FEN, move lists, status, and engine state.
Add SwiftChessTools as a package dependency, then depend on the ChessUI
product from your target. Most apps that use ChessUI also depend on
ChessCore.
import SwiftUI
import ChessCore
import ChessUIFor local development in this workspace, use a path dependency:
.package(path: "../SwiftChessTools")Then add the products your target needs:
.product(name: "ChessCore", package: "SwiftChessTools")
.product(name: "ChessUI", package: "SwiftChessTools")Create a ChessBoardModel, render it with ChessBoardView, and apply legal
moves in the move callback:
struct BoardScreen: View {
@State private var boardModel = ChessBoardModel(
fen: initialFEN,
interactionMode: .legalMovesOnly
)
var body: some View {
ChessBoardView(model: boardModel)
.onMove(applyMove)
.frame(width: 320, height: 320)
}
private func applyMove(_ attempt: ChessBoardMoveAttempt) {
guard attempt.isLegal else {
return
}
boardModel.game.apply(move: attempt.move)
let fen = FENSerializer().fen(from: boardModel.game.position)
boardModel.setFEN(fen, animatedMove: attempt.move)
}
}ChessBoardView does not mutate the game for you. It reports the attempted
move, and your app decides whether and how to apply it.
Own ChessBoardModel at the same level that owns the board's current game
state. For a simple SwiftUI screen, @State is usually the right owner:
@State private var boardModel = ChessBoardModel(fen: initialFEN)If a parent view owns the model, pass the same instance down to child views:
struct BoardPane: View {
var boardModel: ChessBoardModel
var body: some View {
ChessBoardView(model: boardModel)
}
}Avoid keeping separate unsynchronized sources of truth for the same game. If
your app has an external Game, either make that game the authority and push
FEN into the board model, or use boardModel.game as the authority and derive
other UI from it.
To replace the board with FEN, use setFEN:
let didUpdate = boardModel.setFEN(importedFEN)
if !didUpdate {
print(boardModel.fenError ?? "Unknown FEN error")
}Invalid FEN leaves the existing board unchanged and records the parser error in
fenError. Use setFEN(_:animatedMove:) after a known move when you want the
last-move highlight and piece animation to match the update.
ChessBoardView.onMove(_:) receives a ChessBoardMoveAttempt:
.onMove { attempt in
print(attempt.coordinateMove)
print(attempt.sourceSquare)
print(attempt.targetSquare)
print(attempt.isLegal)
}The attempt contains:
move: the parsedMove.isLegal: whether the move is legal in the model's currentGame.sourceSquareandtargetSquare: coordinate strings such ase2ande4.coordinateMove: normalized coordinate notation such ase2e4ore7e8q.promotion: the selected promotion piece, when applicable.
For normal games, set interactionMode to .legalMovesOnly and apply each
reported move:
boardModel.interactionMode = .legalMovesOnlyFor surfaces that need to show rejected attempts, use
.reportsIllegalAttempts and handle attempt.isLegal == false.
For board gestures, ChessBoardView presents its promotion picker when a pawn
move reaches the last rank and at least one promotion choice is legal. The
callback fires after the user selects a piece:
.onMove { attempt in
if attempt.promotion == .queen {
print("Promoted with \(attempt.coordinateMove)")
}
}The resulting move includes the promotion kind:
e7e8q
Most apps do not need to present the picker directly. If you are building a
custom setup or tutorial flow, ChessBoardModel also exposes
presentPromotionPicker(...), dismissPromotionPicker(), and
requiresPromotionChoice(piece:move:).
Use perspective to choose which side appears at the bottom:
boardModel.perspective = .white
boardModel.perspective = .blackThe board keeps coordinates logical after flipping. A tap or drag from e2 to
e4 still reports e2e4 when Black is at the bottom.
Use shouldFlipBoard only when you are building adjacent UI that needs to align
with the board's orientation.
Rank and file coordinate labels are shown by default. Hide them for cleaner diagrams, training modes, or app surfaces that provide their own coordinates:
boardModel.showsCoordinateLabels = falseLegal-move highlights are enabled by default:
boardModel.showsLegalMoveHighlights = trueThe board updates legal highlights during normal selection and drag gestures. You can also control them directly:
boardModel.updateLegalMoveHighlights(for: BoardSquare(row: 1, column: 4))
boardModel.clearLegalMoveHighlights()Last-move highlighting is enabled by default and is normally driven by
setFEN(_:animatedMove:):
boardModel.setFEN(fen, animatedMove: move)
boardModel.clearLastMoveHighlight()Use hints for app-supplied visual markers:
boardModel.hint("e4")
boardModel.hint(["e4", "d5"])
boardModel.hint("e4", for: 1.5)
boardModel.clearHint()Hints are display markers only. They do not affect legal move generation.
Use board arrows for app-supplied visual annotations, such as engine suggestions, training hints, or study marks:
boardModel.arrows = [
ChessBoardArrow(from: "e2", to: "e4", style: .primarySuggestion),
ChessBoardArrow(from: "d2", to: "d4", style: .secondarySuggestion),
ChessBoardArrow(from: "g1", to: "f3", style: .tertiarySuggestion),
].compactMap { $0 }Clear arrows when the position changes or when the annotation no longer applies:
boardModel.clearArrows()The built-in arrow styles are display conventions only:
.primarySuggestion: strongest suggested move..secondarySuggestion: second suggested move..tertiarySuggestion: third suggested move..annotation: general-purpose non-ranked arrow.
If your app shows more than three ranked lines, choose custom styles or use
.annotation for non-ranked extras:
let studyArrow = ChessBoardArrow(
from: BoardSquare(row: 1, column: 4),
to: BoardSquare(row: 3, column: 4),
style: ChessBoardArrowStyle(red: 0.45, green: 0.20, blue: 0.78),
label: "Study annotation"
)Arrows follow board perspective automatically. They do not affect legal move
generation, selection, drag behavior, move application, or game status.
ChessUI does not analyze a position or decide whether a .primarySuggestion
arrow is actually best; the app supplies already-ranked display data.
ChessBoardInteractionMode describes what the board reports:
.readOnly: no tap or drag move interaction..legalMovesOnly: reports legal moves for the side to move..reportsIllegalAttempts: reports legal and illegal attempts for the side to move..freeSetup: reports legal and illegal attempts for either side's pieces.
Use .readOnly for analysis diagrams, PGN replay positions, and passive board
previews:
ChessBoardModel(fen: fen, interactionMode: .readOnly)Use .legalMovesOnly for normal playable boards:
boardModel.interactionMode = .legalMovesOnlyUse .freeSetup for editors or setup surfaces. ChessUI still reports whether
the coordinate move is legal in the current Game, but the app decides what a
drag means.
ChessUI ships with selectable piece sets and board themes. Use the runtime
registries to build pickers:
struct BoardSettingsView: View {
@State private var boardModel = ChessBoardModel(fen: initialFEN)
var body: some View {
@Bindable var editableModel = boardModel
VStack {
Picker("Pieces", selection: $editableModel.pieceSet) {
ForEach(ChessPieceSet.availableSets) { pieceSet in
Text(pieceSet.displayName).tag(pieceSet)
}
}
Picker("Board", selection: $editableModel.boardTheme) {
ForEach(ChessBoardTheme.availableThemes) { theme in
Text(theme.displayName).tag(theme)
}
}
ChessBoardView(model: boardModel)
}
}
}The registries are the stable app-facing way to expose bundled options:
let pieceSets = ChessPieceSet.availableSets
let boardThemes = ChessBoardTheme.availableThemesApps can choose fixed defaults, expose pickers, or store the selected raw values in their own preferences.
ChessEvaluationBar renders caller-supplied evaluation values. It does not
start an engine, parse UCI output, choose moves, or run analysis.
ChessEvaluationBar(
evaluation: .centipawns(85),
orientation: .vertical,
whiteSide: .bottom,
maximumCentipawns: 800
)
.frame(width: 28, height: 320)Centipawns are White-positive:
ChessEvaluation.centipawns(120) // White is better
ChessEvaluation.centipawns(-90) // Black is better
ChessEvaluation.centipawns(0) // EqualForced mate values identify the side delivering mate:
ChessEvaluationBar(evaluation: .mate(moves: 3, side: .white))
ChessEvaluationBar(evaluation: .mate(moves: 2, side: .black))Use .unavailable when the app has no current evaluation:
ChessEvaluationBar(evaluation: .unavailable)If you need text without rendering a view, use
ChessEvaluationBarDisplayState:
let state = ChessEvaluationBarDisplayState(evaluation: .centipawns(85))
print(state.label)
print(state.accessibilityValue)Apps that consume engines such as Stockfish should normalize engine output into these values before passing them to ChessUI.
ChessMoveListView renders display-ready move records. It does not parse PGN,
own a game history, or render a full PGN score sheet.
Build records from moves with ChessMoveRecordBuilder:
let records = try ChessMoveRecordBuilder().records(
initialPosition: Position.standard,
moves: game.moveHistory
)Render a vertical move list:
ChessMoveListView(
records: records,
selectedPly: selectedPly
) { record in
selectedPly = record.ply
}
.frame(height: 160)The default vertical layout groups White and Black moves by full move number. Give the list a fixed height so it can scroll inside a predictable viewport.
Use horizontal layout for a compact move strip:
ChessMoveListView(
records: records,
selectedPly: selectedPly,
title: nil,
layout: .horizontal,
scrollIndicatorVisibility: .hidden
) { record in
selectedPly = record.ply
}
.frame(height: 48)The selected ply is visual state only. The app decides what selection means: jumping to a position, showing annotation, updating a side panel, or doing nothing.
ChessGameStatusView renders caller-supplied GameStatus values:
ChessGameStatusView(
status: game.status,
turn: game.position.state.turn
)For claimable draws, provide a callback:
ChessGameStatusView(
status: game.status,
turn: game.position.state.turn
) { claim in
try? game.claimDraw(claim)
}The callback receives a GameDrawClaim. The app still owns the Game and
decides whether to apply the claim, update surrounding state, write a result,
or ask for confirmation.
Use ChessGameStatusDisplayState when you need text outside SwiftUI:
let display = ChessGameStatusDisplayState(
status: game.status,
turn: game.position.state.turn
)
print(display.text)ChessGameStatusView does not decide resignations, timeouts, adjudications, or
external result markers. Those are app policy.
ChessUI sets stable labels and identifiers for its reusable surfaces. These are useful for VoiceOver, UI tests, and app-level integration checks.
ChessBoardView supports the same source-and-destination move flow through
accessibility actions that it supports through taps:
- Activate a movable piece square to select it.
- Listen for the selected piece and legal destination announcement.
- Activate a destination square to report the move attempt through
onMove(_:). - If the move promotes a pawn, choose the promotion piece from the picker.
ChessUI still does not apply the move for you. Accessibility activation reports
the same ChessBoardMoveAttempt value as tap and drag gestures, so the app
keeps ownership of validation policy, state mutation, engine replies, and
surrounding UI.
Board squares expose coordinate-based identifiers:
ChessUI.square.e4
Square labels describe the visible contents and interaction state, such as:
White pawn, e2
White pawn, e2, selected
Empty, e4, legal destination
Black pawn, d5, legal capture
Black pawn, e7, not side to move
Square hints describe the expected action, such as selecting a piece, moving to a legal destination, capturing, reporting an illegal attempt when configured, or clearing the current selection. Read-only boards remain accessible for inspection, but their squares do not expose move actions.
Other useful identifiers include:
ChessUI.legalMove.e4ChessUI.lastMove.e2ChessUI.hint.d3ChessUI.arrow.e2.e4ChessUI.promotion.queenChessUI.evaluationBarChessUI.moveListChessUI.moveList.move.2ChessUI.gameStatusChessUI.gameStatus.claim.fiftyMoveRule
When wrapping ChessUI views in app-specific containers, avoid hiding the child accessibility tree unless you intentionally replace it with equivalent labels and actions.
Examples/ChessWorkbench is the package-local macOS app for manually checking
ChessCore and ChessUI behavior:
open Examples/ChessWorkbench/ChessWorkbench.xcodeprojRun the ChessWorkbench scheme on My Mac. The app exercises board rendering,
FEN editing, move application, promotion UI, hints, coordinate-label visibility,
app-supplied arrows, piece sets, board themes, move lists, evaluation bars, and
game status display.
The workbench is intentionally thin. Reusable behavior belongs in
Sources/ChessUI or Sources/ChessCore, not in the example app.
ChessUI provides:
- A reusable SwiftUI board.
- Board interaction callbacks.
- Piece-set and board-theme selection.
- Coordinate-label visibility.
- Legal-move, hint, last-move, and app-supplied arrow annotations.
- Board accessibility labels, hints, and square activation actions.
- Promotion picker UI.
- Evaluation bar rendering for caller-supplied values.
- Move-list rendering for caller-supplied records.
- Game-status rendering for caller-supplied status values.
ChessUI does not provide:
- A chess engine.
- Stockfish integration.
- Engine search parsing.
- Automatic move selection.
- Principal-variation ranking or best-move generation.
- PGN parsing or full PGN browsing UI.
- Clocks, resignation, timeout, accounts, sync, online play, or persistence.
Keep app policy in the app. Pass display-ready values into ChessUI, handle callbacks at the app boundary, and use ChessCore for rules and notation.