monoR: a 120,514-parameter policy that plays Pauper Mono Red Madness
A tiny transformer that plays the Pauper Mono Red Madness deck in real Magic: The Gathering rules engines (Forge, then Argentum). Its only job is to kill a goldfish (an opponent that never does anything) in as few turns as possible.
On 1000 held-out games it wins on turn 5.48 on average, 17% of
games by turn 4 and 61% by turn 5. Checkpoint: Simulation run 3 (Argentum), round 3. Games are simulated on
Argentum (earlier rounds on Forge), with
the companion mulligan scorer (mulligan.py) taking keeps and London bottoms.
How it was built, step by step, with replays of its games: vedalken/monoR_madness. Play a game with its suggestions, in your browser: the table.
Inspired by anakin87/tiny-doom-defender.
How it decides
Every decision the rules engine asks for (mulligan, what to play in a main phase, which card to discard, whether to cast a madness card, which mode) becomes one sequence of tokens:
- one global token: turn, life totals, lands and untapped lands, cards drawn and spells cast this turn, decision kind, phase, and what is left in the library;
- one token per card you can see (hand, battlefield, graveyard, exile), with its zone;
- one token per legal option.
A 3-layer transformer (width 64, 4 heads) reads the sequence and scores the option tokens; the softmax runs over legal options only, so the model can never pick an illegal move. A value head on the global token was used during PPO.
How it was trained
- Rules oracle. A hand-written policy (priority lists, a lethal check, a mulligan rule) that only reads what the model sees.
- Supervised cloning of the oracle on thousands of Forge games.
- Hand-tuned rules, each one tested on the same 50 validation seeds (Forge is deterministic per seed, so comparisons are paired).
- Expert iteration from simulation. Play the model, and at up to 8 non-obvious decisions per game try every legal option: replay to that point, reshuffle the library (the future is unknown, as in real play), finish the game with the model, 4 reshuffles per option. The option with the fastest mean kill becomes the label; fine-tune and repeat. Nobody writes the strategy: if Lava Dart on turn 1 is bad, the rollouts show it.
Mulligan scorer
A separate MLP (mulligan.py, weights in mulligan/) predicts the mean kill turn of
keeping a 7-card hand with a given set of cards on the bottom, from card counts. It was
trained on simulated games of those exact keeps, played by this checkpoint (3,556 keeps, 64
reshuffles each). A mulligan is worth a fixed number per
hand size (mulligan/constants.json): turn 6.24 to six, 7.10 to five,
8.05 to four. Keep when the best keep beats it; always keep four.
from mulligan import advise # with `path` and sys.path set as in "Use" below
advise(["Mountain", "Mountain", "Lightning Bolt", "Guttersnipe", "Lava Dart", "Fireblast",
"Sneaky Snacker"], k=0, folder=f"{path}/mulligan")
# {'keep': True, 'bottom': [], 'keep_turn': ..., 'mull_turn': ...}
Results (1000 validation games on Argentum, mean kill turn, lower is better)
| version | mean kill turn | dead by turn 4 | dead by turn 5 |
|---|---|---|---|
| Rules (hand-tuned oracle) | 5.90 | 14% | 51% |
| SFT clone of the original oracle | 5.96 | 8% | 44% |
| PPO from that clone | 5.87 | 11% | 47% |
| SFT clone of the hand-tuned oracle | 5.98 | 12% | 47% |
| Simulation run 1, round 1 | 5.94 | 9% | 45% |
| Simulation run 1, round 2 | 5.85 | 10% | 49% |
| Simulation run 1, round 3 | 5.74 | 12% | 52% |
| Simulation run 2, round 1 | 5.86 | 12% | 50% |
| Simulation run 2, round 2 | 5.74 | 13% | 53% |
| Simulation run 2, round 3 | 5.74 | 14% | 52% |
| Simulation run 2, round 4 | 5.73 | 13% | 52% |
| Simulation run 2, round 5 | 5.65 | 15% | 55% |
| Simulation run 3 (Argentum), round 1 | 5.57 | 15% | 56% |
| Simulation run 3 (Argentum), round 2 | 5.55 | 17% | 59% |
| Simulation run 3 (Argentum), round 3 | 5.48 | 17% | 61% |
Games not won by turn 12 count as 13.
Use
The model reads decision requests from a rules engine through the monoR bridge; model.py
has the featurizer and the network. The Space's goldfish.js is a dependency-free browser
port of both models that builds the requests from a manual tabletop (play it).
import sys
from huggingface_hub import snapshot_download
from safetensors.torch import load_file
path = snapshot_download("vedalken/monoR_madness")
sys.path.insert(0, path)
from model import Policy, collate, featurize
policy = Policy()
policy.load_state_dict(load_file(f"{path}/model.safetensors"))
policy.eval()
# msg = {"kind": "main", "options": [...], "state": {...}} (a bridge decision request)
# logits, value = policy(collate([featurize(msg)]))
Limitations
- Trained only against a goldfish: it ignores the opponent's creatures, blockers and removal.
- One deck list (
deck.dck) and 12 turns maximum. - Discard choices still copy a weakness of the rules: with fewer than 3 lands in play it avoids discarding Mountains, even when flooded.
Files
model.safetensors,config.json: weights and network size.model.py: featurizer and network.deck.dck: the deck (Forge format).results.json: validation summary of this checkpoint.mulligan.py,mulligan/scorer.safetensors,mulligan/constants.json: the mulligan scorer.
- Downloads last month
- 10
