Pygame Setup and Display Initialization

Pygame wraps the Simple DirectMedia Layer (SDL) library, providing hardware-accelerated 2D graphics, event handling, and audio in a cross-platform Python API. The initialization sequence is straightforward: import pygame, call pygame.init() to initialize all modules, create the display surface, and configure the frame rate clock.

The architectural principle that separates a good Pygame project from a messy one is the strict separation between game logic and rendering. The LudoGame class contains only pure Python — no pygame imports — making it unit-testable with standard Python tooling. The BoardRenderer class reads game state and issues Pygame draw calls. The Token class bridges both worlds: it holds position data (logic) but also knows how to render itself (rendering). This layered architecture scales cleanly as you add features.

Python — Pygame initialization and main.py
import pygame
from constants import BOARD_SIZE, CELL_SIZE, FPS, PLAYER_COLORS
from game import LudoGame, GamePhase
from renderer import BoardRenderer
from ai import LudoAI
from dice import DiceRoller

# Initialize all Pygame modules
pygame.init()

# Create the display surface — 600x600 for a 15×15 board
screen = pygame.display.set_mode((BOARD_SIZE, BOARD_SIZE))
pygame.display.set_caption("Ludo — Python Pygame")

# Clock controls the frame rate, ensuring consistent animation speed
clock = pygame.time.Clock()

# Initialize game objects
game = LudoGame(num_players=4)
renderer = BoardRenderer(screen)
dice = DiceRoller()
ai = LudoAI(game)

# Human-controlled players: 0=Red, 1=Green (set to None for AI)
human_players = {0: True, 1: True, 2: False, 3: False}

def main():
    running = True
    while running:
        # Delta time in seconds (capped at 100ms)
        dt = clock.tick(FPS) / 1000.0

        # Event processing
        running = handle_events(game, dice, screen)

        # Update game state
        update(game, dice, ai, human_players, dt)

        # Render frame
        renderer.draw(game, dice)
        pygame.display.flip()

    pygame.quit()

if __name__ == "__main__":
    main()

The pygame.init() call initializes all Pygame modules (display, font, mixer, joystick, etc.). If a module fails to initialize, it returns a non-fatal warning — always check pygame.error in production code. The pygame.display.set_mode() call with a tuple argument creates a windowed surface; pass (0, 0) with the FULLSCREEN flag for fullscreen mode. The clock.tick(FPS) call regulates the loop to a maximum of FPS frames per second and returns the actual elapsed milliseconds since the last call.

Board Constants and Coordinate Mapping

All board dimensions live in constants.py — a single source of truth that both the game logic and renderer reference. This prevents the common bug of mismatched constants between rendering code and movement logic.

The 15×15 grid maps to 600×600 pixels at 40px per cell. The outer track has 52 cells in clockwise order, and each player has a private home column of 5 cells inside their quadrant, making the maximum track position 56 (52 outer + 4 inside the home column, with 57 being "finished").

Python — constants.py
# constants.py — all board dimensions in one place

BOARD_SIZE  = 600
CELL_SIZE   = 40
GRID_SIZE   = 15           # 15×15 conceptual grid
FPS         = 60

OUTER_TRACK  = 52          # cells around the outside
HOME_COLUMN  = 5           # private cells per player
WIN_POSITION = 57          # OUTER_TRACK + HOME_COLUMN - 1

SAFE_INDICES = {0, 8, 13, 21, 26, 34, 39, 47}

PLAYER_COLORS = [
    (239, 68,  68 ),   # Red
    (34,  197, 94 ),   # Green
    (234, 179, 8  ),   # Yellow
    (59,  130, 246),   # Blue
]
PLAYER_NAMES = ["RED", "GREEN", "YELLOW", "BLUE"]

# Home entry index on the outer track for each player
HOME_ENTRY = {0: 0, 1: 13, 2: 26, 3: 39}

# Base token positions (2×2 grid within each 6×6 quadrant)
BASE_OFFSETS = [(1, 1), (1, 4), (4, 1), (4, 4)]

