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 engine/src/puzzle/ (format, state, metadata,
and the assert/ submodule) and in the test runners
engine/tests/puzzle_bulk_runner.rs and
engine/tests/puzzle_golden_check.rs.
How a puzzle is loaded and run
The first-class way to run a puzzle (apply its scripted moves and check its
assertions) is the deepscry puzzle-check subcommand:
# Run one puzzle: applies [p0_script]/[p1_script], evaluates [final_assertions].
deepscry puzzle-check test_puzzles/passing/1993/LEA/fireball.pzl
# Run a whole directory (recurses for **/*.pzl), or mix files + dirs:
deepscry puzzle-check test_puzzles/ puzzles/some_specific.pzl
# No paths → the whole local corpus (test_puzzles/ puzzles/).
deepscry puzzle-check
A single explicitly-named .pzl exits non-zero if its assertions fail —
the developer “did my puzzle pass?” loop. A directory / multi-path run tolerates
the pre-existing known-bad corpus (ds-935 baseline) and exits zero unless NEW
failures appear; add --strict to fail on ANY failure. Other flags:
--junit <FILE> (JUnit XML), -j/--threads <N>, --verbose,
--show-log (print each puzzle’s full play-by-play game log to stdout, with a
per-puzzle header — the dev “let me SEE the game” view; implied by --verbose
on a single named .pzl), --cardsfolder <DIR>. Run
deepscry puzzle-check --help for the full list.
To merely load a puzzle’s board into an interactive game (WITHOUT applying
its scripts or assertions — default controllers take over), pass it as a start
state to tui:
deepscry tui --start-state puzzles/bolt_test.pzl
For CI, the same engine (engine::puzzle::runner) is exercised in bulk by
make puzzle-bulk-check; see
Running and blessing puzzles below.
A .pzl may script either seat with a [p0_script] / [p1_script] section
(one command per line, same grammar as --pN-fixed-inputs; see
Action scripts below). When a script is
present the runner drives that seat with a RichInputController; otherwise it
falls back to the heuristic AI at a fixed seed.
Compatibility-puzzle truthfulness: a local per-card puzzle under
test_puzzles/passing/must actually exercise the card behavior claimed by its path, metadata, comments, and tracking issue, and must assert the result. A puzzle that only loads a board state, or lets the game proceed without casting/activating/triggering the named card, is not smoke coverage; it is false positive evidence. Sidecarfoo.logfiles preserve the game transcript but do not replace semantic assertions. At least one assertion must fail if the claimed card action never happened or hit the wrong object/player; generic facts likegame ended, unchanged life totals, or unrelated board state are not compatibility proof. Coordinators reviewing subagent puzzles must read the.pzland compare its claims against the script/assertions. If the behavior cannot currently pass, put the puzzle undertest_puzzles/broken/and link the bug/card issue.
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
engine/src/puzzle/format.rs.
The recognised sections are [metadata], [state], the optional
[p0_script] / [p1_script], and (when the puzzle-assert feature is on)
[final_assertions] — the end-of-play assertion block. The header
[assertions] is accepted as a deprecated alias for [final_assertions]
(it parses with a warning so older puzzles keep working; new puzzles should use
[final_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
GoldenActionMenuEra: <byte> optionally pins the behavior contract for a puzzle’s
committed golden log. Omitted values retain era 76 for existing sidecars. New
regression puzzles that require a later rule correction should name that era
explicitly; malformed and unknown values fail parsing. This field affects only
the golden-log runner: ordinary puzzle checks still test current engine behavior.
The golden runner prints the effective era beside each puzzle path.
Optional key-value descriptive data. Keys are case-insensitive (lowercased
internally): name, url, goal, turns, difficulty, description,
targets, targetcount, humancontrol. The parser in
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 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.
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 command zone (p0command= / p1command=)
p0command= and p1command= place cards in that player’s command zone. A card
placed there is a commander: the loader flags it is_commander (CR 903.3
makes the designation an attribute of the card), registers it on its player, and
switches the game into Commander mode — which is what turns on the command-zone
cast offer and its CR 903.8 tax, the CR 903.9 zone-change choice, and the
CR 903.10a 21-damage clock.
A player may be given two commanders, which is what a partner-style pair is under CR 903.3b (Partner, Partner with, Choose a Background, Doctor’s companion, Friends forever). Each one carries its own CR 903.8 tax and its own CR 903.10a damage pool:
p0command = CARD#28025; CARD#27996
The |IsCommander card modifier does the same designation for a card placed
somewhere other than the command zone — a commander already on the battlefield,
say. Both routes go through the loader’s single apply_commander_designations
pass (ds-oh4je4).
Before ds-oh4je4 the [state] parser recognised p0command= but the loader
dropped it, so the line parsed cleanly and had no effect on the board.
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, Grizzly Bears|Counters:P1P1=2, or
Frostcliff Siege|ChosenMode:Temur. Boolean modifiers (such as Tapped,
SummonSick, FaceDown) and key-value modifiers (such as Id, Counters,
Damage, AttachedTo, ChosenColor, ChosenType, ChosenMode, and
NamedCard) are parsed by 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 |
discard <card>[, <card> ...] | Choose the exact card(s) to discard at a controller-choice discard (loot / “draw N, discard M”). Lets a puzzle prove the discard is an interactive choice by naming a card the deterministic auto-pick would not take. |
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/passing/1993/LEA/lightning_bolt/kills_creature.pzl: P0 is scripted to bolt
the opponent’s Grizzly Bears, and the [final_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.
Inline assertions inside a script
A [pN_script] may also interleave assert <predicate> lines with its play
actions. Each inline assert is evaluated at the moment script replay reaches
it (not deferred to the end), using the very same assertion DSL described
below. me/opponent are relative to the script’s seat (in [p1_script],
me = P1). A failed inline assert aborts the run as an assertion failure,
naming the predicate. Game-result predicates (game won/...) are not allowed
inline (no result exists yet) — use [final_assertions] for those.
[p0_script]
assert me life eq 20 # checkpoint BEFORE acting
cast Lightning Bolt targeting Grizzly Bears
assert creature died Grizzly Bears # checkpoint right AFTER it resolves
assert opponent battlefield count eq 0
PASS_UNTIL turn=5,phase=MAIN1
A worked inline example: test_puzzles/passing/1993/LEA/lightning_bolt/inline_assert_bolt_checkpoints.pzl.
The assertion DSL
When the engine is built with the puzzle-assert Cargo feature, a
[final_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). The legacy
header [assertions] is a deprecated alias for [final_assertions].
Each non-blank, non-comment line in [final_assertions] is one assertion (and
the identical grammar is used for inline assert directives). The grammar
(parser at 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
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.
[final_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 inengine/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-checkdiscovers every.pzlundertest_puzzles/,puzzles/,forge-java/forge-gui/res/puzzle, andforge-java/forge-gui/res/tutorial, runs each (applying any[pN_script], else two heuristic AIs at a fixed seed), evaluates any[final_assertions](generic load-only fixtures may intentionally omit assertions, but per-card compatibility puzzles must script the claimed behavior and assert its consequence with behavior-specific assertions that would fail if the named card did nothing), 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. It prefers thedeepscry puzzle-checkCLI on the release binary and can fall back to thebulk_puzzle_checktest (engine/tests/puzzle_bulk_runner.rs) on the manualNEXTEST_ARCHIVEreplay path. Both share ONE engine,engine::puzzle::runner::run_puzzles(engine/src/puzzle/runner.rs) — the same code path asdeepscry puzzle-check <file>. -
make puzzle-golden-checkrunsengine/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 sidecar golden file:foo.pzlis checked againstfoo.login the same directory. A mismatch or missing sidecar 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 sidecar.logdiff 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: separate sidecarfoo.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.