Ludo Bot Tutorial — Complete Development Workflow
From reading the game state to submitting moves: a full technical guide to building a Ludo bot that plays autonomously. Covers screen scraping, API-based approaches, difficulty-level move selection, and production-ready action execution with timing controls.
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.
- 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.
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.
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)
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.
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.
# ============================
# 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.
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
/healthendpoint 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
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.
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.
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.
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.
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.
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.