Two Approaches to Building a Ludo Bot

Before writing any code, you need to decide how your bot will interact with the game. There are two fundamental approaches, each with distinct trade-offs in reliability, maintainability, and legal compliance.

Approach 1: Screen Scraping / Computer Vision

Screen scraping captures the game screen via screenshot or video stream, then uses computer vision (OpenCV, pytesseract, or an ML model) to extract token positions, dice values, and player indicators. The bot then simulates taps/clicks using platform automation tools like ADB (Android Debug Bridge) or UIAutomation (iOS).

This approach works without any API access — useful for reverse-engineering closed-source games. However, it is brittle: any UI change breaks the parser, screenshot latency introduces timing errors, and platform TOS violations are a real risk. For a production Ludo bot targeting the LudoKingAPI, this approach is unreliable compared to the alternative.

Approach 2: API-Based Game State Reading

The LudoKingAPI realtime feed delivers structured JSON describing every token's position, the current player, dice outcome, and game phase — no image processing required. Your bot subscribes to a WebSocket channel and receives state deltas as they occur. This is the approach this tutorial uses exclusively.

Why API-first over screen scraping:
  • Deterministic data — no OCR errors or pixel-position guessing
  • Legal and compliant — uses an official API, not game reverse-engineering
  • Low latency — WebSocket push vs screenshot capture delay
  • Easy to debug — JSON logs are human-readable
  • Portable — works across Android, iOS, and web builds

The Complete Bot Architecture

A production-ready Ludo bot consists of four distinct layers that communicate in a pipeline. Understanding these layers before writing code prevents architectural rewrites later.

Layer 1 — Game State Reader: Subscribes to the WebSocket feed and deserializes incoming JSON into an in-memory GameState object. Maintains a local mirror of the board state.

Layer 2 — Move Selector: Given the current GameState, the dice value, and the configured difficulty level, generates all legal moves and scores them. Returns the best candidate move.

Layer 3 — Action Executor: Takes the selected move and sends it to the LudoKingAPI via REST POST. Handles retries, rate-limit backoff, and error recovery.

Layer 4 — Bot Controller: Orchestrates the three layers in a loop, manages configuration (difficulty, delay intervals, session lifecycle), and logs activity.

Step 1 — Model the Game State

The Ludo board is a 52-square circular track divided into four colored home columns. Each player's tokens travel from their home base through the shared track, into their private home column, and finally to the center. Representing this faithfully is the foundation of every decision the bot makes.

Python
from dataclasses import dataclass, field
from enum import IntEnum
from typing import Optional

class PlayerColor(IntEnum):
    RED    = 0
    BLUE   = 1
    GREEN  = 2
    YELLOW = 3

class TokenState(IntEnum):
    IN_BASE   = -1   # Not yet on the board
    ON_TRACK  = 0   # 0-51: shared main track
    IN_HOME   = 52  # Entry into private home column
    FINISHED  = 57  # Reached the center (4 steps from entry)

@dataclass
class Token:
    color:      PlayerColor
    index:      int       # 0-3 within the player's set
    state:      TokenState
    track_pos:  int = 0   # Raw position on shared track (0-51)
    home_pos:   int = 0   # Steps into home column (0-5)

@dataclass
GameState:
    game_id:          str
    current_player:   PlayerColor
    dice_value:       int
    dice_rolled:      bool
    phase:            str   # "roll" | "move" | "game_over"
    tokens:           list[Token] = field(default_factory=list)
    turn_number:      int = 0
    move_deadline_ms: int = 30000  # 30-second move window

@dataclass
LegalMove:
    token_index:   int
    from_state:    TokenState
    to_state:      TokenState
    captures:      list[int] = field(default_factory=list) # indices of captured tokens
    score:         float = 0.0

Step 2 — Game State Reader Pattern

The GameStateReader manages the WebSocket connection, parses incoming messages, and emits Python events. Using a callback-based design keeps the I/O loop decoupled from your bot logic — a pattern borrowed from reactive programming that makes testing trivial.

Python
import threading, json, logging
from websocket import WebSocketApp

logger = logging.getLogger(__name__)

