Skip to main content

mtg — a python-chess-style API for an MTG rules engine

One stateful object holds the whole game; you read the legal moves, push one, pop to take it back — the python-chess shape, but with Magic's vocabulary (players, turns, steps, the stack, and the zones — battlefield, hand, library, graveyard, command zone).

Quickstart

Run from the repository root (see the packaging note below).

import mtg

g = mtg.Game()                    # the demo Gruul-vs-Dimir matchup (real cards), seed 0
g.legal_moves                            # the action tuples you may play now
g.push(g.legal_moves[0])                 # make a move; advances to the next decision point
g.pop()                                  # take it back (no snapshots needed — env.step is pure)

g.turn                                   # whose decision it is
g.life()                                 # {'alice': 20, 'bob': 20}
g.battlefield()                          # {permanent: controller}
g.hand('alice')                          # ['grizzly_bears_6', ...]
g.is_game_over(), g.outcome(), g.result()

while not g.is_game_over():              # a trivial "first legal option" policy
    g.push(g.legal_moves[0])

A move is an action tuple from the referee (env.legal_actions):

tuple meaning
("cast", player, spell, {choices}) cast a spell with its forced sub-choices (mode/target/name)
("cast_commander", player, name) cast the commander from the command zone (§903.6)
("activate", player, ability_row, {...}) activate an ability
("attack", frozenset(attackers)) declare attackers
("block", frozenset((blocker, attacker))) declare blockers
("pass",) pass priority / end the window

Game.describe_move(move) renders any of these to a short label.

Keys for tree search

cast/activate moves carry a {choices} dict, so the raw tuple is not hashable. For tree modeling:

mtg.Game.move_key(move)   # hashable canonical move (key a policy / visited table on this)
g.key()                          # hashable transposition key of the POSITION (driver._facts_key)
g.copy()                         # cheap, independent branch — push/pop without touching the parent
g.push(move, checked=False)      # skip the legality re-check in hot loops

with g.branch(move):             # push on enter, pop on exit — for recursive tree walks
    visit(g.key())               # g is the child here; restored to the parent on exit (even on exception)

g.key() keys a transposition table / repetition set directly; equal keys denote engine-equivalent states. branch() is pure push/pop ergonomics — it does not use the engine's incremental rollback (measured ~1.0× for this sorcery-speed move space; the tree is too narrow to amortize — see incremental/README.md).

Inspecting cards (the piece_at analog)

Zone accessors return opaque ids; card()/permanents() return their derived characteristics:

g.card("grizzly_bears_6")
# {'id':..., 'zone':'battlefield', 'controller':'alice', 'types':['creature'], 'subtypes':['bear'],
#  'colors':['green'], 'is_creature':True, 'power':2, 'toughness':2, 'keywords':[],
#  'tapped':False, 'summoning_sick':False}

g.permanents()                       # views for everything on the battlefield (one engine eval for all)
g.permanents(player="alice")         # filter by controller
g.permanents(type="creature")        # filter by printed type

Power/toughness/creature-ness/keywords/control are the engine's derived values (effects applied); types/colors/subtypes are printed. Fields that don't apply (controller off the battlefield, P/T of a land) come back None/[]/False.

Serialization (the FEN analog)

Persist a position to JSON and reconstruct an engine-equivalent game — future play included, since the RNG position is preserved:

blob = g.serialize()                 # JSON str (round-trips sets/tuples/dicts/RNG that JSON can't hold)
g2 = mtg.Game.deserialize(blob)
assert g2.key() == g.key()           # same position; replaying the same moves yields identical games

g3 = mtg.Game.from_state(other_game.state)   # wrap a raw state dict in-memory (no JSON)

