Skip to main content

Client + MCTS helpers for board game AI integration with self-play support.

Project description

Game AI Client SDK

A Python SDK for integrating AI into turn-based board games using Monte Carlo Tree Search (MCTS). This framework provides a generic interface that allows game developers to add AI opponents to their games with minimal coupling to game-specific logic.

Features

  • Generic Game Interface: Implement any turn-based game with a simple protocol
  • MCTS AI Engine: Powerful AI using Monte Carlo Tree Search with customizable strategies
  • AI vs AI Gameplay: Built-in support for AI vs AI matches with configurable difficulty levels
  • Three Difficulty Levels: Easy, Medium, and Hard AI opponents with different playing strengths
  • Design Patterns: Built with Strategy and State patterns for extensibility
  • Event Logging: Optional match and move logging via RabbitMQ
  • Minimal Integration: Keep full control of your game logic

Installation

pip install game-ai-client==0.1.2

Quick Start

Basic Usage

Import the SDK:

from game_sdk import AIGameClient
from game_sdk.utils import build_generic_state

AI vs AI Quick Start

For games implementing TurnBasedGame, you can immediately run AI vs AI matches:

from your_game import YourGame, state_to_game, game_to_state

# Create initial game
game = YourGame(initial_board, players, starting_player)

# Run AI vs AI match
result = game.ai_vs_ai_difficulty_selection(
    difficulty1="medium",
    difficulty2="hard",
    state_to_game_fn=state_to_game,
    game_to_state_fn=game_to_state,
    game_id="your_game"
)

print(f"Winner: {result['winner']}")

Available difficulties: "easy", "medium", "hard"

RabbitMQ Setup (Optional)

The SDK includes built-in event logging to RabbitMQ. To enable it, start RabbitMQ using Docker:

docker-compose up -d

This will start RabbitMQ on:

  • AMQP port: 6000
  • Management UI: http://localhost:15672

Configure via environment variables:

export RABBITMQ_HOST=localhost
export RABBITMQ_PORT=6000
export RABBITMQ_USER=guest
export RABBITMQ_PASS=guest

If RabbitMQ is unavailable, the SDK gracefully falls back to stdout logging.

Integration Guide

You keep full control over your game logic. To use the framework, you only need to provide:

  1. Players list
  2. Legal moves function
  3. Apply-move function for AI state transitions
  • Players list:
players = [
    {"id": "P1", "type": "human",  "symbol": "X"},
    {"id": "P2", "type": "ai_mcts","symbol": "O"},
]
  • Legal moves function Return all legal moves for the current player, as a list of dicts:
def compute_legal_moves(board, players, current_player_symbol):
    player_index = next(i for i, p in enumerate(players)
                        if p["symbol"] == current_player_symbol)
    moves = []
    for r in range(len(board)):
        for c in range(len(board[0])):
            if board[r][c].strip() == "":
                moves.append({
                    "id": f"PLACE_{r}_{c}",         # string id is recommended
                    "player_index": player_index,   # index in players[]
                    "type": "PLACE_MARK",
                    "position": {"row": r, "col": c},
                })
    return moves

Apply-Move Function

This is the only function needed from your game to enable AI. It takes a generic state and move, returns a new state:

from typing import Dict, Any, List
from game_sdk.utils import build_generic_state

State = Dict[str, Any]
Move = Dict[str, Any]

def apply_move_my_game(state: State, move: Move) -> State:
    players: List[Dict[str, Any]] = state["players"]

    # --- decode board from generic state ---
    board_info = state["board"]
    int_board = board_info["cells"]              # 2D ints
    symbol_map = {0: " "}
    for idx, p in enumerate(players):
        symbol_map[idx + 1] = p["symbol"]

    board = [[symbol_map[v] for v in row] for row in int_board]

    # --- apply the move ---
    r = move["position"]["row"]
    c = move["position"]["col"]
    player_index = move["player_index"]
    symbol = players[player_index]["symbol"]
    board[r][c] = symbol

    # --- next player ---
    next_player_index = (player_index + 1) % len(players)
    next_symbol = players[next_player_index]["symbol"]

    # --- terminal & result: YOUR logic here ---
    # is_terminal_and_winner() should be implemented by you and return:
    #   (finished: bool, winner_symbol: Optional[str])
    finished, winner_symbol = is_terminal_and_winner(board)

    result_map = None
    if finished:
        # winner_to_results() should return (result_str, result_map)
        # e.g. result_map = {"P1": 1.0, "P2": -1.0}
        _, result_map = winner_to_results(winner_symbol, players)

    move_count = state["extra"].get("move_count", 0) + 1
    legal_moves = [] if finished else compute_legal_moves(board, players, next_symbol)

    # --- build and return the new state ---
    return build_generic_state(
        game_id=state["game_id"],
        board=board,
        players=players,
        current_player_symbol=next_symbol,
        move_count=move_count,
        finished=finished,
        legal_moves=legal_moves,
        result=result_map,
    )

