Building PyMinMaximus Part 6: UCI Protocol and Tournament Testing

Welcome to the final post in the PyMinMaximus series, implementing the UCI protocol! Over the past five posts, we’ve built a complete chess engine from scratch:

  • Part 1: Board representation and move generation
  • Part 2: Minimax search with alpha-beta pruning
  • Part 3: Sophisticated position evaluation
  • Part 4: Opening book integration
  • Part 5: Endgame tablebase support

PyMinMaximus can now play chess. But there’s one critical piece missing: we can’t easily test it against other engines or measure its true strength.

In this final post, we’ll:

  • Implement the Universal Chess Interface (UCI) protocol
  • Integrate PyMinMaximus with CuteChess CLI
  • Run automated tournaments against other engines
  • Calculate an accurate ELO rating
  • Analyze our engine’s strengths and weaknesses

Let’s find out how strong PyMinMaximus really is! Just want the code? Head to our GitHub repository for PyMinMaximus.

Understanding the UCI Protocol

What is UCI?

To communicate with other engines, we will use the Universal Chess Interface (UCI). UCI is a standard protocol for communication between chess engines and GUIs (Graphical User Interfaces) or tournament managers. It was created by Rudolf Huber and Stefan Meyer-Kahlen for the Shredder chess engine.

Why UCI Matters

Without UCI, every chess GUI would need custom code for each engine. With UCI:

  • ✅ One implementation works with all UCI GUIs
  • ✅ Engines can play in tournaments automatically
  • ✅ Users can analyze positions with any engine
  • ✅ Easy testing and benchmarking

How UCI Works

UCI uses simple text-based communication via stdin/stdout. Below is a terminal example of some of these commands.

Key UCI Commands

From GUI to Engine:

  • uci – Initialize UCI mode
  • isready – Check if engine is ready
  • ucinewgame – Start a new game
  • position [fen | startpos] moves ... – Set position
  • go [options] – Start searching
  • stop – Stop searching
  • quit – Shut down engine

From Engine to GUI:

  • id name <name> – Engine identification
  • id author <author> – Author name
  • uciok – UCI initialization complete
  • readyok – Ready to receive commands
  • bestmove <move> – Best move found
  • info – Search information (depth, score, nodes, etc.)

Here is the full UCI specification.

📬 Enjoying this post?

Get monthly tutorials, project updates, and coding insights delivered to your inbox. No spam, unsubscribe anytime.

Implementing UCI Protocol

Let’s build our UCI handler:

# uci.py
import sys
import time
from board import Board
from search import SearchEngine
from evaluation import Evaluator
from opening_book import OpeningBook
from move import Move
from constants import *
import os
import threading

