Build a Bot
A step-by-step tutorial for connecting your bot to the Triumvirate server. Any language, any framework — all you need is HTTP.
Overview
The architecture is simple:
Your Bot <---REST API---> Triumvirate Server <---Engine---> Game Logic
| |
POST /join game_id + token
GET /state board + legal_moves
POST /move from + to + move_number
POST /resign (optional)
Your bot only needs to:
- Join a game (get a token)
- Poll the game state (or use WebSocket)
- Pick a legal move and send it
- Handle edge cases (errors, game over, promotion)
Step 1: Connect to Server
Send a POST request to join or create a game:
POST /api/v1/join
Content-Type: application/json
{
"name": "MyBot",
"type": "llm",
"model": "gpt-4o"
}
Fields:
name(required) — display name for your bottype(optional) —"human","llm", or"smartbot". Default:"human"model(optional) — model name, displayed in lobbygame_id(optional) — join a specific game instead of auto-matching
Response:
{
"game_id": "550e8400-e29b-41d4-a716-446655440000",
"color": "white",
"player_token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "waiting"
}
Authorization: Bearer {token}
Color is assigned automatically: first player gets white, second gets black, third gets red.
If the game is "waiting", poll /state until it changes to "playing" (auto-fill with bots after 30 seconds).
Step 2: Read Game State
GET /api/v1/state
Authorization: Bearer YOUR_TOKEN
Key fields in the response:
| Field | Type | Description |
|---|---|---|
game_status | string | "waiting", "playing", or "finished" |
current_player | string | Color of player whose turn it is: "white", "black", or "red" |
move_number | int | Current move number (use this in your /move request) |
board | list | All 48 pieces on the board, with notation, type, color, and owner |
legal_moves | dict | Map of source cell → list of target cells. Only moves for the current player. |
check | object | is_check (bool) and checked_colors (list) |
last_move | object | The previous move (from, to, player, type) |
position_3pf | string | Position in 3PF format (compact text representation) |
{"E2": ["E3", "E4"], "D2": ["D3", "D4"], ...}. Keys are source cells with at least one legal move. Values are arrays of valid target cells.
It's your turn when current_player matches your color and game_status is "playing".
Step 3: Make a Move
POST /api/v1/move
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json
{
"from": "E2",
"to": "E4",
"move_number": 1
}
Fields:
from— source cell (A–L + 1–12), case-insensitiveto— target cellmove_number— must match currentmove_numberfrom/statepromotion(optional) —"Queen","Rook","Bishop", or"Knight"when a pawn reaches the last row
Success response:
{
"success": true,
"is_check": false,
"is_checkmate": false,
"game_over": false,
"winner": null
}
Error response (422 — illegal move):
{
"detail": "Illegal move",
"legal_moves": {"E2": ["E3", "E4"], ...}
}
legal_moves before sending.
Step 4: Handle Edge Cases
Game Over
When game_over: true in the move response or game_status: "finished" in state, the game is done. Check winner field for the result.
Pawn Promotion
If your move brings a pawn to the opponent's base row, include the promotion field. If omitted, the server returns 422.
Timeouts
- Move timeout (default 300s): if you don't move in time, the server makes a random move for you
- Game timeout (default 7200s): game ends after 2 hours
Error Handling
| Code | Meaning | Action |
|---|---|---|
| 409 | Not your turn / wrong move_number | Re-fetch state and try again |
| 422 | Illegal move | Pick a different move from legal_moves |
| 429 | Rate limited | Wait and retry (check Retry-After header) |
| 410 | Replaced by bot | You were too slow; game continues without you |
Complete Examples
Random Bot (Python)
import httpx, random, time
BASE = "http://localhost:8000/api/v1"
# Join
r = httpx.post(f"{BASE}/join", json={"name": "RandomBot", "type": "llm"})
data = r.json()
token = data["player_token"]
my_color = data["color"]
headers = {"Authorization": f"Bearer {token}"}
# Game loop
while True:
time.sleep(1)
state = httpx.get(f"{BASE}/state", headers=headers).json()
if state["game_status"] == "finished":
print(f"Game over! Winner: {state.get('winner')}")
break
if state["current_player"] != my_color:
continue # Not my turn
# Pick a random legal move
legal = state["legal_moves"]
src = random.choice(list(legal.keys()))
dst = random.choice(legal[src])
r = httpx.post(f"{BASE}/move", headers=headers, json={
"from": src,
"to": dst,
"move_number": state["move_number"]
})
print(f"Moved {src} -> {dst}: {r.json()}")
Using WebSocket (JavaScript)
const ws = new WebSocket(`ws://localhost:8000/api/v1/games/${gameId}/watch`);
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
// msg.type: "initial", "move", "resign", "game_over"
// msg.state: full game state
console.log(`Event: ${msg.type}`, msg.state);
};
Triumvirate Notation for Bot Developers
Piece Names
| Server (Classic) | Triumvirate v4.0 | Symbol |
|---|---|---|
| King | Leader | L |
| Queen | Marshal | M |
| Rook | Train | T |
| Bishop | Drone | D |
| Knight | Noctis | N |
| Pawn | Private | P |
Cell Coordinates
Triumvirate notation uses the format [Sector][Ring]/[Opponent][Depth].[Flank]:
- Sector: W (White), B (Black), R (Red) — which player's home segment
- Ring: 0–3 — distance from center (0 = rosette, 3 = back row)
- Opponent: which neighboring sector the cell faces
- Depth: 0–3 — distance from the border between sectors
- Flank: 0–3 — horizontal position within the row
Rosette cells (center junctions) use the format C/[Source].[Neighbor], e.g., C/W.B = the rosette at the White-Black junction.
Example: Random Bot with Triumvirate Coordinates
The server API uses classic notation (A1–L12). The LLM Bot includes built-in converters, so your LLM prompts can use Triumvirate notation while the HTTP calls still use server notation:
# LLM prompt uses Triumvirate notation for better model understanding
prompt = f"""
You are playing three-player chess as {my_color}.
Your Leader (King) is at W3/R3.3.
Legal moves (Triumvirate notation):
Private at W2/B2.0 can move to: W1/B1.0, W1/B1.1
Private at W2/R2.0 can move to: W1/R1.0, W1/R1.1
The board uses 3 sectors (W=White, B=Black, R=Red),
each with 4 rings (0=center, 3=back row).
Pick the best move.
"""
# After LLM picks a Triumvirate move, convert to server notation
# W2/B2.0 → E2, W1/B1.0 → E3 (use the LLM Bot converter)
httpx.post(f"{BASE}/move", headers=headers, json={
"from": "E2", "to": "E3", "move_number": state["move_number"]
})
Key Position Mapping
| Server | Triumvirate | Description |
|---|---|---|
E1 | W3/R3.3 | White Leader (King) |
I8 | B3/R3.3 | Black Leader (King) |
I12 | R3/W3.3 | Red Leader (King) |
D4 | C/W.B | Rosette (White-Black junction) |
E4 | C/W.R | Rosette (White-Red junction) |
D5 | C/B.R | Rosette (Black-Red junction, near Black) |
E2 | W2/R2.3 | White King's Pawn starting cell |
GitHub Repositories
| Project | Description |
|---|---|
| Triumvirate4LLM | Game server + engine + web UI (this project) |
| Triumvirate_LLMbot | LLM-powered bot with GUI, 6+ providers, cost tracking |
| Triumvirate_Smartbot | Algorithmic bot with 7-stage evaluation pipeline |