GitHub ↗

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

web/ — FastAPI routes · WebSocket hub · threaded SimulationRunner · static frontend (app.js) thin shell: parses requests, streams progress, persists results deck/Moxfield + ScryfallCardData, Deck session/Session, SimResultJSON store engine/GameState, actions,mana, simulator cards/one file per card+ registry formats/Duel Commandervalidation properties/spec · compiler · evaluator llm/Anthropic · Ollama · stub configenv, paths, model
cards/ plugs behaviour into the engine; properties/ compiles English into code the engine evaluates at checkpoints.
PackageResponsibility
configEnvironment and paths (ANTHROPIC_API_KEY, MTG_DATA_DIR, selected model).
deckCardData/Deck models, cached Scryfall client, Moxfield and MTGTop8 importers. Commander/companion roles come from the source's boards, cross-checked against Scryfall facts.
formatsFormat rules (Duel Commander): starting life and hand, deck validation.
cardsRuntime card behaviour. One file per card, auto-discovered; build_card returns the registered Card or an UnimplementedCard vanilla approximation.
engineGameState (cloneable), turn phases, mana (+ payment planner), actions, and the exhaustive simulator.
propertiesPropertySpec 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.
llmProvider interface + Anthropic + local Ollama + deterministic offline stub (used to compile English properties into code).
sessionSession/SimResult models and a JSON-file store.
webFastAPI 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:

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:

HookMeaning
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_actionsNon-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_manaMana 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_etbTriggered abilities. The hook body is the resolution effect; the engine wraps it in a stack item automatically.
dynamic_power/toughness, equip_modCharacteristic-defining stats, equipment bonuses.
Approximations are documented per card

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:

Cast a spellpay full cost Activate an abilitytap · sac · mana · life Trigger firesETB, landfall, upkeep… card on stackcast triggers fire StackAbilitylabel + resolve fn StackAbilityqueued in order Resolution effect applies in one step LIFO — top of stack first
Permanents enter the battlefield on resolution; instants and sorceries apply their effect and go to the graveyard. Replacement effects and mana abilities bypass the stack entirely.

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

engine/simulator.py runs one search per game (see The search). Implementation highlights:

The web layer

Browserapp.js (vanilla) FastAPIREST + /ws hub SimulationRunnerworker thread SessionStoreJSON on disk REST WebSocket progress
The engine is CPU-bound and synchronous, so each run executes in its own thread and pushes progress to WebSocket clients.

Extending