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

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 mtg command-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:

SubcommandPurpose
tuiInteractive / scripted gameplay. The main entry point for playing or simulating a single game. Also loads puzzles via --start-state.
resumeResume a previously saved game from a snapshot file.
profileRun many games back-to-back for profiling (flamegraph / heaptrack).
tourneyRun many games in parallel and collect win/loss statistics.
statsPrint statistics about the card database.
deck-buildInteractive fast deck-entry TUI for typing in paper decks.
export-wasmExport the card database and selected decks for browser (WASM) builds.
hash-assetPrint the content-addressed hash of a file (a small scripting utility).
hash-web-assetsHash the web-asset bundle (part of the content-addressed deploy pipeline).
downloadDownload card images from Scryfall.
build-card-lookupBuild the compact card→image lookup table used by the client.
serverHeadless multiplayer WebSocket game server (no static files).
server-webFull browser product: static web assets and the lobby on one port.
connectConnect to a running multiplayer server as a client.

Discrepancy (flagged): Some older documents refer to an mtg puzzle <file> subcommand. There is no puzzle subcommand in the binary. Puzzles are loaded by passing a .pzl file to tui via --start-state: mtg tui --start-state puzzles/bolt_test.pzl. (Inside the agentplay/ Python harness, --puzzle is a script-level convenience flag that ends up calling tui --start-state underneath; it is not a binary subcommand.)

Feature flags

Some subcommands only exist when the binary is built with the right Cargo feature:

  • server and server-web require the web-server / network features.
  • connect requires the network feature.

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:

