PyMinMaximus Part 3: Position Evaluation – Teaching the Engine Chess Strategy

Welcome back to the PyMinMaximus series! In Part 1, we built our chess board representation, and in Part 2, we implemented minimax search with alpha-beta pruning. Our engine can now think ahead and find tactical combinations, but it only understands one thing: material count.

This limitation means PyMinMaximus plays like a beginner who only cares about capturing pieces. It doesn’t understand:

  • Why knights belong in the center
  • Why controlling the center matters
  • Why doubled pawns are weak
  • Why king safety is important

In this post, we’ll build a more sophisticated position evaluation function that teaches PyMinMaximus the strategic principles of chess. By the end, our engine will play much more positionally sound chess.

What Makes a Position Good?

Before writing code, let’s think about chess evaluation. Material is important, but so are:

1. Piece Activity and Placement

  • Knights are stronger in the center than on the edge
  • Rooks belong on open files
  • Bishops are better on long diagonals
  • The king should be safe in the middlegame but active in the endgame

2. Pawn Structure

  • Passed pawns (no enemy pawns can stop them) are valuable
  • Doubled pawns are weak
  • Isolated pawns are targets
  • Connected pawns support each other

3. King Safety

  • Castled kings are safer
  • Pawn shields protect the king
  • Open files near the king are dangerous

4. Piece Coordination

  • Pieces working together are stronger
  • Controlling key squares matters
  • Mobility (number of legal moves) indicates piece strength

5. Game Phase

  • Opening: develop pieces, control center, king safety
  • Middlegame: piece activity, attacks
  • Endgame: king activity, pawn promotion

Piece-Square Tables: Positional Bonuses

The most powerful evaluation technique is piece-square tables (PST) – bonuses for pieces on good squares.

Understanding the Tables

Each piece gets a table with 64 values representing how good that square is for that piece. Higher values = better squares. The tables are from white’s perspective, but we will flip for black.

from constants import *

