MTG AI
A Magic: The Gathering rules engine, an AI language model that turns card text into data the engine can run, and an agent trained to play it.
What it is
Magic: The Gathering is a card game with more than thirty thousand different cards, and nearly every card has its own small rule printed on it. This project is a program that plays Magic by the real rules, plus a second program that learns to play it by playing thousands of games.
Why I made it
I wanted a player that could pick up any card, and with this many cards I did not think I could write every one by hand. So the first plan was to have a language model, the same kind of AI as a chatbot, read each card once and rewrite its text as a short, strict checklist. The game engine only ever reads the checklist, a bit like turning every recipe in a huge cookbook into the same numbered format so a kitchen robot never has to understand English.
I have since gone another way for most of the cards I actually play with. Every card hides rules and edge cases, and with that many cards I did not want to depend on a model reading each one and getting all of it right. So I write those cards by hand, in Python, and the checklists only cover the rest.
What it does
The engine handles turns, combat and the rest of the rules. The learning part looks at the board, picks one of the things it is allowed to do (play a land, which is the kind of card that makes mana, cast a spell, attack, block, pass), and at the end of the game gets told whether it won.
How it went
The card-reading part held up on a small test. Of 83 random cards, 71 came out clean and 12 had small mistakes, where the model made up actions the engine does not have. I only checked the format, not that each checklist means what the card says.
The player is better than a random one and not much more than that. Against a random opponent it wins about three games in four, and against one that follows a simple rule of thumb about six in ten. That is not strong Magic, and it only plays a handful of decks. It also stopped passing so much: early on it passed on about a third of its decisions, by the end about one in ten.
Some things did not work. My first hand-written learning code won nothing in two thousand games; rebuilt on a well-known machine-learning library, it won within fifty. A bug once let damage spells be aimed at lands, which cannot be hurt in any useful way, and that quietly spoiled every run before I found it. I also made it look ahead while guessing the opponent's hidden hand, and I do not trust those results. The plan changed for a different reason: every card hides its own rules and edge cases, and I did not want to depend on a language model getting all of them right every time, so many cards in my test decks are now written by hand.
What's next
Decide whether the language model stays at all, read all 28,000 or so cards instead of a sample, and put the newer code online. Only the first two days of work are public so far.
The problem
I wanted an agent that could play any Magic card without someone writing rules for each one. The card database I load has 31,934 unique cards (the Scryfall bulk file I start from is 2.5 GB and about 526,000 printings), and writing rules for that many cards by hand looked unrealistic. So the first plan was to let a language model read each card's rules text once, write it down as JSON, and have the engine run the JSON.
That is not quite where it ended up. Every card carries its own rules and edge cases, and with this many cards I did not want to rely on an LLM parsing each one and getting all of it right. So for the cards in my decks I now write the definitions by hand in Python, and the LLM output is only a fallback. I describe both below.
I started on 5 April 2026 and put a first version on GitHub two days later. Everything past that first version, including the transformer agent and the search code, is not public yet.
How it works
Parsing is offline. Gemini 2.0 Flash gets a long system prompt with worked examples (about 150 lines and nine examples at first, twelve examples now) and returns a JSON effect schema for each card, which goes into SQLite. The Gemini API is never called during a game. The engine is plain Python: a 12-step turn, a stack with priority, state-based actions, an event bus for triggers, a layer that recomputes anthems and keyword grants, and about 25 effect types.
A schema is a small piece of JSON per ability. Below is the one for "when this enters, destroy target creature an opponent controls". Tap a line of it, or a button under it, to see what that part does and which piece of the engine picks it up.
The first agent was PPO with illegal actions masked out: a 335-number observation, 71 discrete actions, and a 335 to 256 to 256 to 71 network. Training starts against a random player, moves to a greedy one (a hand-written player that always takes the locally best-looking action), then to past versions of itself.
What is PPO?
Proximal Policy Optimization is a reinforcement-learning method. The agent plays games, and afterwards its decision-making network is nudged towards the choices that led to better outcomes than expected. The "proximal" part means each nudge is capped, so one unlucky batch of games cannot wreck what it already learned.
What does "masking illegal actions" mean?
At every decision the engine tells the network which of its options are legal right now. The illegal ones get a score of minus infinity before the choice is made, so they can never be picked and the agent never has to learn the rules by being punished for breaking them.
The later agent addresses two gaps of the first: it could not choose targets and it could not act at instant speed. It sees 52 tokens of 416 numbers each: a 384-number embedding of the card's text from all-MiniLM-L6-v2, plus 32 game features. A two-layer transformer (d_model 256, 8 heads) of about 1.3 million parameters feeds a small LSTM that resets each turn, and three heads pick a category, then a subject, then a target. It can respond to spells.
Results
The parser held up on a small sample. It passed a hand-picked 20-card suite 20 out of 20. On a stratified random sample of 83 cards, 71 came out fine (85.5%), 12 had warnings (14.5%) and none failed. All 12 warnings were the model inventing effect names the schema does not have, such as become_creature and return_to_battlefield. That run used the first version of the prompt, before I added rules and examples for those two names.
The checks only confirm the output is valid JSON with known ability and effect types. Whether a schema means what the card says, or runs correctly in the engine, is untested. The full pool of roughly 28,000 eligible cards is not parsed. At the free API tier of 1,500 requests a day that is about 20 days.
These early numbers are from runs before the trigger bus, effect executor and static layer existed. Card effects then came from a regex parser that covered roughly 17 to 41% of cards depending on type.
| Setting | Win rate |
|---|---|
| Hardcoded test cards, vs the greedy player, best point in the training log (episode 500) | 82% |
| Real cards (20,000 from Scryfall), after 500 episodes, opponent not recorded | 56 to 68% |
The checkpoint in the public repo was trained for 3000 episodes on hardcoded decks, and I did not record a separate evaluation of it. For the later agent I evaluated checkpoints every 500 episodes, 300 games against each opponent, with three decks for the agent and five for the opponent.
The checkpoint I kept, episode 5500, scored 73.67% against random and 59.00% against greedy, 66.33% combined. I picked it from these same evaluations, so that figure is a little optimistic. Winning about three games in four against a random player is not strong play. Two of the five opponent decks are green decks with broken mechanics; I left them in on purpose so the agent sees odd opponents, but it means these win rates are not a measure of general Magic strength.
What does "one standard error is about 3 points" mean?
With 300 games, a measured win rate can easily land two or three points away from the true one just by the luck of the draw. So two neighbouring checkpoints that differ by two or three points cannot really be told apart. Only the larger moves in the chart mean anything.
With 300 games one standard error is roughly 3 points, so most neighbouring checkpoints are indistinguishable. The clearest change is how often it passes. At episode 500 it passed on about 31% of its decisions against both opponents. Against greedy that was about 10% by episode 5500 and about 8% by episode 8000.
What didn't work
The early dead ends first. A hand-written NumPy version of PPO won 0% for 2000 episodes, and the PyTorch rewrite reached 60% within 50. Training against the greedy player from episode one also gave 0%, which is why there is a curriculum. Later, a bug let burn spells target lands, and it had been corrupting every run before it. I fixed it in the v14 run, and the evaluations above all come from v20.
I also added search over my guesses of the opponent's hidden hand: Monte Carlo tree search with sampled hands. These are the logged training win rates on one deck, red burn, over 200 episodes. Pick a run to see its numbers.
What is MCTS, and what are "beliefs"?
Monte Carlo tree search plays out many imagined futures from the current position and picks the move that did best on average. In Magic the opponent's hand is hidden, so each imagined future needs a guess at it. A "belief" is that guess: the first version only knew how many cards the opponent held, and the second also used what was in their deck and graveyard.
Read quickly, those lines say that guessing the opponent's hand better makes search stronger, and later evaluations of the search agents came in at 96 to 98%. I do not think they show that. The plain policy had already reached 100% on that deck, the later evaluations were 30 games each, and the same checkpoint scored 91.7% when I evaluated it once and 96.7% when I evaluated it again. I count the whole search-and-beliefs line as unproven.
The engine has gaps too. A check I did on 10 April found no planeswalkers, scry and reveal as no-ops, and no damage prevention. In every evaluation above the agent never once chose to activate an ability.
The plan changed because there are too many cards, each with its own rules and edge cases, and I did not want to rely on an LLM parsing every one of them and getting all of it right. In my latest version, the cards in the decks come from hand-written Python: about a dozen data files (some 3,800 lines) plus roughly 200 per-card scripts. The loader reads those first and skips the LLM output for any card they cover. The LLM schema is attached to only 485 cards in the evaluation runs.
What is unfinished
First job is getting the transformer agent and the search code into a state I can publish, since only the 7 April version is online. The newest card work is not finished enough for that yet.
Still open: fixing the parser's roughly 15% of warnings, parsing the full card pool, and deciding whether the LLM is part of the pipeline at all given how many cards are now written by hand. I also set up training across 21 decks, but I only have training win rates for it (46 to 76% from episode 400 on), no proper evaluation.
This is an unofficial fan project. Magic: The Gathering is a trademark of Wizards of the Coast. I do not show card art or redistribute the card data.
- Stack
- Python, PyTorch (CUDA), Gemini 2.0 Flash for offline parsing, SQLite, Scryfall data, all-MiniLM-L6-v2 embeddings (later agent)
- Status
- Prototype
- Code
- github.com/Forkei/mtg-ai (early snapshot only; later work not public)