Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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. Sidecar foo.log files 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 like game ended, unchanged life totals, or unrelated board state are not compatibility proof. Coordinators reviewing subagent puzzles must read the .pzl and compare its claims against the script/assertions. If the behavior cannot currently pass, put the puzzle under test_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.md says 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.md calls 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 both destroy specified permanents and an undocumented alias destroy specified creatures, and the win-race goal is spelled win before opponent's next turn (the doc drops the 's next). When authoring goals, check metadata.rs rather 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 / ai as valid per-player prefixes. In the code, per-player state lines only recognise the p0 / p1 prefixes (the parser strips a 2-char prefix). human / ai work only for the activeplayer line, not for …life / …hand lines — so a puzzle that writes humanlife= 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: DECLAREATK and Unknown counter type: TIME. Checked against code: the phase abbreviation DECLAREATK is indeed not accepted (use DECLAREATTACKERS), but TIME is a valid counter type (core/types.rs maps it to CounterType::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):

CommandMeaning
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.
passPass priority
PASS_UNTIL turn=N,phase=PHASEPass 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.

KindSyntaxChecksData source
Lifelife <cmp> <int>a player’s life totalfinal state
ZoneCount<zone> count <cmp> <int>number of cards in a zonefinal state
ZoneContains<zone> contains <card name>a named card is present in a zone (case-insensitive)final state
LibraryTopContainslibrary top <N> contains <card name>a named card is among the top N of the libraryfinal state
GameResultgame won / lost / drawn / endedthe game’s resultgame result
TurnNumberturn <cmp> <int>number of turns playedgame result
TriggerFiredtrigger fired / trigger fired from <card name>a triggered ability fired (optionally from a named source)event log
SpellCastspell cast / spell cast <card name>a spell was cast (optionally a named one)event log
CreatureDiedcreature died / creature died <card name>a creature died (optionally a named one)event log
LifeGainedlife gained <cmp> <int>total life a player gained over the gameevent 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):

SyntaxMatches
trigger firedany triggered ability fired
trigger fired from <card name>a trigger fired from the named source
spell castany spell was cast
spell cast <card name>the named spell was cast
creature diedany 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 cast matches any spell, spell cast Lightning Bolt matches only that spell.
  • life gained is scope-aware like the other player-scoped predicates, so opponent life gained ge 5 checks 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’s LogEvent stream (defined in engine/src/game/log_event.rs). The variants matched are LogEvent::TriggerFired, SpellCast, CreatureDied, and (for life 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 is LogEvent (viewed through EventLogView). The design doc’s GameEvent name was never the shipped one.

A couple of smaller behavioural notes the docs omit, confirmed in evaluator.rs:

  • library top N contains X clamps N to the library size if N is larger than the library, rather than erroring.
  • game ended is 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 as ended (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-check discovers every .pzl under test_puzzles/, puzzles/, forge-java/forge-gui/res/puzzle, and forge-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 the deepscry puzzle-check CLI on the release binary and can fall back to the bulk_puzzle_check test (engine/tests/puzzle_bulk_runner.rs) on the manual NEXTEST_ARCHIVE replay path. Both share ONE engine, engine::puzzle::runner::run_puzzles (engine/src/puzzle/runner.rs) — the same code path as deepscry puzzle-check <file>.

  • make puzzle-golden-check runs engine/tests/puzzle_golden_check.rs. For locally-authored puzzles only (test_puzzles/ and puzzles/; 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.pzl is checked against foo.log in the same directory. A mismatch or missing sidecar fails the check.

  • make puzzle-bless re-records every golden log from the current engine output (it runs the golden test with MTG_BLESS_GOLDEN=1). Use it after an intentional change to the log format, then review the sidecar .log diff before committing.

Discrepancy (flagged): the assertion-DSL doc describes the golden mechanism as a [golden_log] section carrying a hash inside the .pzl file, refreshed with a --rebless flag. The implemented mechanism is different: separate sidecar foo.log files holding the full text log, refreshed via the MTG_BLESS_GOLDEN=1 environment variable (the make puzzle-bless target). 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 gained kinds 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.