class Evaluator:
    def __init__(self):
        self.piece_values = {
            PAWN: 100,
            KNIGHT: 320,
            BISHOP: 330,
            ROOK: 500,
            QUEEN: 900,
            KING: 20000
        }

        # Piece-square tables for middlegame
        self.setup_piece_square_tables()

    def setup_piece_square_tables(self):
        """
        Set up piece-square tables.
        Values are from White's perspective (higher = better for white).
        Tables are stored with rank 0 = rank 1, rank 7 = rank 8.
        """
        
        # Pawn table - encourage center control and advancement
        self.pawn_table = [
            [  0,   0,   0,   0,   0,   0,   0,   0],  # Rank 1
            [ 50,  50,  50,  50,  50,  50,  50,  50],  # Rank 2
            [ 10,  10,  20,  30,  30,  20,  10,  10],  # Rank 3
            [  5,   5,  10,  25,  25,  10,   5,   5],  # Rank 4
            [  0,   0,   0,  20,  20,   0,   0,   0],  # Rank 5
            [  5,  -5, -10,   0,   0, -10,  -5,   5],  # Rank 6
            [  5,  10,  10, -20, -20,  10,  10,   5],  # Rank 7
            [  0,   0,   0,   0,   0,   0,   0,   0]   # Rank 8
        ]
        
        # Knight table - encourage center control
        self.knight_table = [
            [-50, -40, -30, -30, -30, -30, -40, -50],
            [-40, -20,   0,   0,   0,   0, -20, -40],
            [-30,   0,  10,  15,  15,  10,   0, -30],
            [-30,   5,  15,  20,  20,  15,   5, -30],
            [-30,   0,  15,  20,  20,  15,   0, -30],
            [-30,   5,  10,  15,  15,  10,   5, -30],
            [-40, -20,   0,   5,   5,   0, -20, -40],
            [-50, -40, -30, -30, -30, -30, -40, -50]
        ]
        
        # Bishop table - encourage long diagonals
        self.bishop_table = [
            [-20, -10, -10, -10, -10, -10, -10, -20],
            [-10,   0,   0,   0,   0,   0,   0, -10],
            [-10,   0,   5,  10,  10,   5,   0, -10],
            [-10,   5,   5,  10,  10,   5,   5, -10],
            [-10,   0,  10,  10,  10,  10,   0, -10],
            [-10,  10,  10,  10,  10,  10,  10, -10],
            [-10,   5,   0,   0,   0,   0,   5, -10],
            [-20, -10, -10, -10, -10, -10, -10, -20]
        ]
        
        # Rook table - encourage open files and 7th rank
        self.rook_table = [
            [  0,   0,   0,   0,   0,   0,   0,   0],
            [  5,  10,  10,  10,  10,  10,  10,   5],
            [ -5,   0,   0,   0,   0,   0,   0,  -5],
            [ -5,   0,   0,   0,   0,   0,   0,  -5],
            [ -5,   0,   0,   0,   0,   0,   0,  -5],
            [ -5,   0,   0,   0,   0,   0,   0,  -5],
            [ -5,   0,   0,   0,   0,   0,   0,  -5],
            [  0,   0,   0,   5,   5,   0,   0,   0]
        ]
        
        # Queen table - slight center preference, stay back early
        self.queen_table = [
            [-20, -10, -10,  -5,  -5, -10, -10, -20],
            [-10,   0,   0,   0,   0,   0,   0, -10],
            [-10,   0,   5,   5,   5,   5,   0, -10],
            [ -5,   0,   5,   5,   5,   5,   0,  -5],
            [  0,   0,   5,   5,   5,   5,   0,  -5],
            [-10,   5,   5,   5,   5,   5,   0, -10],
            [-10,   0,   5,   0,   0,   0,   0, -10],
            [-20, -10, -10,  -5,  -5, -10, -10, -20]
        ]
        
        # King middlegame table - encourage castling and safety
        self.king_middlegame_table = [
            [-30, -40, -40, -50, -50, -40, -40, -30],
            [-30, -40, -40, -50, -50, -40, -40, -30],
            [-30, -40, -40, -50, -50, -40, -40, -30],
            [-30, -40, -40, -50, -50, -40, -40, -30],
            [-20, -30, -30, -40, -40, -30, -30, -20],
            [-10, -20, -20, -20, -20, -20, -20, -10],
            [ 20,  20,   0,   0,   0,   0,  20,  20],
            [ 20,  30,  10,   0,   0,  10,  30,  20]
        ]
        
        # King endgame table - encourage centralization
        self.king_endgame_table = [
            [-50, -40, -30, -20, -20, -30, -40, -50],
            [-30, -20, -10,   0,   0, -10, -20, -30],
            [-30, -10,  20,  30,  30,  20, -10, -30],
            [-30, -10,  30,  40,  40,  30, -10, -30],
            [-30, -10,  30,  40,  40,  30, -10, -30],
            [-30, -10,  20,  30,  30,  20, -10, -30],
            [-30, -30,   0,   0,   0,   0, -30, -30],
            [-50, -30, -30, -30, -30, -30, -30, -50]
        ]
    
    def get_piece_square_value(self, piece_type, row, col, is_white, is_endgame):
        """
        Get the piece-square table value for a piece.
        Black's tables are flipped vertically from White's.
        """
        # Flip row for black pieces
        table_row = row if is_white else 7 - row
        
        if piece_type == PAWN:
            return self.pawn_table[table_row][col]
        elif piece_type == KNIGHT:
            return self.knight_table[table_row][col]
        elif piece_type == BISHOP:
            return self.bishop_table[table_row][col]
        elif piece_type == ROOK:
            return self.rook_table[table_row][col]
        elif piece_type == QUEEN:
            return self.queen_table[table_row][col]
        elif piece_type == KING:
            if is_endgame:
                return self.king_endgame_table[table_row][col]
            else:
                return self.king_middlegame_table[table_row][col]
        
        return 0

Detecting Game Phase

Next, we need to know if we’re in the endgame to evaluate correctly. To determine whether we are in the endgame, we use a simple assessment: either no queens or if a side has a queen, they only have 1 minor piece.

