Latest pachinko-pybind: 0.8.1
Bot Development
Build bots that compete in live multiplayer games. Bots run on your own machineand connect to the Pachinko servers over WebSocket using a per-bot token. Write a deterministic Python script, or load a reinforcement-learning checkpoint — the runtime is the same.
Overview
Each bot is a Python process you start yourself with a token issued by this site. The SDK opens a long-lived connection to the matchmaker, waits for an assignment, and drives one game at a time. When a game ends the SDK keeps the session open and waits for the next match, so a single process can play indefinitely.
Deterministic (.py)
Subclass Bot, implement on_tick(state), return a list of actions. Full control, no training required. Good for rule-based strategies.
RL agent (.py)
Train any model using pachinko_gym (Gymnasium wrapper). Load your weights inside on_game_start and run inference in on_tick— full freedom over architecture and framework.
Quick start
1. Go to My Botsand create a bot. Copy the token shown to you — it is only displayed once.
2. Install the SDK and write a script that connects with your token:
from pachinko_sdk import Bot, run_online, AttackMove, ToggleSpawn
class MyBot(Bot):
def on_tick(self, state) -> list:
actions = []
for b in state.my_buildings:
if b.kind in ("squarracks", "circhery", "triables") and not b.spawn_toggle:
actions.append(ToggleSpawn(b.id, True))
if state.enemy_buildings and state.idle_units():
t = state.enemy_buildings[0]
for u in state.idle_units():
actions.append(AttackMove(u.id, t.x, t.y))
return actions
if __name__ == "__main__":
run_online(MyBot(),
orchestrator_url="https://pachinko-rts.com/orchestrator",
bot_token="bot_...paste_your_token_here...")3. Run it. The bot stays connected and plays match after match until you stop it. Watch it in action on the Spectate page.
Installation
One package, both modules
pip install pachinko-pybindPython 3.9+ required. pachinko-pybind is a single pre-built wheel that ships two Python modules: pachinko_sdk (the live-server client used by every bot) and pachinko_gym(Gymnasium environment used for offline RL training). The game simulation runs in compiled Rust under the hood — no source build needed. requests, websocket-client, and numpy install automatically.
Optional: training extras
For RL training you'll also want a learning library. The examples below use stable-baselines3 / sb3-contrib:
pip install "pachinko-pybind[train]"Versions & upgrades
Keep pachinko-pybind up to date — latest is 0.8.1, and the matchmaker requires at least 0.8.1:
pip install -U pachinko-pybindThe matchmaker enforces the minimum version at connect time (an outdated SDK gets HTTP 426 with an upgrade hint). Releases are backwards-compatible unless the changelog says otherwise, so upgrading is always the right move.
Deterministic bots
Create a .py file, subclass Bot, and implement on_tick. The SDK handles all communication with the game.
Want to see what a complete, competitive bot looks like? The reference bot page walks through a full Python port of the built-in AI — production counters, upgrades, garrison, node control, attack waves, harass, and kiting.
Minimal example
from pachinko_sdk import Bot, run_online, AttackMove, ToggleSpawn
class MyBot(Bot):
def on_tick(self, state) -> list:
actions = []
# Keep all military buildings spawning
for b in state.my_buildings:
if b.kind in ("squarracks", "circhery", "triables"):
if not b.spawn_toggle:
actions.append(ToggleSpawn(b.id, True))
# Attack-move idle units toward the nearest enemy building
if state.enemy_buildings:
target = state.enemy_buildings[0]
for unit in state.idle_units():
actions.append(AttackMove(unit.id, target.x, target.y))
return actions
if __name__ == "__main__":
run_online(MyBot(),
orchestrator_url="https://pachinko-rts.com/orchestrator",
bot_token="bot_...your_token...")Counter-unit production
Count the visible enemy unit types and spawn the counter. Square beats Triangle, Triangle beats Circle, Circle beats Square.
def on_tick(self, state) -> list:
actions = []
# Count visible enemy types
sq = sum(1 for u in state.enemy_units if u.unit_type == "square")
ci = sum(1 for u in state.enemy_units if u.unit_type == "circle")
tr = sum(1 for u in state.enemy_units if u.unit_type == "triangle")
if tr > max(sq, ci):
primary = "squarracks" # squares beat triangles
elif sq > max(ci, tr):
primary = "circhery" # circles beat squares
else:
primary = "triables" # triangles beat circles
for b in state.my_buildings:
if b.kind in ("squarracks", "circhery", "triables"):
actions.append(ToggleSpawn(b.id, b.kind == primary))
return actionsBuying upgrades
# Upgrade types: "spawn_rate", "weapon_damage", "armor", "move_speed", "mining_rate"
from pachinko_sdk import Upgrade
def on_tick(self, state) -> list:
actions = []
RESERVE = 600 # keep this much hematite in reserve
for b in state.my_buildings:
if b.kind == "squarracks":
if b.upgrades["spawn_rate"] < 5 and state.resources > 300 + RESERVE:
actions.append(Upgrade(b.id, "spawn_rate"))
if b.kind == "hematite":
if b.upgrades["mining_rate"] < 5 and state.resources > 400 + RESERVE:
actions.append(Upgrade(b.id, "mining_rate"))
return actionsLocal testing & self-play
Before you connect a bot live, run it offline against up to 7 opponents with run_local— no server, no token. It drives the exact same lifecycle as online (on_game_start → on_tick / on_bet_action → on_match_settled / on_game_over) and hands your bot the identical GameState per seat, so a bot that works here works live.
from pachinko_sdk import run_local
from my_bot import MyBot
# your bot in seat 0; the other 7 seats are the built-in AI
result = run_local(MyBot(), num_players=8)
print(result.winner_slot, result.settlement, result.bankrolls)
# mirror match — your own bots in several seats, the rest auto-fill with AI
run_local({0: MyBot(), 1: MyBot(), 2: RivalBot()}, num_players=8)
# RTS only (no betting), custom scenario
run_local(MyBot(), num_players=6, betting=False, flat_terrain=True)bots is a single Bot (seat 0), a list (index = seat), or a {seat: controller} dict, and seats can be heterogeneous: an SDK Bot, a plain callable f(state) -> list[Action] (e.g. an RL policy adapter), or "builtin" / Nonefor the built-in AI. The full pre-game runs offline too: each seat is dealt its 5 mulligan items (rerolls capped and charged exactly like live) and the poker layer runs the real engine economy — built-in AI seats bet with the same fold/call/raise logic as ranked, folds resign the seat (its army leaves the field), and the pot settles to a typed Settlement. Pass betting=False for an RTS-only match. Scenario keywords (flat_terrain, symmetric_map, resource_nodes, star_count, starting_resources, no_fow) are forwarded to the engine. Everything is deterministic per seed — the same seed replays the same match, ideal for regression-testing a bot.
| Name | Type | Description |
|---|---|---|
| run_local(bots, num_players=2, betting=True, mulligan=True, seed=0, max_ticks=7200, **scenario) | LocalResult | Run one offline match of 2–8 players and return the outcome. |
| LocalResult.winner_slot | int | Winning seat (0-based), or -1 (draw / unresolved at max_ticks). |
| LocalResult.placement | list[int] | Finishing position per seat (1-based, 1 = winner; ties share a rank). |
| LocalResult.net_chips | list[int] | None | Net chip P/L per seat (Σ == 0), or None if betting=False. |
| LocalResult.my_net(seat) / my_placement(seat) | int | Convenience readers for one seat's chips / rank. |
| LocalResult.settlement | Settlement | None | Terminal pot payout (None if betting=False). |
| LocalResult.bankrolls | list[int] | None | Post-match chip stack per seat (None if betting=False). |
| LocalResult.ticks | int | Ticks simulated. |
Batched evaluation
To measure a bot properly, run many matches and aggregate. run_local_batch plays n_games full matches (deterministic per game: seeds are base_seed + game_index) and returns the numbers evaluation cares about for one seat:
from pachinko_sdk import run_local_batch
res = run_local_batch(lambda i: {0: MyBot(), 1: "builtin", 2: "builtin", 3: "builtin"},
num_players=4, n_games=100, workers=4)
print(res.first_place_rate(), res.avg_placement(), res.mean_net(), res.placement_counts())The factory is called once per game so controllers never share state across matches. workers > 1 uses a process pool (the factory must be picklable).
For RL training (numpy observations at thousands of steps/s), use pachinko_gym instead — run_local exercises the exact live Bot code path, including stars, item drops, and fog, so what your bot sees here is what it sees online.
API reference
Bot lifecycle methods
| Name | Type | Description |
|---|---|---|
| on_game_start(data) | None | Called once at game start. data includes the GameStart payload (map_seed, players, num_players, and map_info — the static terrain grid). The parsed map is also on every state.map_info. |
| on_tick(state) | list[Action] | Called every tick. Return a list of action objects. |
| on_mulligan(obs) | MulliganDecision | Called once when the mulligan phase opens, then again after every reroll. Default: accept. |
| on_bet_action(obs) | BetDecision | Called whenever the server asks this bot for a betting action. Default: call (goes all-in if the call exceeds the bankroll). |
| on_match_settled(settlement) | None | Called just before on_game_over with the terminal payout as a typed Settlement (pot + per-slot net deltas, Σ net == 0). Default: no-op. |
| on_game_over(data) | None | Called when the game ends. data is the GameOver payload, or {reason: 'ws_closed' | 'ws_eof'} if the server dropped the WS without a GameOver. |
GameState fields
| Name | Type | Description |
|---|---|---|
| tick | int | Current game tick (24 ticks per second). |
| resources | int | Your current hematite balance. |
| my_units | list[Unit] | All your living units. |
| my_buildings | list[Building] | All your buildings. |
| enemy_units | list[Unit] | Visible enemy units. |
| enemy_buildings | list[Building] | Visible enemy buildings. |
| resource_nodes | list[ResourceNode] | All hematite nodes on the map. |
| game_over | bool | True once the match has ended. |
| winner_slot | int | Winning player slot (0-based), or -1 while in progress / on a mutual-KO refund. |
| player_slot | int | Your own slot (0-based) — interpret winner_slot and per-entity owner against this, even after all your units die. |
| won | bool (property) | True iff game_over and winner_slot == player_slot. |
| result | str (property) | Outcome relative to you: "in_progress" | "win" | "loss". No "draw" — a mutual KO refunds and reads as "loss". |
| finishing_ranks | tuple[int] | Per-slot finishing position (1-based, 1 = winner; ties share a rank) once the match ends; empty while in progress. |
| my_placement | int (property) | Your own finishing position, or -1 while the game is running. |
| scores | list[int] | Per-slot live display score (0–400), indexed by slot — the same number the score panel shows. A dense progress signal. |
| stars | list[Star] | Neutral stars your team can currently see (fog-filtered). |
| dropped_items | list[DroppedItem] | Items lying on the map your team can see (fog-filtered). |
| items_collected | int | How many items your team has picked up so far. |
| map_info | MapInfo | None | Static map geometry + terrain grid. Sent once at game start; same object every tick. None until the first frame. |
| visibility | ndarray | None | (H, W) uint8 3-state fog grid in MapInfo cell coords: 2 = visible this tick, 1 = explored but stale, 0 = never seen. Lazily decoded; needs numpy + map_info. |
| feature_planes(featurizer=None) | ndarray | Stacked (C, H, W) float32 CNN feature planes for this tick. Pass a reused Featurizer to cache the static terrain planes. |
| idle_units() | list[Unit] | Helper: your units with no current order. |
| units_of_type(t) | list[Unit] | Helper: filter by type string. |
| nearest_enemy(x, y) | Unit | None | Helper: closest visible enemy unit to a point. |
| military_buildings() | list[Building] | Helper: your squarracks/circhery/triables. |
| enemy_military_buildings() | list[Building] | Helper: visible enemy military buildings. |
Unit fields
| Name | Type | Description |
|---|---|---|
| id | int | Unique entity ID. |
| x, y | float | World position (0–2500 on each axis). |
| unit_type | str | "square" | "circle" | "triangle" |
| hp / max_hp | int | Current and maximum hit points. |
| has_target | bool | True if the unit is currently executing an order. |
| attack_moving | bool | True if the unit is attack-moving. |
| owner | int | Owning player slot (0-based) — the real owner even for enemy units, so multi-player observations attribute every entity. |
| facing | float | Heading in radians (populated for own + enemy units). |
| target_id | int | Entity ID of this unit's attack target, or -1. Own units only (enemy intent is hidden → -1). |
| destination | tuple | None | (x, y) move-order target, or None. Own units only (enemy → None). Backed by dest_x/dest_y. |
Building fields
| Name | Type | Description |
|---|---|---|
| id | int | Unique entity ID. |
| x, y | float | World position. |
| kind | str | "squarracks" | "circhery" | "triables" | "hematite" | "arrow_tower" |
| hp / max_hp | int | Current and maximum hit points. |
| spawn_toggle | bool | Whether this building is currently spawning units. |
| upgrades | dict | Keys: spawn_rate, weapon_damage, armor, move_speed, mining_rate, healing_aura. Values: int (0–5). |
| owner | int | Owning player slot (0-based) — real owner even for enemy buildings. |
ResourceNode fields
| Name | Type | Description |
|---|---|---|
| x, y | float | World position. |
| richness | float | Income rate multiplier. |
| control | str | "neutral" | "own" | "enemy" (relative to you). |
| owner_player_id | int | Owning player slot (0-based), or -1 if neutral. Absolute attribution, unlike the relative control field. |
| contested | bool | True when ≥2 players have units within the capture radius. |
Star fields
| Name | Type | Description |
|---|---|---|
| id | int | Unique star ID. |
| x, y | float | World position. |
| hp / max_hp | int | Current and maximum hit points. |
| tier | int | 0 = weak, 1 = medium, 2 = strong. |
| tier_name | str | Property: "weak" | "medium" | "strong". |
DroppedItem fields
| Name | Type | Description |
|---|---|---|
| id | int | Unique item ID. |
| x, y | float | World position. |
| kind | ItemKind | Property: the item kind enum (decoded from kind_id). |
| rarity_name | Rarity | Property: "common" | "uncommon" | "rare" | "legendary". |
| unit_type | int | 0=square, 1=circle, 2=triangle, 255=N/A (item not unit-specific). |
| value_f / value_i | float / int | Item-specific magnitude (which one applies depends on kind). |
MapInfo fields
Static map geometry, delivered once at game start and attached to every state.map_info. The terrain grid is row-major and shares the coordinate system of state.visibility, so terrain, fog, and your rasterized units all line up on the same grid.
| Name | Type | Description |
|---|---|---|
| world_width, world_height | float | World size in game units (2500 × 2500). |
| cell_size | float | World units per terrain cell (20). |
| grid_width, grid_height | int | Terrain grid dimensions in cells (125 × 125). |
| terrain | list[int] | Row-major terrain codes: 0=ground, 1=hill, 2=mountain (impassable), 3=slope. |
| spawn_positions | list[(float, float)] | Per-slot start locations, masked per recipient for fairness: only YOUR slot is real, every other slot reads (-1, -1). Scout to find opponents. |
| my_spawn | (float, float) | None | Your own start location (the one real spawn_positions entry). |
| resource_nodes | list[{x, y, richness}] | Static node layout, delivered pre-game so on_mulligan / on_bet_action can weigh starting economy before betting. Positions and richness never change. |
| nodes_near(x, y, k) | list | Helper: the k nodes closest to a point, e.g. map_info.nodes_near(*map_info.my_spawn, k=3). |
| world_to_cell(x, y) | (int, int) | Map a world position to its (gx, gy) grid cell. |
| cell_to_world(gx, gy) | (float, float) | Center world position of a grid cell. |
| terrain_code_at(x, y) | int | Terrain code at a world position. |
| is_walkable(x, y) | bool | False only on mountain cells. |
| height_at(x, y) | int | 1 on hills (high-ground combat bonus), else 0. |
| as_numpy() | ndarray | (grid_height, grid_width) uint8 terrain grid for ML. Needs numpy. |
Actions
| Name | Type | Description |
|---|---|---|
| Move(unit_id, x, y) | int, float, float | Move to position without attacking. |
| AttackMove(unit_id, x, y) | int, float, float | Move and auto-attack enemies encountered. |
| Attack(unit_id, target_id) | int, int | Direct attack on a specific enemy unit. |
| Halt(unit_id) | int | Cancel current order. |
| ToggleSpawn(building_id, on) | int, bool | Enable or disable unit production. |
| Upgrade(building_id, upgrade_type) | int, str | Purchase one upgrade level. upgrade_type: see Building fields. |
| Patrol(unit_id, x, y) | int, float, float | Patrol back and forth to position. |
| AttackMoveGroup(unit_ids, x, y) | list[int], float, float | Attack-move a list of units together. |
| MoveGroup(unit_ids, x, y) | list[int], float, float | Move a list of units to the same position. |
| HaltGroup(unit_ids) | list[int] | Halt a list of units. |
Betting + mulligan overview
A match is wrapped in poker-style betting: one round before the RTS phase opens and one mid-game. The pot aggregates across roundsand is paid out at the end of the game — nothing is awarded between rounds. RTS-eliminated bots keep betting until they fold or run out of bankroll. Folding is resigning from the battlefield: your remaining units and buildings are removed and you forfeit the pot, but chips you already committed stay in it. Destroying an opponent's last military building earns an elimination bounty (one small blind) off the top of the pot.
If you only care about the RTS layer, do nothing — the base Bot class auto-accepts every mulligan and auto-calls every betting decision (going all-in if the call exceeds your bankroll). To play the betting game, override on_mulligan and/or on_bet_action.
from pachinko_sdk import (
Bot, BetDecision, BetObservation,
MulliganDecision, MulliganObservation,
)
class MyBot(Bot):
def on_mulligan(self, obs: MulliganObservation) -> MulliganDecision:
# Reroll the two lowest-rarity items if we can afford it.
from pachinko_sdk import Rarity
cheap = [i for i, item in enumerate(obs.items) if item.rarity == Rarity.COMMON]
return MulliganDecision.reroll(cheap[:2]) if cheap else MulliganDecision.accept()
def on_bet_action(self, obs: BetObservation) -> BetDecision:
# Fold to aggression if anyone has raised this round; otherwise call.
from pachinko_sdk import BetActionKind
raised = any(ev.action == BetActionKind.RAISE for ev in obs.history_this_round)
if raised and obs.to_call > obs.me.bankroll * 0.1:
return BetDecision.fold()
return BetDecision.call()MulliganObservation fields
| Name | Type | Description |
|---|---|---|
| items | list[StartingItem] | Your 5 dealt items: kind, rarity, unit_type, value. |
| bankroll | int | Chips remaining (rerolls cost big_blind each). |
| big_blind | int | Cost per rerolled index. |
| reroll_cost | int | Alias for big_blind. |
StartingItem fields
| Name | Type | Description |
|---|---|---|
| kind | ItemKind | Enum: ExtraProjectile, Ballistics, DamageBoost, SpeedBoost, ArmorBoost, SpawnRateBoost, HpBoost, CircleRangeBoost, CircleAttackSpeedBoost, CircleSplashDamage, CleaveAttack, ResourceRateBoost, HematiteBonus, HealthRegen, VisionBoost. |
| rarity | Rarity | Common, Uncommon, Rare, Legendary. |
| unit_type | UnitType | None | Square / Circle / Triangle for unit-targeted kinds; None otherwise. |
| value | float | Kind-specific magnitude (multiplier or flat bonus). |
| raw | dict | Original wire dict, in case you need a field this dataclass omits. |
BetObservation fields
| Name | Type | Description |
|---|---|---|
| round | int | 0 = pre-game round, 1+ = mid-game rounds. |
| my_player_id | int | Your slot id. |
| my_seat | int | Your index in this round's action_order. |
| action_order | list[int] | Player slots in the order they act this round. |
| pot | int | Cumulative chips in the pot (across all rounds). |
| current_bet | int | Total committed needed to stay in the round. |
| players | list[PlayerBetState] | Per-slot snapshot: bankroll, committed, status. |
| my_items | list[StartingItem] | Your mulligan items, retained from the pre-game phase. |
| history_this_round | list[BetEvent] | Every BetUpdate seen this round, in order. |
| history_prior_rounds | list[list[BetEvent]] | Closed rounds, oldest first. |
| to_call | int (property) | Chips needed to call (0 means a check is free). |
| can_check | bool (property) | True if to_call == 0. |
| me | PlayerBetState (property) | Shorthand for players[my_player_id]. |
BetEvent fields
| Name | Type | Description |
|---|---|---|
| player | int | Slot of the player who acted. |
| action | BetActionKind | Ante, Check, Bet, Call, Raise, Fold, AllIn. |
| amount | int | Chips put in for this action (0 for check/fold). |
| pot_after | int | Total pot after this action. |
| current_bet_after | int | current_bet after this action. |
Decision constructors
| Name | Type | Description |
|---|---|---|
| BetDecision.call() | BetDecision | Call (or check if obs.to_call == 0). Goes all-in if the call exceeds bankroll. |
| BetDecision.fold() | BetDecision | Fold. |
| BetDecision.raise_(amount) | BetDecision | Raise by `amount` chips on top of current_bet. |
| BetDecision.all_in() | BetDecision | All-in. |
| MulliganDecision.accept() | MulliganDecision | Lock in your current items. |
| MulliganDecision.reroll([0, 2]) | MulliganDecision | Reroll items at the listed indices (0..=4). Costs big_blind each; capped at 2 rerolled cards per match (the human limit) — extra indices are dropped uncharged. |
Settlement fields
Passed to on_match_settled at the end of a match. The same shape is produced live (from the wire) and in self-play (from PyBettingState.distribute_winnings), so a bot reads it identically whether it trained offline or played online.
| Name | Type | Description |
|---|---|---|
| pot | int | Total cumulative pot for the match, in chips. |
| entries | tuple[SettlementEntry] | Per-slot terminal result, by slot. Each entry: player_id, net (bankroll delta, negative = loss), won (net > 0), rank (finishing position, 1-based; ties share). |
| my_rank | int (property) | Your own finishing position from entries, or -1 if unknown. |
| net | int (property) | Your own bankroll delta this match (0 if your slot is unknown). |
| won | bool (property) | True iff your own entry netted positive. |
| winners | list[int] (property) | Slots whose net was positive. |
| entry(player_id) | SettlementEntry | None | Look up one slot's result. |
| raw | dict | None | Original wire dict (online only); None in self-play. |
RL bots (Gymnasium)
RL bots are just regular .py bot scripts — the same format as deterministic bots. You train your model offline using pachinko_gym, then load the weights inside on_game_start and call your model in on_tick. You choose the architecture, the framework (PyTorch, JAX, anything), and the inference code. The game does not care how you produce the actions.
Train offline
pachinko_gym ships inside the same pachinko-pybind wheel you already installed for the live SDK. Use any RL library. The example below uses MaskablePPO from sb3-contrib, but any algorithm or framework works.
from pachinko_gym import PachinkoSeriesEnv
from sb3_contrib import MaskablePPO
env = PachinkoSeriesEnv()
model = MaskablePPO("MultiInputPolicy", env, verbose=1)
model.learn(total_timesteps=5_000_000)
model.save("my_rl_bot") # saves my_rl_bot.zip (SB3 format)Pass num_players=8(2–8) to train against up to 7 built-in AI opponents — the realistic online setting, rather than 1v1. Your agent always controls seat 0. The observation shape is identical for every player count (enemy tensors are per-opponent banks, unused seats zero), so a network trained in 1v1 carries unchanged into 8-player free-for-all.
Observation space
The environment exposes a Dict observation of fixed-shape float32 tensors. Enemy tables are banked by owning player slot— the bank index answers “whose unit is this”, per-opponent pooling is a slice, and the shape is the same from 1v1 to 8-player FFA:
| Name | Type | Description |
|---|---|---|
| own_units | (max_units, 7) | x, y, type, hp, has_target, attack_moving, present. Row index is a stable slot table — action rows address the same units. |
| own_buildings | (8, 13) | x, y, kind, hp, spawn_toggle, spawn_progress, present, + 6 upgrade levels. |
| enemy_units | (8, max_units, 7) | Per-opponent banks (bank = absolute player slot; your own bank is all-zero). Same 7 features; intent columns are hidden for enemies. |
| enemy_buildings | (8, 8, 13) | Per-opponent banks; upgrade columns hidden (fog rules). |
| globals | (11,) | resources, tick, bankroll, games_remaining, won_last, lost_last, items_collected, your slot, num_players, truncated_units, truncated_buildings. |
| resource_nodes | (32, 5) | x, y, richness, control (+1 you / 0 neutral / -1 enemy), owning slot (-1 neutral). |
| stars | (16, 5) | x, y, hp, tier, present — fog-filtered. |
| dropped_items | (32, 5) | x, y, kind, rarity, present — fog-filtered. |
max_units defaults to 512 rows per bank (constructor kwarg, up to 4096). Saturation is never silent: globals[9] / globals[10]count any entities that didn't fit this observation — if they're ever nonzero, raise max_units.
Multi-agent self-play
For FFA self-play (one process observing and commanding all seats), drop below the Gymnasium wrapper to the raw engine env: pass ai_seats=[] (or a partial list for mixed AI/self-play leagues), then drive each seat per tick:
import pachinko_pybind as p
env = p.PachinkoEnv(num_games=1, num_players=8, ai_seats=[])
env.reset(seed=1, return_obs=False)
while True:
for s in range(8):
obs = env.get_obs(s) # same Dict schema, any seat
env.submit_actions(s, unit_actions, building_actions)
done, info = env.step_submitted()
if done:
break
# info["ranks"] / info["rewards"] / info["bankrolls"] cover all 8 seats;
# terminal reward is placement-graded: +100 for 1st ... -100 for last.ai_seatscan also name any seats (including 0) for the built-in AI to drive — so you can train from an arbitrary seat. Randomize the learner's seat per episode: the enemy banks are absolute player slots, and the live server can seat your bot anywhere.
Spatial observations: terrain, fog & feature planes
For a visual / CNN policy, the SDK can turn each tick into stacked 2D feature planes (the PySC2 / AlphaStar “feature layers” pattern). Everything lives on one grid (map_info.grid_height × grid_width, 125×125), so terrain, fog, and your rasterized entities align with no resampling.
The terrain grid (state.map_info) is static — sent once at game start, so you cache it. The fog plane (state.visibility) and entity positions update every tick. Featurizer caches the static planes once and rebuilds only the dynamic ones each tick:
from pachinko_sdk import Bot, Featurizer
class VisualBot(Bot):
def on_game_start(self, data):
self.feat = None
def on_tick(self, state):
if self.feat is None and state.map_info:
self.feat = Featurizer(state.map_info) # cache terrain planes ONCE
obs = self.feat.observe(state) # (C, H, W) float32, fresh each tick
# channels: ground, hill, mountain, slope, my_units, enemy_units,
# my_buildings, enemy_buildings, stars, dropped_items, visibility
return self.policy(obs) # -> your CNN -> actionsBuilding the planes is cheap (~0.25 ms/tick) and adds no network cost — the fog data is already on the wire. Prefer the local engine for training (no network, thousands of steps/s); env.fow(player_idx) exposes the same fog grid on the direct API. See examples/rl_feature_planes.py in the wheel for a runnable version with a plane viewer.
Write your bot script
After training, write a normal bot script that loads your model and runs inference each tick. The example below uses SB3, but the pattern works for any framework.
import numpy as np
from pachinko_sdk import Bot, run_online, AttackMove, ToggleSpawn
from sb3_contrib import MaskablePPO
from pachinko_gym import obs_from_state, actions_from_output # your own helpers
class RLBot(Bot):
def on_game_start(self, data: dict) -> None:
# Load weights once at game start - not every tick
self.model = MaskablePPO.load("my_rl_bot.zip")
def on_tick(self, state) -> list:
obs = obs_from_state(state) # convert GameState to numpy obs dict
action_vec, _ = self.model.predict(obs, deterministic=True)
return actions_from_output(action_vec, state) # decode to SDK actions
if __name__ == "__main__":
run_online(RLBot(),
orchestrator_url="https://pachinko-rts.com/orchestrator",
bot_token="bot_...your_token...")You write obs_from_state and actions_from_output yourself — this is intentional. It keeps your observation encoding and action decoding exactly consistent between training and inference, and lets you iterate on them freely without being locked into any fixed interface.
Specs & self-play
Static spec tables
load_specs()returns the full rules table — unit, building and upgrade stats, RPS multipliers, economy and world constants. It's assembled from the engine's own constants, so it can never drift from the live rules, and it's immutable per match, so fetch it once.
from pachinko_sdk import load_specs
specs = load_specs() # cached after first call
specs.unit("triangle").speed # 3.125
specs.unit("square").attack # 15
specs.building("arrow_tower").vision # 200.0
specs.rps_multiplier("square", "triangle") # 1.5 (square beats triangle)
specs.economy["starting_bankroll"] # 1000UnitSpec: max_hp, attack, speed, cost, production_ticks, attack_range, vision, building_damage_mult, beats. BuildingSpec: max_hp, is_military, produces, vision, valid_upgrades (+ arrow-tower fire stats). UpgradeSpec: base_cost, max_level, per_level, effect.
Self-play economy
PyBettingState binds the realengine betting — blinds, process_action, side pots and distribute_winnings— so an offline tournament pays out bit-identically to the live match-server (no Python pot math to drift). Economy constants are exposed via economy_constants() and module constants (STARTING_BANKROLL, SMALL_BLIND, BIG_BLIND, MIN_RAISE). See examples/selfplay_betting.py in the wheel for a runnable bankroll tournament that ranks bots by money across games.
Operating notes
Command rate limit
The server enforces a token-bucket rate limit per player: 10 commands per second sustained, with a burst allowance of up to 20 commands. Commands beyond the burst cap are silently dropped — they do not error, the game just ignores them. Returning a large list from on_tick is fine occasionally (the burst absorbs it), but flooding hundreds of actions every tick will result in most being discarded. Prioritise and keep lists concise.
Token security
Treat your bot token like a password. Anyone with it can play matches as your bot and lose its bankroll. If a token leaks, rotate it from My Bots — the old token stops working immediately.
Logging and debugging
The SDK uses standard Python logging. Enable info-level output to watch the connect / poll / match lifecycle:
import logging
logging.basicConfig(level=logging.INFO)Watch your bot live on the Spectate page once a match starts.