class GameStateReader:
    """
    Connects to the LudoKingAPI WebSocket feed and converts raw JSON
    messages into typed GameState objects. Notifies registered callbacks
    on each state update and on connection lifecycle events.
    """

    SAFE_SQUARES = {0, 8, 13, 21, 26, 34, 39, 47}
    START_SQUARES = {0: PlayerColor.RED, 13: PlayerColor.BLUE,
                     26: PlayerColor.GREEN, 39: PlayerColor.YELLOW}
    # Each player's entry into home column (track position right before home stretch)
    HOME_ENTRY = {PlayerColor.RED: 51, PlayerColor.BLUE: 12,
                  PlayerColor.GREEN: 25, PlayerColor.YELLOW: 38}

    def __init__(self, api_key: str, on_state: callable, on_error: callable = None):
        self.api_key    = api_key
        self.on_state   = on_state    # callback(GameState) — your bot's entry point
        self.on_error   = on_error    # callback(Exception)
        self._ws:        Optional[WebSocketApp] = None
        self._thread:   Optional[threading.Thread] = None
        self._running   = False

    def _parse_message(self, raw: bytes) -> Optional[GameState]:
        """Convert raw WebSocket bytes into a typed GameState."""
        try:
            msg = json.loads(raw)
            if msg.get("type") != "game_state":
                return None
            payload = msg["state"]
            tokens = [
                Token(color=PlayerColor(t["color"]), index=t["index"],
                      state=TokenState(t["state"]),
                      track_pos=t.get("track_pos", 0),
                      home_pos=t.get("home_pos", 0))
                for t in payload.get("tokens", [])
            ]
            return GameState(
                game_id=payload["game_id"],
                current_player=PlayerColor(payload["current_player"]),
                dice_value=payload.get("dice_value", 0),
                dice_rolled=payload.get("dice_rolled", False),
                phase=payload.get("phase", "move"),
                tokens=tokens,
                turn_number=payload.get("turn_number", 0),
                move_deadline_ms=payload.get("move_deadline_ms", 30000),
            )
        except (json.JSONDecodeError, KeyError, TypeError) as e:
            logger.error(f"Failed to parse message: {e}")
            return None

    def _on_message(self, ws, data: bytes):
        state = self._parse_message(data)
        if state:
            self.on_state(state)

    def _on_error(self, ws, error):
        logger.error(f"WebSocket error: {error}")
        if self.on_error:
            self.on_error(error)

    def connect(self, game_id: str):
        "Subscribe to a specific game's realtime feed."
        self._ws = WebSocketApp(
            "wss://api.ludokingapi.site/v1/ws",
            header={"Authorization": f"Bearer {self.api_key}"},
            on_message=self._on_message,
            on_error=self._on_error,
        )
        self._running = True
        # Send subscribe message after connection opens
        original_on_open = self._ws.on_open
        def on_open(ws):
            ws.send(json.dumps({"action": "subscribe", "game_id": game_id}))
        self._ws.on_open = on_open
        self._thread = threading.Thread(target=self._ws.run_forever, daemon=True)
        self._thread.start()

    def disconnect(self):
        self._running = False
        if self._ws:
            self._ws.close()

Step 3 — Move Selector with Difficulty Tiers

The move selector is where your bot's "intelligence" lives. A single scoring function is too blunt — real Ludo strategy varies dramatically based on game phase and opponent positions. The solution is a tiered selector that adjusts its scoring weights by difficulty level.

Legal Move Generation

Before scoring, you must enumerate every legal move. A token can move if:

  • It is in base and the dice shows a 6 (the only way to leave base)
  • It is on the track and the resulting position does not exceed the finish
  • It is in the home column and the resulting step is within bounds (0–5)
Python
from dataclasses import dataclass
from enum import Enum
from typing import Optional

class Difficulty(Enum):
    EASY   = "easy"   # Random valid move, for casual bot players
    MEDIUM = "medium" # Weighted heuristics, beatable but competent
    HARD   = "hard"   # Full minimax/expectimax with deep lookahead

@dataclass
class ScoringWeights:
    # Token lifecycle rewards
    token_home_bonus:       float = 50.0   # Token enters home column
    token_finish_bonus:     float = 200.0  # Token reaches the center
    token_exit_base_bonus:  float = 10.0   # Token leaves base (dice=6)
    # Board position rewards
    track_progress_weight: float = 0.8     # Per-step progress on main track
    safe_square_bonus:      float = 15.0   # Landing on a star square
    start_square_bonus:     float = 20.0   # Landing on own start square
    home_entry_bonus:       float = 25.0   # Entering home column
    # Combat rewards
    capture_enemy_bonus:    float = 40.0   # Sending opponent to base
    # Penalties
    exposed_penalty:        float = -20.0  # Token will be captured next turn
    base_token_penalty:    float = -5.0   # Token stuck in base