class Evaluator:
    # ... previous code ...

    def is_endgame(self, board):
        """
        Determine if we're in the endgame.
        Simple heuristic: both sides have no queens, or
        every side which has a queen has additionally no other pieces or one minor piece maximum.
        """
        white_queens = 0
        black_queens = 0
        white_minor = 0  # knights and bishops
        black_minor = 0
        white_major = 0  # rooks
        black_major = 0
        
        for row in range(8):
            for col in range(8):
                piece = board.board[row][col]
                if piece == EMPTY:
                    continue
                
                piece_type = piece & 7
                color = piece & 24
                
                if piece_type == QUEEN:
                    if color == WHITE:
                        white_queens += 1
                    else:
                        black_queens += 1
                elif piece_type in [KNIGHT, BISHOP]:
                    if color == WHITE:
                        white_minor += 1
                    else:
                        black_minor += 1
                elif piece_type == ROOK:
                    if color == WHITE:
                        white_major += 1
                    else:
                        black_major += 1
        
        # No queens = endgame
        if white_queens == 0 and black_queens == 0:
            return True
        
        white_limited = (white_queens == 1 and white_minor + white_major <= 1)
        black_limited = (black_queens == 1 and black_minor + black_major <= 1)

        # Queen but limited material = endgame
        if  white_limited or black_limited:
            return True
        
        return False

Pawn Structure Evaluation

Pawns are the soul of chess. Let’s evaluate their structure. This code block is somewhat complex. We start by creating two lists for black and white pawns. Each list is built around the board files and captures the row numbers within the file that have pawns.

Once we have the pawn lists, we conduct a number of evaluations such as determining whether there are more than one pawn in a file (bad) or a passed pawn with no enemy pawns in the same file (good).

class Evaluator:
    # ... previous code ...

    def evaluate_pawn_structure(self, board):
        """
        Evaluate pawn structure features.
        Returns a score from White's perspective.
        """
        score = 0
        
        # Analyze each file for pawn structure
        white_pawns = [[] for _ in range(8)]  # List of rows for each file
        black_pawns = [[] for _ in range(8)]
        
        # Collect pawn positions
        for row in range(8):
            for col in range(8):
                piece = board.board[row][col]
                if piece == (WHITE | PAWN):
                    white_pawns[col].append(row)
                elif piece == (BLACK | PAWN):
                    black_pawns[col].append(row)
        
        # Evaluate white pawns
        for col in range(8):
            if len(white_pawns[col]) > 1:
                # Doubled pawns penalty
                score -= 10 * (len(white_pawns[col]) - 1)
            
            if len(white_pawns[col]) > 0:
                # Check for isolated pawns (no friendly pawns on adjacent files)
                is_isolated = True
                if col > 0 and len(white_pawns[col - 1]) > 0:
                    is_isolated = False
                if col < 7 and len(white_pawns[col + 1]) > 0:
                    is_isolated = False
                
                if is_isolated:
                    score -= 15
                
                # Check for passed pawns (no enemy pawns ahead on same or adjacent files)
                for pawn_row in white_pawns[col]:
                    is_passed = True
                    
                    # Check same file
                    for black_row in black_pawns[col]:
                        if black_row > pawn_row:
                            is_passed = False
                            break
                    
                    # Check adjacent files
                    if is_passed:
                        for adjacent_col in [col - 1, col + 1]:
                            if 0 <= adjacent_col < 8:
                                for black_row in black_pawns[adjacent_col]:
                                    if black_row > pawn_row:
                                        is_passed = False
                                        break
                    
                    if is_passed:
                        # Passed pawn bonus increases with advancement
                        bonus = 10 + (pawn_row * 10)
                        score += bonus
        
        # Evaluate black pawns (same logic, opposite scoring)
        for col in range(8):
            if len(black_pawns[col]) > 1:
                score += 10 * (len(black_pawns[col]) - 1)
            
            if len(black_pawns[col]) > 0:
                is_isolated = True
                if col > 0 and len(black_pawns[col - 1]) > 0:
                    is_isolated = False
                if col < 7 and len(black_pawns[col + 1]) > 0:
                    is_isolated = False
                
                if is_isolated:
                    score += 15
                
                for pawn_row in black_pawns[col]:
                    is_passed = True
                    
                    for white_row in white_pawns[col]:
                        if white_row < pawn_row:
                            is_passed = False
                            break
                    
                    if is_passed:
                        for adjacent_col in [col - 1, col + 1]:
                            if 0 <= adjacent_col < 8:
                                for white_row in white_pawns[adjacent_col]:
                                    if white_row < pawn_row:
                                        is_passed = False
                                        break
                    
                    if is_passed:
                        bonus = 10 + ((7 - pawn_row) * 10)
                        score -= bonus
        
        return score

King Safety Evaluation

