A python-chess-style API + driver for an MTG rules engine (runs the Datalog/souffle build)
Project description
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=Nonedraws from the game's own seeded RNG (so the game is reproducible from its seed), an explicitseedgives the player its own independent RNG.GreedyPlayer(= basePlayer) — 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 (usegame.copy(),game.key(),game.push/pop, the zone accessors); overridedecide()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 / PBS —
observe.observeis the public projection; the belief isworldsdeterminizations (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.stepcost), then CFR runs as arithmetic over the cached tree; leaves are scored byvalue_fn(defaultheuristic_value). perfect_info=Truesolves 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'
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file python_mtg-0.1.0-py3-none-manylinux_2_34_x86_64.whl.
File metadata
- Download URL: python_mtg-0.1.0-py3-none-manylinux_2_34_x86_64.whl
- Upload date:
- Size: 6.1 MB
- Tags: Python 3, manylinux: glibc 2.34+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3f6302fbc8598e556a7ed24bf1ad51fdf2d7b432a2a91f302b2079240b5fd220
|
|
| MD5 |
4c98aa15890faeb8228509bbbc80bfa8
|
|
| BLAKE2b-256 |
dce226a777824f0faba854e8f64551f97564768d6944f991127f3c322c1e4442
|
File details
Details for the file python_mtg-0.1.0-py3-none-macosx_11_0_universal2.whl.
File metadata
- Download URL: python_mtg-0.1.0-py3-none-macosx_11_0_universal2.whl
- Upload date:
- Size: 6.8 MB
- Tags: Python 3, macOS 11.0+ universal2 (ARM64, x86-64)
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
44a6d08e9db3f904f2ee779967cb5b042df6a334defd7ea96e2ee9f265043749
|
|
| MD5 |
df1734a386453d7204602380718cc2d3
|
|
| BLAKE2b-256 |
77196469c4d8280ff808c5484d83b3ca8830cd253d893d6dac6029d98d5c7d71
|