Loading learning content...
Move validation is the intellectual core of a chess engine. Every piece has unique movement patterns. Special moves like castling and en passant have intricate conditions. Most critically, no move can leave the player's own King in check. This creates a multi-layered validation system that must be both correct (FIDE-compliant) and efficient (responsive UX).
In this page, we dissect move validation into its constituent parts, design a clean architecture for rule enforcement, and implement the most challenging validations: castling legality, en passant timing, and check constraints. By the end, you'll understand how to build a validation system that is extensible enough to support chess variants yet rigorous enough to reject every illegal move.
Master the design of move validation in chess: pseudo-legal vs legal moves, piece-specific movement patterns, sliding vs jumping pieces, special move conditions, check detection, and the Strategy pattern for extensible movement rules.
Understanding the distinction between pseudo-legal and legal moves is fundamental to chess engine design. This separation enables cleaner code and better performance.
Pseudo-Legal Moves:
Legal Moves:
Why this separation?
piece.getPossibleMoves(board)game.getLegalMoves(position)1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
/** * Get all legal moves for a piece at the given position * * Flow: * 1. Get pseudo-legal moves from the piece * 2. Filter out moves that leave King in check * 3. Add eligible special moves (castling, en passant) */class Game { public getLegalMoves(from: Position): Position[] { const piece = this.board.getPieceAt(from); if (!piece || piece.color !== this.getCurrentPlayer().getColor()) { return []; } // Step 1: Get pseudo-legal moves const pseudoLegal = piece.getPossibleMoves(this.board); // Step 2: Filter for legality (check constraint) const legal = pseudoLegal.filter(to => !this.wouldLeaveKingInCheck(piece, from, to) ); // Step 3: Add special moves if eligible if (piece.type === PieceType.KING) { legal.push(...this.getCastlingMoves(piece)); } if (piece.type === PieceType.PAWN) { const enPassant = this.getEnPassantMove(piece, from); if (enPassant) legal.push(enPassant); } return legal; } /** * Test if a move would leave the current player's King in check * * This is the most expensive operation per move candidate. * We optimize by cloning only affected board state, not the entire game. */ private wouldLeaveKingInCheck( piece: Piece, from: Position, to: Position ): boolean { // Create a temporary board state const testBoard = this.board.clone(); // Simulate the move testBoard.movePiece(from, to); // Handle en passant capture (pawn captured is not at destination) if (this.isEnPassantCapture(piece, from, to)) { const capturedPawnPos = Position.at(to.file, from.rank); testBoard.removePiece(capturedPawnPos); } // Find the King's position (may have moved if piece is King) const kingColor = piece.color; const kingPos = piece.type === PieceType.KING ? to : testBoard.findKing(kingColor); // Check if any enemy piece attacks the King return this.isSquareAttackedOnBoard(kingPos, this.opponentColor(), testBoard); } /** * Check if a square is attacked by any piece of the given color */ private isSquareAttackedOnBoard( target: Position, byColor: Color, board: Board ): boolean { const attackers = board.getAllPieces(byColor); return attackers.some(piece => piece.canAttack(target, board)); }}Each piece type has characteristic movement patterns. We categorize them into sliding pieces (move multiple squares in a direction until blocked) and jumping pieces (fixed offset regardless of obstacles).
Sliding Pieces:
Jumping Pieces:
Hybrid Movement:
| Piece | Pattern | Sliding? | Special Rules |
|---|---|---|---|
| King | One square any direction | No | Cannot move into check; castling |
| Queen | Any direction, unlimited | Yes | None |
| Rook | Horizontal/vertical, unlimited | Yes | Used in castling; has moved tracking |
| Bishop | Diagonal, unlimited | Yes | Stays on same color squares |
| Knight | L-shape (2+1) | No (jumps) | Only piece that can jump |
| Pawn | Forward 1 (or 2 from start) | No | Captures diagonally; en passant; promotion |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
/** * SlidingPiece - Base class for Queen, Rook, Bishop * * Encapsulates the common sliding movement logic: * - Move in a direction until hitting edge, own piece, or opponent * - Capture is possible at the end of a slide */abstract class SlidingPiece extends Piece { /** * Directions this piece can slide. * Each direction is [fileOffset, rankOffset]. */ protected abstract getDirections(): [number, number][]; public getPossibleMoves(board: Board): Position[] { const moves: Position[] = []; for (const [dFile, dRank] of this.getDirections()) { let distance = 1; while (true) { const targetFile = String.fromCharCode( this.position.file.charCodeAt(0) + dFile * distance ); const targetRank = this.position.rank + dRank * distance; // Check bounds if (!Position.isValid(targetFile, targetRank)) { break; } const target = Position.at(targetFile, targetRank); const pieceAtTarget = board.getPieceAt(target); if (!pieceAtTarget) { // Empty square: can move here, continue sliding moves.push(target); distance++; } else if (pieceAtTarget.color !== this.color) { // Opponent piece: can capture, but stop sliding moves.push(target); break; } else { // Own piece: blocked, stop sliding break; } } } return moves; } public canAttack(target: Position, board: Board): boolean { // For sliding pieces, we check if target is reachable // We can't just use getPossibleMoves because we need to consider // the target square even if occupied by own piece (for attack purposes) for (const [dFile, dRank] of this.getDirections()) { let distance = 1; while (true) { const checkFile = String.fromCharCode( this.position.file.charCodeAt(0) + dFile * distance ); const checkRank = this.position.rank + dRank * distance; if (!Position.isValid(checkFile, checkRank)) break; const checkPos = Position.at(checkFile, checkRank); if (checkPos.equals(target)) { return true; // Can reach target square } if (board.isOccupied(checkPos)) { break; // Blocked before reaching target } distance++; } } return false; }} /** * Rook - Slides horizontally and vertically */class Rook extends SlidingPiece { protected getDirections(): [number, number][] { return [ [0, 1], // Up [0, -1], // Down [1, 0], // Right [-1, 0], // Left ]; } getSymbol(): string { return 'R'; }} /** * Bishop - Slides diagonally */class Bishop extends SlidingPiece { protected getDirections(): [number, number][] { return [ [1, 1], // Up-right [1, -1], // Down-right [-1, 1], // Up-left [-1, -1], // Down-left ]; } getSymbol(): string { return 'B'; }} /** * Queen - Slides in all eight directions (combines Rook + Bishop) */class Queen extends SlidingPiece { protected getDirections(): [number, number][] { return [ [0, 1], [0, -1], [1, 0], [-1, 0], // Rook directions [1, 1], [1, -1], [-1, 1], [-1, -1], // Bishop directions ]; } getSymbol(): string { return 'Q'; }}Notice how SlidingPiece uses the Template Method pattern: the algorithm is defined in getPossibleMoves(), but the specific directions are provided by subclasses via getDirections(). This eliminates code duplication across Queen, Rook, and Bishop.
The Pawn is deceptively complex despite being the 'simplest' piece. Unlike other pieces, pawns:
This complexity requires careful design to avoid a monolithic mess:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
/** * Pawn - The most complex piece due to conditional movement * * Key complexities: * - Directional movement (White moves up, Black moves down) * - Asymmetric move vs capture patterns * - Double move from starting rank * - En passant (handled at Game level, but affects canAttack) * - Promotion (handled at Game level during move execution) */class Pawn extends Piece { constructor(color: Color, position: Position) { super(PieceType.PAWN, color, position); } /** * Get the direction this pawn moves (1 for White up, -1 for Black down) */ private get direction(): number { return this.color === Color.WHITE ? 1 : -1; } /** * Get the starting rank for this pawn's color */ private get startingRank(): number { return this.color === Color.WHITE ? 2 : 7; } /** * Get the promotion rank for this pawn's color */ private get promotionRank(): number { return this.color === Color.WHITE ? 8 : 1; } public getPossibleMoves(board: Board): Position[] { const moves: Position[] = []; const currentFile = this.position.file; const currentRank = this.position.rank; // === FORWARD MOVEMENT === // One square forward (if empty) const oneForward = this.tryPosition(currentFile, currentRank + this.direction); if (oneForward && board.isEmpty(oneForward)) { moves.push(oneForward); // Two squares forward from starting position (if both squares empty) if (currentRank === this.startingRank) { const twoForward = this.tryPosition( currentFile, currentRank + 2 * this.direction ); if (twoForward && board.isEmpty(twoForward)) { moves.push(twoForward); } } } // === DIAGONAL CAPTURES === const captureFiles = [ String.fromCharCode(currentFile.charCodeAt(0) - 1), // Left String.fromCharCode(currentFile.charCodeAt(0) + 1), // Right ]; for (const captureFile of captureFiles) { const capturePos = this.tryPosition( captureFile, currentRank + this.direction ); if (capturePos) { const pieceAtCapture = board.getPieceAt(capturePos); if (pieceAtCapture && pieceAtCapture.color !== this.color) { moves.push(capturePos); } // Note: En passant is added at Game level, not here } } return moves; } /** * Can this pawn attack the target square? * Used for check detection. Pawns attack diagonally forward. */ public canAttack(target: Position, board: Board): boolean { const currentFile = this.position.file; const currentRank = this.position.rank; // Pawn attacks one square diagonally forward const attackRank = currentRank + this.direction; const fileDiff = Math.abs( target.file.charCodeAt(0) - currentFile.charCodeAt(0) ); return target.rank === attackRank && fileDiff === 1; } /** * Check if this pawn can promote (reached final rank) */ public canPromote(): boolean { return this.position.rank === this.promotionRank; } /** * Check if this pawn's move is a double-move (for en passant tracking) */ public isDoubleMove(from: Position, to: Position): boolean { return Math.abs(to.rank - from.rank) === 2; } private tryPosition(file: string, rank: number): Position | null { if (Position.isValid(file, rank)) { return Position.at(file, rank); } return null; } getSymbol(): string { return ''; } // Pawns use file letter in notation clone(): Piece { const copy = new Pawn(this.color, this.position); copy.hasMoved = this.hasMoved; return copy; }}En passant is only legal immediately after the opponent's pawn double-move. This requires Game-level tracking of the 'en passant target square'—a position where en passant capture is currently legal. This square resets after each move.
Check detection is fundamental—it determines:
Algorithm:
Optimizations:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
/** * Check detection system * * Key insight: We ask "is the King attacked?" rather than * "is any piece attacking the King?" This is more direct * and allows early termination when any attacker is found. */class CheckDetector { /** * Determine if the given color's King is in check */ public static isInCheck(board: Board, color: Color): boolean { const kingPos = board.findKing(color); const opponentColor = color === Color.WHITE ? Color.BLACK : Color.WHITE; return this.isSquareAttacked(board, kingPos, opponentColor); } /** * Determine if a square is attacked by any piece of the attacking color * * Used for: * - Check detection * - Castling validation (King can't pass through attacked squares) * - Move validation (can't move King to attacked square) */ public static isSquareAttacked( board: Board, target: Position, attackingColor: Color ): boolean { const attackers = board.getAllPieces(attackingColor); // Check each potential attacker for (const attacker of attackers) { if (attacker.canAttack(target, board)) { return true; // Early termination } } return false; } /** * Find all pieces attacking a specific square * Useful for UI (highlighting attackers) and advanced analysis */ public static getAttackers( board: Board, target: Position, attackingColor: Color ): Piece[] { return board.getAllPieces(attackingColor) .filter(piece => piece.canAttack(target, board)); } /** * Get all pieces giving check to a King */ public static getCheckingPieces(board: Board, color: Color): Piece[] { const kingPos = board.findKing(color); const opponentColor = color === Color.WHITE ? Color.BLACK : Color.WHITE; return this.getAttackers(board, kingPos, opponentColor); }} /** * Double Check Detection * * Double check occurs when two pieces simultaneously attack the King. * This is significant because the ONLY response to double check is * moving the King—blocking or capturing won't work. */class Game { public isDoubleCheck(): boolean { const currentColor = this.getCurrentPlayer().getColor(); const checkers = CheckDetector.getCheckingPieces(this.board, currentColor); return checkers.length >= 2; } /** * Get legal moves considering check constraints * * Special case: In double check, only King moves are legal */ public getLegalMoves(from: Position): Position[] { const piece = this.board.getPieceAt(from); if (!piece) return []; // In double check, only King can move if (this.isDoubleCheck() && piece.type !== PieceType.KING) { return []; } // Standard legal move generation const pseudoLegal = piece.getPossibleMoves(this.board); return pseudoLegal.filter(to => !this.wouldLeaveKingInCheck(piece, from, to) ); }}Check detection runs for every candidate move (to filter out illegal moves). With ~20-30 moves per position and ~16 opponent pieces, this is O(moves × pieces × board_scan). Optimize by: (1) caching piece positions, (2) early termination, (3) only rechecking affected lines after a move.
Castling is the only move in chess where two pieces move simultaneously. It has five precise conditions that must ALL be satisfied:
The two forms of castling:
| Type | King Start | King End | Rook Start | Rook End | Squares King Crosses |
|---|---|---|---|---|---|
| White Kingside | e1 | g1 | h1 | f1 | f1, g1 |
| White Queenside | e1 | c1 | a1 | d1 | d1, c1 (b1 only needs clear) |
| Black Kingside | e8 | g8 | h8 | f8 | f8, g8 |
| Black Queenside | e8 | c8 | a8 | d8 | d8, c8 (b8 only needs clear) |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
/** * Castling validation - comprehensive check of all conditions */class CastlingValidator { /** * Check if kingside castling (O-O) is legal for the given color */ public static canCastleKingside(game: Game, color: Color): boolean { const board = game.getBoard(); const backRank = color === Color.WHITE ? 1 : 8; // Get King and Rook positions const kingPos = Position.at('e', backRank); const rookPos = Position.at('h', backRank); // Check pieces exist and haven't moved const king = board.getPieceAt(kingPos); const rook = board.getPieceAt(rookPos); if (!this.validatePiecesForCastling(king, rook, color)) { return false; } // Check path is clear (f and g files) const pathSquares = [ Position.at('f', backRank), Position.at('g', backRank), ]; if (!this.isPathClear(board, pathSquares)) { return false; } // Check King is not in check if (CheckDetector.isInCheck(board, color)) { return false; } // Check King doesn't pass through or land on attacked square const squaresToCheck = [ Position.at('f', backRank), // King passes through Position.at('g', backRank), // King lands ]; const opponentColor = color === Color.WHITE ? Color.BLACK : Color.WHITE; for (const square of squaresToCheck) { if (CheckDetector.isSquareAttacked(board, square, opponentColor)) { return false; } } return true; } /** * Check if queenside castling (O-O-O) is legal for the given color */ public static canCastleQueenside(game: Game, color: Color): boolean { const board = game.getBoard(); const backRank = color === Color.WHITE ? 1 : 8; // Get King and Rook positions const kingPos = Position.at('e', backRank); const rookPos = Position.at('a', backRank); // Check pieces exist and haven't moved const king = board.getPieceAt(kingPos); const rook = board.getPieceAt(rookPos); if (!this.validatePiecesForCastling(king, rook, color)) { return false; } // Check path is clear (b, c, d files) // Note: b-file only needs to be clear, not unattacked const pathSquares = [ Position.at('b', backRank), Position.at('c', backRank), Position.at('d', backRank), ]; if (!this.isPathClear(board, pathSquares)) { return false; } // Check King is not in check if (CheckDetector.isInCheck(board, color)) { return false; } // Check King doesn't pass through or land on attacked square // (b-file doesn't need to be unattacked, only clear) const squaresToCheck = [ Position.at('d', backRank), // King passes through Position.at('c', backRank), // King lands ]; const opponentColor = color === Color.WHITE ? Color.BLACK : Color.WHITE; for (const square of squaresToCheck) { if (CheckDetector.isSquareAttacked(board, square, opponentColor)) { return false; } } return true; } private static validatePiecesForCastling( king: Piece | null, rook: Piece | null, expectedColor: Color ): boolean { // Pieces must exist if (!king || !rook) return false; // Must be correct types if (king.type !== PieceType.KING) return false; if (rook.type !== PieceType.ROOK) return false; // Must be correct color if (king.color !== expectedColor) return false; if (rook.color !== expectedColor) return false; // Neither can have moved if (king.hasBeenMoved()) return false; if (rook.hasBeenMoved()) return false; return true; } private static isPathClear(board: Board, squares: Position[]): boolean { return squares.every(pos => board.isEmpty(pos)); }} /** * Integrate castling into Game's legal move generation */class Game { private getCastlingMoves(king: Piece): Position[] { const moves: Position[] = []; const color = king.color; const backRank = color === Color.WHITE ? 1 : 8; if (CastlingValidator.canCastleKingside(this, color)) { moves.push(Position.at('g', backRank)); } if (CastlingValidator.canCastleQueenside(this, color)) { moves.push(Position.at('c', backRank)); } return moves; }}En passant (French for 'in passing') is a special pawn capture with strict timing requirements:
Conditions for en passant:
Why en passant exists:
Historically, pawns could only move one square. The double-move rule was added to speed up openings. En passant prevents a pawn from 'sneaking past' an opposing pawn by using the double-move.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
/** * En Passant State Management * * The key insight: We track the "en passant target square"— * the square where en passant capture is currently legal. * This resets after every move. */class Game { // The square where en passant is legal (null if not available) private enPassantTarget: Position | null = null; /** * After executing a move, update en passant state */ private updateEnPassantTarget(move: Move): void { // En passant only becomes available after pawn double-move if ( move.piece.type === PieceType.PAWN && Math.abs(move.to.rank - move.from.rank) === 2 ) { // The en passant target is the square the pawn "passed through" const passedRank = (move.from.rank + move.to.rank) / 2; this.enPassantTarget = Position.at(move.from.file, passedRank); } else { // Any other move clears en passant eligibility this.enPassantTarget = null; } } /** * Get en passant capture position for a pawn, if available */ private getEnPassantMove(pawn: Piece, from: Position): Position | null { if (!this.enPassantTarget) return null; if (pawn.type !== PieceType.PAWN) return null; // Pawn must be on correct rank const expectedRank = pawn.color === Color.WHITE ? 5 : 4; if (from.rank !== expectedRank) return null; // Pawn must be adjacent to the en passant target file const targetFile = this.enPassantTarget.file; const fileDiff = Math.abs( from.file.charCodeAt(0) - targetFile.charCodeAt(0) ); if (fileDiff !== 1) return null; // En passant target must be one rank ahead of the pawn const expectedTargetRank = pawn.color === Color.WHITE ? 6 : 3; if (this.enPassantTarget.rank !== expectedTargetRank) return null; // Validate that this en passant doesn't leave King in check if (this.wouldEnPassantLeaveKingInCheck(pawn, from, this.enPassantTarget)) { return null; } return this.enPassantTarget; } /** * Special check for en passant—capturing pawn is not at destination * * This is a rare but real edge case: en passant can expose the King * to check along the rank (since both pawns leave the rank). */ private wouldEnPassantLeaveKingInCheck( capturingPawn: Piece, from: Position, enPassantTarget: Position ): boolean { // The captured pawn's position (same file as target, but current rank) const capturedPawnPos = Position.at(enPassantTarget.file, from.rank); // Clone board and simulate both pieces leaving const testBoard = this.board.clone(); testBoard.removePiece(from); // Remove capturing pawn testBoard.removePiece(capturedPawnPos); // Remove captured pawn testBoard.placePiece(capturingPawn, enPassantTarget); // Place at new pos // Check if King is in check const kingPos = testBoard.findKing(capturingPawn.color); const opponentColor = capturingPawn.color === Color.WHITE ? Color.BLACK : Color.WHITE; return CheckDetector.isSquareAttacked(testBoard, kingPos, opponentColor); }} /** * Example of en passant edge case: * * Position: White King on e1, White Pawn on g5 * Black Rook on a5, Black Pawn on f7 * * Black plays f7-f5 (double move). * En passant (gxf6) seems legal, but it would expose the White King * to the Black Rook along the 5th rank! * * This is why wouldEnPassantLeaveKingInCheck() removes BOTH pawns * before checking for discovered attack. */With all validation components understood, let's see how they fit together in a clean architecture. The goal: clear separation of concerns, easy testing, and extensibility for chess variants.
Validation Pipeline:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
/** * MoveValidator - Centralized validation with clear error reporting * * Uses the Chain of Responsibility pattern implicitly: * each validation step can either pass (continue) or fail (return error). */class MoveValidator { private game: Game; constructor(game: Game) { this.game = game; } /** * Validate a complete move request * Returns detailed validation result with error reason if invalid */ public validate( from: Position, to: Position, promoteTo?: PieceType ): ValidationResult { const board = this.game.getBoard(); const currentPlayer = this.game.getCurrentPlayer(); // === Step 1: Basic Validation === const piece = board.getPieceAt(from); if (!piece) { return ValidationResult.invalid('NO_PIECE_AT_SOURCE', `No piece at ${from.toAlgebraic()}`); } if (piece.color !== currentPlayer.getColor()) { return ValidationResult.invalid('NOT_YOUR_PIECE', `The piece at ${from.toAlgebraic()} belongs to your opponent`); } if (from.equals(to)) { return ValidationResult.invalid('SAME_SQUARE', 'Source and destination are the same'); } // === Step 2: Identify Move Type === const moveType = this.identifyMoveType(piece, from, to); // === Step 3: Validate Based on Move Type === switch (moveType) { case MoveCategory.CASTLING_KINGSIDE: return this.validateCastling(piece, true); case MoveCategory.CASTLING_QUEENSIDE: return this.validateCastling(piece, false); case MoveCategory.EN_PASSANT: return this.validateEnPassant(piece as Pawn, from, to); case MoveCategory.PROMOTION: return this.validatePromotion(piece as Pawn, from, to, promoteTo); case MoveCategory.STANDARD: default: return this.validateStandardMove(piece, from, to); } } private validateStandardMove( piece: Piece, from: Position, to: Position ): ValidationResult { // Check if piece can reach destination (pseudo-legal) const possibleMoves = piece.getPossibleMoves(this.game.getBoard()); if (!possibleMoves.some(pos => pos.equals(to))) { return ValidationResult.invalid('INVALID_PIECE_MOVEMENT', `${piece.type} cannot move from ${from.toAlgebraic()} to ${to.toAlgebraic()}`); } // Check if move leaves King in check (legality) if (this.game.wouldLeaveKingInCheck(piece, from, to)) { if (CheckDetector.isInCheck(this.game.getBoard(), piece.color)) { return ValidationResult.invalid('KING_IN_CHECK', 'Your King is in check. You must block or move the King.'); } else { return ValidationResult.invalid('WOULD_EXPOSE_KING', 'This move would leave your King in check.'); } } // Determine if capture const capturedPiece = this.game.getBoard().getPieceAt(to); const isCapture = capturedPiece !== null; return ValidationResult.valid( isCapture ? MoveType.CAPTURE : MoveType.NORMAL, { capturedPiece } ); } private validateCastling(king: Piece, kingside: boolean): ValidationResult { const validator = kingside ? CastlingValidator.canCastleKingside : CastlingValidator.canCastleQueenside; if (!validator(this.game, king.color)) { return ValidationResult.invalid('CASTLING_NOT_ALLOWED', this.getCastlingErrorReason(king.color, kingside)); } return ValidationResult.valid( kingside ? MoveType.CASTLING_KINGSIDE : MoveType.CASTLING_QUEENSIDE ); } private getCastlingErrorReason(color: Color, kingside: boolean): string { const board = this.game.getBoard(); const backRank = color === Color.WHITE ? 1 : 8; // Check each condition to give specific feedback const king = board.getPieceAt(Position.at('e', backRank)); if (king?.hasBeenMoved()) { return 'Your King has already moved'; } const rookFile = kingside ? 'h' : 'a'; const rook = board.getPieceAt(Position.at(rookFile, backRank)); if (rook?.hasBeenMoved()) { return 'Your Rook has already moved'; } if (CheckDetector.isInCheck(board, color)) { return 'You cannot castle while in check'; } return 'Castling is not allowed in this position'; } private identifyMoveType(piece: Piece, from: Position, to: Position): MoveCategory { // King moving two squares = castling if (piece.type === PieceType.KING) { const fileDiff = to.file.charCodeAt(0) - from.file.charCodeAt(0); if (fileDiff === 2) return MoveCategory.CASTLING_KINGSIDE; if (fileDiff === -2) return MoveCategory.CASTLING_QUEENSIDE; } // Pawn special moves if (piece.type === PieceType.PAWN) { const promotionRank = piece.color === Color.WHITE ? 8 : 1; if (to.rank === promotionRank) return MoveCategory.PROMOTION; // En passant: diagonal capture to empty square if (from.file !== to.file && !this.game.getBoard().isOccupied(to)) { return MoveCategory.EN_PASSANT; } } return MoveCategory.STANDARD; }} enum MoveCategory { STANDARD = 'STANDARD', CASTLING_KINGSIDE = 'CASTLING_KINGSIDE', CASTLING_QUEENSIDE = 'CASTLING_QUEENSIDE', EN_PASSANT = 'EN_PASSANT', PROMOTION = 'PROMOTION',} class ValidationResult { public readonly isValid: boolean; public readonly errorCode?: string; public readonly errorMessage?: string; public readonly moveType?: MoveType; public readonly metadata?: Record<string, unknown>; static valid(moveType: MoveType, metadata?: Record<string, unknown>): ValidationResult { return { isValid: true, moveType, metadata }; } static invalid(code: string, message: string): ValidationResult { return { isValid: false, errorCode: code, errorMessage: message }; }}Notice how ValidationResult includes both a code (for programmatic handling) and a human-readable message (for UI display). This dual approach enables clean error handling at both layers.
Move validation is the most intricate aspect of chess engine design. Let's consolidate the key concepts:
What's Next:
With move validation complete, we turn to game state management—tracking whose turn it is, detecting checkmate and stalemate, handling draw conditions, and maintaining the complete game lifecycle from start to termination.
You now understand the complete move validation system for a chess game. This is the computational heart of the engine—everything else builds upon correct move validation. Next, we'll explore how game state evolves through these validated moves.