King safety is crucial in the middlegame:

class Evaluator:
    # ... previous code ...

    def evaluate_king_safety(self, board, is_endgame):
        """
        Evaluate king safety.
        Only important in middlegame.
        """
        if is_endgame:
            return 0
        
        score = 0
        
        # Find kings
        white_king_pos = None
        black_king_pos = None
        
        for row in range(8):
            for col in range(8):
                piece = board.board[row][col]
                if piece == (WHITE | KING):
                    white_king_pos = (row, col)
                elif piece == (BLACK | KING):
                    black_king_pos = (row, col)
        
        if white_king_pos:
            score += self.evaluate_single_king_safety(board, white_king_pos, WHITE)
        
        if black_king_pos:
            score -= self.evaluate_single_king_safety(board, black_king_pos, BLACK)
        
        return score
    
    def evaluate_single_king_safety(self, board, king_pos, color):
        """
        Evaluate safety for a single king.
        Returns positive value for safe king.
        """
        safety = 0
        row, col = king_pos
        
        # Bonus for castled position
        if color == WHITE:
            if row == 0 and (col == 6 or col == 2):
                safety += 30
        else:
            if row == 7 and (col == 6 or col == 2):
                safety += 30
        
        # Evaluate pawn shield
        if color == WHITE:
            shield_row = row + 1
            if shield_row < 8:
                for dcol in [-1, 0, 1]:
                    shield_col = col + dcol
                    if 0 <= shield_col < 8:
                        piece = board.board[shield_row][shield_col]
                        if piece == (WHITE | PAWN):
                            safety += 10
        else:
            shield_row = row - 1
            if shield_row >= 0:
                for dcol in [-1, 0, 1]:
                    shield_col = col + dcol
                    if 0 <= shield_col < 8:
                        piece = board.board[shield_row][shield_col]
                        if piece == (BLACK | PAWN):
                            safety += 10
        
        # Penalty for open files near king
        for dcol in [-1, 0, 1]:
            check_col = col + dcol
            if 0 <= check_col < 8:
                has_pawn = False
                for check_row in range(8):
                    piece = board.board[check_row][check_col]
                    if piece & 7 == PAWN:
                        has_pawn = True
                        break
                
                if not has_pawn:
                    safety -= 15  # Open file near king is dangerous
        
        return safety

Mobility Evaluation

Piece mobility (number of legal moves) is a good indicator of piece strength. Note that this calculation is slow. In simple testing, this was approximately 40% slower with very little improvement in the result, so enable wisely:

class Evaluator:
    # ... previous code ...

    def evaluate_mobility(self, board):
        """
        Evaluate piece mobility.
        More legal moves = better position.
        """
        # Count legal moves for current side
        our_mobility = len(board.generate_legal_moves())
        
        # Switch sides and count opponent mobility
        board.to_move = BLACK if board.to_move == WHITE else WHITE
        their_mobility = len(board.generate_legal_moves())
        board.to_move = BLACK if board.to_move == WHITE else WHITE
        
        # Mobility difference
        mobility_score = (our_mobility - their_mobility) * 2
        
        # Return from White's perspective
        if board.to_move == WHITE:
            return mobility_score
        else:
            return -mobility_score

Bishop Pair Bonus

Having both bishops is often advantageous:

class Evaluator:
    # ... previous code ...

    def evaluate_bishop_pair(self, board):
        """
        Bonus for having the bishop pair.
        """
        white_bishops = 0
        black_bishops = 0
        
        for row in range(8):
            for col in range(8):
                piece = board.board[row][col]
                if piece == (WHITE | BISHOP):
                    white_bishops += 1
                elif piece == (BLACK | BISHOP):
                    black_bishops += 1
        
        score = 0
        if white_bishops >= 2:
            score += 30
        if black_bishops >= 2:
            score -= 30
        
        return score

Putting It All Together

Now let’s combine all these evaluation components:

