Introduction
DeepScry is a high-performance reimplementation of a Magic: The Gathering rules engine in Rust, inspired by the Java Forge project. It can run fully-automated games between AI players, drive interactive games in a terminal or a web browser, and serve networked multiplayer matches.
This guide is organised into three parts.
-
Part I — User Guide. How to drive the
mtgcommand-line binary: the subcommands, running games, building reproducible bug reports, the deck-file format, scripted play, and the web server. Read this first if you want to use DeepScry. -
Part II — Internal Architecture and Principles. The engineering core: the network architecture, deterministic simulation, and the replicated state-machine model that keeps a server and its clients bit-for-bit in step. Read this if you want to understand why the engine is built the way it is.
-
Part III — Reference: The Scripting Languages. Precise references for the three small languages DeepScry understands: the fixed-input controller language (for scripting a player’s choices), the puzzle (
.pzl) language (for setting up board states and self-checking assertions), and the card-script language (the card-definition format inherited from Java Forge).
A note on accuracy
This guide cross-checks its claims against the actual source code wherever it can. Some of the source documents it draws on had drifted from the code; where that happened, this guide follows the code and flags the discrepancy in an admonition box so you are never misled by a polished-looking but stale claim. Look for blockquotes that begin with Status: or Discrepancy:.
Building this guide
This book is compiled with mdBook. From the repository root:
make docs-guide # or: mdbook build docs/guide
The compiled HTML is written into web/guide/, so it ships as part of the
deployed site. The Markdown sources live in docs/guide/src/; the compiled
output is not tracked in git (it is regenerated on each build, the same way
other generated content under web/ is handled).
Overview of the mtg Binary
Everything in DeepScry is driven through a single command-line program, mtg.
It is a clap-based multi-tool: you pick a subcommand,
then pass options to it.
cargo run --release --bin mtg -- <SUBCOMMAND> [OPTIONS]
# or, once built / deployed:
mtg <SUBCOMMAND> [OPTIONS]
The authoritative definition of every subcommand and flag lives in
mtg-engine/src/main.rs. When in doubt, run mtg --help or
mtg <SUBCOMMAND> --help — the help text is generated from that file and will
always match the binary you actually have.
The subcommands
The complete list of subcommands, taken directly from the Commands enum in
mtg-engine/src/main.rs:
| Subcommand | Purpose |
|---|---|
tui | Interactive / scripted gameplay. The main entry point for playing or simulating a single game. Also loads puzzles via --start-state. |
resume | Resume a previously saved game from a snapshot file. |
profile | Run many games back-to-back for profiling (flamegraph / heaptrack). |
tourney | Run many games in parallel and collect win/loss statistics. |
stats | Print statistics about the card database. |
deck-build | Interactive fast deck-entry TUI for typing in paper decks. |
export-wasm | Export the card database and selected decks for browser (WASM) builds. |
hash-asset | Print the content-addressed hash of a file (a small scripting utility). |
hash-web-assets | Hash the web-asset bundle (part of the content-addressed deploy pipeline). |
download | Download card images from Scryfall. |
build-card-lookup | Build the compact card→image lookup table used by the client. |
server | Headless multiplayer WebSocket game server (no static files). |
server-web | Full browser product: static web assets and the lobby on one port. |
connect | Connect to a running multiplayer server as a client. |
Discrepancy (flagged): Some older documents refer to an
mtg puzzle <file>subcommand. There is nopuzzlesubcommand in the binary. Puzzles are loaded by passing a.pzlfile totuivia--start-state:mtg tui --start-state puzzles/bolt_test.pzl. (Inside theagentplay/Python harness,--puzzleis a script-level convenience flag that ends up callingtui --start-stateunderneath; it is not a binary subcommand.)
Feature flags
Some subcommands only exist when the binary is built with the right Cargo feature:
serverandserver-webrequire theweb-server/networkfeatures.connectrequires thenetworkfeature.
The standard release build used throughout this guide enables them:
cargo build --release --features network
The rest of Part I walks through the subcommands you will use most: running
games (tui, resume, tourney), the agent-play workflow for building
reproducers, the deck-file format, scripted play, card-lookup/asset building,
and the web server.
Running Games
The mtg tui subcommand runs a single game. Despite the name (“text UI”), it
covers every single-game mode: a human playing in the terminal, two AIs playing
each other, a scripted reproducer, or a puzzle.
A first game
The simplest invocation takes one or two deck files:
# Human (player 1) vs heuristic AI (player 2) — the defaults
mtg tui decks/old_school/01_rogue_rogerbrand.dck decks/old_school/02_thedeck_peterschnidrig.dck
# One deck file is used for both players if you give only one
mtg tui decks/simple_bolt.dck
By default player 1 is the interactive tui controller and player 2 is the
heuristic AI. You change who drives each seat with --p1 and --p2.
Controller types
Each seat is driven by a controller. The controller types (the
ControllerType enum in mtg-engine/src/main.rs) are:
| Controller | Behaviour |
|---|---|
zero | Always picks the first meaningful action. Deterministic; good for smoke tests. |
random | Makes random (but seeded, reproducible) choices. |
tui | Interactive text UI for a human, reading from stdin. |
fancy | Full-screen multi-panel terminal UI (ratatui). |
heuristic | The strategic AI. |
fixed | Replays a predetermined script of choices (see Scripted Play). |
fancy-fixed | Like fixed, but renders the fancy UI and can capture screenshots. |
Example — watch two AIs play:
mtg tui decks/a.dck decks/b.dck --p1 heuristic --p2 heuristic --seed 42
Determinism and seeds
A game is fully determined by its deck files, its starting hands, the seed, and the sequence of controller choices. The same inputs always produce the same game. The relevant flags:
--seed N— the master seed for the engine and the controllers. Pass--seed from_entropyfor a non-deterministic game.--seed-p1 N/--seed-p2 N— override the per-controller seed.--deck-seed N— seed used only for the initial library shuffle. After the shuffle, the RNG is re-seeded to--seedfor the rest of the game. Because the whole library order is fixed by that one shuffle, both the opening hand and the entire draw sequence come from--deck-seed; you cannot vary the draws independently of the opening hand. Use this to hold the shuffle fixed (same opening hand and same draws) while varying every other source of randomness — random choices, coin flips, AI tie-breaks, random discard/target picks — via--seed, or vice versa.--p1-draw "Mountain;Lightning Bolt;Mountain"/--p2-draw "..."— pin the opening hand (1–7 cards, semicolon-separated).
Controlling output
--verbosity / -v <0..3>—0silent,1minimal,2normal (default),3verbose.--log-tail K— only print the lastKlines of the log at exit. Handy with--stop-on-choiceto keep output a constant size.--no-color-logs— disable ANSI colour (also respects theNO_COLORenv var).--json— write snapshots in JSON instead of the binary format.--tag-gamelogs— prefix each official game-action log line with[GAMELOG TurnN STEP], which makes it possible to diff a local game’s log against a networked game’s log line-for-line.
Stopping, snapshotting, and resuming
You can stop a game part-way through and save its exact state:
--stop-on-choice N— stop afterNchoices.N:p1/N:p2counts only one seat.--stop-when-fixed-exhausted— stop when a fixed-input script runs out (used to build reproducers incrementally).--snapshot-output FILE— where to write the snapshot (defaultgame.snapshot).
Resume later with the resume subcommand:
# Capture a snapshot after 10 choices played by two heuristic AIs
mtg tui DECK1.dck DECK2.dck --seed 100 --stop-on-choice 10 \
--p1 heuristic --p2 heuristic --snapshot-output game.snapshot
# Resume — by default it restores controllers, RNG state, and choices exactly.
mtg resume game.snapshot
# Resume but swap in fixed-input controllers for a hand-crafted finish
mtg resume game.snapshot \
--override-p1 fixed --p1-fixed-inputs "0;1;2" \
--override-p2 fixed --p2-fixed-inputs "0;1;2"
Snapshots preserve everything — game state, RNG state, controller state, turn and choice counters — so a resume produces a byte-identical continuation. This is the same machinery the engine uses internally for rewind and replay; see Snapshot and Replay in Part II.
Tournaments
mtg tourney runs many games in parallel and reports aggregate statistics:
# 1000 games among three decks, mirror matches excluded
mtg tourney decks/a.dck decks/b.dck decks/c.dck --games 1000 --seed 42
# Run for a fixed wall-clock budget instead of a game count
mtg tourney decks/a.dck decks/b.dck --seconds 30
# Only mirror matches (each deck against itself)
mtg tourney decks/a.dck --mirror-only --games 200
--games and --seconds are mutually exclusive. Both seats default to the
heuristic AI.
Agent Play and Reproducers
You cannot type at the interactive terminal UI from a script, but DeepScry ships
a small Python toolchain under agentplay/ that drives deterministic games one
choice at a time — either by hand, or by letting an AI agent make each choice.
Every session is replayable from a single self-contained shell script.
This chapter summarises the workflow; the authoritative, fuller version lives in
docs/HOWTO_AGENTPLAY+REPRODUCERS.md.
The three entry points
| Script | Purpose |
|---|---|
agentplay/agent_game.py | Recommended. End-to-end AI-driven game; an LLM (or a --mock random selector) makes each choice, with optional scenario / bug-detection prompting. |
agentplay/start_game.py | Manually start a session: run up to the first choice, write the session files, print the menu. |
agentplay/continue_game.py | Manually append one player’s next choice and replay the whole game so far. |
All three produce the same on-disk session layout and the same reproducer script, so you can switch freely between agent-driven and manual modes against one session.
Quick start
# AI vs AI, with built-in bug detection (the agent can STOP and emit a bug report)
./agentplay/agent_game.py -- decks/old_school/01_rogue_rogerbrand.dck decks/old_school/02_thedeck_peterschnidrig.dck
# Mock mode: local random choices, no API tokens spent — good for smoke tests
./agentplay/agent_game.py --mock --seed 42 -- decks/a.dck decks/b.dck
# Drive a puzzle instead of a normal game
./agentplay/agent_game.py --puzzle puzzles/bolt_test.pzl
Note: the
--puzzleflag here is a script-level convenience ofagent_game.py; under the hood it launches the engine withtui --start-state <file>. Themtgbinary itself has nopuzzlesubcommand.
Useful agent_game.py flags include --scenario "<text>" (keep a reproduction
target in the prompt every turn), --mode {agent-vs-heuristic, agent-vs-random, agent-vs-agent, random-vs-random}, --max-turns N, --p1-draw / --p2-draw,
and --seed N. Run ./agentplay/agent_game.py --help for the full list.
Manual sessions
When you want a tight scripted reproducer (and don’t want to spend agent tokens), drive the game by hand:
# Start a session — runs up to the first choice and prints the menu
./agentplay/start_game.py decks/grizzly_bears.dck decks/royal_assassin.dck \
--p1-draw="Forest;Grizzly Bears;Forest"
# Add choices one at a time; each call replays the whole game and stops at the
# next decision. The first argument selects which player's choice file to extend.
./agentplay/continue_game.py p1 "play mountain"
./agentplay/continue_game.py p1 "cast lightning bolt"
./agentplay/continue_game.py p1 "target bob"
./agentplay/continue_game.py p2 "pass"
Choices may be numeric (menu indices, e.g. "0", "3" — simple but fragile
to menu reordering) or rich text (e.g. "play mountain", "cast lightning bolt" — robust to option ordering). You can mix both. The full input grammar is
covered in Scripted Play and the
Fixed-Input reference.
Session directory layout
Sessions live under agentplay/, normally in a numbered NNN.game/ directory.
Each session directory contains, among other files:
| File | Contents |
|---|---|
p1_choices.txt / p2_choices.txt | Each player’s choices, one per line, in order. |
initial_args.txt | The original mtg tui argv. |
snapshot.json / game.snapshot | Latest replayed state (JSON / binary). |
game.log | Engine log from the last replay. |
reproduce_game.sh | An executable script that replays the whole session deterministically. |
Reproducers
reproduce_game.sh is regenerated after every choice and inlines a single
deterministic mtg tui command, for example:
cargo run --release --bin mtg -- tui decks/old_school/01_rogue_rogerbrand.dck decks/old_school/02_thedeck_peterschnidrig.dck \
--p1=fixed --p2=fixed \
--p1-fixed-inputs="0;1;pass;play swamp" \
--p2-fixed-inputs="0;1;pass;play swamp" \
--stop-on-choice=5 \
--seed=42 --json --log-tail=100
This is exactly what you should paste into a bug report: it is self-contained, deterministic, and survives session cleanup. Because reproducers replay from scratch (same seed + same choices ⇒ same outcome) rather than loading a snapshot, they remain valid across engine changes that don’t alter behaviour.
Tips for good reproducers: start with the smallest decks that reproduce the
issue, pin the opening hand with --p1-draw / --p2-draw, trim trailing
choices so the script stops as soon as the bug fires, and re-run it once to
confirm determinism before filing.
Deck Files (.dck)
DeepScry reads decks in Forge’s .dck format — a plain-text, INI-style format
that is compatible with Java Forge deck files. This chapter is a practical
summary; the full specification is in docs/DCK_FORMAT.md, and the parser lives
in mtg-engine/src/loader/deck.rs.
File structure
A .dck file has up to three sections:
[metadata]
Name=Lightning Bolt Burn
Description=Classic red burn deck
[Main]
40 Lightning Bolt
20 Mountain
[Sideboard]
15 Shock
[metadata]
Key-value pairs. Name is required; Description is optional. Unknown metadata
keys are silently ignored.
[Main] and [Sideboard]
Each line is a quantity followed by a card name:
<quantity> <card name>[|<set code>][|<art index>]
<quantity>— number of copies (typically 1–255).<card name>— the full card name as printed.<set code>— optional three-letter set code (e.g.LEA,M10).<art index>— optional 1-based art-variant index.
Examples:
4 Lightning Bolt
3 Lightning Bolt|M10
1 Lightning Bolt|M10|2
20 Mountain
The [Sideboard] section uses the same line format as [Main].
How card names are matched
When the loader maps a deck line to a card file in cardsfolder/, it normalises
the name: lowercase it, replace spaces and hyphens with underscores, and strip
apostrophes, commas, colons, exclamation marks, and question marks. So:
| Card name | Resolved file |
|---|---|
All Hallow's Eve | all_hallows_eve.txt |
Nevinyrral's Disk | nevinyrrals_disk.txt |
Jace, the Mind Sculptor | jace_the_mind_sculptor.txt |
Matching is case-insensitive.
Current implementation status
Taken from docs/DCK_FORMAT.md:
Supported: the [metadata], [Main], and [Sideboard] sections; quantity
parsing; card-name normalisation; comments (#) and blank lines.
Parsed but not yet acted on: set codes and art indices are parsed but
currently ignored during loading — the loader resolves a card by its normalised
name only. Additional metadata fields beyond Name/Description are ignored.
Note: the
.dckformat is shared with Java Forge by design, so DeepScry can read the historical Forge deck corpus directly. Example decks live underdecks/(seedecks/old_school/for complex historical lists).
Scripted Play (Fixed Inputs)
The fixed controller replays a predetermined script of choices. This is the
backbone of reproducers and regression tests: a fixed script plus a seed fully
determines a game.
You pass a script with --p1-fixed-inputs / --p2-fixed-inputs (and select the
fixed controller with --p1 fixed / --p2 fixed):
mtg tui decks/a.dck decks/b.dck \
--p1 fixed --p1-fixed-inputs="play mountain;cast bolt;target bob" \
--p2 fixed --p2-fixed-inputs="0;0;pass"
Commands are semicolon-separated (not commas, not spaces).
Two ways to name a choice
- Numeric — the menu index, e.g.
0,1,3.0always means “pass priority”. Simple, but fragile: indices shift as cards enter and leave. - Rich text — a human-readable command like
play mountain,cast lightning bolt,attack grizzly. Robust to menu reordering, so it is preferred for durable scripts.
Card names in rich text match case-insensitively, by prefix, ignoring spaces and
underscores, and with trailing punctuation stripped — so light, Lightning Bolt, and lightning_bolt all resolve to “Lightning Bolt”.
Robustness primitives
Two features keep scripts from breaking when unrelated things change between turns:
-
Wildcard
*— skip (pass priority on) commands until the next command matches an available action. Use it when you care about availability: “pass until I can cast Fireball”.--p1-fixed-inputs="play mountain;*;cast fireball" -
PASS_UNTIL— pass priority through every intervening step until a named turn and/or phase is reached. Use it when you care about timing:PASS_UNTIL turn=3,phase=MAIN2 # wait for turn 3, post-combat main PASS_UNTIL phase=COMBAT # next combat, any turn PASS_UNTIL turn=2 # start of turn 2, any phasePhase names are case-insensitive:
untap,upkeep,draw,main1,combat,beginCombat,declareAttackers,declareBlockers,combatDamage,endCombat,main2,end,cleanup.
PASS_UNTIL only ever reads the public game state (the turn number and the
current step). It never inspects the stack, a hand, or any hidden information,
which is exactly why it behaves identically in local and networked play — see
the controller information-independence rule in
Network Architecture.
Targeting, abilities, and blocking
target grizzly # target a creature
target player1 # target a player
activate forest # first ability
activate forest[2] # second ability (1-indexed)
serra blocks assassin # declare a block
serra blocks grizzly;knight blocks elf # multiple blocks, semicolon-separated
Error behaviour
In normal mode, a command that matches no available action is a hard error that lists what was available:
Error: Command 'cast fireball' did not match any available action.
Available actions: ["play Mountain", "play Swamp"]
After a wildcard *, non-matching commands silently pass priority until the
next non-wildcard command that fails to match.
The complete grammar — every verb, every special case — is in the
Fixed-Input reference in Part III. The parsing
implementation lives in mtg-engine/src/game/command_parsing.rs and
mtg-engine/src/game/rich_input_controller.rs.
Card Lookup and Web Assets
A handful of mtg subcommands exist to build the data the browser client needs:
the card database export, card images, and the compact card→image lookup table.
You only run these when preparing a web build or a deploy; they are not part of
playing a game.
stats — inspect the card database
mtg stats
Prints statistics about the cards loaded from cardsfolder/. Useful as a quick
sanity check that the card database parses.
export-wasm — package data for the browser
mtg export-wasm
Exports the card database and a selection of decks into the binary format the WASM (browser) build loads. By default it includes the old-school and booster-draft deck sets; glob patterns let you include more.
download — fetch card images
mtg download --only-deck decks/old_school/01_rogue_rogerbrand.dck
Downloads card images from Scryfall. Options control the output directory, the
image sizes (small, normal), concurrency, and a politeness delay between
requests.
build-card-lookup — the card→image table
mtg build-card-lookup
Builds the compact lookup table (card-lookup.bin) that maps each card to an
immutable Scryfall CDN image URL. It fetches Scryfall’s unique_artwork.json
bulk dump once (cached locally), picks the oldest art per card identity, and
writes the table the client and the download command use to construct image
URLs. If Scryfall’s URL format ever drifts, this command hard-errors and keeps
the existing table rather than writing something wrong. It is meant to run at
deploy time, not on every build.
hash-asset / hash-web-assets — content addressing
These are part of the content-addressed asset pipeline used by the deploy. They compute stable BLAKE3 hashes of asset files so the deployed site can cache aggressively and bust the cache precisely when an asset’s bytes change.
mtg hash-asset path/to/file # print the 16-hex BLAKE3 hash of one file
The generated lookup table and image assets are regenerated at deploy time and are not tracked in git.
The Web Server and Deployment
DeepScry can serve networked multiplayer games. There are two server subcommands and one client subcommand.
server — headless lobby
mtg server --port 17771
A long-lived, headless WebSocket game server. It speaks the lobby protocol
defined in mtg-engine/src/network/protocol.rs: clients Register a unique
username, then ListGames, CreateGame, or JoinGame. The server hosts
multiple concurrent games. It serves no static files — it is the game engine
only.
Notable options: --password (require a join password), --starting-life,
--seed (deterministic games), --max-memory-percent (refuse new games above a
host-memory threshold; in-flight games are never killed), and --network-debug
(attach state hashes to every message and validate them after each choice — the
early-detection mechanism described in
Network Architecture).
server-web — the full browser product
mtg server-web --bind 0.0.0.0:8080 --static-dir ./web
This is the single process that powers the deployed website. On one port it serves both:
- Static files from
--static-dir(default./web) at every path that is not the lobby WebSocket path: the lobby HTML, the WASM bundle, card-image data, JavaScript modules, and other assets. - A WebSocket lobby at
--lobby-path(default/lobby). The browser connects here for the whole flow (register, browse, create/join, set deck, set ready, play, reconnect, bug report). Behind the proxy, an embedded game server runs on a private loopback port that is never directly reachable from outside.
TLS is enabled when both --tls-cert and --tls-key (or the MTG_TLS_CERT /
MTG_TLS_KEY env vars) point at valid PEM files. When they are unset the server
speaks plain HTTP — the standard setup when a CDN such as Cloudflare terminates
TLS at its edge and forwards plaintext to the origin.
On SIGTERM or Ctrl-C the server stops accepting new connections, notifies open
clients with a fatal error message, and drains for up to 30 seconds.
connect — join as a client
mtg connect decks/a.dck --server localhost:17771 --name alice
Connects to a running server with the given deck and joins the lobby.
The web front end
The deployed front end lives under web/: a public landing page and lobby
(web/index.html), a WASM AI-vs-AI demo, and the in-game UIs. The landing page
collects a username, connects to the server over WebSocket, and launches into a
game page. It is intended to be self-explanatory in the browser, so this guide
does not walk through the on-screen controls.
Deployment
Deployment is mechanised by scripts/deploy-cloud.sh, which has two phases:
deploy-cloud.sh config— one-time per-VM bootstrap: installs the service unit, writes the environment file, opens the firewall port. Idempotent.deploy-cloud.sh deploy— run on every code change: rebuilds the WASM artefacts and the releasemtgbinary, rsyncsweb/,cardsfolder/, and the binary to the VM, and restarts the service.
The deploy build uses the dedicated release-deploy Cargo profile (strip +
fat LTO + abort-on-panic) to produce a small binary suitable for rsync. All
site-specific values (user, host, ports, TLS paths, service name) come from a
gitignored local config file (.deepscry-deploy.env), CLI flags, or environment
variables — never hardcoded. See the deploy script and its --help for the
specifics; this guide intentionally keeps deployment at a high level.
Architecture Overview
Part II explains the engineering heart of DeepScry: how a game can run identically on a server and on every client, how the engine reproduces a game bit-for-bit on demand, and how the two ideas combine into a replicated state-machine model for networked multiplayer.
Three principles tie the whole engine together. They are worth stating up front because every later chapter is an elaboration of one of them.
-
Deterministic sequential simulation. Given the same inputs — decks, starting hands, seed, and the ordered sequence of player choices — the engine always produces exactly the same game. There is no wall-clock dependence, no parallel race, no hidden randomness. This is what makes reproducers, snapshots, and replay possible at all.
-
The replicated state machine. A networked game is one server running the authoritative game and two clients each running an identical copy. The server does not stream pixels or diffs to the clients; it streams the same inputs into the same deterministic engine, and every copy stays in step on its own. A client only ever knows what it is allowed to know — hidden information (an opponent’s hand, the library order) stays hidden — yet its copy still computes the same public state.
-
Desync is always fatal. Because the copies are supposed to be identical, any divergence between them is a bug, full stop. The engine never tries to “recover” from a desync by patching state; it treats divergence as an immediate fatal error. Extra validation data carried in network messages exists only to detect divergence early, never to repair it.
How the chapters fit together
-
Network Architecture is the north-star document for the networked model: the replicated golden/shadow state, linear control transfer, the list of forbidden patterns, and the controller-information-independence rule. (This chapter is the project’s canonical
docs/NETWORK_ARCHITECTURE.md, included verbatim.) -
Deterministic Simulation drills into principle 1: where determinism comes from, what threatens it, and the project conventions that protect it.
-
The Replicated State Machine drills into principle 2: how identical engine copies are kept in sync by replicating inputs, and how hidden information is reconciled with deterministic shared identifiers for cards.
-
Snapshot and Replay covers the rewind / replay mechanism that the single-player snapshot feature and the networked shadow state both rely on. (Included from the project’s
ai_docs/reference/snapshot_architecture.md.)
A word on freshness. Two of these chapters are included directly from existing project documents that were judged current and clean. The other two were written fresh for this guide because their source documents had drifted from the code — most importantly, the card-identity model was reworked (the “late-binding card identifier” change) and older docs still described the superseded design. Where this guide states how something works today, it follows the code; the Replicated State Machine chapter calls out exactly which older design it supersedes.
Network Architecture
This document describes the fundamental principles of the MTG network multiplayer architecture. These principles are inviolable - any code that violates them is a bug.
Core Principle: Deterministic Sequential Simulation
The MTG game is a deterministic state machine. Given the same initial state (decks, RNG seed) and the same sequence of player choices, the game will always produce identical results.
In network multiplayer, this state machine is split across machines:
- One server
- Two clients (one per player)
Despite being distributed, the simulation remains sequential:
- Control transfers linearly from server to client (for choices) and back
- There is never parallel or concurrent execution of game logic
- At any moment, exactly ONE entity has “control”
CRITICAL: Desync is ALWAYS a Fatal Error
Any desynchronization between server and client is an immediate, fatal error.
This principle is absolute and admits NO exceptions:
-
Never paper over desync - If client and server have different views of the game state, the correct response is to crash with a clear error message, NOT to silently “fix” the discrepancy with recovery heuristics.
-
No half-working hacks - We have NO interest in code that “stumbles along for a few more turns” in a desynced state. Such code masks bugs and makes debugging nearly impossible.
-
Desync means a bug exists - When desync is detected, the correct action is:
- Log detailed diagnostic information (state hashes, action counts, choice indices)
- Terminate the game immediately with
FATAL ERROR: DESYNC DETECTED - File a bug report with reproduction steps
-
Validation, not recovery - Extra data sent in messages (like
spell_abilityinChoiceResponse) is for validation and early detection only. If validation fails, we crash immediately - we do NOT use the extra data to “recover” from inconsistent state.
Why This Matters
The deterministic simulation model is the foundation of network correctness. If we allow recovery hacks:
- Bugs become invisible (game continues despite corruption)
- State corruption compounds (one wrong choice leads to cascading errors)
- Debugging becomes impossible (the “fix” obscures the original cause)
- Trust in the system erodes (players experience random failures)
By crashing immediately on desync, we:
- Catch bugs early with clear error messages
- Get reproducible failure cases
- Maintain trust in the correctness of successful games
- Keep the codebase simple (no complex recovery logic)
Replicated Game State
All three parties (server + 2 clients) maintain a copy of the game state:
┌─────────────────────────────────────────────────────────────────────┐
│ GAME STATE │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Server │ │ Client 1 │ │ Client 2 │ │
│ │ (golden) │ │ (shadow) │ │ (shadow) │ │
│ │ │ │ │ │ │ │
│ │ Full state │ │ Sees own │ │ Sees own │ │
│ │ No hidden │ │ cards only │ │ cards only │ │
│ │ info │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Server: Has the “golden” copy with full visibility (no hidden information). Used to validate client states and resolve disputes.
Clients: Have “shadow” copies where opponent’s hidden information (hand cards, library order) is not revealed until game rules require it.
The states must remain identical except for:
- Hidden information not yet revealed
- The PlayerController implementations (local vs remote)
The Action Log (Undo Log)
The game maintains a sequential action log (implemented as undo_log) that
records every game action:
#![allow(unused)]
fn main() {
enum GameAction {
MoveCard { card_id, from_zone, to_zone, owner },
RevealCard { card_id, name, revealed_to }, // WHO sees this reveal
ChoicePoint { player_id, choice_type, ... },
TapCard { card_id },
// ... etc
}
}
This log is deterministic - given identical inputs, all parties produce identical logs. It functions like a blockchain: an agreed-upon sequential history of all game events.
RevealCard Actions
Card reveals are first-class game actions in the action log. When a card’s
identity needs to be known (draw, play, mill to graveyard, etc.), the engine
logs a RevealCard action BEFORE any downstream actions that depend on knowing
the card.
Key properties of RevealCard:
- Target audience: Each reveal specifies WHO sees it (P1, P2, or both)
- Deduplication: If a card was already revealed to a player, no redundant reveal is logged for that player
- Ordering: Reveals appear in the log BEFORE actions that depend on them
Example sequence when P1 draws a card:
1. RevealCard { card_id: 42, name: "Lightning Bolt", revealed_to: P1 }
// P2 doesn't see this - hand is hidden
2. MoveCard { card_id: 42, from: Library, to: Hand, owner: P1 }
Example when a card goes to battlefield (public zone with ETB triggers):
1. RevealCard { card_id: 42, name: "Serra Angel", revealed_to: BOTH }
// Everyone sees cards entering public zones
2. MoveCard { card_id: 42, from: Hand, to: Battlefield, owner: P1 }
// Move processing can now check ETB triggers, enters-tapped, etc.
The reveal MUST come before the move because:
- Move processing may need card identity (ETB triggers, enters-tapped checks)
- Any code touching the card can assume it’s already revealed
- Simpler invariant: “reveal before first use”
The Message History
Communication happens over a single WebSocket per client, creating a sequential message history:
Server ←──────────────────────────────────→ Client
│ │
│ ServerMessage (ordered) │
│ ─────────────────────────────► │
│ │
│ ClientMessage (ordered) │
│ ◄───────────────────────────── │
│ │
Messages are processed in exact order of arrival. No reordering is possible because:
- Single WebSocket = single TCP connection = ordered delivery
- Single channel internally = no race conditions
The server→client message stream is essentially an ordered, abbreviated subset of the action log. When catching up a client before a choice request, the server reads RevealCard actions from the log and sends corresponding CardRevealed messages.
Linear Control Transfer
┌────────┐ ┌────────┐ ┌────────┐
│ Server │ ──────► │Client 1│ ──────► │ Server │ ──────► ...
│(execute│ │(choose)│ │(execute│
│ game) │ │ │ │ game) │
└────────┘ └────────┘ └────────┘
│ │ │
▼ ▼ ▼
Actions Choice Actions
logged made logged
- Server executes game logic until a player choice is needed
- Server sends reveals + ChoiceRequest to the deciding player’s client
- Client processes reveals, then presents choice to player
- Client sends ChoiceResponse back to server
- Server applies choice, sends confirmation, notifies opponent
- Repeat
At each step, only ONE party is “active” - others are waiting.
What This Architecture PROHIBITS
No Sleeps or Retries
#![allow(unused)]
fn main() {
// WRONG - violates linear model
loop {
if data_available() { break; }
sleep(10ms); // NO! This indicates a protocol bug
}
}
If you find yourself wanting to sleep/retry, it means messages are arriving in the wrong order. Fix the protocol, don’t paper over it.
No Select Over Multiple Channels
#![allow(unused)]
fn main() {
// WRONG - introduces nondeterminism
tokio::select! {
msg = channel_a.recv() => { ... }
msg = channel_b.recv() => { ... } // Race condition!
}
}
Each party waits on exactly ONE source at a time. The single-channel architecture ensures this.
No Parallel Message Processing
#![allow(unused)]
fn main() {
// WRONG - violates sequential processing
spawn(process_reveals());
spawn(process_choices()); // These might race!
}
All messages are processed sequentially in arrival order.
No Reveal Logic in Server Handlers
#![allow(unused)]
fn main() {
// WRONG - reveals belong in the core engine
fn handle_choice_request() {
// Scanning undo_log for reveals here is wrong
let reveals = collect_reveals_from_log();
send_reveals(reveals);
}
}
Reveals are GameActions logged by the core GameLoop. The server just reads them from the log and forwards to clients.
Correct Reveal Architecture
Reveals are generated in the core GameLoop as deterministic game actions:
┌─────────────────────────────────────────────────────────────────────┐
│ GAME LOOP (Core Engine) │
│ │
│ 1. Card moves from hidden zone (Library, Hand) │
│ 2. Engine checks: who needs to see this card? │
│ 3. Engine logs RevealCard action with target audience │
│ 4. Deduplication: skip if already revealed to that audience │
│ 5. Continue with downstream actions │
│ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ SERVER │
│ │
│ 1. At choice point, read action log since last choice │
│ 2. Extract RevealCard actions │
│ 3. Send CardRevealed messages to appropriate clients │
│ 4. Send ChoiceRequest │
│ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ CLIENT │
│ │
│ 1. Receive CardRevealed messages in order │
│ 2. Instantiate cards in shadow game state │
│ 3. Receive ChoiceRequest │
│ 4. Present choice to player │
│ │
└─────────────────────────────────────────────────────────────────────┘
CRITICAL: Controllers Must Be Information-Independent
All controllers (heuristic, random, zero, etc.) MUST produce identical decisions whether running on the server (full state) or on a client (shadow state).
This is a direct consequence of the deterministic simulation model:
-
Controllers must NEVER depend on hidden information - opponent hand contents, library order, or RNG state are not legally visible. A controller that reads this information and uses it to make decisions is cheating AND will cause network desync.
-
Any divergence is a bug - If a controller produces different gamelogs when run locally (where GameStateView exposes everything) vs in network mode (where shadow state hides opponent cards), the controller has an information-leakage bug.
-
GameStateView exposes more than it should in local mode - Methods like
player_hand(opponent_id)return real data locally but empty data on clients. Controllers must not call these methods for opponents, or must handle the empty case identically to having data. -
Testing requirement - The network vs local equivalence E2E test (
tests/network_vs_local_equivalence_e2e.sh) validates gamelog identity across ALL controller types. This is not optional - it catches info-leakage bugs.
Testing Requirements: Always Use --network-debug
When testing any network functionality, always launch the server with --network-debug:
mtg server --port 17771 --password test --network-debug
The --network-debug flag enables full state hash validation after every choice:
- Server computes a hash of the game state after applying each choice
- Client computes the same hash independently and sends it with each
ChoiceResponse - Server validates the hashes match — any mismatch is an immediate fatal error
Without --network-debug, the server still runs the game correctly, but state hash
validation is disabled. The cheap integer-comparison checks (ability count, hand size,
discard count) always run in all builds, but these only catch a subset of desyncs.
The full hash check catches all state divergences.
Every test script and launch helper MUST pass --network-debug to the server.
This includes:
- E2E tests (
web/test_network_*.js,tests/network_vs_local_equivalence_e2e.sh) - Bug-finding infrastructure (
bug_finding/network_test_lib.py) - Launch helpers (
scripts/play-web.sh) - Manual testing scripts (
scripts/network_desync_reproducer.sh)
If you create a new test or script that launches a network server, add --network-debug.
If you find an existing script without it, that’s a bug — fix it.
Related Docs
NETWORK_ACTION_LOG.md— generic append-only,action_count-indexed, non-destructiveActionLog<T>primitive plus its two-store ownership split: per-controller choice buffers (private to eachController) and a separateNetworkClient-owned shadow state-sync log (CardRevealed/LibraryReordered, shadow mode only). The engine sees onlycontroller.choose_at(view, action_count)and an optionalapply_state_sync_at(K)hook — it is controller-agnostic. Replaces the destructive-FIFOpop_*/drain_*paths.
Related Issues
mtg-228: Single-channel architecture to eliminate select! nondeterminismmtg-229: Shadow state desync debuggingmtg-218: Hidden information architecturemtg-176: Main networking tracking issue
Summary
- Deterministic simulation split across machines
- Sequential action log agreed upon by all parties
- Sequential message history via single WebSocket/channel
- Linear control transfer - one active party at a time
- No sleeps, retries, or selects - each party waits for its turn
- Reveals are core game actions - not server handler logic
Fuzz & stress harnesses
The randomized harnesses that exercise this loopback model (native
determinism, local-vs-network equivalence, native-vs-WASM equivalence,
snapshot/resume) and the deterministic validate legs that guard them are
inventoried in
../docs/FUZZ_AND_STRESS_TESTING_STRATEGY.md.
Validate = deterministic fixed-seed legs; unbounded random sweeps live in
bug_finding/.
Deterministic Simulation
Determinism is the foundation everything else is built on. If a game is not reproducible, then reproducers do not reproduce, snapshots do not resume faithfully, and a networked copy cannot be trusted to match the server. This chapter explains where determinism comes from in DeepScry and what the codebase does to protect it.
What “deterministic” means here
A DeepScry game is a pure function of four things:
- the two decks,
- the starting hands,
- the random seed,
- and the ordered sequence of choices made by the controllers.
Given those, the engine always produces the same sequence of game actions and the same final state — down to the bytes. There is deliberately no other source of variation. In particular:
- No wall-clock time. Nothing in the game loop branches on the current time.
- No ambient randomness. All randomness flows from the seed through an explicit, serialised RNG. There are no calls to a global random source.
- No parallelism inside a game. A single game is simulated strictly sequentially. (Tournaments run many independent games in parallel, but each individual game is sequential.)
Because of this, the reproducer scripts described in Agent Play and Reproducers can replay a game from scratch — same seed plus same choices yields the same game — rather than having to ship a saved state.
Rewind and bit-identical replay
The engine can rewind a game to an earlier point (for example, the start of a turn) and replay forward to reconstruct a later state. The requirement is strict: replay must reproduce a bit-identical state, including internal bookkeeping such as the undo log and the view hashes used for network and in-browser rewind. This is the mechanism the snapshot/resume feature and the networked shadow state both lean on; the mechanics are detailed in Snapshot and Replay.
The single largest threat to bit-identical replay is transient mutable state — a value that one step writes onto the game and a later step reads back. If such a value is not faithfully reconstructed when the game rewinds and replays, the replayed state diverges from the original, and divergence is fatal.
The project conventions that protect determinism
The codebase encodes several rules to keep replay honest. They are stated in the
project’s CLAUDE.md; the short version:
-
Prefer functional/immutable style for game state. Mutating shared state is the most common source of replay divergence, so the engine prefers constructions that don’t depend on a value having been mutated earlier in a specific order.
-
Thread explicit parameters instead of stashing transient state. If a value is only needed across a few call sites for one resolution (say, “who caused this discard”), it is passed as a parameter rather than written to a field and read back later. An explicit parameter carries no reconstruct-on-rewind obligation; a mutable field does.
-
If state must live on the game, it must be serialised. Anything that genuinely has to persist on
GameStateis serialised (with#[serde(default)]) so that snapshot/resume, the network shadow, and in-browser rewind all reconstruct it identically. A field that is skipped from serialisation but still holds real game-loop state across a choice point is “a desync waiting to happen.” -
The litmus test: “if the game rewinds to turn start and replays, does this value come back bit-identical?” If correctness depends on a mutation happening in a particular order, the value must be threaded as a parameter or serialised, not left as ad-hoc mutable state.
Controllers must not smuggle in hidden information
A subtle determinism requirement applies to the decision-makers, not just the engine. Every controller — heuristic, random, zero, fixed — must make the same decision whether it is running on the server (with full information) or on a client (with only the information that client is allowed to see). A controller that peeks at an opponent’s hand, the library order, or raw RNG state would make different decisions in local versus networked play, which is an information-leakage bug and a source of desync.
This is why the scripting primitives are careful to read only public state — for
example, PASS_UNTIL (see Scripted Play) keys off
the turn number and current step and nothing else. The information-independence
rule is treated as part of the network contract; see
Network Architecture for the authoritative
statement.
Verifying determinism in practice
Two mechanisms let you confirm the property holds:
--network-debugattaches a state hash to network traffic and checks, after every choice, that the client’s state hash matches the server’s. A mismatch is reported immediately rather than allowed to fester.--tag-gamelogsprefixes each game-action log line with[GAMELOG TurnN STEP]so a local game’s log and a networked game’s log can be diffed line-for-line — an exact match is the visible proof that both ran the same deterministic simulation.
The Replicated State Machine
A networked DeepScry game is one authoritative game running in three identical copies: the server’s copy (the “golden” state) and one “shadow” copy inside each of the two connected clients. The clients are not thin terminals receiving a rendered view — each one runs the same engine over the same inputs and computes the same public game state locally.
This chapter explains how those copies are kept identical, and how that is possible even though each player is forbidden from seeing the other’s hidden information.
Freshness note. This chapter was written fresh for the guide. It supersedes the older
ai_docs/reference/SHADOW_STATE_AND_CARDID_ALLOCATION.mdand the proposalai_docs/reference/DESIGN_late_binding_cardid.md, whose concrete mechanisms (LibraryMode::Remote,pending_reveals,hidden_card_count) no longer exist in the code. The current design — the “late-binding card identifier” model — is described below and verified against the source.
Replicate the inputs, not the state
The core move is this: rather than send game state across the network, the server sends the same inputs into every copy of the engine, and determinism (see Deterministic Simulation) guarantees each copy ends up in the same place on its own.
Concretely, when it is a player’s turn to make a choice, exactly one copy is “driving”; the resulting choice is distributed and every copy applies it. Because the engine is a deterministic function of its inputs, applying the same choice to the same prior state yields the same next state everywhere. Control passes from copy to copy linearly — there is never more than one decision in flight, and there is no parallel processing to create a race.
Desync is a fatal error, never something to patch
Since the copies are supposed to be identical, any divergence between them is a
bug by definition. The engine therefore treats desync as an immediate fatal
error. It does not attempt to reconcile or repair diverged state. Validation
data carried alongside network messages — most visibly the per-choice state
hashes enabled by --network-debug — exists purely to catch divergence as
early as possible, not to recover from it. This “desync is always fatal” stance
is a hard project invariant; see Network Architecture
for the full discussion and the list of recovery patterns that are explicitly
forbidden.
The hidden-information problem
Replicating inputs runs into an obvious tension: a client must compute the same public state as the server, but it is not allowed to know the opponent’s hand or the order of either library. If a client doesn’t know what a face-down card is, how can its copy of the game agree with the server about that card?
DeepScry resolves this with shared card identifiers whose contents are bound late.
Shared identifiers, deterministic allocation
Every card slot in the game has a stable, public identifier (a CardId) that is
the same number on the server and on both clients. These identifiers are
allocated deterministically: at game start, each side runs the same
initialisation over the same decklists, so every side independently assigns
identical identifiers to identical slots. No identifier has to be negotiated over
the wire — determinism makes them agree for free.
This is why a player’s whole decklist is shared at game start (in the
GameStarted message): not so the opponent can read your cards, but so both
sides can run the same deterministic allocation and arrive at the same set of
identifiers.
Late binding: the identifier is public, the card is not
The key idea is that a CardId being public does not mean the card’s
identity (its name) is public. A card has a per-viewer reveal state: it is
known to some players and unknown to others. In the code this is a bitmask on the
card — revealed_to_mask: u8 in mtg-engine/src/core/card.rs — recording which
players are currently allowed to see that card’s face.
So all three copies agree on “there is a card with identifier 57 in the opponent’s hand”, and they agree on its public properties, but only the copies permitted to see card 57’s face know that it is, say, Lightning Bolt. The opponent’s client tracks the slot and its identifier without ever learning the name.
Revealing is a first-class, undoable action
When hidden information legitimately becomes visible — a card is drawn into a
hand the viewer can see, revealed by an effect, or moved to a public zone — the
engine performs an explicit RevealCard action (mtg-engine/src/undo.rs). That
action records the new reveal mask (and the previous mask, so it can be undone
during rewind). Reveals therefore flow through the same deterministic,
rewind-safe action machinery as everything else: they replay bit-identically and
can be rolled back cleanly.
This “late-binding card identifier” design replaced an earlier model that tracked
hidden cards by counting them and only handed out identifiers at reveal time.
The current model is simpler: identifiers exist up front and are shared; only the
binding of identifier to face is deferred. The header comment in
mtg-engine/src/zones.rs records this explicitly (“Late-Binding CardID
Architecture”) and notes that it “eliminates the old LibraryMode::Remote and
pending_reveals complexity.”
Putting it together
A networked game thus stays in lock-step like this:
- At game start, every copy deterministically allocates the same shared
CardIds from the shared decklists, and sets reveal masks so each player can see only what they should. - Play proceeds by replicating one choice at a time into every copy; each copy advances its own deterministic engine.
- When information legitimately changes hands, an explicit, undoable
RevealCardupdates the per-viewer reveal mask — identically on every copy permitted to see it. - Optional per-choice state hashes confirm the copies still match; if they ever don’t, the game fails fast rather than papering over the divergence.
The result is a replicated state machine in which every participant computes the same public truth, no participant learns more than the rules allow, and any deviation is caught immediately.
Snapshot/Resume Architecture
Snapshot Point Principle
Snapshots are taken BEFORE presenting a choice to the controller.
This design ensures:
- Clean pause point: External agents can review the game state up to this point
- Clear semantics: The snapshot represents “everything that has happened”
- Simple resume logic: When resuming, present the next choice fresh to the controller
Implementation
PREAMBLE Check (Before Choice)
All choice points check stop conditions BEFORE asking the controller:
#![allow(unused)]
fn main() {
// PREAMBLE: Check stop conditions BEFORE asking for choice
if let Some(result) = self.check_stop_conditions(controller, player_id)? {
return Ok(Some(result)); // Snapshot and stop HERE
}
// Ask controller to choose
let choice = controller.choose_spell_ability_to_play(&view, &available);
// Log choice to undo log
self.log_choice_point(player_id, Some(replay_choice));
}
Stop Condition Logic
When --stop-every both:choice:K is specified:
-
Before presenting choice K+1:
count_filtered_choices()returns K (K choices have been made)- PREAMBLE check: K >= K? Yes, snapshot
- Snapshot includes K choices (all made/executed/logged)
- Game pauses BEFORE presenting choice K+1
-
When resuming:
- Replay K choices with suppressed logging (already logged in previous segments)
- Clear replay mode
- Present choice K+1 fresh to the controller
Replay Mode
#![allow(unused)]
fn main() {
// Replay mode suppresses ALL logging because snapshots are taken BEFORE
// presenting choices. All choices in the snapshot were already made/executed/logged.
if self.replaying {
// Suppress stdout logging
}
// Clear replay mode before presenting the NEXT choice
if self.replaying && self.replay_choices_remaining == 0 {
self.replaying = false;
}
}
Example Timeline
Segment 1 (stop after 3 choices):
- Turn 1: Alice plays Forest → logged, executed
- Turn 2: Bob plays Forest → logged, executed
- Turn 3: Alice plays Forest → logged, executed
- PREAMBLE check: count=3, 3 >= 3? Yes
- Snapshot: Turn 3 start + 3 intra-turn choices
- Stop (BEFORE presenting “Alice casts Grizzly Bears”)
Segment 2 (resume from snapshot):
- Load: Turn 3 start, replay_mode(3)
- Replay Alice plays Forest (Turn 1) - suppressed logging
- Replay Bob plays Forest (Turn 2) - suppressed logging
- Replay Alice plays Forest (Turn 3) - suppressed logging
- Clear replay mode
- Present NEW choice: “Alice casts Grizzly Bears” ← Fresh controller decision
Benefits
- External agent workflow: Can pause, review game log/state, then decide next move
- No ambiguity: Snapshot never contains “partially made” choices
- Clean determinism: All choices in snapshot were completed in previous segments
- Simple testing: Can verify game state at natural pause points
Comparison to Old Design
Old (POSTAMBLE):
- Snapshot taken AFTER making choice K but BEFORE executing it
- Choice K in snapshot but never executed/logged to stdout
- Ambiguous: “Was this choice made or not?”
- Complex replay logic: Last choice needs special handling
New (PREAMBLE):
- Snapshot taken BEFORE presenting choice K+1
- All K choices in snapshot were fully made/executed/logged
- Clear: “These choices are done, next one is pending”
- Simple replay logic: Suppress all, then present new choice
Related Files
src/game/game_loop.rs: Main implementationcheck_stop_conditions(): PREAMBLE check before asking controllerwith_replay_mode(): Sets up replay mode when resuminglog_choice_point(): Decrements replay counter
src/game/snapshot.rs: GameSnapshot serializationsrc/undo.rs:rewind_to_turn_start()collects intra-turn choices
Fixed Input Syntax Reference
This document describes the syntax for fixed input commands used with --p1-fixed-inputs
and --p2-fixed-inputs flags, as well as the agentplay workflow scripts.
Overview
Fixed inputs allow you to script player decisions for deterministic game replays. Commands can be either numeric (menu indices) or rich text (human-readable).
Command Syntax
Basic Commands
| Verb | Syntax | Description |
|---|---|---|
pass | pass | Pass priority (same as 0 or p) |
play | play <card> | Play a land card |
cast | cast <card> | Cast a spell |
activate | activate <card> | Activate an ability |
attack | attack <creature> | Declare a creature as attacker |
block | <blocker> blocks <attacker> | Declare a blocking assignment |
equip | equip <equipment> | Shorthand for equip ability |
Card Name Matching
Card names are matched using these rules:
- Case-insensitive:
Mountain,mountain, andMOUNTAINall match - Prefix matching:
lightmatches “Lightning Bolt” - Spaces/underscores ignored:
blackknight,black_knight, andBlack Knightall match - Trailing punctuation stripped:
mountain.andmountain,match “Mountain”
Numeric Commands
| Number | Meaning |
|---|---|
0 | Pass priority (equivalent to pass) |
1 to N | Select menu item N (1-indexed) |
Menu items are always displayed in this order:
[0] pass[1]- First available action (usually play land)[2]- Second available action- etc.
Examples
# Play a land
play mountain
play "Serra Angel"
# Cast a spell
cast lightning bolt
cast "Black Knight"
# Activate an ability
activate forest
activate forest[2] # Second ability (1-indexed)
# Declare attackers
attack grizzly
attack serra
# Declare blockers
blackknight blocks whiteknight
serra blocks assassin
# Using quotes (optional)
cast "Black Lotus"
play "Tropical Island"
# Numeric choices
0 # Pass priority
1 # Select first action
Multiple Commands
Commands are separated by semicolons (;):
--p1-fixed-inputs="play mountain;pass;cast bolt;target player2"
PASS_UNTIL: Semantic Anti-Overfitting Command
PASS_UNTIL is the primary anti-brittleness primitive. Instead of counting
action indices across turns, you declare the turn+phase you want to act in,
and the controller passes priority through all intervening steps automatically —
regardless of what triggers fire, how many actions are offered, or what state
changes occur in between.
Syntax
PASS_UNTIL turn=N,phase=PHASE # wait for specific turn + phase
PASS_UNTIL phase=PHASE # wait for next occurrence of PHASE (any turn)
PASS_UNTIL turn=N # wait for start of turn N (any phase)
phase (or step) accepts case-insensitive names:
untap, upkeep, draw, main1, combat, beginCombat,
declareAttackers, declareBlockers, combatDamage, endCombat,
main2, end, cleanup
Examples
# Pass all priority windows until Turn 3 post-combat main phase
PASS_UNTIL turn=3,phase=MAIN2
# Pass until combat phase (begin-combat step) of any turn
PASS_UNTIL phase=COMBAT
# Combine: reach a specific state, then act by name
PASS_UNTIL turn=2,phase=MAIN1
cast Grizzly Bears
PASS_UNTIL phase=declareAttackers
attack Grizzly Bears
Why Use PASS_UNTIL vs Wildcard (*)
Wildcard * | PASS_UNTIL | |
|---|---|---|
| Passes until | Next command matches available action | Turn+phase reached |
| Affected by trigger order? | Yes (command must be available) | No |
| Affected by new actions in menu? | No | No |
| Affected by action count changes? | No | No |
| Expressiveness | “wait until castable” | “wait until this turn+step” |
Use * when you care about availability; use PASS_UNTIL when you care
about timing. Scripts that mix both are fine.
Information Independence
PASS_UNTIL only uses the public game state (turn number and current step).
It does not inspect the stack, hand, opponent’s choices, or any hidden
information. This makes it safe for both local and network-mode play.
Wildcard Mode
Use * to skip commands until the next one matches:
# Play mountain, then pass until we can cast fireball
--p1-fixed-inputs="play mountain;*;cast fireball"
# Equip, then wait until attack phase
--p1-fixed-inputs="equip accorder;*;attack grizzly"
In wildcard mode:
- Non-matching commands pass priority without error
- The controller waits until the specified command becomes available
- Useful for scripts that need to span multiple turns
Error Handling
Normal Mode (no wildcard)
Commands MUST match an available action. If they don’t, the controller returns an error:
Error: InvalidAction("Controller error: Command 'cast fireball' did not match
any available action. Available actions: [\"play Mountain\", \"play Swamp\"]")
Wildcard Mode (after *)
Non-matching commands silently pass priority. No error is raised until the next non-wildcard command that doesn’t match.
Menu Display Format
The game displays available actions in this format to match the input syntax:
Player1 available actions:
[0] pass
[1] play Mountain
[2] play Mountain
[3] play Swamp
You can copy the text directly (without the [N] prefix) as input.
Special Cases
Multiple Abilities on Same Permanent
Use indexed activation for permanents with multiple abilities:
activate forest # First ability
activate forest[1] # Also first ability (1-indexed)
activate forest[2] # Second ability
Targeting
Targeting commands use the same card matching rules:
target grizzly # Target creature
target player1 # Target player
target "Black Knight"
Inline targeting clause (robust to forced targets)
A standalone target ... command only works when the engine actually asks
the controller to choose a target. When a spell has exactly one legal target
(e.g. Lightning Bolt vs. the only creature on board), the engine auto-selects
it WITHOUT a target prompt (CR 601.2c forced choice), and a following
target ... line would strand and error.
To make targeted plays robust either way, append an inline targeting <selector>
clause to the cast / activate command:
cast Lightning Bolt targeting Grizzly Bears
cast Shock targeting p2 # p2 = the second player (also accepts p1/p0)
activate Prodigal Sorcerer targeting Grizzly Bears
The selector after targeting is matched against the engine’s offered valid
targets using the same anti-overfitting card matcher (prefix / case- /
space-insensitive), or a pN player sentinel. If the engine prompts for a
target, the named one is chosen; if the target was forced, the clause is a
harmless no-op. This is the preferred form for scripted puzzle actions (see
the [p0_script] / [p1_script] puzzle sections).
Blocking Multiple Attackers
Use semicolon-separated clauses:
--p2-fixed-inputs="serra blocks grizzly;knight blocks elf"
Best Practices
- Use rich text over numeric: Menu indices can change as cards enter/leave
- Use wildcards for timing:
*;cast fireballwaits until fireball is castable - Quote card names with spaces:
"Black Knight"(optional but clearer) - Test reproducibility: Run the same command twice to verify determinism
Implementation Details
The parsing logic is implemented in:
mtg-engine/src/game/command_parsing.rs- Core parsing functionsmtg-engine/src/game/rich_input_controller.rs- Rich input controllermtg-engine/src/game/controller.rs- Menu formatting
See Also
- HOWTO: Play MTG Games and Build Reproducers
mtg tui --help- CLI optionsagentplay/*.sh- Wrapper scripts for interactive play
Controller Architecture
Overview
The PlayerController trait defines the interface for AI and UI implementations to make game decisions. This design closely matches Java Forge’s PlayerController.java, providing a unified interface for all player decisions during gameplay.
PlayerController Trait
Located in src/game/controller.rs, the trait provides methods for all decision points during an MTG game:
#![allow(unused)]
fn main() {
pub trait PlayerController {
fn player_id(&self) -> PlayerId;
// Main priority decision
fn choose_spell_ability_to_play(
&mut self,
view: &GameStateView,
available: &[SpellAbility]
) -> Option<SpellAbility>;
// Spell casting decisions
fn choose_targets(&mut self, view: &GameStateView, spell: CardId, valid_targets: &[CardId]) -> SmallVec<[CardId; 4]>;
fn choose_mana_sources_to_pay(&mut self, view: &GameStateView, cost: &ManaCost, available_sources: &[CardId]) -> SmallVec<[CardId; 8]>;
// Combat decisions
fn choose_attackers(&mut self, view: &GameStateView, available_creatures: &[CardId]) -> SmallVec<[CardId; 8]>;
fn choose_blockers(&mut self, view: &GameStateView, available_blockers: &[CardId], attackers: &[CardId]) -> SmallVec<[(CardId, CardId); 8]>;
fn choose_damage_assignment_order(&mut self, view: &GameStateView, attacker: CardId, blockers: &[CardId]) -> SmallVec<[CardId; 4]>;
// Other decisions
fn choose_cards_to_discard(&mut self, view: &GameStateView, hand: &[CardId], count: usize) -> SmallVec<[CardId; 7]>;
// Notifications
fn on_priority_passed(&mut self, view: &GameStateView);
fn on_game_ended(&mut self, view: &GameStateView, won: bool);
}
}
Key Design Principles
1. Unified Spell Ability Selection
Instead of separate methods for lands, spells, and abilities, choose_spell_ability_to_play() returns any available action:
- Land plays (if can play lands this turn)
- Castable spells (if have mana and in appropriate phase)
- Activated abilities (if can activate)
This matches Java Forge’s design where SpellAbility represents any playable action.
2. Correct Mana Timing
Mana is tapped during step 6 of 8 in the casting process (MTG Rules 601.2g), AFTER the spell is on the stack. This is why choose_mana_sources_to_pay() is separate from choose_spell_ability_to_play().
Casting Process:
- Announce spell
- Choose modes (if any)
- Choose targets →
choose_targets()called - Distribute effects
- Check legality
- Determine total cost
- Activate mana abilities →
choose_mana_sources_to_pay()called - Pay costs
3. GameStateView for Read-Only Access
Controllers receive a GameStateView that provides read-only access to game state:
#![allow(unused)]
fn main() {
pub struct GameStateView<'a> {
game: &'a GameState,
player_id: PlayerId,
}
}
Available information:
hand()- Cards in this player’s handbattlefield()- All cards on battlefieldgraveyard()- Cards in this player’s graveyardplayer_hand(player_id)- Any player’s handplayer_graveyard(player_id)- Any player’s graveyardis_card_in_zone(card_id, zone)- Check card locationget_card(card_id)- Get card detailsget_mana_pool(player_id)- Check available manacurrent_phase()- Current game phasecurrent_step()- Current game stepactive_player()- Whose turn it is
4. Zero-Copy Principles
All methods use:
&[CardId]slices for input (no allocation)SmallVecfor output (stack allocation for small collections)&GameStateViewborrows (no cloning)
This maintains high performance even during tree search with millions of game states.
Current Implementations
1. RandomController (random_controller.rs)
- Makes random decisions using a seeded RNG
- Used for testing and baseline performance
- Fully deterministic with same seed
2. ZeroController (zero_controller.rs)
- Always chooses the first available option
- Deterministic and predictable
- Used for testing
3. HeuristicController (heuristic_controller/ module dir)
- Evaluation-based AI ported from Java Forge
- Considers creature quality, removal priority, combat outcomes
- Most sophisticated AI currently available
4. FixedScriptController (fixed_script_controller.rs)
- Replays pre-recorded decisions from a script
- Used for determinism testing and replay functionality
- Verifies game state is reproducible
5. InteractiveController (interactive_controller.rs)
- Human player via stdin/stdout
- Provides text-based UI for testing
- Shows available options and accepts numeric choices
6. ReplayController (replay_controller.rs)
- Replays choices from a recorded game
- Used with snapshot/resume functionality
- Ensures identical gameplay when resuming from snapshots
Java Forge Compatibility
This design closely matches Java Forge’s architecture:
Java Forge:
public interface PlayerController {
SpellAbility chooseSpellAbilityToPlay();
List<Card> chooseTargetsFor(SpellAbility sa);
// ... other methods
}
Rust Version:
#![allow(unused)]
fn main() {
pub trait PlayerController {
fn choose_spell_ability_to_play(...) -> Option<SpellAbility>;
fn choose_targets(...) -> SmallVec<[CardId; 4]>;
// ... other methods
}
}
Key differences:
- Rust uses
Option<T>instead of null - Rust uses
SmallVecfor efficiency instead ofArrayList - Rust uses
&[T]slices instead ofList<T>for zero-copy - Rust separates read-only view (GameStateView) from mutable GameState
GameLoop Integration
The GameLoop (game_loop.rs) orchestrates the interaction:
- Detect decision point (e.g., player has priority)
- Gather available options (e.g., get castable spells from game state)
- Call controller with options
- Execute chosen action on game state
Example from priority handling:
#![allow(unused)]
fn main() {
// Gather available actions
let available_spells = self.get_available_spell_abilities(player_id);
// Ask controller
let choice = controller.choose_spell_ability_to_play(&view, &available_spells);
if let Some(ability) = choice {
// Execute the chosen action
self.execute_spell_ability(player_id, ability)?;
} else {
// Pass priority
controller.on_priority_passed(&view);
}
}
Testing
Controller tests are in controller_tests.rs and include:
- Unit tests for each controller type
- Integration tests for full game scenarios
- Determinism tests (same seed → same outcome)
- Snapshot/resume tests with ReplayController
Future Enhancements
- MCTS/Minimax Controllers: Tree search algorithms for stronger play
- Neural Network Controllers: ML-based decision making
- Profile-Based Heuristics: Different AI personalities (aggressive, control, etc.)
- Learning Controllers: Adapt strategy based on opponent behavior
Summary
The PlayerController trait provides a clean, efficient interface for implementing game AI and UI. It closely matches Java Forge’s proven design while leveraging Rust’s zero-cost abstractions for better performance.
The Puzzle (.pzl) Language
A puzzle file (.pzl) describes a pre-built Magic board state and, optionally, a
set of assertions about how a game played from that state should turn out.
Puzzles let the engine be tested against concrete scenarios — “from this board,
the AI should win by turn 2” — without writing per-scenario Rust.
This chapter is written against the code, not just the older design notes.
Where the existing reference documents (ai_docs/reference/PZL_GRAMMAR.md and
ai_docs/reference/PUZZLE_ASSERTION_DSL.md) disagree with what the parser and
runners actually do, this chapter follows the code and flags the difference in a
box like this:
Discrepancy (flagged): an example of a place where a source doc and the code disagree.
The relevant code lives in mtg-engine/src/puzzle/ (format, state, metadata,
and the assert/ submodule) and in the test runners
mtg-engine/tests/puzzle_bulk_runner.rs and
mtg-engine/tests/puzzle_golden_check.rs.
How a puzzle is loaded and run
There is no mtg puzzle subcommand. A puzzle is loaded by passing it as a
start state to tui:
mtg tui --start-state puzzles/bolt_test.pzl
For automated testing, two test runners discover and run puzzles in bulk; see Running and blessing puzzles below. Both runners drive both seats with the heuristic AI at a fixed seed — there is no in-puzzle scripting of moves.
Discrepancy (flagged): older notes describe puzzle “controller commands” or a scripted-action section. There is none. The
.pzlformat sets up a board and (optionally) assertions; it does not script the moves. The only “who plays” knob is theHumanControlmetadata flag, which is stored but not consulted by the bulk runners. To script moves, use the fixed-input controller (see Scripted Play) on a normal game.
File format
A .pzl file is an INI-style file: lines are grouped under [section] headers,
blank lines and #-comment lines are ignored, and within a section each line is
a key: value or key = value pair (both separators are accepted). Parsing is
done by tokenised splitting, never substring matching. The parser is in
mtg-engine/src/puzzle/format.rs.
The recognised sections are [metadata], [state], and (when the
puzzle-assert feature is on) [assertions]. Unknown sections are kept but
ignored, for forward compatibility.
Discrepancy (flagged):
PZL_GRAMMAR.mdsays both[metadata]and[state]are required. In the code, only[state]is required — its absence is the one hard error (“Missing [state] section in puzzle file”,format.rs).[metadata]is optional and defaults are used when it is absent.
Discrepancy (flagged):
PZL_GRAMMAR.mdcalls the parser a “manual recursive descent parser” over a formal grammar and quotes line counts and a “100% success on 351 files” benchmark. The actual parser is a flat, line-oriented INI splitter, and the corpus is now larger (hundreds of forge-java files, with some documented load failures). Treat the grammar document’s parser-internals and benchmark sections as out of date.
[metadata] section
Optional key-value descriptive data. Keys are case-insensitive (lowercased
internally): name, url, goal, turns, difficulty, description,
targets, targetcount, humancontrol. The parser in
mtg-engine/src/puzzle/metadata.rs defines the accepted values.
Discrepancy (flagged): the exact goal strings the code accepts differ slightly from
PZL_GRAMMAR.md— for example the code accepts bothdestroy specified permanentsand an undocumented aliasdestroy specified creatures, and the win-race goal is spelledwin before opponent's next turn(the doc drops the's next). When authoring goals, checkmetadata.rsrather than the grammar doc.
[state] section
The required section. It sets the turn, the active player, the active phase, and
per-player zones. The parser is mtg-engine/src/puzzle/state.rs.
[state]
turn = 3
activeplayer = p0
activephase = MAIN1
p0life = 20
p0hand = Lightning Bolt; Mountain
p0battlefield = Mountain|Tapped; Grizzly Bears
p1life = 5
p1battlefield = Llanowar Elves
Per-player lines use a p0 / p1 prefix followed by the field name. The
recognised fields are the scalar/counter fields life, landsplayed,
landsplayedlastturn, counters, manapool, persistentmana, and the zone
names hand, battlefield, graveyard, library, exile, command (see the
match field arm in state.rs). Zone contents are semicolon-separated card
notations. (command is parsed but currently dropped at load time — see the
note below.)
Discrepancy (flagged): the grammar doc lists
human/aias valid per-player prefixes. In the code, per-player state lines only recognise thep0/p1prefixes (the parser strips a 2-char prefix).human/aiwork only for theactiveplayerline, not for…life/…handlines — so a puzzle that writeshumanlife=will not load that line as intended.The
commandzone is a subtler case: the[state]parser does recognisep0command=and parses it into acommand: Vec<CardDefinition>field (see thecommandarms instate.rs). However, the loader currently drops it, because the runtime game state (PlayerZones) has no command zone yet (the puzzleREADME.mdnotes “Command zone not yet in PlayerZones (requires architecture change)”). Sop0command=parses cleanly but has no effect on the loaded board until a command zone is added to the runtime.
Discrepancy (flagged): the bulk runner’s own comments cite some load failures as
Unknown phase: DECLAREATKandUnknown counter type: TIME. Checked against code: the phase abbreviationDECLAREATKis indeed not accepted (useDECLAREATTACKERS), butTIMEis a valid counter type (core/types.rsmaps it toCounterType::Time) — so that particular root-cause note is stale.
Card notation
Within a zone list, each card may carry pipe-separated modifiers, e.g.
Mountain|Tapped or Grizzly Bears|Counters:P1P1=2. Boolean modifiers (such as
Tapped, SummonSick, FaceDown) and key-value modifiers (such as Id,
Counters, Damage, AttachedTo) are parsed by
mtg-engine/src/puzzle/card_notation.rs. Unknown modifiers are ignored for
forward compatibility.
Action scripts ([p0_script] / [p1_script])
By default a puzzle runner drives both seats with the heuristic AI. The AI plays reasonably but will not, on cue, cast a particular spell at a particular target — so puzzles that rely only on the AI can test passive and combat-keyword cards but not active aspects (targeted burn, removal, pingers, activated abilities). An action script fixes that: it scripts one player’s exact moves.
Add an optional [p0_script] (or [p1_script]) section. Each non-blank,
non-comment line is one semantic command in the shared rich-input / fixed-input
vocabulary (docs/FIXED_INPUT_SYNTAX.md):
[p0_script]
# Cast a burn spell at a named target, then idle to a later turn.
cast Lightning Bolt targeting Grizzly Bears
PASS_UNTIL turn=2,phase=MAIN1
When a player has a script, the runner drives that player with a
RichInputController replaying the commands; an unscripted player keeps the
default HeuristicController. A puzzle with no script sections behaves
exactly as before — there is no behavioural change and no overhead.
Key commands (full grammar in docs/FIXED_INPUT_SYNTAX.md):
| Command | Meaning |
|---|---|
cast <card> | Cast a spell from hand by (prefix) name |
cast <card> targeting <selector> | Cast and aim it at a named card or pN player |
activate <card> | Activate an ability (e.g. a pinger or equip) |
attack <card> | Declare an attacker |
<blocker> blocks <attacker> | Declare a block |
pass | Pass priority |
PASS_UNTIL turn=N,phase=PHASE | Pass until a turn+phase is reached |
* | Wildcard: pass until the next command becomes available |
Selectors (card names and the targeting clause) use the anti-overfitting
matcher — prefix, case- and space-insensitive — not raw menu indices, so a
script survives unrelated board changes and card renames that keep a shared
prefix. The targeting clause is preferred over a standalone target line
because it is robust to whether the engine actually prompts for a target (a
single-legal-target spell is auto-targeted with no prompt; the clause is then a
harmless no-op).
Determinism. A script issues only the same public commands a human types and
matches only against the engine’s offered options — it never reads hidden
information (opponent hand, library order, RNG). So a scripted controller
produces identical decisions on server, client, native, and WASM, exactly like
every other controller (see the network-determinism rules in the project
CLAUDE.md).
A worked example lives at
test_puzzles/script_lightning_bolt_kills_creature.pzl: P0 is scripted to bolt
the opponent’s Grizzly Bears, and the [assertions] then check spell cast Lightning Bolt, creature died Grizzly Bears, and opponent graveyard contains Grizzly Bears — an active-card aspect that the AI-only runner could not assert.
The assertion DSL
When the engine is built with the puzzle-assert Cargo feature, a [assertions]
section makes a puzzle self-checking: the expected outcome is written inside
the .pzl file, so any runner can verify it without separate per-puzzle Rust.
With the feature off, the section is parsed and stored but the evaluator is
compiled out entirely (zero runtime overhead).
Each non-blank, non-comment line in [assertions] is one assertion. The grammar
(parser at mtg-engine/src/puzzle/assert/parser.rs):
assertion ::= 'NOT'? scope? predicate
scope ::= 'me' | 'opponent' (default: me = the puzzle's p0)
predicate ::= <keyword> ... (keyword selects the assertion kind)
A predicate always starts with one of the leading keywords the parser
recognises (see the error enumeration at the bottom of parse_predicate in
parser.rs):
life | hand | graveyard | battlefield | exile | library
| game | turn | trigger | spell | creature
The life keyword leads two different predicates: life <cmp> <int> (the
final life total) and life gained <cmp> <int> (a life-gain event count — see
the event assertions below). The keywords trigger, spell, and creature
all lead event-backed predicates.
The assertion kinds are exactly the variants of AssertionKind in
mtg-engine/src/puzzle/assert/mod.rs, evaluated by
assert/evaluator.rs:
The first six kinds read final game state or the game result. The last four are event-backed: they read the structured event log (see Event-backed assertions below), and the table marks their data source accordingly.
| Kind | Syntax | Checks | Data source |
|---|---|---|---|
Life | life <cmp> <int> | a player’s life total | final state |
ZoneCount | <zone> count <cmp> <int> | number of cards in a zone | final state |
ZoneContains | <zone> contains <card name> | a named card is present in a zone (case-insensitive) | final state |
LibraryTopContains | library top <N> contains <card name> | a named card is among the top N of the library | final state |
GameResult | game won / lost / drawn / ended | the game’s result | game result |
TurnNumber | turn <cmp> <int> | number of turns played | game result |
TriggerFired | trigger fired / trigger fired from <card name> | a triggered ability fired (optionally from a named source) | event log |
SpellCast | spell cast / spell cast <card name> | a spell was cast (optionally a named one) | event log |
CreatureDied | creature died / creature died <card name> | a creature died (optionally a named one) | event log |
LifeGained | life gained <cmp> <int> | total life a player gained over the game | event log |
Where <zone> is one of hand, graveyard, battlefield, exile, library
(the AssertZone enum), and <cmp> is one of eq, ne, lt, le, gt,
ge. For the event-backed kinds, the trailing <card name> is optional: an
empty name matches any trigger / spell / creature death, and matching is
case-insensitive.
[assertions]
# P0 (me) must end at 20 life
life eq 20
# Opponent took damage
opponent life lt 20
# At least three permanents on my battlefield
battlefield count ge 3
# Lightning Bolt ended in my graveyard
me graveyard contains Lightning Bolt
# A specific card is on top of my library
library top 1 contains Forest
# I won the game
game won
# ...within two turns
turn le 2
# And I did NOT lose
NOT game lost
# A Lightning Bolt spell was cast at some point (event-backed)
spell cast Lightning Bolt
# Some trigger fired (event-backed, any source)
trigger fired
# My opponent gained at least 5 life over the game (event-backed)
opponent life gained ge 5
What backs each assertion
The first six kinds (Life, ZoneCount, ZoneContains, LibraryTopContains,
GameResult, TurnNumber) read final game state (life totals, zone
contents, library order) or the game result (winner / turns played). The
evaluator reads these from GameState and GameResult directly.
Event-backed assertions
The remaining four kinds are event-backed: instead of inspecting the final board, they ask “did this happen at any point during the game?” by scanning the structured event log. They are live and wired into the DSL (this was the puzzle “Phase 2” feature):
| Syntax | Matches |
|---|---|
trigger fired | any triggered ability fired |
trigger fired from <card name> | a trigger fired from the named source |
spell cast | any spell was cast |
spell cast <card name> | the named spell was cast |
creature died | any creature died |
creature died <card name> | the named creature died |
life gained <cmp> <int> | a player’s total life gained (scope-aware) compares to <int> |
How they behave:
- Names are case-insensitive, and an empty name means “any”:
spell castmatches any spell,spell cast Lightning Boltmatches only that spell. life gainedis scope-aware like the other player-scoped predicates, soopponent life gained ge 5checks the opponent’s total life gained. It sums the positive life-change deltas recorded in the event log.- These kinds consume the event log: the evaluator scans an
EventLogView<'_>over the engine’sLogEventstream (defined inmtg-engine/src/game/log_event.rs). The variants matched areLogEvent::TriggerFired,SpellCast,CreatureDied, and (forlife gained)LifeChanged. - The puzzle runners always enable the event log, so these assertions work
out of the box: the e2e runner enables it explicitly, and the bulk runner
passes
final_game.logger.events()into the evaluator. If a run is performed without the event log, an event assertion fails with the error message “event log not enabled for this puzzle run” rather than silently passing.
Historical note: an earlier draft of this guide (and the assertion-DSL design doc) described these event assertions as a future family that “does not exist yet” and named the event type
GameEvent. That is now stale: the event assertions shipped, and the real event type isLogEvent(viewed throughEventLogView). The design doc’sGameEventname was never the shipped one.
A couple of smaller behavioural notes the docs omit, confirmed in evaluator.rs:
library top N contains XclampsNto the library size ifNis larger than the library, rather than erroring.game endedis true only when there is a winner or an explicit draw; a game that stops by hitting the turn limit with no winner is not counted asended(there is a dedicated test for this).
Running and blessing puzzles
Two test runners exercise puzzles, wired to Make targets in the repository
Makefile:
-
make puzzle-bulk-checkrunsmtg-engine/tests/puzzle_bulk_runner.rs. It discovers every.pzlundertest_puzzles/,puzzles/,forge-java/forge-gui/res/puzzle, andforge-java/forge-gui/res/tutorial, runs each with two heuristic AIs at a fixed seed, evaluates any[assertions](puzzles with none just smoke-test that they load and run), and writes a JUnit XML report. It runs in parallel, bounded to the CPU count, and gates against a known-bad baseline of panics / assertion failures / load errors. -
make puzzle-golden-checkrunsmtg-engine/tests/puzzle_golden_check.rs. For locally-authored puzzles only (test_puzzles/andpuzzles/; the forge-java corpus is excluded because it has many pre-existing panics), it captures the game’s text log and diffs it against a committed golden file attest_puzzles/goldens/<stem>.golden.logorpuzzles/goldens/<stem>.golden.log. A mismatch fails the check. -
make puzzle-blessre-records every golden log from the current engine output (it runs the golden test withMTG_BLESS_GOLDEN=1). Use it after an intentional change to the log format, then review the diff (git diff test_puzzles/goldens/ puzzles/goldens/) before committing.
Discrepancy (flagged): the assertion-DSL doc describes the golden mechanism as a
[golden_log]section carrying a hash inside the.pzlfile, refreshed with a--reblessflag. The implemented mechanism is different: separategoldens/*.golden.logfiles holding the full text log, refreshed via theMTG_BLESS_GOLDEN=1environment variable (themake puzzle-blesstarget). There is no[golden_log]section. The golden oracle compares the text log buffer, not the structured event stream.
Status note for the phased plan. The assertion-DSL doc is organised as phases (final-state assertions → event stream → golden oracle → bulk runner → rewind diff → migrate external assertions) and still labels itself “Phase 1 implemented.” In reality much more has landed: the final-state assertions, the event-backed assertions (the four
trigger fired/spell cast/creature died/life gainedkinds documented above — the puzzle “Phase 2” work), the golden oracle, and the bulk parallel runner are all live. So the doc understates progress on those fronts while overstating the[golden_log]-hash design it never shipped. This guide’s chapter reflects the code as it stands.
The Card-Script Language
Every card in DeepScry is defined by a small text file in cardsfolder/, using a
key-value scripting language: Name:, ManaCost:, Types:, keyword lines
(K:), ability lines (A:, T:, S:), script variables (SVar:), and so on.
Important — this language is not ours to define. The card-script DSL is owned by the upstream Java Forge project, which is the source of truth for its syntax and semantics. The
cardsfolder/data itself comes from Forge. DeepScry consumes and reproduces this format so it can play the same cards; it does not get to invent or change the language. The reference in this guide documents the format for reading and parsing, not as a specification we control.
Because the format is structured, DeepScry parses it with proper tokenisation —
splitting on the | and $ delimiters and querying the resulting fields — never
with ad-hoc substring matching. (Substring checks on structured data are
explicitly banned in the project conventions: contains("add") would match
“Madden”, contains("Damage") would match “PreventDamage”, and so on.) The
parsing infrastructure lives in the engine’s ability-parser modules; see
ai_docs/reference/ability_parsing_comparison.md for the rationale.
The next page is the card-script specification itself, included from the project’s reference document.
Scope note. The specification that follows describes the fields and ability shapes DeepScry recognises. Where DeepScry’s coverage of a particular keyword or ability is incomplete relative to upstream Forge, that is tracked in the project’s card-compatibility issues, not in this language reference — the language is upstream’s; the coverage is DeepScry’s ongoing work.
MTG Forge Card Script Specification
This document describes the Domain-Specific Language (DSL) used in Forge card scripts found in cardsfolder/.
Overview
Forge card scripts define card properties and abilities using a key-value text format. Each card is stored in a .txt file with fields defining its properties.
Basic Card Structure
Name:<card name>
ManaCost:<mana cost>
Types:<card types>
[PT:<power>/<toughness>] # For creatures
[Loyalty:<loyalty>] # For planeswalkers
[K:<keyword>] # Keyword abilities
[A:<ability script>] # Activated/Spell abilities
[T:<trigger script>] # Triggered abilities
[S:<static ability script>] # Static abilities
[SVar:<variable name>:<value>] # Script variables
[DeckHas:<deck constraints>] # Deck building hints
Oracle:<oracle text> # Official rules text
Field Descriptions
Name
The card’s name.
Name:Lightning Bolt
ManaCost
Mana cost using Forge notation:
- Numbers: Generic mana (1, 2, 3, etc.)
- Letters: Colored mana (W=White, U=Blue, B=Black, R=Red, G=Green)
no cost: For lands and tokens- Hybrid:
W/U,2/W, etc. - Phyrexian:
W/P,U/P, etc.
Examples:
ManaCost:R # One red mana
ManaCost:2 U U # Two generic, two blue
ManaCost:1 G # One generic, one green
ManaCost:no cost # Lands
Types
Space-separated list of types and subtypes.
Types:Instant
Types:Creature Elf Druid
Types:Basic Land Mountain
Types:Legendary Planeswalker Jace
PT (Power/Toughness)
For creatures only.
PT:2/2
PT:1/1
PT:4/4
Loyalty
For planeswalkers only.
Loyalty:3
Loyalty:4
Ability Scripts
Abilities use a pipe-delimited (|) format with a prefix indicating the ability type.
Ability Prefixes
- A: Activated ability or Spell ability
SP$- Spell ability (for instants/sorceries)AB$- Activated ability (requires Cost$)
- T: Triggered ability
- S: Static ability
- K: Keyword ability (simple form)
Common Ability Keywords (K:)
Simple keywords without parameters:
K:Flying
K:Vigilance
K:First Strike
K:Double Strike
K:Trample
K:Haste
K:Lifelink
K:Deathtouch
Spell Abilities (A:SP$)
Format: A:SP$ <API> | <Parameter>$ <Value> | ... | SpellDescription$ <text>
Common APIs:
- DealDamage: Deal damage to targets
- Counter: Counter spells
- Draw: Draw cards
- Destroy: Destroy permanents
- ChangeZone: Move cards between zones
- GainLife: Gain life
- Pump: Modify power/toughness
Example - Lightning Bolt:
A:SP$ DealDamage | ValidTgts$ Any | NumDmg$ 3 | SpellDescription$ CARDNAME deals 3 damage to any target.
Parameters:
ValidTgts$: What can be targeted (Any, Creature, Player, etc.)NumDmg$: Amount of damageSpellDescription$: Rules text (CARDNAME replaced with card name)
Example - Counterspell:
A:SP$ Counter | TargetType$ Spell | ValidTgts$ Card | SpellDescription$ Counter target spell.
Example - Ancestral Recall:
A:SP$ Draw | NumCards$ 3 | ValidTgts$ Player | TgtPrompt$ Select target player | SpellDescription$ Target player draws three cards.
Activated Abilities (A:AB$)
Format: A:AB$ <API> | Cost$ <cost> | <parameters> | SpellDescription$ <text>
The Cost$ parameter defines what must be paid to activate.
Common costs:
T- Tap this permanentSac<N/Type>- Sacrifice N permanents of Type<N> <Color>- Pay manaAddCounter<N/TYPE>- Add counters (planeswalkers)SubCounter<N/TYPE>- Remove counters (planeswalkers)
Example - Llanowar Elves:
A:AB$ Mana | Cost$ T | Produced$ G | SpellDescription$ Add {G}.
Example - Prodigal Sorcerer:
A:AB$ DealDamage | Cost$ T | ValidTgts$ Any | NumDmg$ 1 | SpellDescription$ CARDNAME deals 1 damage to any target.
Example - Black Lotus:
A:AB$ Mana | Cost$ T Sac<1/CARDNAME> | Produced$ Any | Amount$ 3 | AILogic$ BlackLotus | SpellDescription$ Add three mana of any one color.
Triggered Abilities (T:)
Format: T:Mode$ <trigger type> | <conditions> | Execute$ <SVar> | TriggerDescription$ <text>
Common trigger modes:
ChangesZone- When cards change zonesPhase- At the beginning/end of a phaseAttacks- When a creature attacksDamageDone- When damage is dealtSpellCast- When a spell is cast
Example - Soul Warden:
T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | ValidCard$ Creature.Other | TriggerZones$ Battlefield | Execute$ TrigGainLife | TriggerDescription$ Whenever another creature enters, you gain 1 life.
SVar:TrigGainLife:DB$ GainLife | Defined$ You | LifeAmount$ 1
Parameters:
Mode$: Type of triggerOrigin$/Destination$: Zones for ChangesZoneValidCard$: What cards trigger thisTriggerZones$: Where this ability worksExecute$: References an SVar with the effect
Script Variables (SVar:)
Define reusable values or sub-abilities.
Format: SVar:<name>:<value> or SVar:<name>:DB$ <API> | <parameters>
DB$- “Do this” - defines a sub-ability- Plain values - Store numbers, logic flags, etc.
Examples:
SVar:TrigGainLife:DB$ GainLife | Defined$ You | LifeAmount$ 1
SVar:NonCombatPriority:1
SVar:DBChangeZone:DB$ ChangeZone | Origin$ Hand | Destination$ Library | ChangeType$ Card | ChangeNum$ 2 | LibraryPosition$ 0 | Mandatory$ True
Common Parameters
Targeting
ValidTgts$: What can be targetedAny- Any target (player or permanent)Creature- Any creaturePlayer- Any playerCard- Any card (for counters)- Modifiers:
.Other(not this),.YouCtrl(you control), etc.
Zones
Battlefield- In playHand- Player’s handGraveyard- GraveyardLibrary- Library/deckExile- Exile zoneStack- On the stackCommand- Command zone
Definitions
You- The controllerOpponent- An opponentTargeted- The targeted objectSelf- This cardTriggeredCard- Card that caused trigger
Colors
W- WhiteU- BlueB- BlackR- RedG- GreenAny- Any single colorEach- One mana of each color
Complex Examples
Jace, the Mind Sculptor (Planeswalker)
Name:Jace, the Mind Sculptor
ManaCost:2 U U
Types:Legendary Planeswalker Jace
Loyalty:3
A:AB$ Dig | Cost$ AddCounter<2/LOYALTY> | ValidTgts$ Player | TgtPrompt$ Select target player | DigNum$ 1 | AnyNumber$ True | DestinationZone$ Library | LibraryPosition2$ 0 | Planeswalker$ True | SpellDescription$ Look at the top card of target player's library. You may put that card on the bottom of that player's library.
A:AB$ Draw | Cost$ AddCounter<0/LOYALTY> | NumCards$ 3 | SubAbility$ DBChangeZone | Planeswalker$ True | SpellDescription$ Draw three cards, then put two cards from your hand on top of your library in any order.
SVar:DBChangeZone:DB$ ChangeZone | Origin$ Hand | Destination$ Library | ChangeType$ Card | ChangeNum$ 2 | LibraryPosition$ 0 | Mandatory$ True
A:AB$ ChangeZone | Cost$ SubCounter<1/LOYALTY> | Origin$ Battlefield | Destination$ Hand | ValidTgts$ Creature | TgtPrompt$ Select target creature | Planeswalker$ True | SpellDescription$ Return target creature to its owner's hand.
A:AB$ ChangeZoneAll | Cost$ SubCounter<12/LOYALTY> | Origin$ Library | Destination$ Exile | ValidTgts$ Player | TgtPrompt$ Select target player | SubAbility$ DBChangeZone2 | Planeswalker$ True | Ultimate$ True | SpellDescription$ Exile all cards from target player's library, then that player shuffles their hand into their library.
SVar:DBChangeZone2:DB$ ChangeZoneAll | Origin$ Hand | Destination$ Library | Defined$ Targeted | ChangeType$ Card | Shuffle$ True
Implementation Status (Rust Parser)
✅ Currently Implemented
- Basic card loading (Name, ManaCost, Types, PT)
- Simple DealDamage parsing from
A:SP$lines - Extracts
NumDmg$parameter - Creates Effect::DealDamage with TargetRef::None
❌ Not Yet Implemented
High Priority (needed for common cards)
- Keyword abilities (K: lines) - Flying, Vigilance, etc.
- ValidTgts$ parsing (Any, Creature, Player)
- Activated abilities (AB$ with Cost$)
- Mana abilities (AB$ Mana)
- Draw effects (SP$ Draw)
- Counter effects (SP$ Counter)
Medium Priority (common mechanics)
- Triggered abilities (T: lines)
- Static abilities (S: lines)
- ChangeZone effects
- GainLife effects
- Destroy effects
- Pump effects (modify P/T)
Low Priority (advanced features)
- SVar resolution and DB$ sub-abilities
- Planeswalker loyalty abilities
- Complex targeting with modifiers (.Other, .YouCtrl)
- Modal abilities (player chooses one)
- Replacement effects
- Continuous effects
Parser Expansion Plan
- Phase 1: Keywords (K:) - Simple, no parameters
- Phase 2: Activated abilities (AB$) with basic costs
- Phase 3: More spell effects (Draw, Counter, Destroy)
- Phase 4: Triggered abilities (T:) - Common triggers
- Phase 5: Advanced targeting and modifiers
References
- Card files:
./forge-java/forge-gui/res/cardsfolder/ - Total cards: ~31,000
- Java parser:
forge-java/forge-gui/src/main/java/forge/card/CardScriptInfo.java(likely location)
Notes
- The DSL is case-sensitive
- Whitespace around
|separators is ignored CARDNAMEin descriptions is replaced with the card’s name- Multiple abilities of the same type (A:, T:, K:) are listed on separate lines
- The Oracle field contains official Wizards text but isn’t parsed - it’s for reference