Current pachinko-pybind version: 0.4.2

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

Python 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 — current version 0.4.2:

pip install -U pachinko-pybind

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_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 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 the GameStart payload (map_seed, players, num_players).
on_tick(state)list[Action]Called every tick. Return a list of action objects.
on_mulligan(obs)MulliganDecisionCalled once when the mulligan phase opens, then again after every reroll. Default: accept.
on_bet_action(obs)BetDecisionCalled whenever the server asks this bot for a betting action. Default: call (goes all-in if the call exceeds the bankroll).
on_game_over(data)NoneCalled 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

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 + 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.

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

NameTypeDescription
itemslist[StartingItem]Your 5 dealt items: kind, rarity, unit_type, value.
bankrollintChips remaining (rerolls cost big_blind each).
big_blindintCost per rerolled index.
reroll_costintAlias for big_blind.

StartingItem fields

NameTypeDescription
kindItemKindEnum: ExtraProjectile, Ballistics, DamageBoost, SpeedBoost, ArmorBoost, SpawnRateBoost, HpBoost, CircleRangeBoost, CircleAttackSpeedBoost, CircleSplashDamage, CleaveAttack, ResourceRateBoost, HematiteBonus, HealthRegen, VisionBoost.
rarityRarityCommon, Uncommon, Rare, Legendary.
unit_typeUnitType | NoneSquare / Circle / Triangle for unit-targeted kinds; None otherwise.
valuefloatKind-specific magnitude (multiplier or flat bonus).
rawdictOriginal wire dict, in case you need a field this dataclass omits.

BetObservation fields

NameTypeDescription
roundint0 = pre-game round, 1+ = mid-game rounds.
my_player_idintYour slot id.
my_seatintYour index in this round's action_order.
action_orderlist[int]Player slots in the order they act this round.
potintCumulative chips in the pot (across all rounds).
current_betintTotal committed needed to stay in the round.
playerslist[PlayerBetState]Per-slot snapshot: bankroll, committed, status.
my_itemslist[StartingItem]Your mulligan items, retained from the pre-game phase.
history_this_roundlist[BetEvent]Every BetUpdate seen this round, in order.
history_prior_roundslist[list[BetEvent]]Closed rounds, oldest first.
to_callint (property)Chips needed to call (0 means a check is free).
can_checkbool (property)True if to_call == 0.
mePlayerBetState (property)Shorthand for players[my_player_id].

BetEvent fields

NameTypeDescription
playerintSlot of the player who acted.
actionBetActionKindAnte, Check, Bet, Call, Raise, Fold, AllIn.
amountintChips put in for this action (0 for check/fold).
pot_afterintTotal pot after this action.
current_bet_afterintcurrent_bet after this action.

Decision constructors

NameTypeDescription
BetDecision.call()BetDecisionCall (or check if obs.to_call == 0). Goes all-in if the call exceeds bankroll.
BetDecision.fold()BetDecisionFold.
BetDecision.raise_(amount)BetDecisionRaise by `amount` chips on top of current_bet.
BetDecision.all_in()BetDecisionAll-in.
MulliganDecision.accept()MulliganDecisionLock in your current items.
MulliganDecision.reroll([0, 2])MulliganDecisionReroll items at the listed indices (0..=4). Costs big_blind each.

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)

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

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.