class Evaluator:
    # ... all previous code ...

    def evaluate(self, board):
        """
        Complete position evaluation.
        Returns score from White's perspective.
        """
        # 0. Is checkmate
        moves = board.generate_legal_moves()
        if len(moves) == 0 and board.is_in_check(board.to_move):
            return 20000

        score = 0
        is_endgame = self.is_endgame(board)
        
        # 1. Material and piece-square tables
        for row in range(8):
            for col in range(8):
                piece = board.board[row][col]
                
                if piece == EMPTY:
                    continue
                
                piece_type = piece & 7
                color = piece & 24
                is_white = (color == WHITE)
                
                # Base material value
                value = self.piece_values[piece_type]
                
                # Piece-square table bonus
                pst_value = self.get_piece_square_value(
                    piece_type, row, col, is_white, is_endgame
                )
                
                if is_white:
                    score += value + pst_value
                else:
                    score -= value + pst_value
        
        # 2. Pawn structure
        score += self.evaluate_pawn_structure(board)
        
        # 3. King safety (middlegame only)
        score += self.evaluate_king_safety(board, is_endgame)
        
        # 4. Bishop pair
        score += self.evaluate_bishop_pair(board)
        
        # 5. Mobility (expensive, so we scale it down)
        # Uncomment if you want mobility, but it slows search significantly
        # score += self.evaluate_mobility(board) // 2
        
        return score
    
    def evaluate_relative(self, board):
        """
        Evaluate from the perspective of the side to move.
        """
        score = self.evaluate(board)
        return score if board.to_move == WHITE else -score

Tapered Evaluation: Smooth Phase Transitions

A more sophisticated approach uses tapered evaluation – smoothly transitioning from middlegame to endgame values based on material:

class Evaluator:
    # ... previous code ...

    def get_game_phase(self, board):
        """
        Calculate game phase (0 = endgame, 24 = opening).
        Based on material: each piece contributes to the phase.
        """
        phase = 0
        
        for row in range(8):
            for col in range(8):
                piece = board.board[row][col]
                if piece == EMPTY:
                    continue
                
                piece_type = piece & 7
                
                if piece_type == KNIGHT or piece_type == BISHOP:
                    phase += 1
                elif piece_type == ROOK:
                    phase += 2
                elif piece_type == QUEEN:
                    phase += 4
        
        return min(phase, 24)  # Cap at 24 (opening value)
    
    def tapered_eval(self, board):
        """
        Use tapered evaluation for smooth phase transitions.
        """
        phase = self.get_game_phase(board)
        
        # Calculate middlegame and endgame scores separately
        mg_score = 0  # Middlegame score
        eg_score = 0  # Endgame score
        
        for row in range(8):
            for col in range(8):
                piece = board.board[row][col]
                
                if piece == EMPTY:
                    continue
                
                piece_type = piece & 7
                color = piece & 24
                is_white = (color == WHITE)
                
                # Material value
                value = self.piece_values[piece_type]
                
                # Piece-square values for both phases
                mg_pst = self.get_piece_square_value(piece_type, row, col, is_white, False)
                eg_pst = self.get_piece_square_value(piece_type, row, col, is_white, True)
                
                if is_white:
                    mg_score += value + mg_pst
                    eg_score += value + eg_pst
                else:
                    mg_score -= value + mg_pst
                    eg_score -= value + eg_pst
        
        # Add other evaluation terms
        pawn_score = self.evaluate_pawn_structure(board)
        mg_score += pawn_score
        eg_score += pawn_score
        
        # King safety only in middlegame
        mg_score += self.evaluate_king_safety(board, False)
        
        bishop_pair = self.evaluate_bishop_pair(board)
        mg_score += bishop_pair
        eg_score += bishop_pair
        
        # Taper between phases
        # phase 24 = pure middlegame, phase 0 = pure endgame
        score = (mg_score * phase + eg_score * (24 - phase)) // 24
        
        return score

Testing the Evaluation Function

Let’s test our evaluation on some classic positions:

import unittest
from board import Board
from evaluation import Evaluator
from move import Move
from constants import *

