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 0Detecting 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 FalsePawn 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 scoreKing 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 safetyMobility 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_scoreBishop 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 scorePutting 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 -scoreTapered 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 scoreTesting 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_scoreWhat 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:
- No opening book – wastes time “thinking” about known positions
- No endgame tablebases – can’t play simple endgames perfectly
- Static evaluation – doesn’t consider tempo or threats
- No pattern recognition – misses some tactical motifs
- 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.
