Skip to content

The Game Tree

Game is ChessCore's mutable, navigable game model — the move tree, the current position, game state, and PGN/FEN I/O in one class. MoveNode is a single node in that tree: the move played, the position before it, its children (main line first, then variations), and per-node annotations and engine metadata.

Together they are the headline "Game tree" area listed in the package README, and the entry point for any board UI, repertoire tool, or analysis surface built on ChessCore.

Game

public final class Game {
    // Current state
    public private(set) var position: Position
    public private(set) var startPosition: Position
    public private(set) var currentNode: MoveNode?

    // Tree
    public var rootChildren: [MoveNode]
    public var legalMoves: [Move]          // cached per position
    public var lastMove: Move?             // currentNode?.move
    public var mainLine: [MoveNode]        // flat sequence from root
    public var moveHistory: [MoveRecord]   // mainLine as MoveRecords
    public var currentMoveIndex: Int       // -1 at start, 0-based from root
    public var gameState: GameState        // cached per position+node

    // Change counters (for cache invalidation in UI layers)
    public private(set) var treeMutationCount: Int
    public private(set) var nodePropertyVersion: Int

    // Undo/redo state
    public var canUndo: Bool
    public var canRedo: Bool

    // Tags from the last loaded PGN (used by exportPGN)
    public var loadedTags: PGNGame.OrderedTags?
}

Game is a pure domain object — no SwiftUI, no SwiftData, no observation framework, and no interaction state (no selected square, no pending promotion). Those belong to whatever drives it, so a consumer is free to layer an observable wrapper, a clock, or sound and haptic side-effects over apply(_:) without fighting the model for ownership.

let game = Game()

// Cursor movement — never mutates the tree, only `position` / `currentNode`.
game.undoMove()                        // step back one ply
game.redoMove()                        // step forward along the main continuation
game.navigateToNode(someNode)          // jump directly to any node
game.navigateToStart()                 // return to startPosition

// canUndo / canRedo gate UI controls:
if game.canRedo { game.redoMove() }

// currentMoveIndex is -1 at start, then the 0-based ply of the current node:
print(game.currentMoveIndex)

Applying moves

// Apply a pre-validated Move (promotion already resolved).
// Updates the tree and the position, and nothing else — a clock, a sound, or
// a haptic belongs to whatever is driving the game.
game.apply(move)

// Apply a move loaded from PGN. If the identical move already exists
// among the siblings, the cursor moves to it (no duplicate node created).
game.applyMoveFromPGN(move)

Mutation — new game, reset, retract

game.newGame()              // clear tree, reset to initial position
_ = game.loadFEN(fenString) // reset to any position; returns false on bad FEN
game.retractLastPlies(2)    // physically remove the last 2 plies from the tree
                            // (unlike undoMove, retract deletes the nodes)

retractLastPlies is the right call for a physical take-back on a sensing board: it deletes the nodes so the next move played becomes the main line rather than a variation.

Variations

// Promote a variation so it becomes the main continuation.
game.promoteVariation(node)

// Delete a node and its entire subtree.
game.deleteFromNode(node)

Annotations

All annotation calls write to MoveNode fields and bump nodePropertyVersion so UI layers know to re-render node-detail views without a full tree diff:

game.setAnnotation(.brilliant, on: node)        // MoveAnnotation?
game.setComment("Good practical choice", on: node)
game.clearComment(on: node)
game.setEngineResults(on: node, bestMoveUCI: "e2e4", eval: "+0.3")
game.setMoveQuality(.excellent, accuracy: 97.4, on: node)

// After a batch of writes straight to MoveNode fields, coalesce the
// invalidation into a single bump:
game.bumpNodeProperty()

Change counters

Game exposes two monotonic counters that UI caches key off:

Counter When it increments
treeMutationCount tree structure changes: new move, promote/delete variation, loadPGN, newGame
nodePropertyVersion node-field writes: annotation, comment, engine results, quality — without structural change

Navigation (undoMove, navigateToNode, …) bumps neither counter.

Reconciliation

reconcile(toPlacementOf:maxPly:) catches up the game tree to an external position without discarding history — useful when a sensing board or remote game feed skips ahead by one or two moves:

// Returns true and applies the moves through the tree if reachable within maxPly.
// Returns false with no mutation if it cannot reconcile.
let ok = game.reconcile(toPlacementOf: targetPosition, maxPly: 4)

// Query-only variant — returns the move sequence without applying it:
if let path = game.reconcilePath(toPlacementOf: target, maxPly: 4) {
    for m in path { myAdapter.apply(m) }   // apply with custom side-effects
}