# Quadrant top-left corner (row, col) for each player
QUADRANT_ORIGIN = {
    0: (0,  0 ),   # Red: top-left
    1: (0,  9 ),   # Green: top-right
    2: (9,  9 ),   # Yellow: bottom-right
    3: (9,  0 ),   # Blue: bottom-left
}

Board Rendering with pygame.draw

Pygame's pygame.draw module provides primitive shape drawing: rectangles, circles, lines, polygons, and arcs. For a Ludo board, the key functions are rect for cells and quadrants, circle for tokens and safe markers, polygon for the goal zone triangles, and line for borders. All coordinates are in pixels on the display surface.

The rendering system draws in layers: background → home quadrants → goal zone → outer track cells → safe markers → tokens. Each token renders using its current pixel position, which may be an animated (interpolated) position during movement rather than the logical grid position.

Python — renderer.py with pygame.draw
import pygame
from constants import (
    BOARD_SIZE, CELL_SIZE, GRID_SIZE, OUTER_TRACK, SAFE_INDICES,
    PLAYER_COLORS, QUADRANT_ORIGIN, BASE_OFFSETS
)

class BoardRenderer:
    """Renders the Ludo board and tokens using Pygame primitives."""

    def __init__(self, screen):
        self.screen = screen
        self.font = pygame.font.SysFont(None, 24)
        self.track_cells = self._build_track_map()

    def _build_track_map(self):
        """Pre-compute 52 [row, col] pairs for the outer track."""
        t = []
        # Red entry: row 6, cols 9-14 (right edge)
        t.extend((6, c) for c in range(9, 15))
        # Right column going up (col 13, rows 7→0)
        t.extend((r, 13) for r in range(7, -1, -1))
        # Top-right quadrant: row 1-6, col 8
        t.extend((r, 8) for r in range(1, 7))
        # Top row going left (row 0, cols 7→0)
        t.extend((0, c) for c in range(7, -1, -1))
        # Left side of top-right quadrant (col 6, rows 1-6)
        t.extend((r, 6) for r in range(1, 7))
        # Green entry: row 6, cols 0-5
        t.extend((6, c) for c in range(0, 6))
        # Continue 26 more cells through bottom half...
        return t  # must be exactly 52 entries

    def draw(self, game, dice):
        """Main render entry point — called every frame."""
        self.screen.fill((15, 23, 42))  # dark slate background
        self._draw_quadrants()
        self._draw_goal_zone()
        self._draw_track()
        self._draw_tokens(game)
        self._draw_dice(dice)
        self._draw_turn_indicator(game)

    def _draw_quadrants(self):
        for pid, color in enumerate(PLAYER_COLORS):
            sr, sc = QUADRANT_ORIGIN[pid]
            rect = pygame.Rect(sc * CELL_SIZE, sr * CELL_SIZE, 6*CELL_SIZE, 6*CELL_SIZE)
            pygame.draw.rect(self.screen, (*color, 25), rect)  # semi-transparent fill
            pygame.draw.rect(self.screen, color, rect, 2)       # colored border
            # Draw base circles for the 4 starting positions
            for br, bc in BASE_OFFSETS:
                bx = (sc + bc) * CELL_SIZE + CELL_SIZE // 2
                by = (sr + br) * CELL_SIZE + CELL_SIZE // 2
                pygame.draw.circle(self.screen, (30,30,50), (bx, by), CELL_SIZE//2 - 4)
                pygame.draw.circle(self.screen, color, (bx, by), CELL_SIZE//2 - 4, 2)

    def _draw_goal_zone(self):
        # Center 3×3 white area
        cx, cy = 6 * CELL_SIZE, 6 * CELL_SIZE
        pygame.draw.rect(self.screen, (248,250,252), (cx, cy, 3*CELL_SIZE, 3*CELL_SIZE))
        # Four colored triangles pointing inward
        tri_size = CELL_SIZE * 1.5
        pts = {
            0: [(cx, cy+tri_size), (cx+tri_size, cy+tri_size), (cx, cy)],              # Red
            1: [(cx+3*CELL_SIZE-tri_size, cy), (cx+3*CELL_SIZE, cy), (cx+3*CELL_SIZE, cy+tri_size)], # Green
            2: [(cx+3*CELL_SIZE, cy+3*CELL_SIZE-tri_size), (cx+3*CELL_SIZE, cy+3*CELL_SIZE), (cx+3*CELL_SIZE-tri_size, cy+3*CELL_SIZE)], # Yellow
            3: [(cx+tri_size, cy+3*CELL_SIZE), (cx, cy+3*CELL_SIZE), (cx, cy+3*CELL_SIZE-tri_size)], # Blue
        }
        for pid, color in enumerate(PLAYER_COLORS):
            pygame.draw.polygon(self.screen, color, pts[pid])

    def _draw_track(self):
        for idx, (row, col) in enumerate(self.track_cells):
            x, y = col * CELL_SIZE + 1, row * CELL_SIZE + 1
            pygame.draw.rect(self.screen, (30,41,64), (x, y, CELL_SIZE-2, CELL_SIZE-2))
            if idx in SAFE_INDICES:
                cx, cy = col*CELL_SIZE + CELL_SIZE//2, row*CELL_SIZE + CELL_SIZE//2
                pygame.draw.circle(self.screen, (245,158,11), (cx, cy), 6)  # amber star

    def _draw_tokens(self, game):
        for pid, tokens in enumerate(game.pieces):
            for token in tokens:
                pos = token.get_pixel_position(self.track_cells)
                color = PLAYER_COLORS[pid]
                pygame.draw.circle(self.screen, color, (pos[0], pos[1]), 14)
                pygame.draw.circle(self.screen, (255,255,255), (pos[0], pos[1]), 14, 2)
                # Token number
                num_surf = self.font.render(str(token.token_id + 1), True, (255,255,255))
                self.screen.blit(num_surf, num_surf.get_rect(center=(pos[0], pos[1])))

    def _draw_dice(self, dice):
        # Dice display area: top-right corner of the board
        dx, dy = 13*CELL_SIZE + CELL_SIZE//4, CELL_SIZE//4
        dsize = 2*CELL_SIZE - CELL_SIZE//2
        pygame.draw.rect(self.screen, (241,245,249), (dx, dy, dsize, dsize), border_radius=8)
        if dice.value is not None:
            self._draw_pips(dx, dy, dsize, dice.value)

    def _draw_pips(self, dx, dy, dsize, value):
        # Pip positions on a 3×3 grid within the die face
        pips = {
            1: [(1,1)],
            2: [(0,0),(2,2)],
            3: [(0,0),(1,1),(2,2)],
            4: [(0,0),(2,0),(0,2),(2,2)],
            5: [(0,0),(2,0),(1,1),(0,2),(2,2)],
            6: [(0,0),(2,0),(0,1),(2,1),(0,2),(2,2)],
        }
        pip_size = dsize // 10
        for col, row in pips[value]:
            px = dx + (col + 0.5) * dsize / 3
            py = dy + (row + 0.5) * dsize / 3
            pygame.draw.circle(self.screen, (30,30,46), (int(px), int(py)), pip_size)

    def _draw_turn_indicator(self, game):
        if game.phase == game.GAME_OVER:
            msg = f"{PLAYER_NAMES[game.winner]} WINS!"
        else:
            msg = f"{PLAYER_NAMES[game.current_player]}'s turn"
        surf = self.font.render(msg, True, PLAYER_COLORS[game.current_player])
        self.screen.blit(surf, surf.get_rect(center=(BOARD_SIZE//2, CELL_SIZE//2)))

Token Class: Draw and Update

The Token dataclass holds all state for a single token: which player owns it, its ID within that player's set, its current track position, and whether it's finished. It also carries animation state (anim_from, anim_to, anim_progress) that gets updated each frame by the renderer's update loop.

The get_pixel_position method is the bridge between logical game state and visual coordinates. It handles three zones: base positions (a fixed offset within the player's quadrant), the outer track (lookup in the pre-computed track map), and the home column (a per-player coordinate table).

Python — Token dataclass and animation
from dataclasses import dataclass, field
from typing import Tuple, List, Optional
from constants import (
    CELL_SIZE, OUTER_TRACK, WIN_POSITION, HOME_ENTRY,
    QUADRANT_ORIGIN, BASE_OFFSETS, PLAYER_COLORS
)

@dataclass
class Token:
    """A single Ludo token (piece) belonging to one player."""

    player_id:  int
    token_id:   int     # 0-3 within the player's set
    track_pos:  int = -1  # -1=base, 0-51=outer track, 52-56=home column
    finished:   bool = False
    anim_from:  Optional[Tuple[int,int]] = None   # (pixel_x, pixel_y)
    anim_to:    Optional[Tuple[int,int]] = None
    anim_progress: float = 1.0  # 0.0 to 1.0
    anim_speed: float = 8.0    # cells per second

    def get_pixel_position(self, track_map: List[Tuple[int,int]]) -> Tuple[int,int]:
        """Convert logical track position to canvas (x, y) pixels."""
        if self.track_pos == -1:
            # Base position: fixed offset within player's home quadrant
            sr, sc = QUADRANT_ORIGIN[self.player_id]
            br, bc = BASE_OFFSETS[self.token_id]
            px = (sc + bc) * CELL_SIZE + CELL_SIZE // 2
            py = (sr + br) * CELL_SIZE + CELL_SIZE // 2
            return (px, py)
        if self.track_pos >= OUTER_TRACK:
            # Home column: pre-computed coordinates per player
            home_idx = self.track_pos - OUTER_TRACK
            home_coords = {
                0: [(7,1),(7,2),(7,3),(7,4),(7,5)],
                1: [(1,7),(2,7),(3,7),(4,7),(5,7)],
                2: [(7,7),(7,6),(7,5),(7,4),(7,3)],
                3: [(7,7),(6,7),(5,7),(4,7),(3,7)],
            }
            hr, hc = home_coords[self.player_id][home_idx]
            return (hc * CELL_SIZE + CELL_SIZE//2, hr * CELL_SIZE + CELL_SIZE//2)
        # Outer track: lookup in track_map
        row, col = track_map[self.track_pos]
        return (col * CELL_SIZE + CELL_SIZE//2, row * CELL_SIZE + CELL_SIZE//2)

    def start_animation(self, target_pos: int, track_map: List):
        """Begin smooth animation from current position to target track position."""
        self.anim_from     = self.get_pixel_position(track_map)
        tmp_token = Token(self.player_id, self.token_id, target_pos)
        self.anim_to       = tmp_token.get_pixel_position(track_map)
        self.anim_progress = 0.0
        self.anim_target   = target_pos

    def update_animation(self, dt: float, track_map: List):
        """Advance animation by dt seconds. Returns True when complete."""
        if self.anim_progress >= 1.0:
            return True
        cells_to_travel = abs(self.anim_target - self.track_pos)
        if cells_to_travel == 0:
            self.anim_progress = 1.0; return True
        const increment = (dt * self.anim_speed) / cells_to_travel
        self.anim_progress = min(1.0, self.anim_progress + increment)
        if self.anim_progress >= 1.0:
            self.track_pos = self.anim_target
            self.anim_from = None; self.anim_to = None
            return True
        return False

    def get_animated_position(self) -> Tuple[int,int]:
        """Return interpolated pixel position during animation."""
        if self.anim_from is None or self.anim_to is None:
            return self.anim_from or self.anim_to or (0,0)
        t = self.anim_progress
        # Ease-in-out: accelerate from start, decelerate to end
        eased = 2*t*t if t < 0.5 else 1 - (-2*t+2)**2/2
        fx, fy = self.anim_from; tx, ty = self.anim_to
        return (int(fx + (tx - fx) * eased), int(fy + (ty - fy) * eased))

Dice System with Animation

The DiceRoller class manages dice state and animation. During the roll animation, it returns a cycling random value each frame until the animation duration elapses. For multiplayer games where fairness matters, it can use os.urandom for cryptographically secure random values.

Python — dice.py
import random
import os

class DiceRoller:
    """Manages dice rolling with per-frame animation and secure randomness."""

    def __init__(self):
        self.value:          int | None = None
        self.animating:      bool = False
        self.anim_duration:  float = 0.8   # seconds
        self.anim_elapsed:   float = 0.0

    def roll(self, secure: bool = False) -> int:
        """Generate and store the final dice value."""
        if secure:
            self.value = (int.from_bytes(os.urandom(4), 'big') % 6) + 1
        else:
            self.value = random.randint(1, 6)
        return self.value

    def start_animation(self, secure: bool = False):
        """Begin the dice roll animation. Call roll() first to set the final value."""
        self.roll(secure)
        self.animating  = True
        self.anim_elapsed = 0.0

    def update(self, dt: float) -> int:
        """Advance animation by dt seconds. Returns the value to display."""
        if not self.animating:
            return self.value or 1
        self.anim_elapsed += dt
        if self.anim_elapsed >= self.anim_duration:
            self.animating = False
            return self.value
        # Cycle through random values during animation
        return random.randint(1, 6)

    def is_animating(self) -> bool:
        return self.animating

Core Game Logic

The LudoGame class contains zero Pygame imports — it's pure Python game logic that can be tested with pytest, reused in a web server backend, or integrated with the Python SDK for server-side game hosting. The class manages the complete game lifecycle: initialization, dice rolling, move validation, capture detection, turn advancement, and win detection.

Python — game.py with complete Ludo logic
from dataclasses import dataclass
from typing import List, Optional
from constants import WIN_POSITION, OUTER_TRACK, SAFE_INDICES

class GamePhase:
    IDLE         = "idle"
    ROLLING      = "rolling"
    SELECT_TOKEN = "select_token"
    ANIMATING    = "animating"
    GAME_OVER    = "game_over"

class LudoGame:
    """Pure Python Ludo game logic — no rendering dependencies."""

    def __init__(self, num_players: int = 4):
        self.num_players      = num_players
        self.current_player   = 0
        self.phase           = GamePhase.IDLE
        self.dice_value: int | None = None
        self.consecutive_sixes = 0
        self.winner: int | None = None
        self.scores = [0] * num_players

        # 4 tokens per player
        from token import Token
        self.pieces: List[List[Token]] = [
            [Token(player_id=i, token_id=j) for j in range(4)]
            for i in range(num_players)
        ]

    def roll(self, value: int):
        """Set dice value and advance phase based on the result."""
        self.dice_value = value
        if value == 6:
            self.consecutive_sixes += 1
            if self.consecutive_sixes >= 3:
                self.consecutive_sixes = 0
                self.advance_turn()
                return
        else:
            self.consecutive_sixes = 0
        if not self.get_movable_tokens():
            self.advance_turn()
        else:
            self.phase = GamePhase.SELECT_TOKEN

    def get_movable_tokens(self) -> List[Token]:
        """Return tokens the current player can legally move."""
        if self.dice_value is None: return []
        return [t for t in self.pieces[self.current_player] if self.can_move(t)]

    def can_move(self, token: Token) -> bool:
        if token.finished: return False
        if token.track_pos == -1: return self.dice_value == 6
        return token.track_pos + self.dice_value <= WIN_POSITION

    def move_token(self, token: Token) -> dict:
        """Execute a token move. Returns capture/finish info."""
        if not self.can_move(token):
            return {"error": "Illegal move"}
        new_pos = token.track_pos + self.dice_value
        token.track_pos = new_pos
        if new_pos == WIN_POSITION:
            token.finished = True
            self.scores[token.player_id] += 1
        captures = self.check_capture(token)
        if self.dice_value != 6 or self.consecutive_sixes >= 3:
            self.advance_turn()
        return {"captures": captures, "finished": token.finished}

    def check_capture(self, token: Token) -> List[Token]:
        """Capture opponents on the same square (unless safe). Returns captured tokens."""
        if token.track_pos in SAFE_INDICES or token.track_pos == -1:
            return []
        captured = []
        for pid, pts in enumerate(self.pieces):
            if pid == token.player_id: continue
            for t in pts:
                if t.track_pos == token.track_pos and not t.finished:
                    t.track_pos = -1
                    captured.append(t)
        return captured

    def advance_turn(self):
        if self.scores[self.current_player] == 4:
            self.winner = self.current_player
            self.phase  = GamePhase.GAME_OVER
            return
        self.current_player = (self.current_player + 1) % self.num_players
        self.dice_value     = None
        self.phase           = GamePhase.IDLE

Event Loop Structure

The Pygame event loop processes all input events each frame: QUIT (window close), KEYDOWN (keyboard), MOUSEBUTTONUP (mouse clicks on tokens or dice area), and MOUSEMOTION (optional hover effects). The handle_events function returns False when the user closes the window, signaling the main loop to exit.

Human players interact by clicking the dice area to roll, then clicking a movable token to move it. AI players are controlled by the update function, not by events — the AI decides and executes its move automatically after a short delay for visual feedback.

Python — Event handling in main.py
import pygame
from constants import BOARD_SIZE, CELL_SIZE
from game import LudoGame, GamePhase
from dice import DiceRoller

def handle_events(game: LudoGame, dice: DiceRoller, screen) -> bool:
    """Process Pygame events. Returns False to quit the game."""
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            return False
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                return False
            if event.key == pygame.K_r:
                game.__init__()  # restart
        if event.type == pygame.MOUSEBUTTONUP:
            if event.button != 1: continue  # left click only
            mx, my = event.pos
            handle_click(mx, my, game, dice)
    return True

def handle_click(mx: int, my: int, game: LudoGame, dice: DiceRoller):
    """Handle mouse click: dice area or token selection."""
    # Dice area: top-right 2×2 cells
    dice_x = 13 * CELL_SIZE
    dice_y = 0
    dice_w = 2 * CELL_SIZE
    dice_h = 2 * CELL_SIZE
    if dice_x <= mx < dice_x + dice_w and dice_y <= my < dice_y + dice_h:
        if game.phase == GamePhase.IDLE and not dice.animating:
            dice.start_animation()
            game.phase = GamePhase.ROLLING
        return
    # Token selection
    if game.phase == GamePhase.SELECT_TOKEN:
        clicked = hit_test_token(mx, my, game)
        if clicked and clicked.player_id == game.current_player:
            if game.can_move(clicked):
                result = game.move_token(clicked)
                clicked.start_animation(clicked.track_pos, renderer.track_cells)
                game.phase = GamePhase.ANIMATING

def hit_test_token(mx, my, game: LudoGame) -> Token | None:
    """Find the token closest to (mx, my) within the hit radius."""
    best, best_dist = None, float('inf')
    for token in game.pieces[game.current_player]:
        pos = token.get_pixel_position(renderer.track_cells)
        dist = ((mx - pos[0])**2 + (my - pos[1])**2) ** 0.5
        if dist < 18 and dist < best_dist:
            best_dist = dist; best = token
    return best

def update(game, dice, ai, human_players, dt):
    # Advance dice animation
    if dice.animating:
        display_value = dice.update(dt)
        if not dice.animating:
            game.roll(dice.value)

    # Token animations
    for pid, tokens in enumerate(game.pieces):
        for token in tokens:
            if token.anim_progress < 1.0:
                token.update_animation(dt, renderer.track_cells)

    # AI player logic
    if game.phase == GamePhase.IDLE and not human_players.get(game.current_player, False):
        ai.take_turn(game, dice)

AI Opponent Integration

The LudoAI class evaluates each movable token and selects the best move based on a heuristic scoring system. The AI is decoupled from the game logic — it reads the game state, computes scores, and returns a decision. This makes it straightforward to swap the heuristic AI for a minimax search, a reinforcement learning policy, or the LudoBot Python integration from this site.

The heuristic weights encode Ludo strategy: leaving base with a 6 is valuable (freeing a token), finishing a token scores highest (progress toward winning), captures are prioritized, and general track advancement is rewarded proportionally to how far along the home stretch the token is.

Python — ai.py with heuristic AI opponent
import time
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from game import LudoGame, Token
from dice import DiceRoller
from constants import SAFE_INDICES, OUTER_TRACK, WIN_POSITION

class LudoAI:
    """Heuristic-based AI opponent for Ludo. Extensible to minimax or RL."""

    def __init__(self, think_delay: float = 0.5):
        # think_delay: seconds to wait before AI acts (makes AI feel more natural)
        self.think_delay = think_delay
        self.last_turn_start = 0.0

    def take_turn(self, game: "LudoGame", dice: "DiceRoller"):
        """Called each frame when AI's turn is active. Executes roll then move."""
        if game.phase == "idle":
            self.last_turn_start = time.time()
            dice.start_animation()
        elif game.phase == "rolling" and not dice.animating:
            game.roll(dice.value)
        elif game.phase == "select_token":
            elapsed = time.time() - self.last_turn_start
            if elapsed >= self.think_delay:
                token = self.select_best_move(game)
                if token:
                    game.move_token(token)

    def select_best_move(self, game: "LudoGame") -> "Token | None":
        """Score each movable token and return the highest-scoring one."""
        movable = game.get_movable_tokens()
        if not movable: return None
        scored = [(token, self._evaluate(token, game)) for token in movable]
        scored.sort(key=lambda x: x[1], reverse=True)
        return scored[0][0]

    def _evaluate(self, token: "Token", game: "LudoGame") -> int:
        score = 0
        dice  = game.dice_value
        new_pos = token.track_pos + dice

        # Leaving base: high value — a free token is always useful
        if token.track_pos == -1:
            score += 60

        # Finishing: maximum priority — brings us closer to winning
        if new_pos == WIN_POSITION:
            score += 10000

        # Capturing: eliminate an opponent's token, gain a turn advantage
        if new_pos not in SAFE_INDICES:
            opp_count = self._opponents_at(game, new_pos)
            score += 200 * opp_count

        # Home column progress: accelerate tokens already in the home stretch
        if token.track_pos >= OUTER_TRACK:
            score += (token.track_pos - OUTER_TRACK) * 15

        # General track progress: reward tokens moving forward
        if token.track_pos >= 0:
            score += new_pos * 3

        # Safety preference: avoid moving onto unsafe squares unnecessarily
        if new_pos in SAFE_INDICES:
            score += 5

        # Cluster bonus: move tokens together for safety in numbers
        if new_pos >= 0:
            same_pos = sum(1 for t in game.pieces[token.player_id]
                      if t.token_id != token.token_id and t.track_pos == new_pos)
            score += same_pos * 20

        return score

    def _opponents_at(self, game: "LudoGame", pos: int) -> int:
        return sum(
            1 for pid, tokens in enumerate(game.pieces)
            if pid != game.current_player
            for t in tokens
            if t.track_pos == pos and pos != -1
        )

The AI integrates into the main loop's update function through a simple state machine: when it's an AI player's turn and the phase is idle, the AI rolls. When the dice animation finishes, the AI evaluates all movable tokens, selects the best one, and executes the move. A configurable think_delay adds a half-second pause between rolling and moving, giving human players time to see what the AI rolled before it acts. You can extend this AI by implementing minimax with alpha-beta pruning for the outer track portion, adding Monte Carlo Tree Search for probabilistic reasoning, or integrating a trained reinforcement learning model from Stable Baselines3.

If you want a more sophisticated opponent, the LudoBot Python integration provides a pre-trained bot that can be dropped into this architecture. The bot communicates via a simple JSON API — instantiate LudoBotClient, send the game state after each roll, and receive the selected move.

Frequently Asked Questions

pygame.display.flip() re-renders the entire display surface — it is the correct call for a fully-redrawn frame where the board changes every tick. pygame.display.update() only refreshes the portions of the screen that have changed since the last frame, which can be faster for games with mostly-static content. For Ludo, where the entire board redraws every frame anyway, flip() is simpler and preferred. Both calls block until the next vertical blank (display refresh), so they inherently synchronize with the monitor's refresh rate when used with clock.tick(FPS).
Call pygame.mixer.init() before the game loop, then load sounds with pygame.mixer.Sound('dice.wav'). Trigger sounds at key game events: call sounds['roll'].play() when dice.start_animation() is called, sounds['move'].play() when token.start_animation() begins, sounds['capture'].play() in check_capture() when a capture occurs, and sounds['win'].play() when a player finishes their fourth token. Keep sound files under 1 second each and use WAV or OGG format for lowest-latency playback.
Yes, and it's strongly recommended. Python 3.10+ with from __future__ import annotations enables post-hoc type evaluation that avoids circular import issues. The @dataclass decorator on the Token class generates __init__, __repr__, and equality methods automatically, reducing boilerplate. Use TYPE_CHECKING imports to avoid circular dependencies between modules — import type hints inside if TYPE_CHECKING: blocks and use string annotations for forward references. Run mypy or pyright on the codebase for compile-time error detection.
Use PyInstaller to create a single-file executable. Run pip install pyinstaller, then pyinstaller --onefile --windowed main.py. The --windowed flag hides the console on Windows. For a smaller binary (~5MB vs PyInstaller's default ~150MB), use Cx_Freeze or Nuitka. With Nuitka, compile with nuitka --standalone --onefile --windows-disable-console main.py. Include asset files (images, sounds) by adding them to the --include-data-dir parameter or by embedding them as base64 strings in the source code for a truly single-file deployment.
Create a GameState enum with values like MENU, PLAYING, PAUSED, and GAME_OVER. The main loop dispatches to state-specific update and render functions: MENU draws the title screen and handles start-button clicks, PLAYING runs the normal game loop, PAUSED freezes updates but continues rendering a dimmed board. Add game mode selection by storing human_players as a dictionary in the menu: allow 1 human vs 3 AI (default), 2 human hot-seat, or 4 human hot-seat. The LudoGame class itself remains unchanged — only the menu screen and human_players configuration need modification.
Extract the LudoGame, Token, and DiceRoller classes into a shared ludo_core package. On the server, FastAPI endpoints receive player actions (roll, select_token) and call ludo_game.move_token() directly. Serialize the game state as JSON for transmission to clients. The Pygame frontend becomes optional — you can serve a JavaScript/HTML5 Canvas client (see the JavaScript implementation) that communicates with the same FastAPI backend. The Python SDK on this site provides a ready-made integration layer with WebSocket support for real-time multiplayer synchronization.
Pygame's software renderer easily sustains 2000+ FPS for a Ludo board — the game's draw call count (under 150 primitives per frame) is so far below Pygame's throughput that frame rate is never a concern on any hardware from the last 15 years. The actual performance bottleneck is AI computation, which benefits from profiling with cProfile. Switch from Pygame to a faster framework (Pyglet, Arcade, or Panda3D) only if you need 3D rendering, hardware-accelerated shaders, or are porting to mobile where Pygame's SDL1 compatibility layer lacks modern touch APIs. For the desktop Ludo use case, Pygame is more than sufficient.

Need Help with Your Python Ludo Project?

Get guidance on Pygame rendering, AI implementation, Python backend architecture, and multi-player game server design.