Writing Your Own AI

This guide covers py-draughts features designed for AI developers building custom agents, neural networks, or reinforcement learning systems.

Quick Example

Here’s a minimal neural network agent using PyTorch:

import torch
from draughts import Board, Agent

class NeuralAgent:
    def __init__(self, model):
        self.model = model

    def select_move(self, board: Board):
        # Convert board to tensor (4 channels, 50 squares)
        x = torch.from_numpy(board.to_tensor()).unsqueeze(0)

        # Get policy logits from your network
        with torch.no_grad():
            logits = self.model(x)[0]

        # Mask illegal moves
        mask = board.legal_moves_mask()
        logits[~mask] = float('-inf')

        # Sample or take argmax
        idx = logits.argmax().item()
        return board.index_to_move(idx)

# Usage
board = Board()
agent = NeuralAgent(your_trained_model)
move = agent.select_move(board)

Agent Interface

The Agent protocol defines the minimal interface for AI agents:

from draughts import Agent, Board, Move

class MyAgent:  # Implicitly implements Agent protocol
    def select_move(self, board: Board) -> Move:
        # Your logic here
        return board.legal_moves[0]

# Type checking confirms protocol compliance
agent: Agent = MyAgent()

For agents needing configuration, extend BaseAgent:

from draughts import BaseAgent, Board, Move

class ConfigurableAgent(BaseAgent):
    def __init__(self, temperature: float = 1.0):
        super().__init__(name="SoftmaxBot")
        self.temperature = temperature

    def select_move(self, board: Board) -> Move:
        # Use self.temperature for sampling
        ...
class draughts.Agent(*args, **kwargs)[source]

Protocol for draughts-playing agents.

Implement select_move(board) -> Move to create agents compatible with AgentEngine and Benchmark.

select_move(board: BaseBoard) Move[source]

Select a move to play.

class draughts.BaseAgent(name: str | None = None)[source]

Abstract base class for agents with optional configuration.

Extend this class when you need: - Named agents for logging/display - Configuration that affects move selection - State that persists between moves

For stateless agents, implement Agent protocol directly.

Attributes:

name: Agent name for display purposes.

Example:

from draughts import BaseAgent, Board, Move

class GreedyAgent(BaseAgent):
    '''Always captures the most pieces possible.'''

    def __init__(self):
        super().__init__(name="GreedyBot")

    def select_move(self, board: Board) -> Move:
        moves = board.legal_moves
        # Sort by capture count, return best
        return max(moves, key=lambda m: len(m.captured_list))
abstractmethod select_move(board: BaseBoard) Move[source]

Select a move to play.

Args:

board: Current board position.

Returns:

A legal Move to play.

as_engine() AgentEngine[source]

Wrap this agent as an Engine for use with Benchmark.

Returns:

An AgentEngine wrapping this agent.

Example:

from draughts import Benchmark, BaseAgent

class MyAgent(BaseAgent):
    def select_move(self, board):
        return board.legal_moves[0]

# Use with Benchmark
stats = Benchmark(
    MyAgent().as_engine(),
    AlphaBetaEngine(depth_limit=4),
    games=10
).run()

Using Agents with Benchmark

To use agents with Benchmark, wrap them as engines:

from draughts import AgentEngine, Benchmark, BaseAgent

class GreedyAgent(BaseAgent):
    def select_move(self, board):
        return max(board.legal_moves, key=lambda m: len(m.captured_list))

# Method 1: Use as_engine() on BaseAgent
engine1 = GreedyAgent().as_engine()

# Method 2: Wrap any Agent with AgentEngine
class RandomAgent:
    def select_move(self, board):
        import random
        return random.choice(board.legal_moves)

engine2 = AgentEngine(RandomAgent(), name="Random")

# Now benchmark them
stats = Benchmark(engine1, engine2, games=10).run()
class draughts.AgentEngine(agent: Agent, name: str | None = None)[source]

Adapter that wraps any Agent as an Engine.

This allows agents to be used with Benchmark and other Engine-based APIs.

Attributes:

agent: The wrapped agent. name: Engine name (from agent or custom).

Example:

from draughts import AgentEngine, Benchmark
import random

class RandomAgent:
    def select_move(self, board):
        return random.choice(board.legal_moves)

# Wrap and benchmark
engine = AgentEngine(RandomAgent(), name="Random")
stats = Benchmark(engine, AlphaBetaEngine(depth_limit=4)).run()

Example with BaseAgent:

from draughts import BaseAgent

class GreedyAgent(BaseAgent):
    def select_move(self, board):
        return max(board.legal_moves, key=lambda m: len(m.captured_list))

# BaseAgent has as_engine() shortcut
engine = GreedyAgent().as_engine()
property inspected_nodes: int