class TestEvaluation(unittest.TestCase):
    def test_evaluation(self):
        """Test evaluation on various positions."""
        
        evaluator = Evaluator()
        
        positions = [
            {
                'name': 'Starting Position',
                'fen': 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
                'expected': 'About equal (0)'
            },
            {
                'name': 'White Up a Queen',
                'fen': 'rnb1kbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
                'expected': 'White winning (+900)'
            },
            {
                'name': 'Knight on Rim vs Center',
                'fen': '8/8/8/4N3/8/8/n7/8 w - - 0 1',
                'expected': 'White better (knight in center better than rim)'
            },
            {
                'name': 'Passed Pawn',
                'fen': '8/8/8/8/8/3P4/8/8 w - - 0 1',
                'expected': 'Passed pawn bonus'
            },
            {
                'name': 'Doubled Pawns',
                'fen': '8/8/8/8/3P4/3P4/8/8 w - - 0 1',
                'expected': 'Doubled pawns penalty'
            },
            {
                'name': 'Castled vs Uncastled King',
                'fen': 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1',
                'expected': 'Equal material, but evaluation considers king safety'
            }
        ]
        
        for pos in positions:
            print(f"\n{'='*60}")
            print(f"Position: {pos['name']}")
            print(f"Expected: {pos['expected']}")
            print('='*60)
            
            board = Board()
            board.from_fen(pos['fen'])
            
            score = evaluator.evaluate(board)
            print(f"Evaluation: {score:+d} centipawns ({score/100:+.2f} pawns)")
            
            # Show breakdown
            print("\nBreakdown:")
            
            # Material only
            material_eval = Evaluator()
            material_eval.piece_values = evaluator.piece_values
            material_score = 0
            for row in range(8):
                for col in range(8):
                    piece = board.board[row][col]
                    if piece != EMPTY:
                        piece_type = piece & 7
                        color = piece & 24
                        value = material_eval.piece_values[piece_type]
                        if color == WHITE:
                            material_score += value
                        else:
                            material_score -= value
            
            print(f"  Material: {material_score:+d}")
            print(f"  Positional: {score - material_score:+d}")


    def test_positions(self):
        """Compare how evaluation changes after moves."""
        
        print("\n" + "="*60)
        print("Comparing Evaluation Before and After Moves")
        print("="*60)
        
        board = Board()
        evaluator = Evaluator()
        
        # Test opening principles
        print("\n1. Center Control: e4 vs. h4")
        
        board1 = Board()
        score_start = evaluator.evaluate(board1)
        
        # e4 - good center move
        board1.make_move(Move(1, 4, 3, 4))
        score_e4 = evaluator.evaluate(board1)
        
        board2 = Board()
        # h4 - poor edge move
        board2.make_move(Move(1, 7, 3, 7))
        score_h4 = evaluator.evaluate(board2)
        
        print(f"Starting position: {score_start:+d}")
        print(f"After 1.e4: {score_e4:+d} (improvement: {score_e4 - score_start:+d})")
        print(f"After 1.h4: {score_h4:+d} (improvement: {score_h4 - score_start:+d})")
        print(f"e4 is better by: {score_e4 - score_h4:+d} centipawns")
        
        # Test knight development
        print("\n2. Knight Development: Nf3 vs. Na3")
        
        board3 = Board()
        board3.make_move(Move(1, 4, 3, 4))  # e4
        score_before = evaluator.evaluate(board3)
        
        # Nf3 - good square
        board3_nf3 = Board()
        board3_nf3.from_fen(board3.to_fen())
        board3_nf3.make_move(Move(0, 6, 2, 5))  # Nf3
        score_nf3 = evaluator.evaluate(board3_nf3)
        
        # Na3 - rim square
        board3_na3 = Board()
        board3_na3.from_fen(board3.to_fen())
        board3_na3.make_move(Move(0, 1, 2, 0))  # Na3
        score_na3 = evaluator.evaluate(board3_na3)
        
        print(f"After 1.e4 (White to move): {score_before:+d}")
        print(f"After 1.e4 Nf3: {score_nf3:+d}")
        print(f"After 1.e4 Na3: {score_na3:+d}")
        print(f"Nf3 is better by: {score_nf3 - score_na3:+d} centipawns")


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

Playing with the Improved Engine

Now let’s integrate our new evaluation function with the search engine from Part 2:

# main.py - Simple game interface

from board import Board
from search import SearchEngine
from evaluation import Evaluator
from move import Move
from constants import *