Building a state for the AI / logging

On each turn, describe the current position using build_generic_state:

state = build_generic_state(
    game_id="my_game_id",
    board=board,                        # 2D list of symbols, e.g. [["X"," ","O"], ...]
    players=players,
    current_player_symbol=current_sym,  # whose turn it is
    move_count=move_count,
    finished=False,                     # or True if you know it’s over
    legal_moves=compute_legal_moves(board, players, current_sym),
    result=None,                        # for terminal state: {player_id: score}
)

Using AIGameClient in your loop

Create client and start match:

client = AIGameClient(
    game_id="my_game_id",
    api_key="demo-key",
    apply_move_fn=apply_move_my_game,
)

match_id = client.start_match(players=players, metadata={"mode": "casual"})

Each turn:

while not finished:
    current_sym = ...                 # your turn logic
    legal_moves = compute_legal_moves(board, players, current_sym)

    state = build_generic_state(
        game_id="my_game_id",
        board=board,
        players=players,
        current_player_symbol=current_sym,
        move_count=move_count,
        finished=False,
        legal_moves=legal_moves,
    )

    current_player = next(p for p in players if p["symbol"] == current_sym)

    if current_player["type"] == "ai_mcts":
        # ---- AI turn ----
        client.send_state(match_id, state)
        move = client.best_move(match_id, iterations=800)
    else:
        # ---- human turn ----
        move = get_human_move_somehow(legal_moves)

    # Apply move in your game
    apply_move_on_real_board(board, move, current_sym)
    
    # log the move 
    client.log_move(match_id=match_id, state=state, move=move)

    # Update finished / winner using your own logic
    finished, winner_symbol = is_terminal_and_winner(board)
    move_count += 1

End the match:

result_str, result_map = winner_to_results(winner_symbol, players)

final_state = build_generic_state(
    game_id="my_game_id",
    board=board,
    players=players,
    current_player_symbol=current_sym,
    move_count=move_count,
    finished=True,
    legal_moves=[],
    result=result_map,
)

client.end_match(
    match_id=match_id,
    result=result_str,
    final_state=final_state,
)

Architecture

Design Patterns

The SDK implements multiple design patterns for extensibility and maintainability:

Strategy Pattern (MCTS Customization)

The MCTS algorithm is decomposed into four pluggable strategies:

from game_sdk.mcts import (
    MCTSStrategy,
    DefaultSelectionStrategy,
    DefaultExpansionStrategy,
    DefaultSimulationStrategy,
    DefaultBackpropagationStrategy,
)

# Use custom strategies
custom_mcts = MCTSStrategy(
    selection_strategy=DefaultSelectionStrategy(),
    expansion_strategy=DefaultExpansionStrategy(),
    simulation_strategy=DefaultSimulationStrategy(),
    backpropagation_strategy=DefaultBackpropagationStrategy(),
)

Each strategy can be independently customized:

  • SelectionStrategy: How to traverse the tree (default: UCB1)
  • ExpansionStrategy: How to add nodes (default: single unexpanded move)
  • SimulationStrategy: How to simulate games (default: random rollout)
  • BackpropagationStrategy: How to update values (default: visit counts + rewards)

State Pattern (MCTS Phases)

MCTS iterations follow a state machine:

  • Selection Phase → Expansion Phase → Simulation Phase → Backpropagation Phase → Complete

Each phase handles its work and transitions to the next state, providing clean separation of concerns.

Protocol-Based Design

The SDK uses Python protocols for game integration. Implement the TurnBasedGame protocol for compile-time type checking:

from game_sdk.ai_client import TurnBasedGame, Move
from typing import List

