Move Generation & Perft¶
MoveGenerator is a namespace of static functions that operate on a Position.
It provides legal move generation, move application, SAN notation, and attack
detection, all validated by a perft suite.
API¶
public enum MoveGenerator {
static func legalMoves(for position: Position) -> [Move]
static func findLegalMoves(for position: Position, piece: PieceType, to target: Square) -> [Move]
static func hasAnyLegalMove(for position: Position) -> Bool
static func isInCheck(_ position: Position) -> Bool
static func applyMoveUnchecked(_ position: inout Position, _ move: Move)
static func algebraicNotation(for move: Move, in position: Position, legalMoves: [Move]? = nil) -> String
static func findKing(in position: Position, color: PieceColor) -> Square
static func isSquareAttacked(_ square: Square, by color: PieceColor, in position: Position) -> Bool
}
Legal moves¶
legalMoves(for:) returns the fully-legal moves — pseudo-legal moves filtered to
exclude any that leave the mover's own king in check.
Generation is uncached. Magic-bitboard generation is fast enough that the old
positionKey-keyed result cache was removed as a net pessimization: building the
BitBoard attack tables from scratch on every call was cheaper than the
cache's string-key overhead. Game maintains its own single-slot cache (keyed
by Position equality) for the current position, but MoveGenerator itself
re-generates on every call.
To narrow generation — for SAN disambiguation or parsing a PGN move — use
findLegalMoves(for:piece:to:):
// Which knight moves can reach f3?
let toF3 = MoveGenerator.findLegalMoves(for: position, piece: .knight, to: Square(algebraic: "f3")!)
Applying a move¶
applyMoveUnchecked(_:_:) mutates a Position in place and handles every
special case:
- promotion (replaces the pawn with the promotion piece)
- en passant (removes the captured pawn)
- castling (moves the rook too)
- sets / clears the en-passant target
- updates castling rights when a king or rook moves
- advances the halfmove clock and fullmove counter
- flips the side to move
It does not check legality — pass it a move you generated, or one you
validated with UCIParser.
var position = Position.initial()
let e4 = MoveGenerator.legalMoves(for: position).first { $0.uci == "e2e4" }!
MoveGenerator.applyMoveUnchecked(&position, e4)
Check, mate, stalemate¶
let inCheck = MoveGenerator.isInCheck(position)
let canMove = MoveGenerator.hasAnyLegalMove(for: position)
// checkmate == inCheck && !canMove ; stalemate == !inCheck && !canMove
let king = MoveGenerator.findKing(in: position, color: .white)
let attacked = MoveGenerator.isSquareAttacked(king, by: .black, in: position)
SAN notation¶
algebraicNotation(for:in:) produces full SAN: disambiguation, capture x,
promotion =Q, and check + / mate # suffixes.
let san = MoveGenerator.algebraicNotation(for: e4, in: .initial()) // "e4"
// If you already have the legal moves, pass them to skip recomputation:
let legal = MoveGenerator.legalMoves(for: position)
let san2 = MoveGenerator.algebraicNotation(for: move, in: position, legalMoves: legal)
Perft¶
perft(n) counts the leaf nodes of the move tree to depth n. Matching the
known exact counts for standard positions is the standard method of verifying a
move generator, and these counts pin behavior across any future implementation
change.
ChessCore's test suite checks these counts. You can reproduce the walk with the public API:
func perft(_ position: Position, depth: Int) -> Int {
if depth == 0 { return 1 }
let moves = MoveGenerator.legalMoves(for: position)
if depth == 1 { return moves.count }
var nodes = 0
for move in moves {
var next = position
MoveGenerator.applyMoveUnchecked(&next, move)
nodes += perft(next, depth: depth - 1)
}
return nodes
}
let start = Position.initial()
assert(perft(start, depth: 1) == 20)
assert(perft(start, depth: 2) == 400)
assert(perft(start, depth: 3) == 8902)
assert(perft(start, depth: 4) == 197_281)
// "Kiwipete" — pins, castling, en passant, promotion all in one position:
let kiwipete = Position(fen:
"r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1")!
assert(perft(kiwipete, depth: 3) == 97_862)