def play_game():
    """Play a game against PyMinMaximus."""
    board = Board()
    evaluator = Evaluator()
    engine = SearchEngine(board, evaluator)
    
    print("Welcome to PyMinMaximus!")
    print("You are White. Enter moves in format: e2e4")
    print("Enter 'quit' to exit\n")
    
    while True:
        # Display board
        print(board)
        
        # Check game over
        legal_moves = board.generate_legal_moves()
        if len(legal_moves) == 0:
            if board.is_in_check(board.to_move):
                winner = "Black" if board.to_move == WHITE else "White"
                print(f"\nCheckmate! {winner} wins!")
            else:
                print("\nStalemate!")
            break
        
        if board.to_move == WHITE:
            # Human move
            while True:
                move_str = input("Your move: ").strip().lower()
                
                if move_str == 'quit':
                    return
                
                # Parse move
                if len(move_str) < 4:
                    print("Invalid format. Use: e2e4")
                    continue
                
                try:
                    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
                    
                    # Check for promotion
                    promotion = None
                    if len(move_str) == 5:
                        promo_map = {'q': QUEEN, 'r': ROOK, 'b': BISHOP, 'n': KNIGHT}
                        promotion = promo_map.get(move_str[4])
                    
                    # Find matching legal move
                    user_move = None
                    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:
                                user_move = move
                                break
                    
                    if user_move:
                        board.make_move(user_move)
                        break
                    else:
                        print("Illegal move. Try again.")
                
                except (ValueError, IndexError):
                    print("Invalid format. Use: e2e4")
        
        else:
            # Engine move
            print("\nPyMinMaximus is thinking...")
            best_move, score = engine.iterative_deepening(5, time_limit=3.0)
            
            if best_move:
                print(f"PyMinMaximus plays: {best_move} (eval: {score:+d})")
                board.make_move(best_move)
            else:
                print("PyMinMaximus has no legal moves!")
                break
                
if __name__ == "__main__":
    play_game()

Before and After: Strength Comparison

Let’s see how much stronger our engine has become with positional evaluation:

# main.py - Simple game interface
# ... previous code ...

def self_play_comparison():
    """
    Compare material-only vs. positional evaluation.
    Play the two versions against each other.
    """
    from board import Board
    from search import SearchEngine
    from evaluation import Evaluator
    
    print("Material-Only vs. Positional Evaluation")
    print("Playing 1 game at depth 5\n")
    
    board = Board()
    
    # Create two engines
    material_evaluator = Evaluator()
    # Disable all positional evaluation
    material_evaluator.pawn_table = [[0]*8 for _ in range(8)]
    material_evaluator.knight_table = [[0]*8 for _ in range(8)]
    material_evaluator.bishop_table = [[0]*8 for _ in range(8)]
    material_evaluator.rook_table = [[0]*8 for _ in range(8)]
    material_evaluator.queen_table = [[0]*8 for _ in range(8)]
    material_evaluator.king_middlegame_table = [[0]*8 for _ in range(8)]
    material_evaluator.king_endgame_table = [[0]*8 for _ in range(8)]
    
    positional_evaluator = Evaluator()  # Full evaluation
    
    engine1 = SearchEngine(board, material_evaluator)
    engine2 = SearchEngine(board, positional_evaluator)
    
    move_count = 0
    max_moves = 100
    
    while move_count < max_moves:
        legal_moves = board.generate_legal_moves()
        
        if len(legal_moves) == 0:
            if board.is_in_check(board.to_move):
                winner = "Positional" if board.to_move == WHITE else "Material"
                print(f"\nCheckmate! {winner} evaluation wins!")
            else:
                print("\nStalemate!")
            break
        
        if board.to_move == WHITE:
            # Material-only engine
            best_move, score = engine1.find_best_move_alphabeta(5)
            print(f"{move_count + 1}. Material: {best_move}")
        else:
            # Positional engine
            best_move, score = engine2.find_best_move_alphabeta(5)
            print(f"{move_count + 1}... Positional: {best_move}")
        
        if best_move:
            board.make_move(best_move)
            move_count += 1
        else:
            break
    
    if move_count >= max_moves:
        print(f"\nGame drawn by move limit ({max_moves} moves)")

Tuning Evaluation Parameters

Fine-tuning evaluation parameters can significantly improve strength. To fine-tune our engine, I am using chess puzzles that are available in the Lichess Open Database. The database is huge, so I split into smaller files (in my case ~500k puzzles). The puzzles are rated and tagged with themes, which allows testing types of positions. We use pandas to load and process the puzzles (also allows us to easily sample the puzzles).

Here’s the tuning framework:

from board import Board
from search import SearchEngine
from evaluation import Evaluator

import pandas as pd