Captures the position only, not the move history (so pop() can't cross the boundary, like a chess FEN) or an installed policies seam (re-supply it on the rebuilt game if needed).

Readable names

Engine ids/slugs are underscored (grizzly_bears_6); recover the printed name (with MTG's casing) anywhere:

g.name("grizzly_bears_6")        # 'Grizzly Bears'
g.name(commander_id)             # 'Magda, Brazen Outlaw'  (corpus-correct, not naive title-case)
g.card(id)["name"]               # card()/permanents() views include a 'name' field
g.describe(move)                 # 'alice: cast Grizzly Bears'  (vs Game.describe_move(move), id-form)

Imperfect information (what one seat sees)

obs = g.observation("alice")     # a redacted, READ-ONLY Game from alice's seat (§103)
obs.hand("alice")                # alice's real hand
obs.hand("bob")                  # [] — hidden; but obs.hand_count("bob") gives the true size
obs.library_size("bob")          # true count (rows hidden, count carried)
obs.battlefield(); obs.life()    # public info kept
obs.library_top()                # cards this seat scried/looked at, in order (else [])
obs.push(...)                    # RuntimeError — observations are read-only (legality lives on the true game)

This is the view an agent should reason over to "play like a real player." Note terminal/turn bookkeeping (_loser, _turn) is redacted, so is_game_over()/turn_number aren't meaningful on an observation — it's a snapshot for reasoning about an in-progress position, not for driving.

Engine backend

g = mtg.Game(incremental=True)   # in-process incremental update backend; g.incremental reports if it engaged
mtg.engine_available()           # 'incremental' | 'native' | 'inproc' | 'interpreter'

incremental=True is byte-identical to the default backend (verified across full games), ~2× faster on large states and neutral on small. It's a process-global selection (the driver reads it per eval) and degrades gracefully — if the souffle fork isn't built it warns and falls back.

Players

A Player answers the two kinds of decision the engine raises, both wired through the shim: a top-level move (choose_move(game) — pick from game.legal_moves) and an internal sub-choice (decide(view, key, options, default) — a target/mode/blocks/discard resolved inside step). play() runs a full game with each seat driven by its player; a seat's player drives both its moves and its sub-choices.

from mtg import Player, RandomPlayer, GreedyPlayer, play

result = play({"alice": RandomPlayer(), "bob": GreedyPlayer()}, seed=7)
result.outcome()          # ('alice', 'bob lost')

# a custom heuristic — subclass Player, override choose_move; you get the whole Game
class Aggro(Player):
    def choose_move(self, game):
        atk = [m for m in game.legal_moves if m[0] == "attack"]
        return max(atk, key=lambda m: len(m[1])) if atk else game.legal_moves[0]
    # (optionally also override decide() to steer targets/modes/blocks)

play({"alice": Aggro(), "bob": RandomPlayer(seed=1)}, variant="two-player", seed=3)
  • RandomPlayer(seed=None) — uniform-random; seed=None draws from the game's own seeded RNG (so the game is reproducible from its seed), an explicit seed gives the player its own independent RNG.
  • GreedyPlayer (= base Player) — takes the engine's hand-tuned default at every decision; a complete, legal opponent with zero config.
  • Custom — subclass Player. choose_move(game) is where heuristics/search live (use game.copy(), game.key(), game.push/pop, the zone accessors); override decide() only if you want to steer the nested sub-choices too.

(London mulligan resolves with the engine default — keep — before play begins.)

Benchmarking a heuristic

Drop in a Player and measure it. Self-play is fast (no Forge); the seats are swapped every other game and Random vs Random lands at ~0.50, so the win-rate is honest.

from mtg import benchmark, RandomPlayer, Player

class MyBot(Player):
    def choose_move(self, game): ...

benchmark(MyBot(), games=50)                 # -> {'win_rate': 0.62, 'avg_turns': 14.1, 'games_per_s': 1.4, ...}
benchmark(MyBot(), RandomPlayer(seed=1))     # vs a fixed-seed baseline

To benchmark against Forge (Forge referees — the source of truth — and runs the mirror: mtg reconstructs the board from Forge's observation stream every decision and runs in parallel):

from mtg.benchmark import benchmark_vs_forge
benchmark_vs_forge("engine", games=10)
# -> {'bot_win_rate': 0.3, 'avg_turns': 23,
#     'mirror_modeled_frac': 0.93,   # how much of Forge's real game mtg could MODEL
#     'mirror_endorsed_frac': 0.17,  # how much it could independently ENDORSE as legal (the engine gaps)
#     'source_of_truth': 'forge', ...}

benchmark_vs_forge needs Forge installed and spins up one JVM per game (heavy). The two mirror fractions are the differential-fidelity signal — high modeled / low endorsed means mtg recognizes the board but can't yet derive the play, which is the precise place to extend the rules engine.

ReBeL player (search over public belief states)

ReBeLPlayer is a search agent in the style of ReBeL (Brown et al. 2020, arXiv:2007.13544): at each decision it solves a depth-limited CFR subgame over the public belief state and acts on the resulting average (near-equilibrium) strategy. CPU-only, no GPU, no engine changes — it sits entirely on the shim.

from mtg import ReBeLPlayer, RandomPlayer, play
play({"alice": ReBeLPlayer(worlds=4, iterations=100, depth=3, time_budget=5.0),
     "bob":   RandomPlayer()})
  • Belief / PBSobserve.observe is the public projection; the belief is worlds determinizations (full states sampling the opponent's hidden hand/library partition from the known deck). Each is a perfect-information world the engine evaluates exactly — the perfect-info aspect used inside the PBS.
  • Shared infosets — regrets are keyed by observe()-derived infostate, so the acting seat shares one strategy across worlds it can't distinguish (the imperfect-information constraint, for free).
  • Depth-limited CFR — the subgame is expanded once per world (the only env.step cost), then CFR runs as arithmetic over the cached tree; leaves are scored by value_fn (default heuristic_value).
  • perfect_info=True solves the true game (one world) — same machinery, full information.
  • Bounded by worlds / iterations / depth / action_cap / time_budget.

The leaf evaluator is the lever: with the naive heuristic and a shallow horizon it plays ~even with random (combat damage sits one ply past a depth-2 leaf; depth 3 sees it). Strength comes from replacing the leaf with a trained value function (ReBeL's value network) — value_fn= is the hook.

Trained value net (mtg.rebel_train, CPU)

A tiny numpy value net + self-play data generation — the leaf that fixes the horizon. CPU-only, no GPU (needs numpy; kept out of the top-level import so import mtg stays dependency-free).

from mtg.rebel_train import train
from mtg.rebel import ReBeLPlayer

value_fn = train(games=200, epochs=300)          # self-play -> features + Monte-Carlo outcome -> fit
player = ReBeLPlayer(value_fn=value_fn, worlds=6, iterations=120, depth=2)
value_fn.net.save("vnet")                         # ...and TinyValueNet.load("vnet") later

features(state, seat) is the public-belief feature vector; generate(games, player_factory=…) produces (X, y) (target = the deciding seat's eventual outcome) from any data-generating policy (default random self-play, cheap). A more ReBeL-faithful target is the CFR root value under ReBeL self-play — heavier, optional.

Training run on a fixed pairing, with periodic Forge checks

from mtg.rebel_train import train_loop
out = train_loop(rounds=12, train_decks=("mono_green_landfall", "mono_white_soldiers"),
                 benchmark_every=4, save_path="rebel_vnet")  # alice=green (trains), bob=white (random)
out["history"]   # every round: data size; benchmark rounds also carry win_rate_vs_random + the Forge result

EVERY round is a cheap training round: the training seat (alice) plays self-play against a random opponent (bob) with a value-greedy agent that improves as the net does, then the net refits and saves — no search, so it's light. BENCHMARKS are infrequent (every benchmark_every rounds): there it evaluates ReBeL(net) vs random, and then tests the net against Forge (the only heavy step; forge=False to skip). Fixed known decks make the determinization belief exact ("perfect information to train against"). You can also fire the Forge test off directly — the trained net plays a Forge seat (Forge = source of truth) via a reconstruct + 1-ply net-rank policy:

from mtg.rebel_train import TinyValueNet, NetValue
from mtg.benchmark import benchmark_vs_forge
benchmark_vs_forge(NetValue(TinyValueNet.load("rebel_vnet")), games=5, timeout=300)

(Forge games are heavy and can stall on a small box — use a generous timeout / the Mac Mini; the Forge deck is a ForgeVsBot archetype, a cross-domain check rather than the exact training deck.)

Variants

import game as _setup
g = mtg.Game(_setup.COMMANDER_DECKS, variant="commander", seed=1,
                    commanders=_setup.COMMANDERS)   # §903: 40 life, command zone
mtg.self_play(seed=7)                          # full random game -> winner
result = play({"alice": RandomPlayer(), "bob": RandomPlayer()},
              _setup.COMMANDER_DECKS, variant="commander", commanders=_setup.COMMANDERS)  # players, any variant

Decks (human-readable lists)

Deck lists are plain text — N Card Name or just Card Name, one per line (#/// comments and blank lines ignored). The names are the human-readable oracle names the engine resolves directly (no slug/encode step), so you can hand-write a .txt and read it line by line.

import mtg
mtg.bundled_decks()                          # ['izzet_prowess', 'mono_black_zombies',
                                                    #  'mono_green_landfall', 'selesnya_landfall']
deck = mtg.load_deck("mono_green_landfall")  # bundled name -> flat list ['Forest', 'Forest', ...]
deck = mtg.load_deck("/path/to/my.txt")      # ...or any file path

g = mtg.Game({"alice": mtg.load_deck("izzet_prowess"),
                     "bob":   mtg.load_deck("mono_black_zombies")})
mtg.benchmark(MyBot(), decks={"alice": mtg.load_deck("selesnya_landfall"),
                                     "bob":   mtg.load_deck("selesnya_landfall")})

Four 40-card, limited-style pools ship with the package (mtg/decks/*.txt): mono_green_landfall, selesnya_landfall (GW go-wide), izzet_prowess (UR spells), mono_black_zombies. MTGO/Arena/.dck exports also parse (the loader skips [Section] headers and Name=/metadata lines).

Forge as the source of truth

The default engine is mtg (our datalog rules referee). For a mode where Forge is authoritative and one seat is the "forge player" (Forge's own AI), use mtg.forge — the game actually runs in Forge, and the mtg side connects as a bot seat (over forge_bridge's socket) answering each Forge decision:

import mtg.forge as wf
if wf.forge_available():                      # needs a built Forge fatjar + JDK 17 ($FORGE / $JDK)
    r = wf.play_forge(bot="engine")           # Forge AI vs the mtg 'stockfish' bot; FORGE judges
    print(r["winner"], r["turns"])            # winner is Forge's verdict — the source of truth
    # bot="random" for the baseline seat; witch_deck/opp_deck pick ForgeVsBot archetypes ('vanilla','infect')

This is the only faithful way to involve Forge: there is no reverse bridge that lets Forge's AI choose moves inside a mtg Game, so when Forge is the truth, the game runs in Forge. It reuses the wired forge_integration orchestration (ForgeVsBot + run_bot), so it spins up the JVM and can be slow / memory-hungry; set $JVM_HEAP / $GAME_TIMEOUT on small hosts. The mtg bot seat is driven by an forge_bridge obs-policy ('engine' = the win-search bot, 'random' = baseline); a RandomPlayer maps to 'random', any other Player to 'engine'. (A custom Game-based Player can't drive a Forge seat — Forge hands it an observation + Forge-ids, not a mtg state + moves; write a forge_bridge policy for a custom Forge bot.)

How it sits on the engine

datalog/engine_rules.dl   derives the consequences of a state   — the rules
driver.py                 applies them, owns chance/choice       — the shim
env.py                    enumerates legal actions, steps PURELY — the referee
game.py (top level)       builds a real game (decks, mulligan)   — the setup
mtg.Game           a stateful object wrapping all of it   — this package

Packaging status

This package is the pure-Python API layer. It currently imports the engine modules from the repository root and the engine resolves its data files (datalog/engine_rules.dl, the souffle fork) relative to the current working directory — so for now, run from the repo root. Making it pip install python-mtg-clean (path-independent data lookups + a prebuilt .so/wheel so no C++ toolchain is needed) is deferred and tracked in PACKAGING.md.

Check which engine backend built on your machine:

mtg.engine_available()   # 'incremental' | 'native' | 'inproc' | 'interpreter'

Release files for python-mtg 0.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distributions (wheels)

Table of built distributions (wheels) for python-mtg 0.1.1
File Interpreter ABI Platform
python_mtg-0.1.1-py3-none-manylinux_2_34_x86_64.whl Python 3 none Linux glibc 2.34+ x86-64 Details
python_mtg-0.1.1-py3-none-macosx_11_0_universal2.whl Python 3 none macOS 11.0+ universal2 (ARM64, x86-64) Details

Total release size: 13.0 MB

Release files / python_mtg-0.1.1-py3-none-manylinux_2_34_x86_64.whl

Download URL python_mtg-0.1.1-py3-none-manylinux_2_34_x86_64.whl
Size 6.1 MB
Tags Linux glibc 2.34+ x86-64 Python 3
SHA-256 checksum
How to use checksums
f97aa9e448deff5209786a04618e9c1c6e76c71e0a938dd23a0b84c787fdfae7
BLAKE2b-256 checksum
How to use checksums
19a84fc8e75e4b0098b2b7030decd876faa61d2be979377e7863157edcd0bf05
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.1.0 CPython/3.13.12

Release files / python_mtg-0.1.1-py3-none-macosx_11_0_universal2.whl

Download URL python_mtg-0.1.1-py3-none-macosx_11_0_universal2.whl
Size 6.8 MB
Tags Python 3 macOS 11.0+ universal2 (ARM64, x86-64)
SHA-256 checksum
How to use checksums
328067d060409dbeee638626db142ff71cfbb2d757cefa120dd1bba6ca5f5fa4
BLAKE2b-256 checksum
How to use checksums
6c6f3fa5ca55e3f2cfdb25ac0534967fc2a372667f113810d8d1eb7065121285
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.1.0 CPython/3.13.12

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files

0.1.0

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page