class UCIHandler:
    """
    Universal Chess Interface protocol handler for PyMinMaximus.
    """
    
    def __init__(self):
        self.board = Board()
        self.evaluator = Evaluator()
        
        # Engine configuration
        self.engine_name = "PyMinMaximus"
        self.engine_version = "1.0"
        self.author = "Harlepengren"

        bookfile = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'books/kasparov.bin')
        
        # UCI options
        self.options = {
            'Hash': 64,  # MB for transposition table
            'OwnBook': True,  # Use opening book
            'BookFile': bookfile,
        }
        
        # Initialize components
        self.opening_book = None
        self.engine = None
        self._init_engine()
    
    def _init_engine(self):
        """Initialize the search engine with current options."""
        # Load opening book if enabled
        if self.options['OwnBook'] and self.options['BookFile']:
            try:
                self.opening_book = OpeningBook(self.options['BookFile'])
            except:
                self.opening_book = None
        
        # Create search engine
        self.engine = SearchEngine(self.board, self.evaluator, self.opening_book)
    
    def uci(self):
        """Handle 'uci' command - identify engine and options."""
        print(f"id name {self.engine_name} {self.engine_version}")
        print(f"id author {self.author}")
        
        # Report available options
        print("option name Hash type spin default 64 min 1 max 1024")
        print("option name OwnBook type check default true")
        print("option name BookFile type string default books/performance.bin")
        
        print("uciok")
        sys.stdout.flush()
    
    def isready(self):
        """Handle 'isready' command - confirm ready state."""
        print("readyok")
        sys.stdout.flush()
    
    def ucinewgame(self):
        """Handle 'ucinewgame' command - reset for new game."""
        self.board = Board()
        self._init_engine()
        self.engine.tt.table.clear()  # Clear transposition table
    
    def position(self, args):
        """
        Handle 'position' command - set up position.
        
        Format: position [fen <fenstring> | startpos] moves <move1> <move2> ...
        """
        # Reset to starting position or FEN
        if args[0] == 'startpos':
            self.board = Board()
            move_start = 1
        elif args[0] == 'fen':
            # Find where moves start
            try:
                moves_idx = args.index('moves')
                fen = ' '.join(args[1:moves_idx])
                move_start = moves_idx
            except ValueError:
                # No moves, just FEN
                fen = ' '.join(args[1:])
                move_start = len(args)
            
            self.board = Board()
            self.board.from_fen(fen)
        
        # Apply moves if present
        if move_start < len(args) and args[move_start] == 'moves':
            for move_str in args[move_start + 1:]:
                move = self.board.convert_uci(move_str)
                if move:
                    self.board.make_move(move)
                else:
                    print(f"# Warning: Invalid move '{move_str}' ignored.")
        
        # Update engine with new position
        self.engine.board = self.board
    
    def go(self, args):
        """
        Handle 'go' command - start searching.
        
        Supported options:
        - movetime <ms> - search for exactly this many milliseconds
        - wtime <ms> - white's remaining time
        - btime <ms> - black's remaining time
        - winc <ms> - white's increment
        - binc <ms> - black's increment
        - depth <d> - search to this depth
        - infinite - search until 'stop' command
        """
        # Parse go arguments
        movetime = None
        depth = 6  # Default depth
        wtime = btime = None
        winc = binc = 0
        increment = 500
        
        i = 0
        while i < len(args):
            if args[i] == 'movetime':
                movetime = int(args[i + 1]) / 1000  # Convert ms to seconds
                i += 2
            elif args[i] == 'depth':
                depth = int(args[i + 1])
                i += 2
            elif args[i] == 'wtime':
                wtime = int(args[i + 1])
                i += 2
            elif args[i] == 'btime':
                btime = int(args[i + 1])
                i += 2
            elif args[i] == 'winc':
                winc = int(args[i + 1])
                i += 2
            elif args[i] == 'binc':
                binc = int(args[i + 1])
                i += 2
            elif args[i] == 'infinite':
                movetime = None
                depth = 100
                i += 1
            else:
                i += 1
        
        # Calculate time allocation if using clock
        if movetime is None and wtime is not None and btime is not None:
            # Use simple time management: allocate 1/30 of remaining time + increment
            if self.board.to_move == WHITE:
                if winc is not None:
                    increment = winc
                movetime = (wtime / 1000) / 30 + (increment / 1000) * self.board.fullmove_number
            else:
                if binc is not None:
                    increment = binc
                movetime = (btime / 1000) / 30 + (increment / 1000) * self.board.fullmove_number
            
            # Ensure minimum time
            movetime = max(movetime, 0.1)
        
        print(f"# Starting search: depth={depth}, movetime={movetime}s")
        sys.stdout.flush()
        
        # Calculate move number for opening book
        move_number = self.board.fullmove_number
        
        # Search for best move
        self.search_thread = threading.Thread(target=self._search_with_info, args=(depth, move_number))
        self.search_thread.start()

        self.timer_thread = threading.Timer(movetime, lambda: self.engine.set_stop(True)) if movetime else None
        self.timer_thread.start() if self.timer_thread else None

    def _search_with_info(self, max_depth, move_number):
        """
        Search with UCI info output.
        """
        self.best_move = None
        self.best_score = 0
        self.engine.set_stop(False)
        
        # Check opening book first
        #if self.opening_book and self.opening_book.book_enabled:
        #    book_move_uci = self.opening_book.get_book_move(
        #        self.board, move_number, selection_mode="weighted"
        #    )
        #    
        #    if book_move_uci:
        #        book_move = self.board.convert_uci(book_move_uci)
        #        if book_move:
        #            return book_move, 0
        
        self.engine.nodes_searched = 0
        self.best_move, self.best_score = self.engine.find_best_move_alphabeta(max_depth)
        self.timer_thread.cancel() if self.timer_thread else None

        #self.search_thread.join()
        
        # Report best move
        if self.best_move:
            print(f"bestmove {self.best_move}")
        else:
            # No legal moves - shouldn't happen, but be safe
            legal_moves = self.board.generate_legal_moves()
            if legal_moves:
                print(f"bestmove {legal_moves[0]}")
            else:
                print("bestmove 0000")
        
        sys.stdout.flush()
    
    def setoption(self, args):
        """Handle 'setoption' command - configure engine options."""
        # Format: setoption name <name> value <value>
        if len(args) >= 4 and args[0] == 'name' and args[2] == 'value':
            option_name = args[1]
            option_value = ' '.join(args[3:])
            
            if option_name in self.options:
                # Convert value to appropriate type
                if isinstance(self.options[option_name], int):
                    self.options[option_name] = int(option_value)
                elif isinstance(self.options[option_name], bool):
                    self.options[option_name] = option_value.lower() == 'true'
                else:
                    self.options[option_name] = option_value
                
                # Reinitialize if needed
                if option_name in ['OwnBook', 'BookFile']:
                    self._init_engine()
    
    def run(self):
        """Main UCI loop - read commands and respond."""
        while True:
            try:
                line = input().strip()
                print(f"Received command: {line}")
                
                if not line:
                    continue
                
                parts = line.split()
                command = parts[0]
                args = parts[1:]
                
                if command == 'uci':
                    self.uci()
                
                elif command == 'isready':
                    self.isready()
                
                elif command == 'ucinewgame':
                    self.ucinewgame()
                
                elif command == 'position':
                    self.position(args)
                
                elif command == 'go':
                    self.go(args)
                
                elif command == 'setoption':
                    self.setoption(args)
                
                elif command == 'quit':
                    break
                
                elif command == 'stop':
                    self.engine.set_stop(True)

                elif command == 'status':
                    print(f"Currently searching: {self.search_thread.is_alive() if self.search_thread else False}")
                    print(f"Timer active: {self.timer_thread.is_alive() if self.timer_thread else False}")
                    sys.stdout.flush()
                
            except EOFError:
                break
            except Exception as e:
                # Log errors but don't crash
                print(f"# Error: {e}", file=sys.stderr)
                sys.stderr.flush()

