Source: https://oliviercouthaud.com/mtgai · Status: Prototype · 2026 · Tags: Python, PyTorch, PPO, LLM # 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. ## 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. Figure: Diagram. The pipeline end to end. The embeddings, tokens and transformer belong to the later agent; the first public version used a 335-number vector and a small MLP instead. Hand-written Python card definitions are read before the SQLite output (see "What I ran into"). The 52-token layout and 7 action categories are those of the episode-5500 checkpoint; my newer version has since grown to 57 tokens and 18 categories. 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. ### Reading a schema, part by part { "ability_type": "triggered", "trigger": { "event": "enters_battlefield", "subject": "this" }, "effects": [ { "type": "destroy", "selector": { "type": "target", "filter": "creature", "controller": "opponent" } } ] } **ability_type.** What kind of ability this is. "triggered" means it fires by itself when something happens. The other kinds are spell, activated, mana, static, replacement, alternate cost and parametric keyword. The engine files the ability by this field. **trigger.** The event that sets it off: here the card itself ("this") entering the battlefield. When the permanent comes in, the TriggerBus registers it, and when the event fires the ability goes on the stack. **effects.** A list that runs in order. This one has a single "destroy". The EffectExecutor knows about 25 effect types (damage, draw, destroy, exile, create token, add counter and so on) and looks the type up. **selector.** What the effect hits: a target, filtered to creatures, controlled by the opponent. For triggered abilities the engine picks the target itself with a simple rule rather than asking the agent. This is one of the examples I give the model in its prompt, laid out one field per line. 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. Figure: Diagram. Input and output of the later agent, at the episode-5500 checkpoint. ## 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 using 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, has not been tested yet. 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. **Early win rates** | 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. Figure: Win rate of the later agent at each checkpoint, 300 games per opponent per point. Open circles are the random opponent, filled circles the greedy one. The dashed marker is the checkpoint picked below. ### Step through the 16 checkpoints 73.7% / 59.0% Wins against Random / against Greedy. This is the checkpoint I kept. That is 221 and 177 wins out of 300. Against Greedy by deck: red aggro 63.7% (65 of 102), mono red burn 58.1% (54 of 93), mono white weenie 55.2% (58 of 105). How it spent its decisions against Greedy at episode 5500, in percent. Activating an ability is 0 at every checkpoint. 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 puts it above random play; it is an early level, 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 I ran into The early steps 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 I found a bug that let burn spells target lands and had affected 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. ### Three runs, same deck, 200 episodes each 62% / 86% Final training win rate: hand-size beliefs / deck-composition beliefs. In the chart, "plain" is the plain policy, "deck" is search with deck beliefs and "hand" is search with hand-size beliefs. The plain policy ended at 100%, and was there from episode 100. These are last logged training win rates against the baseline opponents, not comparable with the checkpoint chart above, which is a separate evaluation on three decks. 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 read them as showing 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 treat the whole search-and-beliefs line as open. The engine's coverage has limits 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, activating an ability is 0% of the agent's decisions. 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. ## Where it stands Next 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 in progress and needs to land first. Open items: clearing 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. So far I have its training win rates (46 to 76% from episode 400 on), and a proper evaluation is the next step. 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](https://github.com/Forkei/mtg-ai) (early snapshot only; later work not public)