A 3D render of a wooden chess board during an endgame, featuring only a few metallic pieces—two kings and a lone rook—positioned strategically. In the background, a laptop displays the 'PyMinMaximus' Python code. A faint cyan glow connects the pieces, symbolizing the calculated precision of an endgame tablebase.

PyMinMaximus Part 5: Creating the Endgame Tablebase

Welcome back to the PyMinMaximus series! In Part 1, we built move generation, in Part 2 we added minimax search, in Part 3 we created a sophisticated evaluation function, and in Part 4 we added the ability to search an opening book for a position. We can start a game strong and think deeply in the middle of the game, but now we need to address the other end of the game: endgames. In this post, we will create an endgame tablebase.

The Endgame Problem

Currently, PyMinMaximus struggles in simple endgames:

  • King + Queen vs King: Takes 30-50 moves to mate (should be 10)
  • King + Rook vs King: Often fails to mate within 50 moves
  • KPK positions: Misses wins, doesn’t know theoretical draws

This is frustrating because these positions are solved – we know the perfect play! To address this, we can use an endgame tablebase.

What Are Endgame Tablebases?

An endgame tablebase is a pre-computed database that contains perfect play information for specific endgame positions. For every legal position with a given material configuration, the tablebase knows:

  1. The outcome (Win, Loss, or Draw)
  2. The distance to mate (DTM) – how many moves until mate with perfect play

Unlike game tree search (minimax), which computes positions on-demand, tablebases compute ALL positions once and store the results.

Syzygy Tablebases

Syzygy is the modern standard, created by Ronald de Man. Benefits:

  • Compact size (compared to older Nalimov bases)
  • Fast probing
  • 6-piece tablebases fit in ~150GB
  • Two types: WDL (outcome) and DTZ (distance)

However, Syzygy tablebases are very complex (primarily due to compression).

Simple Endgame Tablebase

We are interested in understanding how different parts of the chess engine work. Therefore, we are going to implement a simple tablebase. This is an educational implementation. This will not beat Stockfish, but it is useful for learning (and hopefully better than our engine without it). We will start with a 3 piece board (king + rook vs. king).

King + Rook vs King (we will refer to this as KRK) is perfect for learning tablebases because:

  • ~447,888 legal positions – Small enough to compute quickly (~5 seconds)
  • Simple material – Only 3 pieces to track
  • Clear outcomes – Almost all positions are wins for White
  • Known mate patterns – Easy to verify correctness
  • Educational value – Teaches retrograde analysis fundamentals

How Tablebases Work

The key insight is retrograde analysis. Traditional game tree search works forward from a position, exploring possible moves. Retrograde analysis works backward from known outcomes.

The basic process is:

  1. Mark terminal positions: Checkmate = win, stalemate = draw
  2. Work backwards: If a position can reach a win in one move, it’s “win in 1”
  3. Iterate: Keep going until all positions are classified
Terminal Positions (Depth 0): checkmate or draw

    ↓

Positions leading to terminals (Depth 1)

    ↓

Positions leading to Depth 1 (Depth 2)

    ↓

... continue until all positions classified

In pseudocode:

Phase 1: Classify Terminal Positions
-------------------------------------
- Checkmate positions → Win in 0 moves
- Stalemate positions → Draw

Phase 2: Iterative Backward Propagation
----------------------------------------
For depth = 1, 2, 3, ... until convergence:
    For each unsolved position:
        Generate all legal moves
        Check outcomes of resulting positions
        If side to move is winning side:
            If ANY move leads to a win → Position is a win
            If ALL moves explored and none win → Draw
        If side to move is losing side:
            If ANY move leads to a draw → Position is a draw  
            If ALL moves lead to losses → Position is a loss

Implementation Details

Position Encoding

Each position is encoded as a tuple:

(white_king_square, white_rook_square, black_king_square, white_to_move)

Symmetry Reduction: We use horizontal mirroring to halve the storage:

  • If White king is on the right half of the board (files e-h), mirror everything to the left

This reduces ~524,288 theoretical positions to ~447,888 legal positions.

Illegal Position Filtering