Number of nodes (always 1 for simple agents).

get_best_move(board: BaseBoard, with_evaluation: bool = False) Move | tuple[Move, float][source]

Get best move by delegating to the wrapped agent.

Args:

board: Current board position. with_evaluation: If True, return (move, 0.0) tuple.

Agents don’t provide evaluations, so score is always 0.

Returns:

Move from the agent, or (Move, 0.0) if with_evaluation=True.

Board Tensor Representation

Use to_tensor() to get a neural-network-ready representation:

from draughts import Board

board = Board()
tensor = board.to_tensor()

print(tensor.shape)  # (4, 50) for 10x10 board

The 4 channels are:

By default, “own” is relative to the current turn. Override with perspective:

from draughts import Color

# Always from white's perspective (useful for training)
tensor = board.to_tensor(perspective=Color.WHITE)

Feature Extraction

For classical ML or analysis, use features():

from draughts import Board

board = Board()
board.push_uci("31-27")
board.push_uci("18-22")

f = board.features()
print(f.white_men)         # 20
print(f.black_men)         # 20
print(f.mobility)          # Number of legal moves
print(f.material_balance)  # (white_men + 2*kings) - (black_men + 2*kings)
print(f.phase)             # 'opening', 'midgame', or 'endgame'
class draughts.BoardFeatures(white_men: int, white_kings: int, black_men: int, black_kings: int, turn: int, mobility: int, material_balance: float, phase: str)[source]

Extracted features from a board position for AI/ML use.

All counts and metrics are computed on-demand and returned as immutable data. This does not store any references to the board.

Attributes:

white_men: Number of white men on the board. white_kings: Number of white kings on the board. black_men: Number of black men on the board. black_kings: Number of black kings on the board. turn: 1 if white to move, -1 if black to move. mobility: Number of legal moves for the side to move. material_balance: (white_men + 2*white_kings) - (black_men + 2*black_kings). phase: Game phase: ‘opening’, ‘midgame’, or ‘endgame’.

Move Indexing for Policy Networks

Policy networks typically output a fixed-size vector over all possible moves. py-draughts provides tools to convert between moves and indices:

board = Board()

# Get legal move mask (shape: SQUARES^2 = 2500 for 10x10)
mask = board.legal_moves_mask()

# Your network outputs logits of shape (2500,)
logits = model(board.to_tensor())

# Mask illegal moves
logits[~mask] = float('-inf')

# Convert winning index back to move
best_idx = logits.argmax()
move = board.index_to_move(best_idx)

# Or convert a move to index (for training targets)
target_idx = board.move_to_index(move)

Index encoding: from_square * SQUARES_COUNT + to_square

For a 10x10 board (50 squares), indices range from 0 to 2499.

Cheap Position Cloning

Tree search and simulation require copying positions. Use copy() for efficient cloning:

board = Board()

# Fast copy - only bitboards, no move history
clone = board.copy()

# Explore a line
for move in some_variation:
    clone.push(move)

# Original unchanged
assert board.position.tolist() != clone.position.tolist()

The copy() method is optimized:

  • Copies only essential state (bitboards, turn, halfmove clock)

  • New board has empty move stack

  • ~10x faster than deepcopy

For full state preservation (including move history), use:

import copy
full_clone = copy.deepcopy(board)

MCTS Example

Here’s a Monte Carlo Tree Search skeleton:

from draughts import Board, BaseAgent, Move
import random

class MCTSAgent(BaseAgent):
    def __init__(self, simulations: int = 1000):
        super().__init__(name=f"MCTS-{simulations}")
        self.simulations = simulations

    def select_move(self, board: Board) -> Move:
        root = Node(board, None)

        for _ in range(self.simulations):
            node = root
            sim_board = board.copy()  # Cheap copy!

            # Selection: walk to leaf
            while node.children and not sim_board.game_over:
                node = node.select_child()
                sim_board.push(node.move)

            # Expansion
            if not sim_board.game_over and not node.children:
                for move in sim_board.legal_moves:
                    node.children.append(Node(sim_board, move))

            # Simulation
            while not sim_board.game_over:
                sim_board.push(random.choice(sim_board.legal_moves))

            # Backpropagation
            result = sim_board.result
            while node:
                node.update(result)
                node = node.parent

        return max(root.children, key=lambda n: n.visits).move

Training Tips

State representation:

# For CNN: reshape to 2D grid
tensor = board.to_tensor()  # (4, 50)
# Note: Only 50 playable squares exist on 10x10 board

# For flattening to MLP:
flat = tensor.flatten()  # (200,)

Data augmentation: Draughts boards have rotational symmetry. A position and its 180° rotation are strategically equivalent (with colors swapped):