Matching is on piece placement + side-to-move (the observable board state). The resulting tree position has fully consistent castling rights and en-passant state regardless of the target FEN's metadata fields.

PGN and FEN I/O

The Game-level I/O methods delegate to PGNParser and PGNExporter (see PGN for the full round-trip):

// Load: returns true on success, false if the PGN is invalid.
_ = game.loadPGN(pgnString)           // first game in the string
_ = game.loadPGNGame(pgnGame)         // from a PGNGame you already have

// Export: emits seven-tag roster + full move text with variations.
let pgn = game.exportPGN()

loadedTags is populated on loadPGNGame and used by exportPGN to round-trip the original headers. Assign nil to clear them (exportPGN will fall back to neutral placeholder tags).


MoveNode

MoveNode is a plain reference type with no observation-framework dependency, so it remains portable across Apple, Linux, and Android consumers. App layers can wrap it with @Observable.

public final class MoveNode: Identifiable {
    public var id: UUID { get }                  // generated lazily, then stable
    public let move: Move
    public let notation: String               // SAN
    public let positionBefore: Position
    public var children: [MoveNode]           // mainline first, then variations
    public weak var parent: MoveNode?
    public var plyIndex: Int                  // zero-based, relative to this tree

    // Per-node data
    public var annotation: MoveAnnotation?
    public var comment: String?
    public var engineBestMoveUCI: String?
    public var engineEval: String?            // e.g. "+0.3"
    public var moveQuality: MoveQuality?
    public var moveAccuracy: Double?
    public var clockSeconds: TimeInterval?
}

Computed members

node.moveNumber          // positionBefore.fullmoveNumber
node.moverColor          // positionBefore.activeColor

// positionAfter is lazily computed and cached on first access:
node.positionAfter       // position after applying node.move

// Tree structure helpers:
node.mainContinuation    // children.first — the main reply
node.variations          // children.dropFirst() — all non-main children
node.hasVariations       // children.count > 1

node.isOnMainLine        // true when every ancestor is also its parent's first child
node.pathFromRoot()      // [MoveNode] from the root to this node (inclusive)

Walking the tree

// Walk the main line from the root:
var node = game.rootChildren.first
while let n = node {
    print(n.moveNumber, n.notation)
    node = n.mainContinuation
}

// Or use the computed property — already the flat main line:
for n in game.mainLine {
    print(n.notation)
}

// pathFromRoot traces back to the root — useful to reconstruct the
// position sequence for any node, including variation nodes:
let path = someVariationNode.pathFromRoot()

PGN metadata

A loaded game's tags are readable as typed values rather than raw strings:

game.playerName(for: .white)   // String?  — the `White` tag, as written
game.elo(for: .black)          // Int?     — `BlackElo`, nil for "?" / "0" / absent
game.initialClockSeconds       // TimeInterval? — base time from `TimeControl`

game.hasClockAnnotations       // Bool — does the game carry {[%clk ...]} values?
game.clockTimes                // (white: TimeInterval?, black: TimeInterval?, active: PieceColor)

clockTimes reconstructs both sides' clocks at the currently selected node from the [%clk] annotations along the path from the root, falling back to initialClockSeconds before a side's first annotated move.

initialClockSeconds covers the TimeControl forms the PGN specification defines: sudden death (600), increment (300+5), moves-per-period (40/5400), hourglass (*180), and multi-period (40/5400:1800:*60, whose first period is where the clock starts). - and ? yield nil.


PGN ↔ live Game bridge

The bridge between PGN text and a live Game tree is in PGNParser and PGNExporter. See PGN — PGN text → live Game → PGN text for a full walkthrough. The key entry points:

Function Description
PGNParser.loadGame(from: String) -> Game? Parse PGN text; return first game as a live Game
PGNParser.loadGame(from: PGNGame) -> Game? Build a live Game from a PGNGame you already parsed
PGNExporter.export(game: Game, tags:) -> String Full PGN with headers + move text
PGNExporter.moveText(for: [MoveNode]) -> String Move text only (no headers)
Game.exportPGN() -> String Convenience wrapper for PGNExporter.export
Game.loadPGN(_ pgn: String) -> Bool Load into the receiver in-place
Game.loadPGNGame(_ pgnGame: PGNGame) -> Bool Load from a PGNGame in-place

SetUp/FEN tags are honored: a PGN that starts from a non-initial position will seed the Game from the tag's FEN before replaying moves.