Positions are illegal if:

  1. Two pieces occupy the same square
  2. The two kings are adjacent (within 1 square)

Move Generation

The implementation includes:

  • King moves: All 8 directions
  • Rook moves: Sliding along ranks and files until blocked
  • Attack detection: Check if a square is under attack

Outcome Classification

Terminal Positions (Depth 0):

  • Checkmate: Black king in check with no legal moves
  • Stalemate: Black king not in check but with no legal moves

Non-terminal Positions:

  • White to move: Win if ANY move leads to a won position
  • Black to move: Draw if ANY move leads to a drawn position

Interesting Findings

  1. Maximum mate in 6: The longest forced mate is only 6 moves! This happens when:
    • Black king is in a corner (e.g., a1)
    • White pieces are on the opposite side
    • White must methodically drive the king to the edge
  2. 184 stalemate positions: Very rare in KRK, mostly corner positions where:
    • Black king is trapped (a1, a8, h1, or h8)
    • Rook blocks escape but doesn’t give check
    • White king controls remaining squares

Generate the Tablebase

Now that we know the theory, how do we implement? We will create a class called KRKTablebase. We start with a few constants (draw, white_win, black_win). We also create a dictionary to hold the table and a counter for the number of positions solved.

#krk_tablebase.py
from constants import *

class KRKTablebase:
    # Outcome constants
    UNKNOWN = 0
    DRAW = 1
    WHITE_WIN = 2
    BLACK_WIN = 3  # Shouldn't occur in KRK, but included for completeness
    
    def __init__(self):
        """Initialize the tablebase."""
        # Dictionary mapping position keys to (outcome, depth)
        # outcome: DRAW or WHITE_WIN
        # depth: moves to mate (DTM - Distance To Mate)
        self.table = {}
        self.positions_solved = 0

Next, we create two methods that allow us to convert a board position into an index (0-63) and back to a board position.