# The position array is already 1D over playable squares
# Reverse it and negate to get the symmetric position
symmetric_pos = -board.position[::-1]

Reward shaping: Use features() for intermediate rewards:

f = board.features()
reward = f.material_balance * 0.01  # Small material reward

Board variants: All methods work on any board variant:

from draughts import AmericanBoard, FrisianBoard

board = AmericanBoard()  # 8x8, 32 squares
tensor = board.to_tensor()  # (4, 32)
mask = board.legal_moves_mask()  # (1024,)

Complete RL Example

See examples/reinforcement_learning.py for a complete working example that trains a policy network using REINFORCE with self-play:

pip install torch
python examples/reinforcement_learning.py

The example includes:

  • Policy network (MLP) for move selection

  • Self-play game generation

  • REINFORCE training loop with discount returns

  • Evaluation against random baseline

  • Temperature annealing for exploration

API Reference

BaseBoard.copy() BaseBoard[source]

Create a fast, cheap copy of the board.

This is optimized for tree search - it copies only the essential state (bitboards, turn, halfmove clock) without deep copying the move stack. The new board has an empty move stack.

Returns:

A new board instance with the same position.

Example:
>>> board = Board()
>>> board.push_uci("31-27")
>>> clone = board.copy()
>>> clone.push_uci("18-22")  # Doesn't affect original
>>> len(board._moves_stack)  # Original unchanged
1
BaseBoard.to_tensor(perspective: Color | None = None) ndarray[source]

Convert board to tensor representation for neural networks.

Returns a 4-channel representation:
  • Channel 0: Own men (1 where present, 0 elsewhere)

  • Channel 1: Own kings (1 where present, 0 elsewhere)

  • Channel 2: Opponent men (1 where present, 0 elsewhere)

  • Channel 3: Opponent kings (1 where present, 0 elsewhere)

Args:
perspective: The player’s perspective. If None, uses current turn.

From this perspective, “own” pieces are in channels 0-1.

Returns:

numpy array of shape (4, SQUARES_COUNT) with float32 dtype. For a 10x10 board, shape is (4, 50).

Example:
>>> board = Board()
>>> tensor = board.to_tensor()
>>> print(tensor.shape)  # (4, 50)
>>> # Channel 0 = white men, Channel 2 = black men (white's perspective)
>>> print(tensor[0].sum())  # 20.0 (20 white men)
Note:

This method does NOT slow down normal board operations. It creates the tensor only when called.

BaseBoard.features() BoardFeatures[source]

Extract features from the current position for AI/ML use.

Returns a lightweight, immutable dataclass containing piece counts, material balance, mobility, and game phase. Computed on-demand with no caching to avoid memory overhead.

Returns:

BoardFeatures with extracted position information.

Example:
>>> board = Board()
>>> f = board.features()
>>> print(f.white_men, f.black_men)  # 20 20
>>> print(f.phase)  # 'opening'
BaseBoard.legal_moves_mask() ndarray[source]

Get a boolean mask indicating which move indices are legal.

This is useful for masking neural network policy outputs. The mask has True at indices corresponding to legal moves and False elsewhere.

The move index is computed as: from_square * SQUARES_COUNT + to_square

Returns:

numpy array of shape (SQUARES_COUNT * SQUARES_COUNT,) with dtype bool. For a 10x10 board, shape is (2500,).

Example:
>>> board = Board()
>>> mask = board.legal_moves_mask()
>>> print(mask.shape)  # (2500,) for 10x10 board
>>> policy = model(board.to_tensor())  # Your NN output
>>> policy[~mask] = float('-inf')  # Mask illegal moves
>>> move_idx = policy.argmax()
>>> move = board.index_to_move(move_idx)
Note:

For captures that visit multiple squares, only the start and final destination are used for indexing.

BaseBoard.move_to_index(move: Move) int[source]

Convert a move to a policy index.

The index encodes the move as: from_square * SQUARES_COUNT + to_square

Args:

move: The move to convert.

Returns:

Integer index in range [0, SQUARES_COUNT^2).

Example:
>>> board = Board()
>>> move = board.legal_moves[0]
>>> idx = board.move_to_index(move)
>>> recovered = board.index_to_move(idx)
>>> move == recovered  # True
BaseBoard.index_to_move(index: int) Move[source]

Convert a policy index back to a move.

Finds the legal move matching the encoded from/to squares.

Args:

index: Policy index from move_to_index or network output.

Returns:

The matching Move object from legal moves.

Raises:

ValueError: If no legal move matches the index.

Example:
>>> board = Board()
>>> move = board.index_to_move(1530)  # sq 30 -> sq 30 % 50 = 30