def _set_board_from_moves(board, moves):
    """Helper generator to set board position from a list of UCI moves. Helps
    with testing."""
    for move_str in moves:
        move = board.convert_uci(move_str)
        if move:
            board.make_move(move)
        print(f"# Applied move: {move_str}")
        yield board


def main():
    """Entry point for UCI mode."""
    handler = UCIHandler()
    handler.run()


if __name__ == "__main__":
    main()

The heart of this code is the run() method. This method is an infinite loop that receives input from input(), processes it and calls the relevant command.

def run(self):
        """Main UCI loop - read commands and respond."""
        while True:
            try:
                line = input().strip()
                print(f"Received command: {line}")
                
                if not line:
                    continue
                
                parts = line.split()
                command = parts[0]
                args = parts[1:]
                
                if command == 'uci':
                    self.uci()
                
                elif command == 'isready':
                    self.isready()
                
                elif command == 'ucinewgame':
                    self.ucinewgame()
                
                elif command == 'position':
                    self.position(args)
                
                elif command == 'go':
                    self.go(args)
                
                elif command == 'setoption':
                    self.setoption(args)
                
                elif command == 'quit':
                    break
                
                elif command == 'stop':
                    self.engine.set_stop(True)

                elif command == 'status':
                    print(f"Currently searching: {self.search_thread.is_alive() if self.search_thread else False}")
                    print(f"Timer active: {self.timer_thread.is_alive() if self.timer_thread else False}")
                    sys.stdout.flush()
                
            except EOFError:
                break
            except Exception as e:
                # Log errors but don't crash
                print(f"# Error: {e}", file=sys.stderr)
                sys.stderr.flush()

The second method, I want to highlight is go(). This command is called by the engine to search for the best move from the current position. The UCI specification requires that if the engine receives a stop command, then the engine must stop and return the current best move. This requires us to do two things. First, we need to be able to take input while we are searching. Second, we need to signal to our search algorithm if we need to stop the search.

