Code architecture
The project is a plain Python package (src/mtg_goldfish/) with a
FastAPI shell on top. The engine and domain logic have no web dependency and can be driven from
a script or from tests.
Packages
| Package | Responsibility |
|---|---|
config | Environment and paths (ANTHROPIC_API_KEY, MTG_DATA_DIR, selected model). |
deck | CardData/Deck models, cached Scryfall client, Moxfield and MTGTop8 importers. Commander/companion roles come from the source's boards, cross-checked against Scryfall facts. |
formats | Format rules (Duel Commander): starting life and hand, deck validation. |
cards | Runtime card behaviour. One file per card, auto-discovered; build_card returns the registered Card or an UnimplementedCard vanilla approximation. |
engine | GameState (cloneable), turn phases, mana (+ payment planner), actions, and the exhaustive simulator. |
properties | PropertySpec model, LLM compiler (English → check(state), resolving approximate card names and reporting a confidence + clarification note), and the sandboxed evaluator with its documented, game-long event/state API. |
llm | Provider interface + Anthropic + local Ollama + deterministic offline stub (used to compile English properties into code). |
session | Session/SimResult models and a JSON-file store. |
web | FastAPI routes, WebSocket Hub, threaded SimulationRunner, static frontend. |
The game state
GameState is the single mutable object the simulator branches over. It is cloned
at every decision point, so it deliberately holds plain, cheap-to-copy data:
- Zones: library, hand, battlefield (a list of
Permanents), graveyard, exile, command zone, and the stack (cards being cast andStackAbilityitems). - Permanent: the card, its behaviour implementation, tapped/summoning-sick flags, counters, temporary P/T, attachments (auras/equipment by uid), chosen modes.
- Tallies: per-turn counters (spells cast, lands played, cards drawn, storm) and game-long history (what entered / hit the graveyard on each turn) — these power the property API.
- Log: every notable event appends a frame — a description plus a compact board snapshot — which is what the replay viewer plays back.
Card data (CardData) and card behaviour (Card
instances) are immutable and shared between clones; only per-game facts are copied. Mid-game
shuffles are deterministic per branch (seeded), so a game's outcome is reproducible from its
seed.
Card behaviour: the Card class
Each implemented card is a file in cards/ with a @register-decorated
subclass of Card. The engine drives cards through hooks; a card only overrides what
it changes:
| Hook | Meaning |
|---|---|
cast_cost(state) | Dynamic costs (domain reduction, commander tax handled by the engine). |
cast_actions(state) | Override the default cast to enumerate targets / {X} values / modes — one CardAction per choice. |
hand_actions / graveyard_actions | Non-cast plays: cycling, channel, escape, MDFC land faces… |
etb_modes(state) / etb_tapped(state) | How a permanent may enter (shockland pay-2/tapped choice, fastlands). |
enters_with_counters(state) | Replacement effect: counters the permanent enters with (depletion, fading…). Never uses the stack. |
mana_abilities… / on_tap_for_mana | Mana production (visible to the payment planner), pain-land side effects. |
battlefield_actions(state, perm) | Activated abilities: fetches, equip, draw engines, loyalty… |
on_etb / on_leave / on_phase / on_attack / on_combat_damage / on_draw_card / on_cast_other / on_other_etb | Triggered abilities. The hook body is the resolution effect; the engine wraps it in a stack item automatically. |
dynamic_power/toughness, equip_mod | Characteristic-defining stats, equipment bonuses. |
There is no opponent, so opponent-facing text is a no-op and is stated as such in each card file's docstring — e.g. a counterspell targets a spell on the stack, and since spells resolve atomically here the stack is empty at every priority window, so counterspells are not castable. Cards with no dedicated implementation still play as vanilla approximations (castable, permanents enter and count toward board state; special text ignored) and are flagged red in the decklist so results relying on them read as approximate.
The stack: casting, activating, triggering
Three kinds of objects go on the stack, and costs are always paid before anything is put there:
In code, an activated ability is a CardAction.activated(label, pre_fn, resolve_fn):
pre_fn pays all costs at activation (returning False aborts — nothing
is stacked), then the ability is pushed; resolve_fn applies the effect when it
resolves. Resolution branches (a fetch enumerates its targets) simply return a list of successor
states — the search explores them all.
The mana planner
Mana is deliberately not a branch point. When a cost must be paid,
plan_payment chooses which sources to tap deterministically and greedily: coloured
pips are covered by the least-flexible, lowest-life-cost sources first, then generic mana is
topped up. Special sources fit the same interface — Ancient Tomb-style life costs, auras like
Wild Growth that add mana when the host is tapped, lands that sacrifice themselves
(Hickory Woodlot's depletion counters are removed by on_tap_for_mana).
The simulator
engine/simulator.py runs one search per game (see
The search). Implementation highlights:
- The frontier is a stack / deque / heap depending on the strategy; every mode is exhaustive and visits identical states, only in a different order.
- Each created state gets a node in a recorded search tree (with per-node
property status), gzip-compressed into the stored result; the UI inflates it client-side with
DecompressionStream. - A decision is returned at every priority window — the main phases
(sorcery-speed plays), declare-attackers (the attack option), and any other step where an
instant-speed play (instant, flash, or an instant-speed ability) is actually available.
legal_actions(state, sorcery_speed_ok=…)gates plays by speed. - Checkpoints (
_check_due) evaluate properties at each phase entry and after every action at a priority window;_viableprunes branches where an unsatisfied property is no longer verifiable. - Per-game wall-clock timeout only — no node cap. The search runs until all properties are satisfied, no branch can satisfy them anymore (the frontier drains), or the timeout fires.
- Exceptions raised while applying an action are caught, recorded (
ctx.record_bug) and shown as error leaves in the tree and a 🐛 count per game — never silently dropped, so a buggy card can't hide viable lines. A crash in one game can't abort the whole run.
The web layer
- Board viewer: every log frame carries a full board snapshot (zones, stack, mana pool, counters), rendered as an MTGO-like board with Scryfall card images; the stack zone shows spells and abilities with their trigger/effect text on hover.
- Search-tree viewer: a self-contained HTML page laying the explored tree left-to-right, one circle per property per node (green verified / orange pending / red unreachable), the winning line in gold.
- Sessions and runs persist to JSON; any previous run can be reloaded with its exact configuration, seed, and replayable winning lines.
Extending
- New card: add
cards/<snake_name>.pywith a@register-edCardsubclass; it is auto-discovered. See the worked examples. - New action/rule: add an
Actioninengine/actions.py;legal_actionsenumerates generically. - New format: subclass
Format, register it informats/__init__.py.