# Difficulty presets — each tier trades off speed vs. quality
DIFFICULTY_WEIGHTS = {
    Difficulty.EASY:   ScoringWeights(
        token_finish_bonus=10.0, capture_enemy_bonus=5.0,
        safe_square_bonus=0.0,   home_entry_bonus=2.0,
        track_progress_weight=0.1,
    ),
    Difficulty.MEDIUM: ScoringWeights(),
    Difficulty.HARD:   ScoringWeights(
        token_finish_bonus=500.0, capture_enemy_bonus=100.0,
        safe_square_bonus=30.0,  home_entry_bonus=50.0,
        track_progress_weight=2.0, exposed_penalty=-50.0,
    ),
}

class MoveSelector:
    """
    Given a GameState, generates all legal moves for the current player,
    scores them using configurable weights, and returns the best candidate.
    """

    SAFE_SQUARES = {0, 8, 13, 21, 26, 34, 39, 47}
    # Track position at which each color's home column begins
    HOME_ENTRY_TRACK = {PlayerColor.RED: 51, PlayerColor.BLUE: 12,
                        PlayerColor.GREEN: 25, PlayerColor.YELLOW: 38}

    def __init__(self, difficulty: Difficulty = Difficulty.MEDIUM):
        self.weights  = DIFFICULTY_WEIGHTS[difficulty]
        self.difficulty = difficulty

    def _can_exit_base(self, token: Token, dice: int) -> bool:
        """A token leaves base only on a roll of 6."""
        return token.state == TokenState.IN_BASE and dice == 6

    def _simulate_move(self, state: GameState, token: Token, dice: int) -> LegalMove:
        """Predict the token's end state after applying the dice roll."""
        player = state.current_player
        home_entry = (self.HOME_ENTRY_TRACK[player] + 1) % 52
        if self._can_exit_base(token, dice):
            start_square = self.HOME_ENTRY_TRACK[player]
            return LegalMove(token_index=state.tokens.index(token),
                                from_state=TokenState.IN_BASE,
                                to_state=TokenState.ON_TRACK,
                                captures=[])
        elif token.state == TokenState.ON_TRACK:
            new_track = (token.track_pos + dice) % 52
            steps_past_home_entry = (new_track - self.HOME_ENTRY_TRACK[player]) % 52
            if steps_past_home_entry <= 5:
                # Move ends inside the home column
                if token.home_pos + steps_past_home_entry > 6:
                    return LegalMove(token_index=state.tokens.index(token),
                                        from_state=TokenState.ON_TRACK,
                                        to_state=TokenState.ON_TRACK,
                                        captures=[])
                new_home = token.home_pos + steps_past_home_entry
                return LegalMove(
                    token_index=state.tokens.index(token),
                    from_state=TokenState.ON_TRACK,
                    to_state=TokenState.IN_HOME if new_home < 6 else TokenState.FINISHED,
                    captures=[])
            # Check for captures
            captures = []
            for idx, t in enumerate(state.tokens):
                if t.color != player and t.state == TokenState.ON_TRACK \
                   and t.track_pos == new_track \
                   and new_track % 13 != 0:  # Not a safe square
                    captures.append(idx)
            return LegalMove(token_index=state.tokens.index(token),
                                from_state=TokenState.ON_TRACK,
                                to_state=TokenState.ON_TRACK,
                                captures=captures)
        elif token.state == TokenState.IN_HOME:
            new_home = token.home_pos + dice
            if new_home >= 6:
                return LegalMove(token_index=state.tokens.index(token),
                                    from_state=TokenState.IN_HOME,
                                    to_state=TokenState.FINISHED,
                                    captures=[])
            return LegalMove(token_index=state.tokens.index(token),
                                from_state=TokenState.IN_HOME,
                                to_state=TokenState.IN_HOME,
                                captures=[])
        return LegalMove(token_index=state.tokens.index(token),
                            from_state=token.state,
                            to_state=token.state,
                            captures=[])

    def _score_move(self, move: LegalMove, state: GameState) -> float:
        w = self.weights
        score = 0.0
        if move.to_state == TokenState.FINISHED:
            return w.token_finish_bonus
        if move.from_state == TokenState.IN_BASE and move.to_state != TokenState.IN_BASE:
            score += w.token_exit_base_bonus
        if move.from_state == TokenState.ON_TRACK and move.to_state == TokenState.IN_HOME:
            score += w.home_entry_bonus
        if move.captures:
            score += w.capture_enemy_bonus * len(move.captures)
        score += len(move.captures) * 10
        return score

    def get_legal_moves(self, state: GameState) -> list[LegalMove]:
        """Enumerate all valid moves for the current player."""
        player = state.current_player
        moves = []
        for token in [t for t in state.tokens if t.color == player and t.state != TokenState.FINISHED]:
            move = self._simulate_move(state, token, state.dice_value)
            if move.to_state != move.from_state:
                move.score = self._score_move(move, state)
                moves.append(move)
        return moves

    def select_move(self, state: GameState) -> Optional[LegalMove]:
        moves = self.get_legal_moves(state)
        if not moves:
            return None
        if self.difficulty == Difficulty.EASY:
            import random
            return random.choice(moves)
        return max(moves, key=lambda m: m.score)