class MyGame(TurnBasedGame):
    def get_legal_actions(self) -> List[Move]:
        # Return all legal moves
        pass

    def is_game_over(self) -> bool:
        # Check if game is finished
        pass

    def game_result(self) -> int:
        # Return result: 1 (win), -1 (loss), 0 (draw) from current player perspective
        pass

    def move(self, action: Move) -> "MyGame":
        # Apply move and return new game state
        pass

Component Overview

┌─────────────────────────────────────────────┐
│   Game Implementation                       │
│   - Your game logic                         │
│   - TurnBasedGame protocol                  │
└─────────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────────┐
│   AIGameClient                              │
│   - State management                        │
│   - MCTS integration                        │
└─────────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────────┐
│   MCTS Engine (Strategy Pattern)            │
│   - Selection → Expansion                   │
│   - Simulation → Backpropagation            │
└─────────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────────┐
│   Event Logging (RabbitMQ)                  │
│   - Match lifecycle events                  │
│   - Move logging                            │
└─────────────────────────────────────────────┘

Advanced Usage

Customizing MCTS Parameters

Control the AI strength and behavior:

# More iterations = stronger AI (but slower)
move = client.best_move(match_id, iterations=1000)

# Fewer iterations = faster but weaker AI
move = client.best_move(match_id, iterations=100)

AI vs AI Gameplay with Difficulty Levels

The SDK provides built-in support for AI vs AI matches with three difficulty levels:

  • Easy: 100 MCTS iterations, 50% random moves (beginner-friendly)
  • Medium: 1000 MCTS iterations, pure MCTS strategy (intermediate)
  • Hard: 5000 MCTS iterations, near-perfect play (expert)

Using the TurnBasedGame Protocol

Any game implementing the TurnBasedGame protocol automatically gets AI vs AI functionality:

from game_sdk.ai_client import TurnBasedGame
from typing import List, Dict, Any

class MyGame(TurnBasedGame):
    def get_legal_actions(self) -> List[Dict[str, Any]]:
        # Return list of legal moves
        pass

    def is_game_over(self) -> bool:
        # Check if game has ended
        pass

    def game_result(self) -> int:
        # Return 1 (win), -1 (loss), or 0 (draw) from current player's perspective
        pass

    def move(self, action: Dict[str, Any]) -> "MyGame":
        # Apply move and return new game state (immutable)
        pass

Running AI vs AI Matches

Once your game implements TurnBasedGame, you can run AI vs AI matches:

# Create initial game state
game = MyGame(initial_board, players, current_player_symbol)

# Play AI vs AI with different difficulties
result = game.ai_vs_ai_difficulty_selection(
    difficulty1="easy",      # First AI plays at easy difficulty
    difficulty2="hard",      # Second AI plays at hard difficulty
    state_to_game_fn=state_to_game_converter,
    game_to_state_fn=game_to_state_converter,
    game_id="my_game",
    verbose=True            # Print game progress
)

# Check results
print(f"Winner: {result['winner']}")
print(f"Total moves: {result['move_count']}")
print(f"Move history: {result['move_history']}")

Converter Functions

You need two adapter functions to convert between your game and the generic SDK state:

def state_to_game_converter(state: Dict[str, Any]) -> MyGame:
    """Convert SDK state dictionary to your game object."""
    board = extract_board_from_state(state)
    players = state["players"]
    current_player = players[state["turn_index"]]["symbol"]
    return MyGame(board, players, current_player)

def game_to_state_converter(game: MyGame, prev_state: Dict[str, Any]) -> Dict[str, Any]:
    """Convert your game object back to SDK state dictionary."""
    from game_sdk.utils import build_generic_state

    return build_generic_state(
        game_id=prev_state["game_id"],
        board=game.board,
        players=game.players,
        current_player_symbol=game.current_player_symbol,
        move_count=prev_state.get("extra", {}).get("move_count", 0) + 1,
        finished=game.is_game_over(),
        legal_moves=[] if game.is_game_over() else game.get_legal_actions(),
        result=compute_result_map(game) if game.is_game_over() else None
    )

Example: Running a Tournament

from typing import Dict

