A 3D render of a wooden chess board with metallic pieces. In the background, a laptop displays Python code for a chess engine named 'PyMinMaximus'. Blue arrows illustrate possible legal moves from a pawn and a rook.

Building PyMinMaximus: A Chess Engine from Scratch

Part 1: Chess Representation and Move Generation

Welcome to the first post in our series on building a chess engine in Python! Over the next five posts, we’ll create PyMinMaximus, a fully functional chess engine that can play against humans and other engines. By the end of this series, you’ll have an engine that uses opening books, endgame tablebases, and can be tested using CuteChess CLI.

In this first post, we’ll lay the foundation by implementing chess board representation and move generation, the essential building blocks of any chess engine.

What You’ll Need

Before we begin, make sure you have:

  • Python 3.11 or higher installed
  • Basic understanding of Python (classes, lists, dictionaries)
  • Knowledge of chess rules (how pieces move, special moves like castling)
  • A code editor of your choice (I use Visual Studio Code)

The Foundation: How Do We Represent Chess?

Before our engine can think about chess, it needs to understand what chess is. We need to answer several key questions:

  1. How do we represent the board?
  2. How do we represent pieces?
  3. How do we represent moves?
  4. How do we generate all legal moves from a position?

Let’s tackle each of these systematically.

Board Representation: Choosing Our Approach

We will start with building the chess board. There are several ways to represent a chess board in code:

  • Bitboards: Use 64-bit integers where each bit represents a square. Fast but complex.
  • 0x88 Board: A 128-element array with built-in boundary detection. Clever but obscure.
  • 8×8 Array: A simple two-dimensional list. Intuitive and easier to debug.

For PyMinMaximus, we’ll use an 8×8 array approach. While bitboards are faster for professional engines, our array-based approach will be easier to understand, debug, and extend—perfect for learning.

Piece and Color Encoding

We’ll represent pieces using integers, which makes our code fast and memory-efficient:

# Piece types
EMPTY = 0
PAWN = 1
KNIGHT = 2
BISHOP = 3
ROOK = 4
QUEEN = 5
KING = 6

# Colors
WHITE = 8
BLACK = 16

# Example: White pawn = WHITE | PAWN = 9
# Example: Black knight = BLACK | KNIGHT = 18

Using bitwise OR (|) allows us to combine piece type and color in a single integer. We can extract them using bitwise AND:

piece = WHITE | QUEEN  # 13
piece_type = piece & 7  # Gets QUEEN (5)
color = piece & 24      # Gets WHITE (8)

The Board Class: Our Chess Universe

Let’s create our main Board class:

class Board:
    def __init__(self):
        # 8x8 board, index [0][0] is a1, [7][7] is h8
        self.board = [[EMPTY for _ in range(8)] for _ in range(8)]
        
        # Game state
        self.to_move = WHITE
        self.castling_rights = {
            'K': True,  # White kingside
            'Q': True,  # White queenside
            'k': True,  # Black kingside
            'q': True   # Black queenside
        }
        self.en_passant_square = None
        self.halfmove_clock = 0
        self.fullmove_number = 1
        
        self.setup_initial_position()
    
    def setup_initial_position(self):
        """Set up the standard starting position."""
        # Black pieces (rank 8 and 7)
        back_rank = [ROOK, KNIGHT, BISHOP, QUEEN, KING, BISHOP, KNIGHT, ROOK]
        for i in range(8):
            self.board[7][i] = BLACK | back_rank[i]
            self.board[6][i] = BLACK | PAWN
        
        # White pieces (rank 2 and 1)
        for i in range(8):
            self.board[1][i] = WHITE | PAWN
            self.board[0][i] = WHITE | back_rank[i]
    
    def piece_at(self, row, col):
        """Get the piece at a given square."""
        return self.board[row][col]
    
    def is_square_attacked(self, row, col, by_color):
        """Check if a square is attacked by a given color."""
        # We'll implement this after move generation
        pass

Move Representation

A move needs to encode several pieces of information:

class Move:
    def __init__(self, from_row, from_col, to_row, to_col, 
                 promotion=None, is_castling=False, is_en_passant=False):
        self.from_row = from_row
        self.from_col = from_col
        self.to_row = to_row
        self.to_col = to_col
        self.promotion = promotion  # QUEEN, ROOK, BISHOP, or KNIGHT
        self.is_castling = is_castling
        self.is_en_passant = is_en_passant
    
    def __str__(self):
        """Convert move to algebraic notation."""
        files = 'abcdefgh'
        from_square = f"{files[self.from_col]}{self.from_row + 1}"
        to_square = f"{files[self.to_col]}{self.to_row + 1}"
        
        if self.promotion:
            piece_symbols = {QUEEN: 'q', ROOK: 'r', BISHOP: 'b', KNIGHT: 'n'}
            return f"{from_square}{to_square}{piece_symbols[self.promotion]}"
        
        return f"{from_square}{to_square}"
    
    def __repr__(self):
        return self.__str__()

Generating Pseudo-Legal Moves

Now for the heart of this post: generating moves. We’ll start with “pseudo-legal” moves—moves that follow piece movement rules but might leave the king in check. We’ll filter for legality later.

class Board:
    # ... previous code ...
    
    def generate_pawn_moves(self, row, col, moves):
        """Generate all pawn moves from a given square."""
        piece = self.board[row][col]
        color = piece & 24
        
        if color == WHITE:
            direction = 1
            start_row = 1
            promotion_row = 7
        else:
            direction = -1
            start_row = 6
            promotion_row = 0
        
        # Single push
        if self.board[row + direction][col] == EMPTY:
            if row + direction == promotion_row:
                # Promotions
                for promo_piece in [QUEEN, ROOK, BISHOP, KNIGHT]:
                    moves.append(Move(row, col, row + direction, col, 
                                    promotion=promo_piece))
            else:
                moves.append(Move(row, col, row + direction, col))
            
            # Double push from starting position
            if row == start_row and self.board[row + 2 * direction][col] == EMPTY:
                moves.append(Move(row, col, row + 2 * direction, col))
        
        # Captures
        for dcol in [-1, 1]:
            new_col = col + dcol
            if 0 <= new_col < 8:
                new_row = row + direction
                target = self.board[new_row][new_col]
                
                # Regular capture
                if target != EMPTY and (target & 24) != color:
                    if new_row == promotion_row:
                        for promo_piece in [QUEEN, ROOK, BISHOP, KNIGHT]:
                            moves.append(Move(row, col, new_row, new_col, 
                                            promotion=promo_piece))
                    else:
                        moves.append(Move(row, col, new_row, new_col))
                
                # En passant
                if self.en_passant_square == (new_row, new_col):
                    moves.append(Move(row, col, new_row, new_col, 
                                    is_en_passant=True))
    
    def generate_knight_moves(self, row, col, moves):
        """Generate all knight moves from a given square."""
        piece = self.board[row][col]
        color = piece & 24
        
        knight_offsets = [
            (-2, -1), (-2, 1), (-1, -2), (-1, 2),
            (1, -2), (1, 2), (2, -1), (2, 1)
        ]
        
        for drow, dcol in knight_offsets:
            new_row, new_col = row + drow, col + dcol
            
            if 0 <= new_row < 8 and 0 <= new_col < 8:
                target = self.board[new_row][new_col]
                if target == EMPTY or (target & 24) != color:
                    moves.append(Move(row, col, new_row, new_col))
    
    def generate_sliding_moves(self, row, col, moves, directions):
        """Generate moves for sliding pieces (bishop, rook, queen)."""
        piece = self.board[row][col]
        color = piece & 24
        
        for drow, dcol in directions:
            new_row, new_col = row + drow, col + dcol
            
            while 0 <= new_row < 8 and 0 <= new_col < 8:
                target = self.board[new_row][new_col]
                
                if target == EMPTY:
                    moves.append(Move(row, col, new_row, new_col))
                elif (target & 24) != color:
                    moves.append(Move(row, col, new_row, new_col))
                    break  # Can't move past a capture
                else:
                    break  # Blocked by own piece
                
                new_row += drow
                new_col += dcol
    
    def generate_bishop_moves(self, row, col, moves):
        """Generate all bishop moves."""
        directions = [(-1, -1), (-1, 1), (1, -1), (1, 1)]
        self.generate_sliding_moves(row, col, moves, directions)
    
    def generate_rook_moves(self, row, col, moves):
        """Generate all rook moves."""
        directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]
        self.generate_sliding_moves(row, col, moves, directions)
    
    def generate_queen_moves(self, row, col, moves):
        """Generate all queen moves."""
        directions = [
            (-1, -1), (-1, 0), (-1, 1),
            (0, -1),           (0, 1),
            (1, -1),  (1, 0),  (1, 1)
        ]
        self.generate_sliding_moves(row, col, moves, directions)
    
    def generate_king_moves(self, row, col, moves):
        """Generate all king moves including castling."""
        piece = self.board[row][col]
        color = piece & 24
        
        # Regular king moves
        for drow in [-1, 0, 1]:
            for dcol in [-1, 0, 1]:
                if drow == 0 and dcol == 0:
                    continue
                
                new_row, new_col = row + drow, col + dcol
                
                if 0 <= new_row < 8 and 0 <= new_col < 8:
                    target = self.board[new_row][new_col]
                    if target == EMPTY or (target & 24) != color:
                        moves.append(Move(row, col, new_row, new_col))
        
        # Castling
        if color == WHITE and row == 0:
            # Kingside
            if (self.castling_rights['K'] and 
                self.board[0][5] == EMPTY and 
                self.board[0][6] == EMPTY):
                moves.append(Move(0, 4, 0, 6, is_castling=True))
            
            # Queenside
            if (self.castling_rights['Q'] and 
                self.board[0][1] == EMPTY and 
                self.board[0][2] == EMPTY and 
                self.board[0][3] == EMPTY):
                moves.append(Move(0, 4, 0, 2, is_castling=True))
        
        elif color == BLACK and row == 7:
            # Kingside
            if (self.castling_rights['k'] and 
                self.board[7][5] == EMPTY and 
                self.board[7][6] == EMPTY):
                moves.append(Move(7, 4, 7, 6, is_castling=True))
            
            # Queenside
            if (self.castling_rights['q'] and 
                self.board[7][1] == EMPTY and 
                self.board[7][2] == EMPTY and 
                self.board[7][3] == EMPTY):
                moves.append(Move(7, 4, 7, 2, is_castling=True))
    
    def generate_pseudo_legal_moves(self):
        """Generate all pseudo-legal moves for the current position."""
        moves = []
        
        for row in range(8):
            for col in range(8):
                piece = self.board[row][col]
                
                if piece == EMPTY or (piece & 24) != self.to_move:
                    continue
                
                piece_type = piece & 7
                
                if piece_type == PAWN:
                    self.generate_pawn_moves(row, col, moves)
                elif piece_type == KNIGHT:
                    self.generate_knight_moves(row, col, moves)
                elif piece_type == BISHOP:
                    self.generate_bishop_moves(row, col, moves)
                elif piece_type == ROOK:
                    self.generate_rook_moves(row, col, moves)
                elif piece_type == QUEEN:
                    self.generate_queen_moves(row, col, moves)
                elif piece_type == KING:
                    self.generate_king_moves(row, col, moves)
        
        return moves