class EvaluationTuner:
    """
    Simple tuner for evaluation parameters.
    In a real engine, you'd use automated tuning methods.
    """
    
    def __init__(self, rating=None):
        puzzles = pd.read_csv('puzzles/chess_puzzles_1.csv')

        if rating:
            puzzles = puzzles[puzzles['Rating'] <= rating]

        puzzle_sample = puzzles.sample(n=100, replace=False)

        self.test_positions = []
        for current_sample in puzzle_sample.itertuples():
            moves = current_sample.Moves.split()
            if len(moves) > 1:
                self.test_positions.append({
                    'fen': current_sample.FEN,
                    'next_move': moves[0],
                    'best_move': moves[1],
                    'weight': 1.0
                })
    
    def test_evaluation_weights(self, evaluator, failure_list=False):
        """
        Test how well the evaluation performs on test positions.
        """
        score = 0
        current_test = 0
        failure_fen = []
        
        for position in self.test_positions:
            print(f'Test {current_test} (score {score})...')
            current_test += 1
            board = Board()
            board.from_fen(position['fen'])
            board.push_uci(position['next_move'])
            
            engine = SearchEngine(board, evaluator)
            best_move, _ = engine.find_best_move_alphabeta(4)
            
            if str(best_move) == position['best_move']:
                score += position['weight']
            else:
                failure_fen.append(position['fen'])
        
        if failure_list:
            return score, failure_fen
        
        return score
        
    
    def tune_parameters(self):
        """
        Simple grid search for parameter tuning.
        """
        best_score = 0
        best_params = None
        
        # Try different values for key parameters
        for doubled_pawn_penalty in [5, 10, 15, 20]:
            for passed_pawn_bonus in [5, 10, 15, 20]:
                evaluator = Evaluator()
                # Modify evaluator parameters here
                
                score = self.test_evaluation_weights(evaluator)
                
                if score > best_score:
                    best_score = score
                    best_params = {
                        'doubled_pawn': doubled_pawn_penalty,
                        'passed_pawn': passed_pawn_bonus
                    }
        
        return best_params, best_score

What We’ve Accomplished

In this post, we’ve dramatically improved PyMinMaximus’s chess understanding:

Piece-square tables for positional play
Game phase detection (middlegame vs. endgame)
Pawn structure evaluation (passed, doubled, isolated pawns)
King safety assessment
Bishop pair bonus
Mobility evaluation (optional)
Tapered evaluation for smooth phase transitions

PyMinMaximus now plays much more like a real chess player, understanding not just tactics but also positional concepts like piece placement, pawn structure, and king safety.

Performance and Strength Estimate

With these improvements, PyMinMaximus:

  • Can find most tactical shots up to depth 5-6
  • Understands basic positional concepts
  • Makes reasonable moves in most positions

Limitations and Future Improvements

Our evaluation still has room for growth:

  1. No opening book – wastes time “thinking” about known positions
  2. No endgame tablebases – can’t play simple endgames perfectly
  3. Static evaluation – doesn’t consider tempo or threats
  4. No pattern recognition – misses some tactical motifs
  5. Hand-tuned parameters – modern engines use machine learning

Next Steps

In Part 4, we’ll add:

  • Opening book integration using Polyglot format
  • Endgame tablebase support using Syzygy tables
  • Custom book creation from PGN files

These additions will make PyMinMaximus much stronger in the opening and endgame phases, addressing two of its biggest weaknesses.

In Part 5, we’ll implement the UCI protocol and test PyMinMaximus against other engines using CuteChess CLI to get an accurate strength measurement!

Complete Code Structure

Your project should now look like:

pyminmaximus/
├── constants.py # Piece and color constants
├── move.py # Move class
├── board.py # Board representation and move generation
├── evaluation.py # NEW: All evaluation functions
├── search.py # Search with improved evaluation
├── main.py # NEW: Play against the engine
└── tests/
├── test_board.py
├── test_evaluation.py # NEW: Evaluation tests
└── test_search.py

Try playing against PyMinMaximus now – you’ll notice it plays much more human-like chess, controlling the center, developing pieces to good squares, and showing some positional understanding!


Questions? Found an interesting evaluation bug? Share in the comments! Next time: Opening books and endgame tablebases. Sign up for our newsletter to get alerted to new posts and see what we are working on.

Leave a Reply

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