Threading

To take input while searching, we need threading. We will create a new thread that will call a method called _search_with_info. This method will in turn call our find_best_move_alphabeta(). We will also set a threaded timer that will take a target movetime. Once the time passes, we will call the engine.set_stop(True). This sets a flag that our alphabeta method watches, indicating we should stop searching.

# Search for best move
        self.search_thread = threading.Thread(target=self._search_with_info, args=(depth, move_number))
        self.search_thread.start()

        self.timer_thread = threading.Timer(movetime, lambda: self.engine.set_stop(True)) if movetime else None
        self.timer_thread.start() if self.timer_thread else None

Two important points about threads. First, variables are shared across threads. Within our SearchEngine class, we add a class variable named stop. This variable is False until we run out of time or we receive the stop command. This variable is shared across the threads. Second, before writing to this variable, we need to lock (to avoid a thread trying to read before we change the value). Therefore, we add a simple function to our search code that allows us to safely set the flag.

 # search.py - SearchEngine class
    def set_stop(self, is_stopped=True):
        """Signal the search to stop"""
        with lock:
            self.stop = is_stopped

Creating a Launcher Script

Now, we will make it easy to run the engine by creating an executable Python script called pyminmaximus.py:

#!/usr/bin/env python3
"""
PyMinMaximus Chess Engine
Main launcher script
"""

import sys
from uci import main as uci_main

if __name__ == "__main__":
    # Check for command line arguments
    if len(sys.argv) > 1:
        print(sys.argv)
        if sys.argv[1] == '--help' or sys.argv[1] == '-h':
            print("PyMinMaximus Chess Engine")
            print("Usage:")
            print("  python pyminmaximus.py           # Run in UCI mode")
            print("  python pyminmaximus.py --help    # Show this help")
            print("\nUCI mode is used by chess GUIs and tournament managers.")
            sys.exit(0)
    
    # Run in UCI mode
    uci_main()

The !/usr/bin/env python3 at the beginning of the script allows us to run this file directly on Linux or Mac. Otherwise, we would need to type python3 pyminmaximus.py. On Linux and Mac, we also need to make the file executable by typing:

sudo chmod +x pyminmaximus.py

Then we can run by typing:

./pyminmaximus.py

Testing UCI Implementation

Before tournament testing, let’s verify UCI works:

# test_uci.py
import subprocess
import time
import unittest
import select

class TestUCIProtocol(unittest.TestCase):
    def setUp(self):
        """Test basic UCI communication."""
        print("="*60)
        print("Testing UCI Protocol")
        print("="*60)
        
        # Start engine process
        self.engine = subprocess.Popen(
            ['python3', 'pyminmaximus.py'],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            bufsize=1
        )
        
    def send_command(self, cmd):
        """Send command and read response."""
        print(f"\n{cmd}")
        self.engine.stdin.write(cmd + '\n')
        self.engine.stdin.flush()
        time.sleep(0.1)
        
        # Read available output
        output = []
        while True:
            # Check if data is available (0.5 second timeout)
            ready, _, _ = select.select([self.engine.stdout], [], [], 0.5)
            if not ready:
                break
            line = self.engine.stdout.readline()
            if not line:
                break
            print(f"← {line.strip()}")
            output.append(line)
        
        return output
        
    def test_uci_initialization(self):
        # Test UCI initialization
        print("\n1. Testing UCI initialization")
        output = self.send_command('uci')
        self.assertIsNotNone('id name' in line for line in output)
        self.assertIsNotNone('uciok' in line for line in output)
        
    def test_ready_check(self):
        # Test ready check
        print("\n2. Testing ready check")
        output = self.send_command('isready')
        self.assertIsNotNone('readyok' in line for line in output), "Missing readyok"
        
    def test_position_setup(self):
        # Test position setup
        print("\n3. Testing position setup")
        self.send_command('position startpos moves e2e4')
        self.send_command('isready')
        print("✓ Position setup OK")
        
    def test_search(self):
        # Test search
        print("\n4. Testing search")
        output = self.send_command('go movetime 1000')
        self.assertIsNotNone('bestmove' in line for line in output), "Missing bestmove"
        
    def tearDown(self):
        try:
            self.send_command('quit')
            self.engine.wait(timeout=2)
        except:
            self.engine.kill()  # Force kill if it doesn't quit
        finally:
            # Close all pipes
            self.engine.stdin.close()
            self.engine.stdout.close()
            self.engine.stderr.close()

        return super().tearDown()

