SCRAPLINE — Bot API (developer contract)
2026-08-25. The contract between a bot and the engine. All game rules and
numbers live in GAME-SPEC.md — this document never restates them, only
references them. The API is frozen for the duration of each season.
Status: pre-build draft v0.1, matching GAME-SPEC v0.1.
1. The model
A bot is a Python module exposing one function:
def act(observation: dict) -> dict
The engine calls act once per tick for both bots simultaneously —
neither bot ever sees the other’s orders for the current tick. Your return
value is your orders for every rig you own plus an optional HQ build
(GAME-SPEC §5–§6).
- Python only in the pilot (league decision: one language, equal terms). Pure-Python standard library subset; no C extensions, threads, sockets, subprocesses, or filesystem.
- State persists: your module stays loaded for the whole match, so module-level or closure state carries across ticks. You do not need to re-derive the world each tick — but you always can, because the observation is complete.
- Full information: there is no fog. Both bots see the entire state. The only hidden thing in the game is the opponent’s code.
2. The observation (raw facts only)
The observation is a plain dict of observations, not interpretations: positions, HP, amounts, ownership. It contains no derived judgements (no “threatened”, no “reachable”, no “safe”) — every predicate you need, you compute, so no two callers can ever disagree about what a field means. Nothing in the API raises: every entity in the dict exists this tick, and entities from previous ticks are simply absent.
| Key | Content |
|---|---|
tick |
the tick about to resolve (1..300); the snapshot is phase 1 of that tick |
you / enemy |
team objects (below) |
map |
width, height, asteroids: [[x,y],…] (static per match) |
nodes |
list of {tile: [x,y], energy: int} — exhausted nodes are absent |
piles |
list of {tile: [x,y], energy: int} |
rng_seed |
your per-match deterministic seed (§5) |
fuel_budget |
your fuel allowance per tick (constant all match) |
Team object (you and enemy have identical shape):
| Key | Content |
|---|---|
hq |
{id, tiles: [[x,y]×4], hp} |
bank |
stored energy |
rigs |
list of {id, chassis, modules: [..], tile: [x,y], hp, max_hp, cargo, hold, speed, last_damaged_tick} (last_damaged_tick is 0 if never damaged) |
ghosts |
list of {id, chassis, modules, tile, hp, ticks_remaining} (0 = becomes a rig at the end of this tick, acts next tick) |
damage_dealt, energy_collected |
the tiebreak counters (GAME-SPEC §8) |
faults |
your bot_fault count so far (on you only) |
Example (abbreviated):
{"tick": 42,
"you": {"hq": {"id": 1, "tiles": [[2,2],[3,2],[2,3],[3,3]], "hp": 500},
"bank": 85,
"rigs": [{"id": 7, "chassis": "MEDIUM", "modules": ["DRILL","DRILL"],
"tile": [6,4], "hp": 100, "max_hp": 100, "cargo": 20,
"hold": 60, "speed": 1, "last_damaged_tick": 0}],
"ghosts": [], "damage_dealt": 0, "energy_collected": 210, "faults": 0},
"enemy": {"...": "same shape"},
"nodes": [{"tile": [7,4], "energy": 480}],
"piles": [{"tile": [11,12], "energy": 66}],
"map": {"width": 24, "height": 24, "asteroids": [[10,3],[13,20]]},
"rng_seed": 813402241, "fuel_budget": 30000000}
3. The orders
Return a dict:
{"orders": {"7": {"action": "MOVE", "steps": ["E", "E"]},
"9": {"action": "FIRE", "target": 14},
"11": {"action": "MINE", "tile": [7, 4]},
"12": {"action": "TRANSFER", "target": 7, "amount": 30},
"13": {"action": "RESTORE", "target": 9}},
"build": {"chassis": "MEDIUM", "modules": ["BLASTER", "BLASTER"],
"tile": [4, 2]},
"intent": "massing lances at mid",
"win_prob": 0.55}
ordersis keyed by your rig ids (strings, JSON keys). Omitted rigs IDLE. Validation and every resolution rule: GAME-SPEC §5–§7.buildis optional (null/absent = no build).intent(optional, ≤40 chars) andwin_prob(optional, 0.0–1.0) are broadcast-only: rendered to spectators and casters, written to the replay (as an integer permille), and never shown to the opponent. The engine ignores both. Intent is public text: the viewer masks a wordlist at display time, and abusive intents get the channel revoked for the season (LEAGUE-RULES §6.1) — the match result is unaffected.- Anything malformed degrades per GAME-SPEC §5 (invalid order → IDLE; unparseable reply → whole-tick fault). Loud in the replay, never fatal.
- What is not a fault: the arena failing to run your bot (a sandbox or thread it could not create, a host call that never returns). That voids the match — no result, no replay — and it is replayed later; it can never be scored against you.
4. Budget: fuel, not milliseconds
Your compute budget per tick is measured in fuel — deterministic WebAssembly instruction counts — not wall-clock time. Consequences you can rely on:
- The same bot, same match, same tick spends the same fuel everywhere: your laptop, the arena server, a replay verification five years from now. Server load can never cost you a tick.
- Running out of fuel = your units IDLE that tick, with a loud marker.
Never a forfeit.
fuel_budgetis in every observation; the exact value is published per season and frozen (pilot: 1 000 000 000 per tick). A fuel-out, a memory-cap hit or a runaway tick restarts your interpreter: module-level state is gone next tick (the crash event saysrestarts). A Python exception inactdoes not restart anything — it is caught, reported, and your state stays. - Fuel counts wasm instructions of the Python interpreter, so cost per
Python operation varies. The calibration harness (
scrapline calibrate bot.py, optionally--replay match.szrto replay recorded observations) prints your fuel per tick (min / median / p90 / max) — measure, don’t guess. Budgets are set so that a straightforward bot uses well under 10% (the starter bot: median 2.6 M, worst tick 66 M of the 1 G budget), and forward simulation of several candidate lines is affordable. Note that the same observation can cost slightly different fuel on later ticks as the interpreter’s caches evolve — deterministically; a replay re-run reproduces the exact figures. - Memory: hard cap per bot (128 MB linear memory in the pilot). Exceeding it is a fault for that tick.
- There is deliberately no wall-clock anywhere in the bot’s world: no clock APIs, no timers.
5. Determinism rules
Matches are bit-reproducible; your bot must not break that (it cannot, if it stays inside the sandbox — these rules describe the sandbox rather than trust):
- No clock, no OS randomness, no environment, no filesystem, no network.
PYTHONHASHSEEDis fixed; iteration order of your own dicts is deterministic under CPython’s insertion order. - If you want randomness, seed it:
random.Random(observation["rng_seed"])gives you a per-match, per-team deterministic stream (derived from the map seed; stable across replays; different per side). - Protests and analysis both work by re-running the match; what you shipped is exactly what will be re-run.
6. The starter kit (the 10-minute path)
The kit ships: bot.py (a working default bot), the engine + local
runner, the replay viewer, and the calibration harness. First match:
- Install the kit, run
scrapline play bot.py bots/doorman— watch the replay in your browser. - Edit
actinbot.py. Run again. That loop is the whole workflow.
The default bot’s strategy (readable in ~40 lines): build two Miners (GAME-SPEC §2 names the standard loadouts); each Miner walks to the nearest node with a free adjacent tile and MINEs; deposit happens automatically when passing the HQ; once the bank clears a threshold, alternate building Troopers; when four Troopers live, walk them at the enemy HQ and FIRE at whatever enemy is nearest, HQ included. It plays a legal, complete game and loses to anyone paying attention — that is its job. The Doorman (the division-entry boss bot) is this bot plus escorts; beating it is the ladder’s front door.
6.1 botkit — helpers you may import
The kit ships botkit (kit/src/scrapline/botkit.py), importable from
your bot in both native and sandboxed runs (import botkit as bk). It is
frozen per season with the rest of the API. It computes predicates from
the observation — nothing in it knows more than you do:
bk.Grid(obs): tiles a rig cannot enter this tick (terrain, both HQs, nodes, every rig and ghost);first_step(start, goals)is a BFS first direction,path(start, goals)the whole BFS path;reserve(tile)marks a destination taken.Grid(obs, ignore_own_rigs=True)plans over terrain only (chains, formations).bk.danger_tiles(obs, mass): tiles an enemy of mass ≥massstands on or could step into next tick — entering one risks the §7.1 mass rule;bk.MASSis the chassis mass table.bk.canonical(obs)/bk.uncanonical(reply, flipped): seat B sees the board point-reflected so it plays “as A” and its reply is reflected back — every coordinate or list-order tie-break in your bot then makes the same choice from both seats — seat fairness. The reference and the archetypes wrapactin this pair.bk.toward(tiles, targets): tiles ordered by distance to the nearest target (spawn nearest the enemy, mining spots nearest home, …).- Determinism rule for your own code: never let a choice depend on
the iteration order of a
setordictof tiles, and never sort with a key that leaves ties (sorted(tiles, key=distance)) — tie order is the set’s iteration order, which differs between the native CPython and the sandbox’s CPython 3.14 build, so your bot would play a different game in the league than on your machine.botkitreturns ordered lists frombeside/spawn_ringandtowarduses a total key; do the same (key=(distance, tile)), and runscrapline doctor bot.pybefore every submit: it plays your bot natively and in the arena sandbox on the same seeds and names the first tick where the two games differ. bk.Orders(obs): builds one tick’s reply.move_toward(rig, goals)reserves the destination so your own rigs never collide under the mass rule;mine,fire,transfer,restore,build(loadout, tile),intent(text);reply()returns the dict to send.bk.spawn_ring(hq_tiles, grid),bk.beside(tiles, grid)(mining / deposit / siege spots),bk.enemy_targets(obs),bk.dist_to_entity,bk.chebyshev,bk.orth_adjacent,bk.LOADOUTS(the named rigs of GAME-DESIGN §5) andbk.cost(loadout).
The reference bot and the red-team archetypes (bots/) are written on
it and are the worked examples.
6.2 What you may import
The freeze gate rejects imports outside this list at submit time with a
message naming the module (SECURITY §3) — the sandbox has no clock,
filesystem, network or processes regardless, so this is about good
errors, not security: abc array bisect collections copy dataclasses decimal enum fractions functools heapq itertools json math operator random re statistics string typing, plus botkit and your own modules
inside the zip. random is seeded from rng_seed if you seed it; an
unseeded random still differs between interpreters — don’t.
7. Versioning and fairness
- The API, GAME-SPEC constants, and the fuel budget are frozen per season; changes ship between seasons with design-intent notes.
- Both bots run under identical budgets, identical sandboxes, identical API — there are no participant classes (league decision #3), and how you built your bot (by hand, with search, with an AI assistant) is irrelevant and uninspected.
- Submissions: one active bot per team; uploading a new version replaces
the old at the next scheduled boundary (details in
LEAGUE-RULES.md§4).