Bot Development
Build bots that compete in live multiplayer games. Two paths: write a deterministic Python script using simple game logic, or train a reinforcement learning agent against the Gymnasium environment and upload the checkpoint.
Overview
The desktop client scans your bots folder at startup. Drop a .py file (deterministic) or a .zip file (SB3 RL checkpoint) and the client picks it up automatically. You can run multiple bots simultaneously — each is launched as a subprocess and communicates with the game over stdin/stdout JSON.
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.
Installation
Bot SDK (required for all bots)
pip install pachinko-sdkNo external dependencies. Python 3.9+ required. The SDK is pure Python — it handles the stdin/stdout protocol between your script and the game client.
Gym package (RL training only)
Training RL bots also requires pachinko-gym, which ships as a pre-built binary package (the game simulation runs in compiled Rust — no source build needed):
pip install pachinko-gym stable-baselines3 sb3-contribDeterministic bots
Create a .py file, subclass Bot, and implement on_tick. The SDK handles all communication with the game.
Minimal example
from pachinko_sdk import Bot, run, AttackMove, ToggleSpawn
class MyBot(Bot):
def on_game_start(self, data: dict) -> None:
self.player_id = data.get("player_id", 0)
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
def on_bet_round(self, data: dict):
# Call if the bet is small, fold if expensive
to_call = data.get("current_bet", 0) - data.get("committed", 0)
bankroll = data.get("chips", 1)
if to_call == 0 or to_call / bankroll < 0.10:
return {"type": "call"}
return {"type": "fold"}
if __name__ == "__main__":
run(MyBot())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 actionsAPI reference
Bot lifecycle methods
| Name | Type | Description |
|---|---|---|
| on_game_start(data) | None | Called once at game start. data includes player_id, world_width, world_height. |
| on_tick(state) | list[Action] | Called every tick. Return a list of action objects. |
| on_bet_round(data) | dict | Called during a bet round. Return {type: call|fold|raise, amount?}. |
| on_game_over(data) | None | Called when the game ends. data includes result. |
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. |
| 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. |
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. Values: int (0–5). |
ResourceNode fields
| Name | Type | Description |
|---|---|---|
| x, y | float | World position. |
| richness | float | Income rate multiplier. |
| control | str | "neutral" | "own" | "enemy" |
Actions
| Name | Type | Description |
|---|---|---|
| Move(unit_id, x, y) | — | Move to position without attacking. |
| AttackMove(unit_id, x, y) | — | Move and auto-attack enemies encountered. |
| Attack(unit_id, target_id) | — | Direct attack on a specific enemy unit. |
| Halt(unit_id) | — | Cancel current order. |
| ToggleSpawn(building_id, on) | — | Enable or disable unit production. |
| Upgrade(building_id, upgrade_type) | — | Purchase one upgrade level. upgrade_type: see Building fields. |
| Patrol(unit_id, x, y) | — | Patrol back and forth to position. |
| AttackMoveGroup(unit_ids, x, y) | — | Attack-move a list of units together. |
| MoveGroup(unit_ids, x, y) | — | Move a list of units to the same position. |
| HaltGroup(unit_ids) | — | Halt a list of units. |
Betting round data
The on_bet_round(data) dict contains:
| Name | Type | Description |
|---|---|---|
| chips | int | Your current bankroll (chips not yet committed). |
| committed | int | Chips you have already put in this round. |
| current_bet | int | The current highest bet to match. |
| phase | str | "pregame" | "midgame" |
Return one of:
{"type": "call"} # match the current bet
{"type": "fold"} # exit this betting round
{"type": "raise", "amount": 200} # raise by this amount
{"type": "allin"} # go all-inRL 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.
Install
pip install pachinko-gymTrain offline
Use any RL library. The example below uses MaskablePPO from sb3-contrib, but you are free to use any algorithm or framework.
from pachinko_gym import PachinkoTrainEnv
from sb3_contrib import MaskablePPO
env = PachinkoTrainEnv(num_players=8)
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)Observation space
The environment exposes a Dict observation with PointNet-style feature arrays — the same information available in on_tick:
| Name | Type | Description |
|---|---|---|
| own_units | (N, 7) float32 | x, y, type, hp, max_hp, has_target, attack_moving |
| enemy_units | (M, 5) float32 | x, y, type, hp, max_hp |
| own_buildings | (B, 8) float32 | x, y, kind, hp, max_hp, spawn_toggle, upgrade levels |
| resources | (1,) float32 | Normalised hematite balance |
| resource_nodes | (K, 4) float32 | x, y, richness, control |
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 sys
import os
import numpy as np
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bot-sdk"))
from pachinko_sdk import Bot, run, 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
def on_bet_round(self, data: dict):
return {"type": "call"}
if __name__ == "__main__":
run(RLBot())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.
Running in-game
1. Drop your .py file in the bots folder
| Name | Type | Description |
|---|---|---|
| Windows | string | C:\Users\<you>\Documents\Pachinko\bots\ |
| macOS / Linux | string | ~/pachinko/bots/ |
You can change the folder path inside the client by pressing D on the bot screen.
2. Select Bot Multiplayer in the client
Launch the client, go to the bot screen, and select your bot. Choose Bot (multiplayer) to queue against other human and bot players online. Set a game count to run a continuous cycle — the client re-queues automatically after each game.
3. Watch your bot play
Head to the Spectate page to watch any live game including your bot's. You can also place bets on it.
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. A human player issues 1–3 commands per second at most, so this limit is generous for normal play. For bots, the practical implication is: 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 of them being discarded. Prioritise the most important commands and keep your lists concise.
Logging and debugging
Write debug output to sys.stderr— it appears in the client's bot log panel and is never sent to the game. Stdout is reserved for the JSON protocol.
import sys
def on_tick(self, state) -> list:
print(f"[tick {state.tick}] resources={state.resources}", file=sys.stderr)
return []