def run_tournament(difficulty1: str, difficulty2: str, num_games: int = 10) -> Dict:
    """Run multiple AI vs AI games and collect statistics."""
    wins = {"Player1": 0, "Player2": 0, "Draw": 0}

    for i in range(num_games):
        game = MyGame(create_empty_board(), players, "X")

        result = game.ai_vs_ai_difficulty_selection(
            difficulty1=difficulty1,
            difficulty2=difficulty2,
            state_to_game_fn=state_to_game_converter,
            game_to_state_fn=game_to_state_converter,
            game_id="my_game",
            verbose=False  # Silent mode
        )

        if result['winner']:
            winner_idx = 0 if result['winner'] == "X" else 1
            key = f"Player{winner_idx + 1}"
            wins[key] += 1
        else:
            wins["Draw"] += 1

    return wins

# Run tournament
stats = run_tournament("medium", "hard", num_games=20)
print(f"Results: {stats}")

Complete Example Files

See the example files for complete working implementations:

  • ai_vs_ai_example.py: Simple AI vs AI game examples
  • game_manager.py: Full game management system with tournaments
  • tictactoe.py: Complete Tic-Tac-Toe implementation with AI

Generic State Format

The SDK uses a standardized state representation:

{
  "game_id": "tictactoe",
  "state_id": "unique-uuid",
  "turn_index": 5,
  "players": [
    {"id": "P1", "type": "human", "symbol": "X"},
    {"id": "P2", "type": "ai_mcts", "symbol": "O"}
  ],
  "board": {
    "representation": "grid",
    "rows": 3,
    "cols": 3,
    "cells": [[1, 0, 2], [0, 1, 0], [2, 0, 0]],
    "legend": {
      "0": "empty",
      "1": "player_1_piece",
      "2": "player_2_piece"
    }
  },
  "status": "IN_PROGRESS",
  "is_terminal": false,
  "legal_moves": [...],
  "result": null,
  "extra": {"move_count": 5}
}

Example Implementation

See tictactoe.py for a complete working example demonstrating:

  • TurnBasedGame protocol implementation
  • Human vs AI gameplay
  • AI vs AI gameplay with multiple difficulty levels
  • State management
  • Event logging

Changelog

Version 0.1.7 (Latest)

Bug Fixes:

  • Fixed winner determination logic in ai_vs_ai_difficulty_selection() method
    • Previously, winners were incorrectly attributed due to inverted logic in result interpretation
    • Hard difficulty now correctly demonstrates unbeatable play
    • Results are now properly assigned when game_result() returns -1 (current player lost)

Improvements:

  • Made RabbitMQ dependency optional
    • SDK now gracefully handles missing pika module
    • Falls back to stdout logging when RabbitMQ is unavailable
    • Allows usage without message bus infrastructure

New Examples:

  • ai_vs_ai_example.py: Demonstrates AI vs AI gameplay
  • game_manager.py: Tournament management system
  • test_difficulty_fix.py: Validation tests for difficulty levels

Requirements

  • Python 3.9+
  • pika 1.3.2 (optional, for RabbitMQ logging)

Credits

Tic-Tac-Toe example adapted from: https://gist.github.com/qianguigui1104/edb3b11b33c78e5894aad7908c773353

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

game_ai_client-0.2.1.tar.gz (30.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

game_ai_client-0.2.1-py3-none-any.whl (34.2 kB view details)

Uploaded Python 3

File details

Details for the file game_ai_client-0.2.1.tar.gz.

File metadata

  • Download URL: game_ai_client-0.2.1.tar.gz
  • Upload date:
  • Size: 30.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.10.11

File hashes

Hashes for game_ai_client-0.2.1.tar.gz
Algorithm Hash digest
SHA256 93b7214477afa0a7c1594a6c96fbad13ed6d93e19d680b6e431f6fd14088f1de
MD5 d5044f0d5180508bff59a79f97c6a0d2
BLAKE2b-256 c64fd4fc979af8f22c5e0219932b9c7cfe77453a526e5568cfd3db96520fb930

See more details on using hashes here.

File details

Details for the file game_ai_client-0.2.1-py3-none-any.whl.

File metadata

  • Download URL: game_ai_client-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 34.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.10.11

File hashes

Hashes for game_ai_client-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 c96489e8908940ebc9242a994d20711a9fbef9c32199fd443ecbebab7290fb6b
MD5 5a9fefc426ef9b9e0c8b14475f3d0133
BLAKE2b-256 8bb7c8c6ba24a039f3a00bfcc20ec3bbf0d1d56474db8ee57156f51fbca8fb8a

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page