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-sdk

No 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-contrib

Deterministic 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 actions

Buying 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 actions

API reference

Bot lifecycle methods

NameTypeDescription
on_game_start(data)NoneCalled 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)dictCalled during a bet round. Return {type: call|fold|raise, amount?}.
on_game_over(data)NoneCalled when the game ends. data includes result.

GameState fields

NameTypeDescription
tickintCurrent game tick (24 ticks per second).
resourcesintYour current hematite balance.
my_unitslist[Unit]All your living units.
my_buildingslist[Building]All your buildings.
enemy_unitslist[Unit]Visible enemy units.
enemy_buildingslist[Building]Visible enemy buildings.
resource_nodeslist[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 | NoneHelper: 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

NameTypeDescription
idintUnique entity ID.
x, yfloatWorld position (0–2500 on each axis).
unit_typestr"square" | "circle" | "triangle"
hp / max_hpintCurrent and maximum hit points.
has_targetboolTrue if the unit is currently executing an order.
attack_movingboolTrue if the unit is attack-moving.

Building fields

NameTypeDescription
idintUnique entity ID.
x, yfloatWorld position.
kindstr"squarracks" | "circhery" | "triables" | "hematite" | "arrow_tower"
hp / max_hpintCurrent and maximum hit points.
spawn_toggleboolWhether this building is currently spawning units.
upgradesdictKeys: spawn_rate, weapon_damage, armor, move_speed, mining_rate. Values: int (0–5).

ResourceNode fields

NameTypeDescription
x, yfloatWorld position.
richnessfloatIncome rate multiplier.
controlstr"neutral" | "own" | "enemy"

Actions

NameTypeDescription
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:

NameTypeDescription
chipsintYour current bankroll (chips not yet committed).
committedintChips you have already put in this round.
current_betintThe current highest bet to match.
phasestr"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-in

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.

Install

pip install pachinko-gym

Train 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:

NameTypeDescription
own_units(N, 7) float32x, y, type, hp, max_hp, has_target, attack_moving
enemy_units(M, 5) float32x, y, type, hp, max_hp
own_buildings(B, 8) float32x, y, kind, hp, max_hp, spawn_toggle, upgrade levels
resources(1,) float32Normalised hematite balance
resource_nodes(K, 4) float32x, 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

NameTypeDescription
WindowsstringC:\Users\<you>\Documents\Pachinko\bots\
macOS / Linuxstring~/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 []