# .. continued from class KRKTablebase
    def square_to_coords(self, square):
        """Convert square index (0-63) to (row, col)."""
        return (square // 8, square % 8)
    
    def coords_to_square(self, row, col):
        """Convert (row, col) to square index (0-63)."""
        if not (0 <= row < 8 and 0 <= col < 8):
            return None
        return row * 8 + col

We then create a method to encode a given position into the tuple referenced above.

    def encode_position(self, wk_sq, wr_sq, bk_sq, white_to_move):
        """
        Encode position as a unique key.
        We use horizontal mirroring symmetry to reduce storage.
        """
        # Apply horizontal mirroring if king is on right half
        wk_row, wk_col = self.square_to_coords(wk_sq)
        wr_row, wr_col = self.square_to_coords(wr_sq)
        bk_row, bk_col = self.square_to_coords(bk_sq)
        
        if wk_col > 3:  # Mirror if on right half
            wk_col = 7 - wk_col
            wr_col = 7 - wr_col
            bk_col = 7 - bk_col
            
            wk_sq = self.coords_to_square(wk_row, wk_col)
            wr_sq = self.coords_to_square(wr_row, wr_col)
            bk_sq = self.coords_to_square(bk_row, bk_col)
        
        return (wk_sq, wr_sq, bk_sq, white_to_move)

We check whether a position is a legal position. When considering KRK, this is incredibly simplified from the main engine. None of the pieces can be on the same square and kings can’t be beside each other.

    def is_legal_position(self, wk_sq, wr_sq, bk_sq):
        """Check if position is legal (no overlapping pieces)."""
        if wk_sq == wr_sq or wk_sq == bk_sq or wr_sq == bk_sq:
            return False
        
        # Kings can't be adjacent
        wk_row, wk_col = self.square_to_coords(wk_sq)
        bk_row, bk_col = self.square_to_coords(bk_sq)
        
        row_diff = abs(wk_row - bk_row)
        col_diff = abs(wk_col - bk_col)
        
        if row_diff <= 1 and col_diff <= 1:
            return False
        
        return True

Next, we add two methods to check whether a square is attacked by a rook or a king.

    def is_attacked_by_rook(self, rook_sq, target_sq, blocking_sq=None):
        """Check if rook attacks a square (with optional blocking piece)."""
        r_row, r_col = self.square_to_coords(rook_sq)
        t_row, t_col = self.square_to_coords(target_sq)
        
        # Rook must be on same rank or file
        if r_row != t_row and r_col != t_col:
            return False
        
        # Check for blocking piece
        if blocking_sq is not None:
            b_row, b_col = self.square_to_coords(blocking_sq)
            
            # If moving horizontally
            if r_row == t_row:
                min_col = min(r_col, t_col)
                max_col = max(r_col, t_col)
                # Blocker must be on same row and between rook and target
                if b_row == r_row and min_col < b_col < max_col:
                    return False
            # If moving vertically
            else:
                min_row = min(r_row, t_row)
                max_row = max(r_row, t_row)
                if b_col == r_col and min_row < b_row < max_row:
                    return False
        
        return True
    
    def is_attacked_by_king(self, king_sq, target_sq):
        """Check if king attacks a square."""
        k_row, k_col = self.square_to_coords(king_sq)
        t_row, t_col = self.square_to_coords(target_sq)
        
        return abs(k_row - t_row) <= 1 and abs(k_col - t_col) <= 1

We add two methods to generate the positions for kings and rooks.

    def generate_king_moves(self, king_sq, occupied_squares):
        """Generate all legal king moves from a square."""
        moves = []
        k_row, k_col = self.square_to_coords(king_sq)
        
        for drow in [-1, 0, 1]:
            for dcol in [-1, 0, 1]:
                if drow == 0 and dcol == 0:
                    continue
                
                new_row = k_row + drow
                new_col = k_col + dcol
                new_sq = self.coords_to_square(new_row, new_col)
                
                if new_sq is not None and new_sq not in occupied_squares:
                    moves.append(new_sq)
        
        return moves
    
    def generate_rook_moves(self, rook_sq, occupied_squares):
        """Generate all legal rook moves from a square."""
        moves = []
        r_row, r_col = self.square_to_coords(rook_sq)
        
        # Horizontal moves
        for dcol in [-1, 1]:
            col = r_col + dcol
            while 0 <= col < 8:
                sq = self.coords_to_square(r_row, col)
                if sq in occupied_squares:
                    break
                moves.append(sq)
                col += dcol
        
        # Vertical moves
        for drow in [-1, 1]:
            row = r_row + drow
            while 0 <= row < 8:
                sq = self.coords_to_square(row, r_col)
                if sq in occupied_squares:
                    break
                moves.append(sq)
                row += drow
        
        return moves

We check for terminal positions.

    def is_checkmate(self, wk_sq, wr_sq, bk_sq):
        """
        Check if black king is checkmated.
        Black is in check and has no legal moves.
        """
        # Black king must be in check from rook
        if not self.is_attacked_by_rook(wr_sq, bk_sq, wk_sq):
            return False
        
        # Check if black king has any legal moves
        occupied = {wk_sq, wr_sq}
        black_king_moves = self.generate_king_moves(bk_sq, occupied)
        
        for bk_new_sq in black_king_moves:
            # Can't move into check from white king
            if self.is_attacked_by_king(wk_sq, bk_new_sq):
                continue
            
            # Can't move into check from rook
            if self.is_attacked_by_rook(wr_sq, bk_new_sq, wk_sq):
                continue
            
            # Found a legal move - not checkmate
            return False
        
        # No legal moves - checkmate!
        return True
    
    def is_stalemate(self, wk_sq, wr_sq, bk_sq):
        """
        Check if black king is stalemated.
        Black is NOT in check but has no legal moves.
        """
        # Black king must NOT be in check from rook
        if self.is_attacked_by_rook(wr_sq, bk_sq, wk_sq):
            return False
        
        # Black king must NOT be in check from white king (shouldn't happen but check anyway)
        if self.is_attacked_by_king(wk_sq, bk_sq):
            return False
        
        # Check if black king has any legal moves
        occupied = {wk_sq, wr_sq}
        black_king_moves = self.generate_king_moves(bk_sq, occupied)
        
        # If no squares to move to at all, it's stalemate
        if len(black_king_moves) == 0:
            return True
        
        for bk_new_sq in black_king_moves:
            # Can't move into check from white king
            if self.is_attacked_by_king(wk_sq, bk_new_sq):
                continue
            
            # Can't move into check from rook
            if self.is_attacked_by_rook(wr_sq, bk_new_sq, wk_sq):
                continue
            
            # Found a legal move - not stalemate
            return False
        
        # No legal moves but not in check - stalemate!
        return True

The next method generates all legal positions. This simply loops over all three pieces and all positions on the board. Before adding the position to the list, we check whether it is legal.

    def generate_all_positions(self):
        """Generate all legal KRK positions."""
        positions = []
        
        for wk_sq in range(64):
            for wr_sq in range(64):
                for bk_sq in range(64):
                    if not self.is_legal_position(wk_sq, wr_sq, bk_sq):
                        continue
                    
                    # Both sides to move
                    positions.append((wk_sq, wr_sq, bk_sq, True))   # White to move
                    positions.append((wk_sq, wr_sq, bk_sq, False))  # Black to move
        
        return positions

Once we have generated all of the positions, next we find which of these positions are terminal (checkmate or stalemate). If yes, then we add to the solved table dictionary (the key is the encoded position).

    def classify_terminal_positions(self, positions):
        """
        Phase 1: Classify all terminal positions (checkmate, stalemate).
        Returns number of positions classified.
        """
        count = 0
        
        for wk_sq, wr_sq, bk_sq, white_to_move in positions:
            key = self.encode_position(wk_sq, wr_sq, bk_sq, white_to_move)
            
            if key in self.table:
                continue
            
            # Check for checkmate (black is mated)
            if self.is_checkmate(wk_sq, wr_sq, bk_sq):
                self.table[key] = (self.WHITE_WIN, 0)
                count += 1
                continue
            
            # Check for stalemate (only when black to move)
            if not white_to_move and self.is_stalemate(wk_sq, wr_sq, bk_sq):
                self.table[key] = (self.DRAW, 0)
                count += 1
                continue
        
        return count

Now, we are to the heart of the tablebase generation process. We now need to conduct the retrograde analysis. For each unsolved position (i.e., the encoded position is not in the solved table):

  • Generate the legal moves
  • If any of those legal moves lead to a solved position then we record this position in the table with the current depth
    def retrograde_iteration(self, positions, current_depth):
        """
        Single iteration of retrograde analysis.
        
        For each unsolved position:
        - Generate all legal moves
        - Check outcomes of resulting positions
        - If we can force a mate, mark this position with current_depth
        
        Returns number of new positions solved.
        """
        newly_solved = 0
        
        for wk_sq, wr_sq, bk_sq, white_to_move in positions:
            key = self.encode_position(wk_sq, wr_sq, bk_sq, white_to_move)
            
            # Skip if already solved
            if key in self.table:
                continue
            
            if white_to_move:
                # White to move - looking for winning moves
                can_win = False
                all_moves_solved = True
                
                occupied = {bk_sq}
                
                # Try white king moves
                wk_moves = self.generate_king_moves(wk_sq, occupied)
                for wk_new_sq in wk_moves:
                    # Can't move into check from rook's original position
                    if self.is_attacked_by_rook(wr_sq, wk_new_sq, bk_sq):
                        continue
                    
                    if not self.is_legal_position(wk_new_sq, wr_sq, bk_sq):
                        continue
                    
                    next_key = self.encode_position(wk_new_sq, wr_sq, bk_sq, False)
                    
                    if next_key not in self.table:
                        all_moves_solved = False
                    elif self.table[next_key][0] == self.WHITE_WIN:
                        can_win = True
                        break
                
                if can_win:
                    self.table[key] = (self.WHITE_WIN, current_depth)
                    newly_solved += 1
                    continue
                
                # Try white rook moves
                if not can_win:
                    occupied_with_king = {wk_sq, bk_sq}
                    wr_moves = self.generate_rook_moves(wr_sq, occupied_with_king)
                    
                    for wr_new_sq in wr_moves:
                        if not self.is_legal_position(wk_sq, wr_new_sq, bk_sq):
                            continue
                        
                        # Check if this move gives check
                        gives_check = self.is_attacked_by_rook(wr_new_sq, bk_sq, wk_sq)
                        
                        # If black is in check, verify it's legal (white king not in check)
                        if gives_check:
                            if self.is_attacked_by_king(bk_sq, wk_sq):
                                continue  # Illegal - white king in check
                        
                        next_key = self.encode_position(wk_sq, wr_new_sq, bk_sq, False)
                        
                        if next_key not in self.table:
                            all_moves_solved = False
                        elif self.table[next_key][0] == self.WHITE_WIN:
                            can_win = True
                            break
                
                if can_win:
                    self.table[key] = (self.WHITE_WIN, current_depth)
                    newly_solved += 1
                elif all_moves_solved:
                    # All moves explored, none win - this is a draw
                    self.table[key] = (self.DRAW, 0)
                    newly_solved += 1
                    
            else:
                # Black to move - can black force a draw or does white win?
                can_draw = False
                all_moves_lose = True
                
                occupied = {wk_sq, wr_sq}
                bk_moves = self.generate_king_moves(bk_sq, occupied)
                
                for bk_new_sq in bk_moves:
                    # Can't move into check from white king
                    if self.is_attacked_by_king(wk_sq, bk_new_sq):
                        continue
                    
                    # Can't move into check from rook
                    if self.is_attacked_by_rook(wr_sq, bk_new_sq, wk_sq):
                        continue
                    
                    next_key = self.encode_position(wk_sq, wr_sq, bk_new_sq, True)
                    
                    if next_key not in self.table:
                        all_moves_lose = False
                    elif self.table[next_key][0] == self.DRAW:
                        can_draw = True
                        break
                
                if can_draw:
                    self.table[key] = (self.DRAW, 0)
                    newly_solved += 1
                elif all_moves_lose:
                    # All moves lead to white win - this is a white win
                    self.table[key] = (self.WHITE_WIN, current_depth)
                    newly_solved += 1
        
        return newly_solved

For code this complicated, I need a flow chart:

The next code generates the tablebase. First, we generate all terminal positions. Then for each terminal position, we conduct the retrograde analysis at increasing depths. Once the retrograde analysis returns 0 (no new positions found at that depth), we are done.

    def generate(self):
        """
        Generate the complete KRK tablebase using retrograde analysis.
        """
        print("Generating KRK Tablebase...")
        print("=" * 60)
        
        # Generate all legal positions
        print("Generating all legal positions...")
        positions = self.generate_all_positions()
        print(f"Total legal positions: {len(positions):,}")
        
        # Phase 1: Classify terminal positions
        print("\nPhase 1: Classifying terminal positions...")
        terminal_count = self.classify_terminal_positions(positions)
        print(f"Terminal positions found: {terminal_count:,}")
        print(f"  - Checkmates and stalemates")
        
        # Phase 2: Retrograde analysis
        print("\nPhase 2: Retrograde analysis...")
        depth = 1
        max_depth = 100  # Safety limit
        
        while depth <= max_depth:
            newly_solved = self.retrograde_iteration(positions, depth)
            
            if newly_solved == 0:
                print(f"\nConverged at depth {depth}")
                break
            
            total_solved = len(self.table)
            percentage = (total_solved / len(positions)) * 100
            print(f"Depth {depth:2d}: +{newly_solved:5,} new positions "
                  f"(Total: {total_solved:6,} / {len(positions):,} = {percentage:5.1f}%)")
            
            depth += 1
        
        # Summary
        print("\n" + "=" * 60)
        print("Tablebase Generation Complete!")
        print("=" * 60)
        print(f"Total positions: {len(positions):,}")
        print(f"Positions solved: {len(self.table):,}")
        print(f"Maximum depth: {depth - 1}")
        
        # Statistics
        wins = sum(1 for outcome, _ in self.table.values() if outcome == self.WHITE_WIN)
        draws = sum(1 for outcome, _ in self.table.values() if outcome == self.DRAW)
        
        print(f"\nOutcome distribution:")
        print(f"  White wins: {wins:,} ({wins/len(self.table)*100:.1f}%)")
        print(f"  Draws:      {draws:,} ({draws/len(self.table)*100:.1f}%)")
        
        # Find longest mate
        max_dtm = max((depth for outcome, depth in self.table.values() 
                       if outcome == self.WHITE_WIN), default=0)
        print(f"\nLongest forced mate: {max_dtm} moves")

Next, we add two methods to probe the database from a given board position.

    def probe(self, wk_sq, wr_sq, bk_sq, white_to_move):
        """
        Look up a position in the tablebase.
        
        Returns: (outcome, depth) or None if position is illegal/not in tablebase
        """
        if not self.is_legal_position(wk_sq, wr_sq, bk_sq):
            return None
        
        key = self.encode_position(wk_sq, wr_sq, bk_sq, white_to_move)
        return self.table.get(key)
    
    def probe_from_board(self, board):
        """
        Probe the tablebase from a Board object.
        Only works if the position is a KRK endgame.
        """
        # Count pieces
        white_king = None
        white_rook = None
        black_king = None
        piece_count = 0
        
        for row in range(8):
            for col in range(8):
                piece = board.board[row][col]
                if piece == EMPTY:
                    continue
                
                piece_count += 1
                piece_type = piece & 7
                color = piece & 24
                
                if piece == (WHITE | KING):
                    white_king = self.coords_to_square(row, col)
                elif piece == (WHITE | ROOK):
                    white_rook = self.coords_to_square(row, col)
                elif piece == (BLACK | KING):
                    black_king = self.coords_to_square(row, col)
        
        # Must be exactly 3 pieces: WK, WR, BK
        if piece_count != 3 or white_king is None or white_rook is None or black_king is None:
            return None
        
        white_to_move = (board.to_move == WHITE)
        return self.probe(white_king, white_rook, black_king, white_to_move)

Finally, we add methods to save and load the tablebase. We don’t need to run the table generation every time. We just need to run it once and save it. When we use the engine, we will simply load the tablebase.

    def save(self, filename):
        """Save tablebase to file."""
        import pickle
        with open(filename, 'wb') as f:
            pickle.dump(self.table, f)
        print(f"\nTablebase saved to {filename}")
        print(f"File size: {len(pickle.dumps(self.table)) / 1024 / 1024:.2f} MB")
    
    def load(self, filename):
        """Load tablebase from file."""
        import pickle
        with open(filename, 'rb') as f:
            self.table = pickle.load(f)
        print(f"Tablebase loaded from {filename}")
        print(f"Positions in tablebase: {len(self.table):,}")

Now that we have the code, generating the database is easy:

from krk_tablebase import KRKTablebase

tb = KRKTablebase()
tb.generate()
tb.save("krktablebase.pkl")

Integrating with Search

Next, we need to integrate into our SearchEngine class. For simplicity, I am not including all existing code in search.

Step 1: Load the Tablebase

In the SearchEngine constructor, we include code to load the tablebase. We create a wrapper method within SearchEngine that tries to load an existing tablebase. If the file doesn’t exist, then we generate it.

# search.py
from krk_tablebase import KRKTablebase

class SearchEngine:
    def __init__(self, board, evaluator=None):
        self.board = board
        self.evaluator = evaluator if evaluator else Evaluator()
        self.nodes_searched = 0
        self.tt = TranspositionTable()

        # Opening book
        self.book = OpeningBook('books/kasparov.bin')

        # Add tablebase
        self.krk_tablebase = None
        self.load_tablebases()
        
    def load_tablebases(self):
        """Load endgame tablebases"""
        try:
            self.krk_tablebase = KRKTablebase()
            self.krk_tablebase.load("tablebase/krk_tablebase.pkl")
            print("KRK tablebase loaded successfully")
        except:
            # Generate if not found
            print("Generating KRK tablebase...")
            self.krk_tablebase = KRKTablebase()
            self.krk_tablebase.generate()
            self.krk_tablebase.save("tablebase/krk_tablebase.pkl")

Step 2: Detect Tablebase Positions

We add a method to check if current position is in tablebase:

    def is_tablebase_position(self, board):
        """Check if position is in a tablebase"""
        # Count pieces and material
        piece_count = 0
        white_pieces = []
        black_pieces = []
        
        for row in range(8):
            for col in range(8):
                piece = board.board[row][col]
                if piece != EMPTY:
                    piece_count += 1
                    if (piece & 24) == WHITE:
                        white_pieces.append(piece & 7)
                    else:
                        black_pieces.append(piece & 7)
        
        # Check for KRK
        if piece_count == 3:
            white_pieces.sort()
            black_pieces.sort()
            
            # White has K+R, Black has K
            if white_pieces == [KING, ROOK] and black_pieces == [KING]:
                return 'KRK_white'
            
            # Black has K+R, White has K (flip the board mentally)
            if white_pieces == [KING] and black_pieces == [KING, ROOK]:
                return 'KRK_black'
        
        return None

Step 3: Probe the Tablebase and Get the Best Move

Add a method to query the tablebase and if the position is in the tablebase, get the best move.

    def probe_tablebase(self, board):
        """
        Probe endgame tablebases.
        Returns (score, best_move) or None if not in tablebase.
        """
        tb_type = self.is_tablebase_position(board)
        
        if tb_type is None:
            return None
        
        if tb_type == 'KRK_white':
            # Query KRK tablebase
            result = self.krk_tablebase.probe_from_board(board)
            if result is None:
                return None
            
            outcome, dtm = result
            
            if outcome == self.krk_tablebase.WHITE_WIN:
                # Convert DTM to score
                # Use high score that decreases with distance
                score = 19000 - dtm
            else:  # DRAW
                score = 0
            
            # Find the best move that maintains this outcome
            best_move = self.find_tablebase_best_move(board, outcome, dtm)
            return (score, best_move)
        
        elif tb_type == 'KRK_black':
            # Flip perspective for black
            result = self.krk_tablebase.probe_from_board(board)
            if result is None:
                return None
            
            outcome, dtm = result
            
            if outcome == self.krk_tablebase.WHITE_WIN:
                # Black is losing
                score = -(19000 - dtm)
            else:
                score = 0
            
            best_move = self.find_tablebase_best_move(board, outcome, dtm)
            return (score, best_move)
        
        return None

    def find_tablebase_best_move(self, board, target_outcome, current_dtm):
        """
        Find the best move according to the tablebase.
        Looks for moves that maintain winning path or quickest mate.
        """
        moves = board.generate_legal_moves()
        best_move = None
        best_dtm = float('inf')
        
        for move in moves:
            undo_info = board.make_move(move)
            
            # Query position after move
            result = self.krk_tablebase.probe_from_board(board)
            
            board.unmake_move(move, undo_info)
            
            if result is None:
                continue
            
            outcome, dtm = result
            
            # If we're winning, look for quickest mate
            if outcome == target_outcome:
                if outcome == self.krk_tablebase.WHITE_WIN:
                    # Want smallest DTM (quickest mate)
                    if dtm < best_dtm:
                        best_dtm = dtm
                        best_move = move
                else:  # DRAW
                    best_move = move
                    break  # Any drawing move is fine
        
        return best_move

Step 4: Integrate with Search

Modify your alpha-beta search to check tablebase first:

    def alphabeta(self, depth, alpha, beta, maximizing_player):
        """
        Alpha-beta with transposition table.
        """
        # Check tablebase FIRST (before any search)
        piece_count = sum(1 for row in self.board.board for p in row if p != EMPTY)
        if piece_count <= 5:
            tb_result = self.probe_tablebase(self.board)
            if tb_result is not None:
                score, _ = tb_result
                # Return from current player's perspective
                if not maximizing_player:
                    score = -score
                return score

We also need to modify the root search.

    def find_best_move_alphabeta(self, depth):
        """
        Find the best move using alpha-beta pruning.
        """
        # Check opening book
        if self.book.is_in_book(self.board):
            moves = self.board.generate_legal_moves()
            book_move = self.book.get_book_move(self.board,len(self.board.move_stack))
            for move in moves:
                if str(move) == book_move:
                    return move, 1000

        # Check tablebase
        piece_count = sum(1 for row in self.board.board for p in row if p != EMPTY)
        if piece_count <= 5:
            tb_result = self.probe_tablebase(self.board)
            if tb_result is not None:
                score, best_move = tb_result
                if best_move is not None:
                    print(f"Tablebase: {best_move} (score: {score})")
                    return best_move, score

Usage Example

Here is a quick example:

from board import Board
from search import SearchEngine

# Create board and search engine
board = Board()
engine = SearchEngine(board)

# Set up a KRK position
board.from_fen("4k3/8/8/8/8/8/4K3/4R3 w - - 0 1")

# Search will automatically use tablebase
best_move, score = engine.find_best_move_alphabeta(5)

print(f"Best move: {best_move}")
print(f"Evaluation: {score}")

Performance Considerations

Our simplistic implementation of one tablebase is very efficient.

  • KRK tablebase: ~3.4 MB (pickled)
  • Keep in memory for fast access

If we wanted to create more tablebases, we would need more sophisticated compression. Below are tables that show the size of different tablebases and how much memory to store various numbers of remaining pieces.

3-Piece Tablebases

TablebasePositionsOur Size (Pickle)Syzygy SizeRatio
KRK223,9443.4 MB~0.1 MB5x larger
KQK223,9443.4 MB~0.1 MB5x larger
KBK111,9721.7 MB~0.05 MB6x larger
KNK111,9721.7 MB~0.05 MB6x larger

For KRK, we only need 3.4 MB, which is manageable. However, it is already 5 times larger than Syzygy. If we look at storage needs up to 6 pieces, we really see the difference.

Summary Chart: Total Storage Needs

PiecesTotal PositionsPickle (Ours)SyzygyFeasibility
3-piece~1M10 MB0.3 MB✅ Easy
4-piece~240M7.5 GB200 MB✅ Manageable
5-piece~28B461 GB7.1 GB⚠️ Challenging
6-piece~150B2.4 TB150 GB❌ Syzygy only
7-piece~500B8+ TB140 GB❌ Syzygy only

Key Takeaways

  1. Tablebases use different algorithm than search
    • Retrograde analysis vs forward minimax
    • Pre-computation vs on-demand
  2. They provide perfect play
    • No search errors
    • Optimal moves guaranteed
    • Exact mate distances
  3. Scalability is the challenge
    • 3 pieces: trivial
    • 5 pieces: manageable
    • 7 pieces: challenging
    • 8+ pieces: impractical
  4. Implementation teaches fundamentals
    • Position encoding
    • Symmetry reduction
    • Retrograde algorithms
    • Storage optimization

Resources for Further Learning

Papers

  • Thompson (1986): “Retrograde Analysis of Certain Endgames”
  • Heinz (2000): “Endgame Databases and Efficient Index Schemes”

Code

Databases

What We’ve Accomplished

In this part, we’ve transformed PyMinMaximus into a complete chess player:
Endgame tablebases – Perfect endgame play
Smart integration – Book → Tablebase → Search hierarchy
Enhanced interface – Information-rich game display
Performance gains – Measurable strength improvement

Complete Code Structure

Your final project structure:

pyminmaximus/
├── constants.py         # Piece and color constants
├── move.py             # Move class
├── board.py            # Board representation
├── evaluation.py       # Position evaluation
├── search.py           # Search with book/tablebase integration
├── zobrist.py          # Zobrist hashing
├── opening_book.py     # Polyglot book reader
├── book_creator.py     # Custom book creation
├── krk_tablebase.py    # Generate, Save, and Load a king + rook and king tablebase

Next Steps

In Part 6 (our finale), we’ll:

  • Implement the UCI protocol
  • Test PyMinMaximus with CuteChess CLI
  • Get accurate strength measurements
  • Compare against other engines
  • Calculate final ELO rating

We’ve built the knowledge base—next we’ll integrate it into the engine!


Questions about tablebases? Built your own tablebase? Share in the comments!

Visited 12 times, 3 visit(s) today

Leave a Reply

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