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-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 — latest is 0.8.1, and the matchmaker requires at least 0.8.1:

pip install -U pachinko-pybind

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

Local 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_starton_tick / on_bet_actionon_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.

NameTypeDescription
run_local(bots, num_players=2, betting=True, mulligan=True, seed=0, max_ticks=7200, **scenario)LocalResultRun one offline match of 2–8 players and return the outcome.
LocalResult.winner_slotintWinning seat (0-based), or -1 (draw / unresolved at max_ticks).
LocalResult.placementlist[int]Finishing position per seat (1-based, 1 = winner; ties share a rank).
LocalResult.net_chipslist[int] | NoneNet chip P/L per seat (Σ == 0), or None if betting=False.
LocalResult.my_net(seat) / my_placement(seat)intConvenience readers for one seat's chips / rank.
LocalResult.settlementSettlement | NoneTerminal pot payout (None if betting=False).
LocalResult.bankrollslist[int] | NonePost-match chip stack per seat (None if betting=False).
LocalResult.ticksintTicks 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

NameTypeDescription
on_game_start(data)NoneCalled 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)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_match_settled(settlement)NoneCalled 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)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.
game_overboolTrue once the match has ended.
winner_slotintWinning player slot (0-based), or -1 while in progress / on a mutual-KO refund.
player_slotintYour own slot (0-based) — interpret winner_slot and per-entity owner against this, even after all your units die.
wonbool (property)True iff game_over and winner_slot == player_slot.
resultstr (property)Outcome relative to you: "in_progress" | "win" | "loss". No "draw" — a mutual KO refunds and reads as "loss".
finishing_rankstuple[int]Per-slot finishing position (1-based, 1 = winner; ties share a rank) once the match ends; empty while in progress.
my_placementint (property)Your own finishing position, or -1 while the game is running.
scoreslist[int]Per-slot live display score (0–400), indexed by slot — the same number the score panel shows. A dense progress signal.
starslist[Star]Neutral stars your team can currently see (fog-filtered).
dropped_itemslist[DroppedItem]Items lying on the map your team can see (fog-filtered).
items_collectedintHow many items your team has picked up so far.
map_infoMapInfo | NoneStatic map geometry + terrain grid. Sent once at game start; same object every tick. None until the first frame.
visibilityndarray | 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)ndarrayStacked (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 | 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.
ownerintOwning player slot (0-based) — the real owner even for enemy units, so multi-player observations attribute every entity.
facingfloatHeading in radians (populated for own + enemy units).
target_idintEntity ID of this unit's attack target, or -1. Own units only (enemy intent is hidden → -1).
destinationtuple | None(x, y) move-order target, or None. Own units only (enemy → None). Backed by dest_x/dest_y.

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, healing_aura. Values: int (0–5).
ownerintOwning player slot (0-based) — real owner even for enemy buildings.

ResourceNode fields

NameTypeDescription
x, yfloatWorld position.
richnessfloatIncome rate multiplier.
controlstr"neutral" | "own" | "enemy" (relative to you).
owner_player_idintOwning player slot (0-based), or -1 if neutral. Absolute attribution, unlike the relative control field.
contestedboolTrue when ≥2 players have units within the capture radius.

Star fields

NameTypeDescription
idintUnique star ID.
x, yfloatWorld position.
hp / max_hpintCurrent and maximum hit points.
tierint0 = weak, 1 = medium, 2 = strong.
tier_namestrProperty: "weak" | "medium" | "strong".

DroppedItem fields

NameTypeDescription
idintUnique item ID.
x, yfloatWorld position.
kindItemKindProperty: the item kind enum (decoded from kind_id).
rarity_nameRarityProperty: "common" | "uncommon" | "rare" | "legendary".
unit_typeint0=square, 1=circle, 2=triangle, 255=N/A (item not unit-specific).
value_f / value_ifloat / intItem-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.

NameTypeDescription
world_width, world_heightfloatWorld size in game units (2500 × 2500).
cell_sizefloatWorld units per terrain cell (20).
grid_width, grid_heightintTerrain grid dimensions in cells (125 × 125).
terrainlist[int]Row-major terrain codes: 0=ground, 1=hill, 2=mountain (impassable), 3=slope.
spawn_positionslist[(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) | NoneYour own start location (the one real spawn_positions entry).
resource_nodeslist[{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)listHelper: 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)intTerrain code at a world position.
is_walkable(x, y)boolFalse only on mountain cells.
height_at(x, y)int1 on hills (high-ground combat bonus), else 0.
as_numpy()ndarray(grid_height, grid_width) uint8 terrain grid for ML. Needs numpy.

Actions

NameTypeDescription
Move(unit_id, x, y)int, float, floatMove to position without attacking.
AttackMove(unit_id, x, y)int, float, floatMove and auto-attack enemies encountered.
Attack(unit_id, target_id)int, intDirect attack on a specific enemy unit.
Halt(unit_id)intCancel current order.
ToggleSpawn(building_id, on)int, boolEnable or disable unit production.
Upgrade(building_id, upgrade_type)int, strPurchase one upgrade level. upgrade_type: see Building fields.
Patrol(unit_id, x, y)int, float, floatPatrol back and forth to position.
AttackMoveGroup(unit_ids, x, y)list[int], float, floatAttack-move a list of units together.
MoveGroup(unit_ids, x, y)list[int], float, floatMove 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

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

NameTypeDescription
potintTotal cumulative pot for the match, in chips.
entriestuple[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_rankint (property)Your own finishing position from entries, or -1 if unknown.
netint (property)Your own bankroll delta this match (0 if your slot is unknown).
wonbool (property)True iff your own entry netted positive.
winnerslist[int] (property)Slots whose net was positive.
entry(player_id)SettlementEntry | NoneLook up one slot's result.
rawdict | NoneOriginal 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:

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

Building 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"]         # 1000

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