Step 4 — Action Executor with Delays

Submitting moves immediately is a rookie mistake. Real players take time to think, and many game servers rate-limit requests that arrive too fast. The ActionExecutor enforces human-like delays, handles HTTP errors, and implements exponential backoff for transient failures.

Python
import time, random, logging, requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

logger = logging.getLogger(__name__)

class ActionExecutor:
    """
    Sends bot moves to the LudoKingAPI with human-like timing and
    production-grade retry logic. Tracks submission latency for monitoring.
    """

    BASE_URL = "https://api.ludokingapi.site/v1/games/move"

    # Human decision time ranges (milliseconds)
    THINK_DELAY_MS = {"easy": (200, 800), "medium": (500, 2000), "hard": (200, 600)}

    def __init__(self, api_key: str, difficulty: str = "medium"):
        self.api_key    = api_key
        self.difficulty = difficulty
        self.session    = self._build_session()
        self.stats      = {"submitted": 0, "failed": 0, "retried": 0}

    def _build_session(self) -> requests.Session:
        session = requests.Session()
        retry = Retry(total=3, backoff_factor=0.5,
                       status_forcelist={429, 500, 502, 503},
                       allowed_methods=["POST"])
        session.mount("https://", HTTPAdapter(max_retries=retry))
        session.headers.update({"Authorization": f"Bearer {self.api_key}",
                                 "Content-Type": "application/json"})
        return session

    def _human_delay(self):
        lo, hi = self.THINK_DELAY_MS.get(self.difficulty, (500, 2000))
        jitter = random.uniform(lo, hi) / 1000.0
        time.sleep(jitter)

    def execute(self, game_id: str, move: LegalMove, dice: int) -> bool:
        """Submit a move to the API. Returns True on success."""
        payload = {
            "game_id":     game_id,
            "token_index": move.token_index,
            "dice":        dice,
        }
        self._human_delay()
        try:
            resp = self.session.post(self.BASE_URL, json=payload, timeout=10)
            if resp.status_code == 200:
                self.stats["submitted"] += 1
                logger.info(f"Move submitted: token={move.token_index}, dice={dice}")
                return True
            elif resp.status_code == 400:
                logger.warning(f"Illegal move rejected: {resp.json()}")
                self.stats["failed"] += 1
                return False
            elif resp.status_code == 429:
                logger.warning("Rate limited — backing off")
                self.stats["retried"] += 1
                time.sleep(5)
                return self.execute(game_id, move, dice)
            else:
                logger.error(f"Unexpected response {resp.status_code}: {resp.text}")
                self.stats["failed"] += 1
                return False
        except requests.RequestException as e:
            logger.error(f"Network error during move submission: {e}")
            self.stats["failed"] += 1
            return False

Step 5 — The Full Bot Loop Pseudocode

With the three layers in place, the controller orchestrates them in a tight event loop. This is the glue that ties everything together. For production use, add watchdog timers and graceful shutdown handling.

Pseudocode
# ============================
# LUDO BOT — MAIN LOOP
# ============================

