SpellBench v2 agents¶
This page is for people who have, or want, an agent for the SpellBench
benchmark, and for anyone who wants to run a gorge bot inside it. It explains
what SpellBench is, the messages its v2 protocol exchanges, and the three ways
gorge speaks it: seating an external v2 agent at a gorge table, serving a
gorge bot as a v2 agent, and serving gorge as a v2 environment. Message and
field reference: SpellBench v2 reference.
Everything shown was run on 2026-10-20 at ad3777023; the mtgsim and
environment examples, the policy listing and the fidelity check below were
run again on 2026-10-21 at 513a2a0c3 with the same results.
What SpellBench is¶
SpellBench is an external,
engine-neutral benchmark for Magic-playing agents. It defines one protocol
between a host that runs games and an agent that answers decisions, and
an engine-neutral observation and candidate vocabulary, so that the same
agent can play on any engine that implements the environment side, and
agents can be rated against each other on fixed deck pools. gorge's
implementation, ported from the SpellBench repository's gorge adapter (MIT),
lives in
server/spellbench.
Transport and framing¶
v2 runs over a child process's stdin and stdout: the host starts the agent (or environment) as a process, writes one request per line, and reads one response per line. There is no network socket, so an agent always runs on the same machine as its host, one process per seat per match. (For a bot on another machine, use the HTTP seat.)
The framing rules
(server/spellbench/wire):
- One JSON object per line,
\n-terminated (a trailing\ris tolerated), at most 8 MiB per line. A longer line is drained and refused, so framing resynchronises at the next line. - Strict JSON: integers only (no floats), within ±(2^53 − 1); no duplicate keys; nesting at most 64 deep; precomposed (NFC) card names.
- Every message carries
"protocol": "spellbench/v2"and arequest_id, echoed in the response. - Canonical JSON (RFC 8785, for this integer-only subset) is what the protocol hashes: deck ids, the card-name domain id and the per-game digest chain are SHA-256 over canonical bytes, so two implementations agree on them byte for byte.
The agent role: host asks, agent answers¶
Four requests, captured from mtgsim driving SpellBench's minimal agent (it
always answers candidate 0) through a relay that logged every line:
| Request | When | Response |
|---|---|---|
hello |
once, at process start | hello_ok, naming the bot and any observation or extension requirements |
game_start |
once per match | ack |
choose |
every decision | choice with a selection.candidate_id |
game_over |
at the end, best effort | ack |
{"request_type":"hello","protocol":"spellbench/v2","protocol_minor":0,"request_id":"r-1"}
{"protocol":"spellbench/v2","request_id":"r-1","response_type":"hello_ok","bot":{"name":"minimal","version":"1.0.0"}}
game_start tells the agent everything fixed for the game: its seat, its own
decklist (and the opponent's when the table reveals it), the format and
rules, the engine's capability profile (decision_kinds, what the
observation includes), the card-name domain, the decision limits and the
time control (max_decision_ms, a bank and an increment), and an
agent_seed for the agent's own randomness.
A choose carries the decision: the acting seat, a step counter, the
decision's context ("kind":"priority"…), an engine-neutral
observation of what the seat can see, and the candidates:
{"request_type":"choose","protocol":"spellbench/v2","request_id":"r-5","game_id":"g-0000000000000000",
"clock":{"max_decision_ms":0,"remaining_ms":600000},
"decision":{"acting_seat":"p0","seat_step":2,
"group":{"group_id":2,"substep_index":0,"substep_count":1},
"context":{"kind":"priority","source":null,"purpose":null,"text":null,"rewind":false},
"observation":{"viewer":"p0","turn":1,"phase_step":"precombat_main","active_seat":"p0","priority_seat":"p0",
"players":[{"seat":"p0","life":20,"hand_count":7,"library_count":53,
"hand":[{"object_id":"o-4d0777330094f7aa","card_name":"Stoneforge Mystic","zone":"hand",
"characteristics":{"types":["creature"],"subtypes":["kor","artificer"],"colors":["white"],
"mana_value":2,"power":1,"toughness":2,"keywords":[]}}, "…"]}, "…"],
"stack":[], "pending_triggers":[], "known":[]},
"candidates":[
{"candidate_id":0,"semantic":{"kind":"pass"},"display_text":null},
{"candidate_id":1,"semantic":{"kind":"play_land","face":0,
"source":{"object_id":"o-de201734884be0b8","card_name":"Plains","owner_seat":"p0","controller_seat":"p0","zone":"hand"}},
"display_text":null}],
"extensions":{}}}
{"protocol":"spellbench/v2","request_id":"r-5","response_type":"choice","selection":{"candidate_id":0}}
Object ids (o-…) are keyed per game, so an agent cannot correlate them
across games. A gorge decision that is several picks (an attack declaration,
a multi-target spell) becomes a group of substeps, one pick each. A
choose request in that game was 1.5 to 24 KB.
The whole of SpellBench's minimal agent is a dozen lines of Python
(server/spellbench/seat/testdata/minimal_bot.py):
for line in sys.stdin.buffer:
request = json.loads(line)
reply = {"protocol": "spellbench/v2", "request_id": request["request_id"]}
if request["request_type"] == "hello":
reply.update(response_type="hello_ok", bot={"name": "minimal", "version": "1.0.0"})
elif request["request_type"] == "choose":
reply.update(response_type="choice", selection={"candidate_id": 0})
else:
reply.update(response_type="ack")
sys.stdout.write(json.dumps(reply) + "\n")
sys.stdout.flush()
Seat an external v2 agent at a gorge table¶
-sb-agent name=/path/to/agent registers the agent as a bot policy named
name, in mtgsim and in gorged. The path is executed directly with no
arguments and no shell, so wrap any arguments in a script. The adapter
(server/spellbench/seat)
starts one process per match, handshakes, translates every gorge decision
into v2 candidates and the answer back into an intent.
$ go run ./cmd/mtgsim -seats 2 -games 1 -verify -a sb-min \
-sb-agent sb-min=server/spellbench/seat/testdata/minimal_bot.py
seed 0: 2 seats, 691 intents, 3577 events, 30 turns, winner=dimir-tempo, chain=6766ab2087f96884
replay OK (chain 6766ab2087f96884)
In gorged the agent becomes one more offered policy, so a play-vs-bot
game, or an HTTP bot, can play against it:
$ ./gorged -addr 127.0.0.1:8093 ... -vsbot -sb-agent sb-gorge-bot=/path/to/gorge-as-v2.sh
$ curl -s http://127.0.0.1:8093/api/bot-policies | jq -c '[.policies[] | [.name, .label]]'
[["bot","Bot"],["forge-heuristic","Forge heuristic"],["lethal-pressure","Lethal Pressure"],["sb-gorge-bot","SpellBench v2 agent"]]
$ python3 gorge_agent.py --server http://127.0.0.1:8093 --bot-policy sb-gorge-bot
...
match finished after 21 turns in 1.5 s: winner us (seat 0, tron)
A v2 seat is two-player and constructed-only, and plays only on tables with
automatic mana payment (its registry entry sets NeedsAutoPay).
Failure is a forfeit. If the agent fails to start, times out, crashes,
answers malformed JSON or an out-of-range candidate, the seat records a
forfeit cause (timeout, transport_error, agent_error,
invalid_selection), concedes at its next priority decision, and answers
with the default bot until then. The decision budget is the host's: 2 s per
decision in gorged, unbounded in mtgsim and the benches. game_over is
written and the process stopped immediately, so an agent must not rely on
receiving it.
Serve a gorge bot as a v2 agent¶
cmd/spellbench-gorge-agent wraps any registered gorge policy as a v2
agent, so gorge's bots can enter SpellBench arenas on any engine.
$ go build -o spellbench-gorge-agent ./cmd/spellbench-gorge-agent
$ ./spellbench-gorge-agent -list-policies
az-redeal az-redeal (AlphaZero search) experimental
bot Bot production
forge-heuristic Forge heuristic experimental
lethal-pressure Lethal Pressure experimental
sb-tactical sb-tactical (SpellBench tactical heuristic) experimental
It has two worlds:
-world gorge(default) reads gorge's own view from thex_gorge_view_v1extension, which a gorge environment offers. It is the exact path, but only a gorge host has the extension; under-sb-agentit refuses the match (agent requirement not offered: extensions).-world neutralrebuilds the decision from the neutral observation and candidates of any v2 engine, and needs the card corpus (-corpus .cards) or a frozen registry (-registry file -registry-sha256 digest).
Because -sb-agent passes no arguments, wrap the command:
#!/bin/sh
exec /path/to/spellbench-gorge-agent -policy bot -world neutral -corpus /path/to/gorge/.cards
That runs gorge's bot through the full v2 round trip against gorge's own
bot. It is a useful fidelity check: over seeds 0–19 with Death & Taxes
against Dimir Tempo, the bot won 17 of 20 games natively (-a bot) and
1 of 20 through the neutral v2 translation (2026-10-20, and again on
2026-10-21 at 513a2a0c3; 20 games took 17 s). Something the
bot relies on does not survive the trip through the neutral observation;
until that is found, compare v2-served policies with each other, not with
native ones.
Latency¶
Timed by a relay between mtgsim and the agent that logged each request's
round trip (3 games per agent, 2026-10-20):
| Agent | hello |
game_start |
choose median |
choose p95 |
|---|---|---|---|---|
| SpellBench minimal (Python) | 8 ms | 2 ms | 0.05 ms | 0.10 ms |
gorge bot, neutral world |
412 ms (opens the corpus) | 3.6 ms | 0.18 ms | 0.31 ms |
The 0.4 s start is paid per match, because the host starts a process per match.
Serve gorge as a v2 environment¶
cmd/spellbench-gorge-env is the other direction: gorge as the engine a
SpellBench harness drives. Its requests are hello (answered with
hello_ok and the deck catalog), validate_deck (deck_ok or an error),
reset (start a game; answered with the first decision), step
(submit a candidate, with expected_step and a semantic_echo the
environment checks; answered with the next decision or the terminal).
probe_resample is answered unsupported_request: gorge's environment has
no probe. validate_deck accepts only the pauper-bo1 format and the decks
of the environment's own catalog.
$ go build -o spellbench-gorge-env ./cmd/spellbench-gorge-env
$ printf '%s\n' '{"request_type":"hello","protocol":"spellbench/v2","request_id":"a","protocol_minor":0}' \
'{"request_type":"validate_deck","protocol":"spellbench/v2","request_id":"v","format":"pauper-bo1","deck":{"catalog_id":"Burn"}}' \
| ./spellbench-gorge-env -corpus .cards
{"catalog":[{"catalog_id":"Wildfire","decklist":[{"count":4,"name":"Twisted Landscape"}, …
{"protocol":"spellbench/v2","request_id":"v","response_type":"deck_ok"}
Errors are response_type: "error" with a code from a closed list
(malformed_json, protocol_mismatch, expected_step_mismatch,
candidate_id_out_of_range, semantic_echo_mismatch, unsupported_deck,
…; server/spellbench/protocol/errors.go).
Mana is paid by the engine's planner by default (-manual-mana offers
interactive taps instead).