Making and Unmaking Moves

To check if a move is legal, we need to make it, check if our king is in check, then unmake it. Let’s implement these critical functions:

class Board:
    # ... previous code ...
    
    def make_move(self, move):
        """Make a move on the board and return information needed to unmake it."""
        # Store state for unmaking
        undo_info = {
            'captured_piece': self.board[move.to_row][move.to_col],
            'castling_rights': self.castling_rights.copy(),
            'en_passant_square': self.en_passant_square,
            'halfmove_clock': self.halfmove_clock
        }
        
        piece = self.board[move.from_row][move.from_col]
        piece_type = piece & 7
        
        # Move the piece
        self.board[move.to_row][move.to_col] = piece
        self.board[move.from_row][move.from_col] = EMPTY
        
        # Handle promotion
        if move.promotion:
            self.board[move.to_row][move.to_col] = (piece & 24) | move.promotion
        
        # Handle en passant capture
        if move.is_en_passant:
            capture_row = move.from_row
            self.board[capture_row][move.to_col] = EMPTY
        
        # Handle castling
        if move.is_castling:
            # Move the rook
            if move.to_col == 6:  # Kingside
                self.board[move.to_row][5] = self.board[move.to_row][7]
                self.board[move.to_row][7] = EMPTY
            else:  # Queenside
                self.board[move.to_row][3] = self.board[move.to_row][0]
                self.board[move.to_row][0] = EMPTY
        
        # Update en passant square
        self.en_passant_square = None
        if piece_type == PAWN and abs(move.to_row - move.from_row) == 2:
            self.en_passant_square = ((move.from_row + move.to_row) // 2, move.from_col)
        
        # Update castling rights
        if piece_type == KING:
            if self.to_move == WHITE:
                self.castling_rights['K'] = False
                self.castling_rights['Q'] = False
            else:
                self.castling_rights['k'] = False
                self.castling_rights['q'] = False
        
        if piece_type == ROOK:
            if self.to_move == WHITE:
                if move.from_row == 0 and move.from_col == 0:
                    self.castling_rights['Q'] = False
                elif move.from_row == 0 and move.from_col == 7:
                    self.castling_rights['K'] = False
            else:
                if move.from_row == 7 and move.from_col == 0:
                    self.castling_rights['q'] = False
                elif move.from_row == 7 and move.from_col == 7:
                    self.castling_rights['k'] = False
        
        # Update halfmove clock
        if piece_type == PAWN or undo_info['captured_piece'] != EMPTY:
            self.halfmove_clock = 0
        else:
            self.halfmove_clock += 1
        
        # Update move counters
        if self.to_move == BLACK:
            self.fullmove_number += 1
        
        self.to_move = BLACK if self.to_move == WHITE else WHITE
        
        return undo_info
    
    def unmake_move(self, move, undo_info):
        """Unmake a move and restore the previous position."""
        # Switch back to the side that made the move
        self.to_move = BLACK if self.to_move == WHITE else WHITE
        
        # Restore move counters
        if self.to_move == BLACK:
            self.fullmove_number -= 1
        
        piece = self.board[move.to_row][move.to_col]
        
        # Handle promotion (restore pawn)
        if move.promotion:
            piece = (piece & 24) | PAWN
        
        # Move piece back
        self.board[move.from_row][move.from_col] = piece
        self.board[move.to_row][move.to_col] = undo_info['captured_piece']
        
        # Handle en passant
        if move.is_en_passant:
            capture_row = move.from_row
            opponent_color = BLACK if self.to_move == WHITE else WHITE
            self.board[capture_row][move.to_col] = opponent_color | PAWN
        
        # Handle castling
        if move.is_castling:
            if move.to_col == 6:  # Kingside
                self.board[move.to_row][7] = self.board[move.to_row][5]
                self.board[move.to_row][5] = EMPTY
            else:  # Queenside
                self.board[move.to_row][0] = self.board[move.to_row][3]
                self.board[move.to_row][3] = EMPTY
        
        # Restore game state
        self.castling_rights = undo_info['castling_rights']
        self.en_passant_square = undo_info['en_passant_square']
        self.halfmove_clock = undo_info['halfmove_clock']

Checking for Check and Legal Moves

Now we can implement the critical is_square_attacked function and filter for legal moves:

class Board:
    # ... previous code ...
    
    def is_square_attacked(self, row, col, by_color):
        """Check if a square is attacked by pieces of a given color."""
        # Check for pawn attacks
        if by_color == WHITE:
            pawn_direction = 1
        else:
            pawn_direction = -1
        
        for dcol in [-1, 1]:
            pawn_row = row - pawn_direction
            pawn_col = col + dcol
            if 0 <= pawn_row < 8 and 0 <= pawn_col < 8:
                piece = self.board[pawn_row][pawn_col]
                if piece == (by_color | PAWN):
                    return True
        
        # Check for knight attacks
        knight_offsets = [
            (-2, -1), (-2, 1), (-1, -2), (-1, 2),
            (1, -2), (1, 2), (2, -1), (2, 1)
        ]
        for drow, dcol in knight_offsets:
            new_row, new_col = row + drow, col + dcol
            if 0 <= new_row < 8 and 0 <= new_col < 8:
                piece = self.board[new_row][new_col]
                if piece == (by_color | KNIGHT):
                    return True
        
        # Check for sliding piece attacks
        # Diagonal (bishop and queen)
        for drow, dcol in [(-1, -1), (-1, 1), (1, -1), (1, 1)]:
            new_row, new_col = row + drow, col + dcol
            while 0 <= new_row < 8 and 0 <= new_col < 8:
                piece = self.board[new_row][new_col]
                if piece != EMPTY:
                    if (piece & 24) == by_color:
                        piece_type = piece & 7
                        if piece_type in [BISHOP, QUEEN]:
                            return True
                    break
                new_row += drow
                new_col += dcol
        
        # Straight (rook and queen)
        for drow, dcol in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
            new_row, new_col = row + drow, col + dcol
            while 0 <= new_row < 8 and 0 <= new_col < 8:
                piece = self.board[new_row][new_col]
                if piece != EMPTY:
                    if (piece & 24) == by_color:
                        piece_type = piece & 7
                        if piece_type in [ROOK, QUEEN]:
                            return True
                    break
                new_row += drow
                new_col += dcol
        
        # Check for king attacks
        for drow in [-1, 0, 1]:
            for dcol in [-1, 0, 1]:
                if drow == 0 and dcol == 0:
                    continue
                new_row, new_col = row + drow, col + dcol
                if 0 <= new_row < 8 and 0 <= new_col < 8:
                    piece = self.board[new_row][new_col]
                    if piece == (by_color | KING):
                        return True
        
        return False
    
    def find_king(self, color):
        """Find the king's position for a given color."""
        for row in range(8):
            for col in range(8):
                piece = self.board[row][col]
                if piece == (color | KING):
                    return (row, col)
        return None
    
    def is_in_check(self, color):
        """Check if the given color's king is in check."""
        king_pos = self.find_king(color)
        if king_pos is None:
            return False
        
        opponent_color = BLACK if color == WHITE else WHITE
        return self.is_square_attacked(king_pos[0], king_pos[1], opponent_color)
    
    def is_legal_move(self, move):
        """Check if a move is legal (doesn't leave king in check)."""
        undo_info = self.make_move(move)
        
        # The king of the side that just moved
        moving_color = BLACK if self.to_move == WHITE else WHITE
        
        # Special check for castling - squares must not be under attack
        if move.is_castling:
            king_row = move.from_row
            opponent_color = BLACK if moving_color == WHITE else WHITE
            
            # Check that king doesn't move through check
            if move.to_col == 6:  # Kingside
                for col in [4, 5, 6]:
                    if self.is_square_attacked(king_row, col, opponent_color):
                        self.unmake_move(move, undo_info)
                        return False
            else:  # Queenside
                for col in [2, 3, 4]:
                    if self.is_square_attacked(king_row, col, opponent_color):
                        self.unmake_move(move, undo_info)
                        return False
        
        legal = not self.is_in_check(moving_color)
        self.unmake_move(move, undo_info)
        return legal
    
    def generate_legal_moves(self):
        """Generate all legal moves for the current position."""
        pseudo_legal = self.generate_pseudo_legal_moves()
        return [move for move in pseudo_legal if self.is_legal_move(move)]

FEN Notation Support

FEN (Forsyth-Edwards Notation) is the standard way to describe chess positions. Let’s add support for it:

class Board:
    # ... previous code ...
    
    def from_fen(self, fen):
        """Load a position from FEN notation."""
        parts = fen.split()
        
        # Parse board position
        rows = parts[0].split('/')
        for row_idx, row in enumerate(rows):
            col_idx = 0
            for char in row:
                if char.isdigit():
                    for _ in range(int(char)):
                        self.board[7 - row_idx][col_idx] = 0
                        col_idx += 1
                else:
                    piece_map = {
                        'P': WHITE | PAWN, 'N': WHITE | KNIGHT, 
                        'B': WHITE | BISHOP, 'R': WHITE | ROOK,
                        'Q': WHITE | QUEEN, 'K': WHITE | KING,
                        'p': BLACK | PAWN, 'n': BLACK | KNIGHT,
                        'b': BLACK | BISHOP, 'r': BLACK | ROOK,
                        'q': BLACK | QUEEN, 'k': BLACK | KING
                    }
                    self.board[7 - row_idx][col_idx] = piece_map[char]
                    col_idx += 1
        
        # Parse side to move
        self.to_move = WHITE if parts[1] == 'w' else BLACK
        
        # Parse castling rights
        castling = parts[2]
        self.castling_rights = {
            'K': 'K' in castling,
            'Q': 'Q' in castling,
            'k': 'k' in castling,
            'q': 'q' in castling
        }
        
        # Parse en passant
        if parts[3] != '-':
            file = ord(parts[3][0]) - ord('a')
            rank = int(parts[3][1]) - 1
            self.en_passant_square = (rank, file)
        else:
            self.en_passant_square = None
        
        # Parse move counters
        self.halfmove_clock = int(parts[4])
        self.fullmove_number = int(parts[5])
    
    def to_fen(self):
        """Convert the current position to FEN notation."""
        fen_parts = []
        
        # Board position
        for row in range(7, -1, -1):
            empty_count = 0
            row_str = ""
            
            for col in range(8):
                piece = self.board[row][col]
                
                if piece == EMPTY:
                    empty_count += 1
                else:
                    if empty_count > 0:
                        row_str += str(empty_count)
                        empty_count = 0
                    
                    piece_type = piece & 7
                    color = piece & 24
                    
                    piece_chars = {
                        PAWN: 'p', KNIGHT: 'n', BISHOP: 'b',
                        ROOK: 'r', QUEEN: 'q', KING: 'k'
                    }
                    char = piece_chars[piece_type]
                    if color == WHITE:
                        char = char.upper()
                    row_str += char
            
            if empty_count > 0:
                row_str += str(empty_count)
            
            fen_parts.append(row_str)
        
        fen = '/'.join(fen_parts)
        
        # Side to move
        fen += ' w' if self.to_move == WHITE else ' b'
        
        # Castling rights
        castling = ''
        if self.castling_rights['K']:
            castling += 'K'
        if self.castling_rights['Q']:
            castling += 'Q'
        if self.castling_rights['k']:
            castling += 'k'
        if self.castling_rights['q']:
            castling += 'q'
        fen += ' ' + (castling if castling else '-')
        
        # En passant
        if self.en_passant_square:
            files = 'abcdefgh'
            fen += f" {files[self.en_passant_square[1]]}{self.en_passant_square[0] + 1}"
        else:
            fen += ' -'
        
        # Move counters
        fen += f" {self.halfmove_clock} {self.fullmove_number}"
        
        return fen

Testing with Perft

Perft (performance test) is a crucial debugging tool. It counts the number of leaf nodes at a given depth, which we can compare against known values:

def perft(board, depth):
    """Count leaf nodes at a given depth."""
    if depth == 0:
        return 1
    
    moves = board.generate_legal_moves()
    if depth == 1:
        return len(moves)
    
    count = 0
    for move in moves:
        undo_info = board.make_move(move)
        count += perft(board, depth - 1)
        board.unmake_move(move, undo_info)
    
    return count

# Test from initial position
board = Board()
print("Perft results from starting position:")
for depth in range(1, 5):
    nodes = perft(board, depth)
    print(f"Depth {depth}: {nodes} nodes")

# Expected results:
# Depth 1: 20 nodes
# Depth 2: 400 nodes
# Depth 3: 8,902 nodes
# Depth 4: 197,281 nodes

Printing a Board

Let’s add a simple class method to our board to allow us to print it to the screen:

class Board:
    # ... previous code ...
  
    def __str__(self):
        """Display the board in a human-readable format."""
        piece_symbols = {
            EMPTY: '.',
            WHITE | PAWN: 'P', WHITE | KNIGHT: 'N', WHITE | BISHOP: 'B',
            WHITE | ROOK: 'R', WHITE | QUEEN: 'Q', WHITE | KING: 'K',
            BLACK | PAWN: 'p', BLACK | KNIGHT: 'n', BLACK | BISHOP: 'b',
            BLACK | ROOK: 'r', BLACK | QUEEN: 'q', BLACK | KING: 'k'
        }
        
        board_string = "\n  a b c d e f g h\n"
        board_string += "  ---------------\n"
        for row in range(7, -1, -1):
            board_string += f"{row + 1}|"
            for col in range(8):
                piece = self.board[row][col]
                board_string += piece_symbols.get(piece, '?') + " "
            board_string += f"|{row + 1}\n"
        board_string += "  ---------------\n"
        board_string += "  a b c d e f g h\n"

        return board_string

Putting It All Together

Let’s create a simple test script to demonstrate our chess board in action:

if __name__ == "__main__":
    print("=== PyMinMaximus: Part 1 Demo ===\n")
    
    # Test 1: Initial position
    print("Test 1: Starting Position")
    board = Board()
    print(board)
    
    moves = board.generate_legal_moves()
    print(f"Legal moves available: {len(moves)}")
    print(f"First 5 moves: {moves[:5]}\n")
    
    # Test 2: Make some moves
    print("Test 2: Playing 1.e4 e5 2.Nf3")
    board.make_move(Move(1, 4, 3, 4))  # e4
    board.make_move(Move(6, 4, 4, 4))  # e5
    board.make_move(Move(0, 6, 2, 5))  # Nf3
    print(board)
    
    # Test 3: FEN notation
    print("Test 3: Loading position from FEN")
    board2 = Board()
    # Famous position: "The Immortal Game" after 10.e5
    fen = "r1bqkb1r/pppp1ppp/2n2n2/2b1p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 0 5"
    board2.from_fen(fen)
    print(board2)
    print(f"FEN: {board2.to_fen()}")
    print(f"Legal moves: {len(board2.generate_legal_moves())}\n")
    
    # Test 4: Check detection
    print("Test 4: Check Detection")
    board3 = Board()
    # Scholar's mate position
    board3.from_fen("r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4")
    print(board3)
    print(f"Black in check: {board3.is_in_check(BLACK)}")
    print(f"Legal moves for black: {len(board3.generate_legal_moves())}\n")
    
    # Test 5: Perft verification
    print("Test 5: Perft Verification")
    print("Running perft tests from starting position...")
    board4 = Board()
    
    expected_results = {
        1: 20,
        2: 400,
        3: 8902,
        4: 197281
    }
    
    for depth in range(1, 5):
        nodes = perft(board4, depth)
        expected = expected_results[depth]
        status = "✓ PASS" if nodes == expected else "✗ FAIL"
        print(f"Depth {depth}: {nodes:,} nodes (expected {expected:,}) {status}")
    
    # Test 6: Special moves
    print("\nTest 6: Special Moves")
    
    # En passant
    print("En Passant:")
    board5 = Board()
    board5.from_fen("rnbqkbnr/ppp1p1pp/8/3pPp2/8/8/PPPP1PPP/RNBQKBNR w KQkq f6 0 3")
    print(board5)
    moves = board5.generate_legal_moves()
    en_passant_moves = [m for m in moves if m.is_en_passant]
    print(f"En passant captures available: {en_passant_moves}\n")
    
    # Castling
    print("Castling:")
    board6 = Board()
    board6.from_fen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
    print(board6)
    moves = board6.generate_legal_moves()
    castling_moves = [m for m in moves if m.is_castling]
    print(f"Castling moves available: {len(castling_moves)}")
    for move in castling_moves:
        side = "Kingside" if move.to_col == 6 else "Queenside"
        color = "White" if move.from_row == 0 else "Black"
        print(f"  - {color} {side}: {move}")
    
    # Promotion
    print("\nPromotion:")
    board7 = Board()
    board7.from_fen("8/P7/8/8/8/8/8/4K2k w - - 0 1")
    print(board7)
    moves = board7.generate_legal_moves()
    promotion_moves = [m for m in moves if m.promotion]
    print(f"Promotion moves available: {len(promotion_moves)}")
    
    print("\n=== All tests complete! ===")
    print("\nYour chess board is working correctly!")

Run this script and you should see output demonstrating all the features we’ve implemented. If your perft results match the expected values, congratulations! Your move generator is working correctly.

What We’ve Accomplished

In this first post, we’ve built the foundation of PyMinMaximus:

Board representation using an intuitive 8×8 array
Complete move generation for all piece types
Special move handling: castling, en passant, promotion
Legal move filtering with check detection
FEN notation support for loading/saving positions
Perft testing to verify correctness

Common Issues and Debugging Tips

If you’re having trouble getting perft to match:

  1. Off-by-one errors: Remember that array indices are 0-7, but chess ranks are 1-8
  2. En passant bugs: Make sure you’re clearing the en passant square after every move
  3. Castling through check: Verify that castling checks all intermediate squares
  4. Pawn promotion: Don’t forget that pawns can promote to any piece, not just queens

Next Steps

Our chess board can now represent positions and generate legal moves, but it doesn’t know how to evaluate positions or search for good moves. In Part 2, we’ll implement:

  • The minimax algorithm for looking ahead
  • Alpha-beta pruning to search more efficiently
  • A basic evaluation function to judge positions
  • The tree structures that make it all work

The brain of PyMinMaximus is coming soon!

Complete Code

You can find the complete code for this post at our github repository. The main file structure:

pyminmaximus/
├── board.py # Board class and move generation
├── move.py # Move class
├── constants.py # Piece and color constants
└── test.py # Testing and demo script

Try experimenting with the code! Load your favorite positions, generate moves, and get familiar with how everything works. Understanding this foundation will make the next posts much easier to follow.

See you in Part 2, where PyMinMaximus learns to think!


Have questions or found a bug? Leave a comment below! If you found this helpful, share it with others learning about chess engines.

Leave a Reply

Your email address will not be published. Required fields are marked *