if __name__ == "__main__":
    unittest.main()

This test script has a test for each of the key commands to ensure they are working correctly:

  • uciok
  • isready
  • position
  • search

Installing CuteChess CLI

Next, we need a way to test engines against each other. CuteChess CLI is a command-line tool for running chess engines that allows us to tweak different options when running the engines.

Installation

Installation is fairly easy:

  • Windows:
    • Download from https://github.com/cutechess/cutechess/releases
    • Extract and add to PATH
  • Linux:
    • Some distributions include cutechess in their package manager. I am using Ubuntu, which does not include it. Therefore, I found it easiest to build from source (pay attention to the required libraries before running cmake:
      • git clone https://github.com/cutechess/cutechess.git
      • cd cutechess
      • mkdir build
      • cd build
      • cmake ..
      • make
    • Mac (homebrew):
      • brew install cutechess

Verify Installation

cutechess-cli –version

Creating a Random Engine Baseline

Let’s create a simple random-move engine for comparison:

# random_engine.py
#!/usr/bin/env python3

import sys
import random
from board import Board
from constants import *

class RandomEngine:
    """Simple engine that plays random legal moves."""
    
    def __init__(self):
        self.board = Board()
        self.name = "RandomMover"
        self.author = "Harlepengren"
    
    def uci(self):
        print(f"id name {self.name}")
        print(f"id author {self.author}")
        print("uciok")
        sys.stdout.flush()
    
    def isready(self):
        print("readyok")
        sys.stdout.flush()
    
    def ucinewgame(self):
        self.board = Board()
    
    def position(self, args):
        if args[0] == 'startpos':
            self.board = Board()
            move_start = 1
        elif args[0] == 'fen':
            try:
                moves_idx = args.index('moves')
                fen = ' '.join(args[1:moves_idx])
                move_start = moves_idx
            except ValueError:
                fen = ' '.join(args[1:])
                move_start = len(args)
            
            self.board = Board()
            self.board.from_fen(fen)
        
        if move_start < len(args) and args[move_start] == 'moves':
            for move_str in args[move_start + 1:]:
                move = self._parse_move(move_str)
                if move:
                    self.board.make_move(move)
    
    def go(self, args):
        # Just pick a random legal move
        legal_moves = self.board.generate_legal_moves()
        
        if legal_moves:
            move = random.choice(legal_moves)
            print(f"bestmove {move}")
        else:
            print("bestmove 0000")
        
        sys.stdout.flush()
    
    def _parse_move(self, move_str):
        from move import Move
        
        from_col = ord(move_str[0]) - ord('a')
        from_row = int(move_str[1]) - 1
        to_col = ord(move_str[2]) - ord('a')
        to_row = int(move_str[3]) - 1
        
        promotion = None
        if len(move_str) == 5:
            promo_map = {'q': QUEEN, 'r': ROOK, 'b': BISHOP, 'n': KNIGHT}
            promotion = promo_map.get(move_str[4].lower())
        
        legal_moves = self.board.generate_legal_moves()
        for move in legal_moves:
            if (move.from_row == from_row and move.from_col == from_col and
                move.to_row == to_row and move.to_col == to_col):
                if promotion is None or move.promotion == promotion:
                    return move
        
        return None
    
    def run(self):
        while True:
            try:
                line = input().strip()
                
                if not line:
                    continue
                
                parts = line.split()
                command = parts[0]
                args = parts[1:]
                
                if command == 'uci':
                    self.uci()
                elif command == 'isready':
                    self.isready()
                elif command == 'ucinewgame':
                    self.ucinewgame()
                elif command == 'position':
                    self.position(args)
                elif command == 'go':
                    self.go(args)
                elif command == 'quit':
                    break
                
            except EOFError:
                break


if __name__ == "__main__":
    engine = RandomEngine()
    engine.run()

The engine is fairly self explanatory. When it receives the go command, it gets a list of legal moves from the Board class and randomly picks one. The majority of the code in this file is processing the position command to set up the board.

Running Your First Tournament

Now let’s run a tournament to test our engine!

Simple Match

We start with a simple match against just the random mover engine. The command line to run is a little long, but it sets both engines as well as overall options for timecontrol (tc), protocol (proto), how many rounds to run and where to store the pgn file recording the moves. We also add a flag -repeat to flip the board every turn. We want to test as both white and black.

cutechess-cli \
  -engine cmd=pyminmaximus.py name=PyMinMaximus \
  -engine cmd=random_engine.py name=RandomMover \
  -each proto=uci tc=10:00 \
  -rounds 10 \
  -repeat \
  -pgnout results.pgn

This runs:

  • 10 rounds
  • 10 minutes per side time control
  • Results saved to results.pgn

In our simple test, our engine is 10-0 against the random mover. This is a good start. However, we made the settings friendly for our engine. Our engine is very slow. If we reduce the amount of time that our engine has to think, then our results are much worse. If we change the time control to 1 minute with additional time after 40 moves (tc=40/60). Our engine only has a record of 6-2-2 (0.700).

Tournament Script

Next the tournament. We will create a script for easy tournament running:

# tournament.py
import subprocess
import sys

def run_tournament(engine1_cmd, engine1_name, engine2_cmd, engine2_name, 
                   rounds=10, time_control="40/60"):
    """
    Run a tournament between two engines.
    
    Args:
        engine1_cmd: Command to start engine 1 (e.g., "python pyminmaximus.py")
        engine1_name: Name for engine 1
        engine2_cmd: Command to start engine 2
        engine2_name: Name for engine 2
        rounds: Number of rounds (each round = 2 games with colors swapped)
        time_control: Time control string (e.g., "40/60" = 60 seconds for 40 moves)
    """
    
    cmd = [
        'cutechess-cli',
        '-engine', f'cmd={engine1_cmd}', f'name={engine1_name}',
        '-engine', f'cmd={engine2_cmd}', f'name={engine2_name}',
        '-each', 'proto=uci', f'tc={time_control}',
        '-rounds', str(rounds),
        '-repeat',
        '-pgnout', f'results/{engine1_name}_vs_{engine2_name}.pgn'
    ]
    
    print("="*60)
    print(f"Tournament: {engine1_name} vs {engine2_name}")
    print(f"Rounds: {rounds} ({rounds * 2} games)")
    print(f"Time control: {time_control}")
    print("="*60)
    
    subprocess.run(cmd)


if __name__ == "__main__":
    # Test against random mover
    run_tournament(
        "../pyminmaximus.py", "PyMinMaximus",
        "../random_engine.py", "RandomMover",
        rounds=20,
        time_control="40/60"
        # alternative
        # time_control="10:00"
    )

This function simplifies running a match, allowing us to specify engines, time control, and rounds.

The Rating Gauntlet

Next, we create a script that allows us to run PyMinMaximus against multiple opponents. We can keep adding new opponents to the opponents list. Each opponent needs a command, name, and description:

# rating_gauntlet.py
import subprocess
import sys

def run_rating_gauntlet(tcontrol="40/60", rounds=20):
    """
    Run PyMinMaximus against a series of opponents with known ratings.
    This helps establish an accurate ELO.
    """
    
    opponents = [
        ("../random_engine.py", "RandomMover", "", "~400 ELO"),
        ("stockfish", "Stockfish-0", "option.Skill Level=0", "~800 ELO"),
        ("stockfish", "Fairy-Weak", "option.Skill Level=5", "~1200 ELO"),
    ]
    
    results = []
    
    for opp_cmd, opp_name, opp_options, opp_rating in opponents:
        print(f"\n{'='*60}")
        print(f"Testing vs {opp_name} ({opp_rating})")
        print(f"{'='*60}\n")
        
        cmd = [
            'cutechess-cli',
            '-engine', 'cmd=../pyminmaximus.py', 'name=PyMinMaximus',
            '-engine', f'cmd={opp_cmd}', f'name={opp_name}', opp_options,
            '-each', 'proto=uci', f'tc={tcontrol}',
            '-rounds', str(rounds),
            '-repeat',
            '-pgnout', f'results/vs_{opp_name}.pgn'
        ]
        
        subprocess.run(cmd)
        
        # Analyze results
        from analyze_results import analyze_tournament
        analyze_tournament(f'results/vs_{opp_name}.pgn')
        
        results.append((opp_name, opp_rating))
    
    print("\n" + "="*60)
    print("Rating Gauntlet Complete")
    print("="*60)
    print("\nOpponents tested:")
    for name, rating in results:
        print(f"  - {name} ({rating})")
    
    print("\nAnalyze individual PGN files for detailed results.")


if __name__ == "__main__":
    if len(sys.argv) > 1:
        argument_dict = {}
        for arg in sys.argv[1:]:
            if "=" in arg:
                key, value = arg.split("=")
                argument_dict[key] = value

    if "tc" in argument_dict:
        tcontrol = argument_dict["tc"]
    else:
        tcontrol = "40/60"

    if "rounds" in argument_dict:
        rounds = int(argument_dict["rounds"])
    else:
        rounds = 20

    run_rating_gauntlet(tcontrol=tcontrol, rounds=rounds)

The Need for Speed

Our engine does not do well against real engines. This is primarily due to the speed of the engine. We take a long time to think, and we can only go to a depth of 6. Real engines are much faster and can go to a much deeper level.

Python, while excellent for rapid prototyping and clear code, is inherently slow for computationally intensive tasks like running a high-performance chess engine due to its nature as an interpreted language. Unlike compiled languages such as C++ or Rust, which are translated directly into machine code before execution, Python code is executed line-by-line by a virtual machine. This interpretation process introduces significant overhead. 

Furthermore, Python’s Global Interpreter Lock (GIL) prevents true thread parallelism, meaning the engine cannot easily leverage multi-core processors to search the game tree simultaneously (note, Python 3.13 allows disabling the GIL for free threading), a critical technique used by top-tier engines like Stockfish to achieve massive search depths and high nodes-per-second (NPS) rates. The performance bottleneck often lies in repeated core operations, such as move generation, evaluation, and transposition table lookups, where Python’s dynamic typing and lack of direct memory control incur a substantial performance penalty.

Common UCI Issues and Solutions

We have significantly increased the complexity of running our engine. Therefore, you may run into problems (aside from just speed issues). Here are a few common issues and solutions.

Issue 1: Engine Doesn’t Respond

Problem: GUI shows “Engine failed to start”

Solutions:

  • Check Python is in PATH
  • Test engine manually: python pyminmaximus.py
  • Type uci and press Enter – should see uciok
  • Check for syntax errors in uci.py

Issue 2: Illegal Moves

Problem: Engine plays illegal moves

Solutions:

# Add validation in _parse_move
def _parse_move(self, move_str):
    # ... existing parsing code ...
    
    # Validate before returning
    legal_moves = self.board.generate_legal_moves()
    for move in legal_moves:
        if (move.from_row == from_row and move.from_col == from_col and
            move.to_row == to_row and move.to_col == to_col):
            if promotion is None or move.promotion == promotion:
                return move
    
    # If we get here, move is illegal
    print(f"# Warning: Illegal move attempted: {move_str}", file=sys.stderr)
    return None

Issue 3: Time Forfeits

Problem: Engine loses on time

Solutions:

  • Reduce search depth
  • Increase time control (tc)
  • Improve time management
  • Profile code for slow operations
# Better time management
def go(self, args):
    # ... parse args ...
    
    # Calculate safe time allocation
    if wtime is not None:
        # Use only 1/40 of remaining time (conservative)
        movetime = (wtime / 1000) / 40
        movetime = max(movetime, 0.05)  # Minimum 50ms
        movetime = min(movetime, 5.0)   # Maximum 5s

An alternative would be to find one the moves are really important. Early moves can be fast middle moves should have more time. Late moves can have less time.

Issue 4: Position Desync

Problem: Engine and GUI have different positions

Solutions:

# Log positions for debugging
def position(self, args):
    # ... existing code ...
    
    # Debug logging
    print(f"# Position set: {self.board.to_fen()}", file=sys.stderr)
    sys.stderr.flush()

Performance Optimization for Tournaments

Speed Improvements

# Faster move generation - cache legal moves
class SearchEngine:
    def __init__(self, board, evaluator=None, opening_book=None):
        # ... existing init ...
        self.move_cache = {}
    
    def get_legal_moves_cached(self):
        """Cache legal moves to avoid regeneration."""
        fen = self.board.to_fen()
        if fen not in self.move_cache:
            self.move_cache[fen] = self.board.generate_legal_moves()
        return self.move_cache[fen]

Memory Management

# Limit transposition table size
class TranspositionTable:
    def store(self, board, depth, score, flag):
        # Clear if too large
        if len(self.table) >= self.size:
            # Keep only high-depth entries
            self.table = {
                k: v for k, v in self.table.items() 
                if v['depth'] >= depth - 2
            }
        
        # ... rest of store logic ...

What We’ve Accomplished

In this final post, we’ve completed PyMinMaximus:

UCI protocol implementation – Full standard compliance
Command parsing – All essential UCI commands
Time management – Basic but functional
Info output – Search statistics for GUIs
CuteChess integration – Automated tournament testing
Result analysis – Performance measurement
ELO estimation – Accurate strength rating
Tournament infrastructure – Repeatable testing

Beyond PyMinMaximus: Future Enhancements

Want to make PyMinMaximus stronger? There are many potential options, but the biggest is increasing the speed. We added a profiling module called profiler.py. Run this and start working through the bottlenecks in the process. Using a bitboard instead of an array would make a significant difference. In addition, you could implement the ability for the engine to think on the opponent’s time.

Final Thoughts

You’ve built a complete chess engine from scratch! PyMinMaximus is:

  • Functional: Plays legal, reasonable chess
  • Tested: Tournament-proven strength
  • Educational: Clean, understandable code
  • Extensible: Foundation for future improvements

More importantly, you’ve learned:

  • Game tree search algorithms
  • Position evaluation techniques
  • Binary file formats
  • Standard protocols
  • Performance testing
  • Software engineering

These concepts apply beyond chess: game AI, planning problems, optimization, and more.

Thank You

Thank you for following this series! Building PyMinMaximus from scratch has been a journey through computer chess, algorithms, and practical programming.

Whether you:

  • Built it alongside the series
  • Read to understand chess engines
  • Adapted ideas for your projects
  • Just enjoyed the journey

I hope you found it valuable.

Resources for Continued Learning

Websites:

  • Chess Programming Wiki: https://www.chessprogramming.org/
  • TalkChess Forum: http://talkchess.com/
  • Computer Chess Rating Lists: http://ccrl.chessdom.com/

Youtube:

Books:

  • “Chess Programming” by Jeroen Noomen
  • “Deep Thinking” by Garry Kasparov (history and philosophy)

Open Source Engines (for study):

  • Stockfish: https://github.com/official-stockfish/Stockfish
  • Sunfish: https://github.com/thomasahle/sunfish (Python!)
  • Vice: https://www.youtube.com/watch?v=bGAfaepBco8 (C tutorial)

Tools:

  • Arena Chess GUI: http://www.playwitharena.de/
  • CuteChess: https://github.com/cutechess/cutechess
  • Lichess: https://lichess.org (play your engine online)

Final Challenge

Test your PyMinMaximus and share:

  1. Run a 50-game tournament vs RandomMover
  2. Calculate your actual ELO estimate
  3. Find your engine’s best game (prettiest checkmate)
  4. Share results in the comments!

The PyMinMaximus Series:

  • Part 1: Board Representation and Move Generation
  • Part 2: Minimax Search and Alpha-Beta Pruning
  • Part 3: Position Evaluation and Strategy
  • Part 4: Opening Books and Zobrist Hashing
  • Part 5: Endgame Tablebases
  • Part 6: UCI Protocol and Tournament Testing ← You are here

Congratulations on building your chess engine! Now go test it, improve it, and most importantly—enjoy it!

Questions? Found a bug? Built an enhancement? Share in the comments below!

Visited 7 times, 1 visit(s) today

Leave a Reply

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