ControllerBehaviour
zeroAlways picks the first meaningful action. Deterministic; good for smoke tests.
randomMakes random (but seeded, reproducible) choices.
tuiInteractive text UI for a human, reading from stdin.
fancyFull-screen multi-panel terminal UI (ratatui).
heuristicThe strategic AI.
fixedReplays a predetermined script of choices (see Scripted Play).
fancy-fixedLike 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_entropy for 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 --seed for 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>0 silent, 1 minimal, 2 normal (default), 3 verbose.
  • --log-tail K — only print the last K lines of the log at exit. Handy with --stop-on-choice to keep output a constant size.
  • --no-color-logs — disable ANSI colour (also respects the NO_COLOR env 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 after N choices. N:p1 / N:p2 counts 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 (default game.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

ScriptPurpose
agentplay/agent_game.pyRecommended. 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.pyManually start a session: run up to the first choice, write the session files, print the menu.
agentplay/continue_game.pyManually 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 --puzzle flag here is a script-level convenience of agent_game.py; under the hood it launches the engine with tui --start-state <file>. The mtg binary itself has no puzzle subcommand.

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:

FileContents
p1_choices.txt / p2_choices.txtEach player’s choices, one per line, in order.
initial_args.txtThe original mtg tui argv.
snapshot.json / game.snapshotLatest replayed state (JSON / binary).
game.logEngine log from the last replay.
reproduce_game.shAn 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 nameResolved file
All Hallow's Eveall_hallows_eve.txt
Nevinyrral's Disknevinyrrals_disk.txt
Jace, the Mind Sculptorjace_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 .dck format is shared with Java Forge by design, so DeepScry can read the historical Forge deck corpus directly. Example decks live under decks/ (see decks/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. 0 always 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 phase
    

    Phase 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 release mtg binary, rsyncs web/, 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.

  1. 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.

  2. 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.

  3. 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:

  1. 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.

  2. 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.

  3. 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
  4. Validation, not recovery - Extra data sent in messages (like spell_ability in ChoiceResponse) 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:

  1. Hidden information not yet revealed
  2. 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
  1. Server executes game logic until a player choice is needed
  2. Server sends reveals + ChoiceRequest to the deciding player’s client
  3. Client processes reveals, then presents choice to player
  4. Client sends ChoiceResponse back to server
  5. Server applies choice, sends confirmation, notifies opponent
  6. 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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  • NETWORK_ACTION_LOG.md — generic append-only, action_count-indexed, non-destructive ActionLog<T> primitive plus its two-store ownership split: per-controller choice buffers (private to each Controller) and a separate NetworkClient-owned shadow state-sync log (CardRevealed / LibraryReordered, shadow mode only). The engine sees only controller.choose_at(view, action_count) and an optional apply_state_sync_at(K) hook — it is controller-agnostic. Replaces the destructive-FIFO pop_* / drain_* paths.
  • mtg-228: Single-channel architecture to eliminate select! nondeterminism
  • mtg-229: Shadow state desync debugging
  • mtg-218: Hidden information architecture
  • mtg-176: Main networking tracking issue

Summary

  1. Deterministic simulation split across machines
  2. Sequential action log agreed upon by all parties
  3. Sequential message history via single WebSocket/channel
  4. Linear control transfer - one active party at a time
  5. No sleeps, retries, or selects - each party waits for its turn
  6. 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 GameState is 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-debug attaches 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-gamelogs prefixes 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.md and the proposal ai_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:

  1. 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.
  2. Play proceeds by replicating one choice at a time into every copy; each copy advances its own deterministic engine.
  3. When information legitimately changes hands, an explicit, undoable RevealCard updates the per-viewer reveal mask — identically on every copy permitted to see it.
  4. 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:

  1. Clean pause point: External agents can review the game state up to this point
  2. Clear semantics: The snapshot represents “everything that has happened”
  3. 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:

  1. 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
  2. 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):

  1. Turn 1: Alice plays Forest → logged, executed
  2. Turn 2: Bob plays Forest → logged, executed
  3. Turn 3: Alice plays Forest → logged, executed
  4. PREAMBLE check: count=3, 3 >= 3? Yes
  5. Snapshot: Turn 3 start + 3 intra-turn choices
  6. Stop (BEFORE presenting “Alice casts Grizzly Bears”)

Segment 2 (resume from snapshot):

  1. Load: Turn 3 start, replay_mode(3)
  2. Replay Alice plays Forest (Turn 1) - suppressed logging
  3. Replay Bob plays Forest (Turn 2) - suppressed logging
  4. Replay Alice plays Forest (Turn 3) - suppressed logging
  5. Clear replay mode
  6. Present NEW choice: “Alice casts Grizzly Bears” ← Fresh controller decision

Benefits

  1. External agent workflow: Can pause, review game log/state, then decide next move
  2. No ambiguity: Snapshot never contains “partially made” choices
  3. Clean determinism: All choices in snapshot were completed in previous segments
  4. 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
  • src/game/game_loop.rs: Main implementation
    • check_stop_conditions(): PREAMBLE check before asking controller
    • with_replay_mode(): Sets up replay mode when resuming
    • log_choice_point(): Decrements replay counter
  • src/game/snapshot.rs: GameSnapshot serialization
  • src/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

VerbSyntaxDescription
passpassPass priority (same as 0 or p)
playplay <card>Play a land card
castcast <card>Cast a spell
activateactivate <card>Activate an ability
attackattack <creature>Declare a creature as attacker
block<blocker> blocks <attacker>Declare a blocking assignment
equipequip <equipment>Shorthand for equip ability

Card Name Matching

Card names are matched using these rules:

  1. Case-insensitive: Mountain, mountain, and MOUNTAIN all match
  2. Prefix matching: light matches “Lightning Bolt”
  3. Spaces/underscores ignored: blackknight, black_knight, and Black Knight all match
  4. Trailing punctuation stripped: mountain. and mountain, match “Mountain”

Numeric Commands

NumberMeaning
0Pass priority (equivalent to pass)
1 to NSelect menu item N (1-indexed)

Menu items are always displayed in this order:

  1. [0] pass
  2. [1] - First available action (usually play land)
  3. [2] - Second available action
  4. 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 untilNext command matches available actionTurn+phase reached
Affected by trigger order?Yes (command must be available)No
Affected by new actions in menu?NoNo
Affected by action count changes?NoNo
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.

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

  1. Use rich text over numeric: Menu indices can change as cards enter/leave
  2. Use wildcards for timing: *;cast fireball waits until fireball is castable
  3. Quote card names with spaces: "Black Knight" (optional but clearer)
  4. 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 functions
  • mtg-engine/src/game/rich_input_controller.rs - Rich input controller
  • mtg-engine/src/game/controller.rs - Menu formatting

See Also

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:

  1. Announce spell
  2. Choose modes (if any)
  3. Choose targetschoose_targets() called
  4. Distribute effects
  5. Check legality
  6. Determine total cost
  7. Activate mana abilitieschoose_mana_sources_to_pay() called
  8. 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 hand
  • battlefield() - All cards on battlefield
  • graveyard() - Cards in this player’s graveyard
  • player_hand(player_id) - Any player’s hand
  • player_graveyard(player_id) - Any player’s graveyard
  • is_card_in_zone(card_id, zone) - Check card location
  • get_card(card_id) - Get card details
  • get_mana_pool(player_id) - Check available mana
  • current_phase() - Current game phase
  • current_step() - Current game step
  • active_player() - Whose turn it is

4. Zero-Copy Principles

All methods use:

  • &[CardId] slices for input (no allocation)
  • SmallVec for output (stack allocation for small collections)
  • &GameStateView borrows (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 SmallVec for efficiency instead of ArrayList
  • Rust uses &[T] slices instead of List<T> for zero-copy
  • Rust separates read-only view (GameStateView) from mutable GameState

GameLoop Integration

The GameLoop (game_loop.rs) orchestrates the interaction:

  1. Detect decision point (e.g., player has priority)
  2. Gather available options (e.g., get castable spells from game state)
  3. Call controller with options
  4. 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 .pzl format sets up a board and (optionally) assertions; it does not script the moves. The only “who plays” knob is the HumanControl metadata 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.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

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 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 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 / 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 is a subtler case: the [state] parser does recognise p0command= and parses it into a command: Vec<CardDefinition> field (see the command arms in state.rs). However, the loader currently drops it, because the runtime game state (PlayerZones) has no command zone yet (the puzzle README.md notes “Command zone not yet in PlayerZones (requires architecture change)”). So p0command= 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: 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 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):

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
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/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.

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.

[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 mtg-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 runs mtg-engine/tests/puzzle_bulk_runner.rs. It discovers every .pzl under test_puzzles/, puzzles/, forge-java/forge-gui/res/puzzle, and forge-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-check runs mtg-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 golden file at test_puzzles/goldens/<stem>.golden.log or puzzles/goldens/<stem>.golden.log. A mismatch 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 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 .pzl file, refreshed with a --rebless flag. The implemented mechanism is different: separate goldens/*.golden.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.

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 damage
  • SpellDescription$: 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 permanent
  • Sac<N/Type> - Sacrifice N permanents of Type
  • <N> <Color> - Pay mana
  • AddCounter<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 zones
  • Phase - At the beginning/end of a phase
  • Attacks - When a creature attacks
  • DamageDone - When damage is dealt
  • SpellCast - 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 trigger
  • Origin$ / Destination$: Zones for ChangesZone
  • ValidCard$: What cards trigger this
  • TriggerZones$: Where this ability works
  • Execute$: 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 targeted
    • Any - Any target (player or permanent)
    • Creature - Any creature
    • Player - Any player
    • Card - Any card (for counters)
    • Modifiers: .Other (not this), .YouCtrl (you control), etc.

Zones

  • Battlefield - In play
  • Hand - Player’s hand
  • Graveyard - Graveyard
  • Library - Library/deck
  • Exile - Exile zone
  • Stack - On the stack
  • Command - Command zone

Definitions

  • You - The controller
  • Opponent - An opponent
  • Targeted - The targeted object
  • Self - This card
  • TriggeredCard - Card that caused trigger

Colors

  • W - White
  • U - Blue
  • B - Black
  • R - Red
  • G - Green
  • Any - Any single color
  • Each - 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

  1. Phase 1: Keywords (K:) - Simple, no parameters
  2. Phase 2: Activated abilities (AB$) with basic costs
  3. Phase 3: More spell effects (Draw, Counter, Destroy)
  4. Phase 4: Triggered abilities (T:) - Common triggers
  5. 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
  • CARDNAME in 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