INIT    api_key       = read_env("LUDO_API_KEY")
INIT    my_player_id  = read_env("BOT_PLAYER_ID")
INIT    difficulty    = Difficulty[read_env("BOT_DIFFICULTY", "MEDIUM")]
INIT    reader        = GameStateReader(api_key, on_state=handle_state)
INIT    selector      = MoveSelector(difficulty)
INIT    executor      = ActionExecutor(api_key, difficulty.name.lower())

ON state_update(state: GameState):
    # Only act when it's our turn and we're in the move phase
    IF state.current_player != my_player_id: RETURN
    IF state.phase != "move": RETURN
    IF state.game_over: RETURN

    # 1. Get legal moves
    legal_moves = selector.get_legal_moves(state)
    IF legal_moves IS EMPTY:
        submit_pass(state.game_id)   # No valid moves available
        RETURN

    # 2. Select best move
    chosen = selector.select_move(state)

    # 3. Execute with timing
    success = executor.execute(state.game_id, chosen, state.dice_value)

    IF NOT success:
        log_error(state.game_id, chosen, executor.stats)
        # Optionally fall back to a lower-priority move
        fallback = second_best(legal_moves)
        IF fallback: executor.execute(state.game_id, fallback, state.dice_value)

# ============================
# STARTUP
# ============================
game_id = input("Enter game ID to join: ")
reader.connect(game_id)

# Block until disconnected or game ends
WHILE reader.is_connected() AND NOT game_over:
    sleep(1.0)

reader.disconnect()
print("Bot session ended. Stats:", executor.stats)

Difficulty Level Strategy Breakdown

Each difficulty tier changes the bot's behavior in specific, measurable ways. Understanding these trade-offs helps you tune the bot for your target use case — whether that's a casual mobile opponent, a training partner, or a competitive AI.

EASY — Exploratory Play

Easy mode prioritizes broad game exploration over optimization. The bot uses a near-uniform random selection from legal moves, with only minimal bias toward token exit and finish. This level is suitable for tutorial bots, practice opponents, or simulations where you want players to feel dominant. Think time is fast (200–800ms) to feel responsive but not threatening.

MEDIUM — Competent Heuristic

Medium mode applies weighted scoring across all game phases. It prioritizes capturing opponents on non-safe squares, entering the home column, and progressing tokens with high advancement potential. It also recognizes basic threats — it will avoid moves that leave a token exposed to obvious capture. This is the default level for most production bot opponents. Think time is moderate (500ms–2s) to simulate human deliberation.

HARD — Optimizing Agent

Hard mode maximizes the weighted score function with aggressive weights on captures, home entry, and finish. It adds threat analysis — before committing to a move, it evaluates whether the resulting position leaves any token vulnerable to the next player. It also applies positional awareness: tokens closer to the home column are valued exponentially higher since they have a higher probability of reaching the finish. Think time is short (200–600ms) to mimic an experienced player who decides quickly. For deep tree search beyond this heuristic, see the AI algorithm guide.

Advanced: Screen Scraping Hybrid

For cases where the LudoKingAPI is unavailable and you must interact with a native game client, a screen scraping layer can be integrated alongside the API reader. The two systems are mutually exclusive at runtime — you choose one at startup. The scraper uses the same GameState dataclass internally, so the rest of the bot pipeline never knows the difference.

Python
import cv2, numpy as np

class ScreenScraper:
    """
    Captures game screen via ADB screenshot (Android) or UIAutomation (iOS)
    and extracts structured board state using OpenCV template matching.
    This is a fallback for environments without API access.
    """

    def capture_screen(self, platform: str = "android") -> np.ndarray:
        if platform == "android":
            import subprocess
            subprocess.run(["adb", "exec-out", "screencap", "-p"],
                           stdout=open("/tmp/screen.png", "wb"))
            return cv2.imread("/tmp/screen.png")
        raise NotImplementedError(f"Platform {platform} not supported")

    def extract_dice(self, frame: np.ndarray) -> int:
        # Template-match the dice ROI against known pip patterns
        roi = frame[y1:y2, x1:x2]
        best_match, best_score = 1, 0.0
        for pip in range(1, 7):
            template = cv2.imread(f"templates/dice_{pip}.png")
            result = cv2.matchTemplate(roi, template, cv2.TM_CCOEFF_NORMED)
            _, score, _, _ = cv2.minMaxLoc(result)
            if score > best_score:
                best_match, best_score = pip, score
        return best_match

    def tap(self, x: int, y: int, platform: str = "android"):
        import subprocess
        if platform == "android":
            subprocess.run(["adb", "shell", "input", "tap", str(x), str(y)])

Deployment Checklist

Before going live, verify these production requirements:

  • Environment variables: API key, player ID, and difficulty level must come from environment, never hardcoded
  • Logging: Capture every state transition, move decision, and API response with timestamps
  • Health endpoint: Expose a simple /health endpoint that returns bot status and move statistics
  • Graceful shutdown: Handle SIGTERM — finish the current move cycle before exiting
  • Rate limiting compliance: Respect the API's per-second and per-minute move limits (see API docs)
  • Containerization: Package with Docker for repeatable deployments — see the Docker guide

Where to Go Next

This tutorial built a heuristic-based bot using weighted scoring. To push into adversarial search, start with the AI algorithm guide which covers minimax, expectimax, and Monte Carlo Tree Search adapted for Ludo's stochastic dice mechanic. For a production-ready Python implementation with full feature parity, see the Python bot library.

If you are evaluating whether building a bot is the right project for your goals, the can I build a Ludo bot? decision guide walks through feasibility assessment, team requirements, and cost estimation.

For a complete game implementation to test against, the Ludo game tutorial covers board rendering, rule enforcement, and multiplayer state management from scratch.

Frequently Asked Questions

The minimum viable bot needs three things: (1) a GameState parser that converts API JSON into an object, (2) a legal move generator that respects Ludo rules, and (3) an API call to submit the chosen move. The code in Steps 1–3 of this tutorial is that minimum — roughly 150 lines of Python. The ActionExecutor and GameStateReader add production reliability but are not required for a proof-of-concept that plays its first game.
Ludo grants a free reroll when a player rolls 6. The API sends this as a state update where dice_rolled=true and the client must roll again via POST /games/roll. The bot controller must detect this condition in the state handler: if phase == "roll" and it is the bot's turn, it sends a roll request. After rolling 6 three times consecutively, the rules enforce a mandatory pass — the bot detects this from the server's response and skips the move. The GameStateReader._parse_message automatically populates dice_rolled from the incoming payload.
The core data model (Token, GameState, LegalMove) is variant-agnostic. The move simulator in MoveSelector._simulate_move is the only method that encodes variant-specific rules. For speed Ludo (tokens exit on 1–6), change the _can_exit_base condition from dice == 6 to dice >= 1. For Ludo Super (two dice, move the token that matches the higher die), replace the single-dice logic with a multi-die scoring loop. All other layers — reader, executor, and controller — remain unchanged. The Python bot library provides variant configuration via constructor parameters.
The LudoKingAPI WebSocket feed delivers state updates in guaranteed order per game room. Each message carries a monotonic sequence number. The GameStateReader maintains a _last_seq counter and discards any message with a sequence number lower than the last processed — this eliminates duplicate deliveries and out-of-order arrivals. If your bot is running multiple game sessions concurrently, use a per-game sequence tracker rather than a global one to isolate streams.
The LudoKingAPI targets sub-100ms end-to-end latency from server-side state change to WebSocket delivery. Your bot's round-trip (read state, compute move, submit, receive confirmation) typically lands under 500ms for the MEDIUM difficulty tier. EASY and HARD difficulty settings only change the ActionExecutor's think delay — the scoring computation itself is fast enough to be negligible. If you are deploying on a distant server from the API, expect an additional 50–150ms of network RTT.
The natural progression is: (1) replace the weighted sum scorer with an expectimax tree that enumerates all possible dice outcomes and evaluates the expected value of each move. (2) Add Monte Carlo Tree Search (MCTS) with 1000+ rollouts per decision to handle Ludo's stochastic branching factor. (3) Train a reinforcement learning agent that learns position values from self-play — the AI algorithm guide covers all three approaches with specific hyperparameter recommendations for Ludo's board size of 52 squares and 4-token player limit.
Yes, but only if each bot controls a distinct player slot. The LudoKingAPI assigns a unique player_id to each connected client. The controller's handle_state callback checks state.current_player == my_player_id before acting — this gate prevents any two bots controlling the same token. If two bots are assigned the same my_player_id, they will both attempt moves simultaneously, resulting in one successful call and one 400 (illegal move) response per turn.

Need Help Building Your Ludo Bot?

Get personalised guidance from the LudoKingAPI team. We'll help you integrate the API, tune difficulty levels, and deploy your bot to production.