Olivier Couthaud

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.

Card textas printed Language modelreads each card once Checklistshort, strict format Game enginefollows the checklist
Diagram. The language model is only used before the game, never during it.

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.

Games won out of 100, best version vs random player 74 vs rule-of-thumb player 59
300 games each. I picked this version from the same tests, so it is a little flattering.

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.

Scryfall bulk JSONCardLoader, streamed Gemini 2.0 Flashoffline, once per card abilities.sqliteschema JSON per card CardDefinition.schema_abilities, read at load Engine (mtg_sim) TurnEngine12 steps StackLIFO TriggerBusevents EffectExecutor25+ effects StaticLayeranthems env_v252 x 416 tokensaction masks TransformerActorCritic embeddings.npzMiniLM, 31,934 x 384 MCTS + beliefsoptional,wraps policyat play time Training Opponents: random, greedy, past selves. Action = category, then subject, then target. PPO, 8 CPU worker processes, model on the GPU.
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 didn't work"). 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.

52 tokens, 416 numbers each 2 player summaries 14 my battlefield 14 opponent battlefield 7 hand 5 my graveyard 5 opponent graveyard 5 stack each token = 384 (card text embedding) + 32 (game features) Transformer encoder 2 layers, 8 heads, d_model 256 masked mean pool LSTM 64, resets every turn about 1.3 million parameters 1 Category7 options: pass, land, cast ... 2 Subjectwhich card or creature, up to 20 3 Targetwhat it is aimed at, up to 20 + a value head (1 number) for PPO. Illegal choices are masked out.
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 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.

Early win rates
SettingWin 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 recorded56 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.

30%40%50%60%70%80%10002000300040005000600070008000training episodevs Randomvs Greedy
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).

Pass9.8Play land24.2Cast spell17.1Attack35.1Block5.2Respond8.6

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 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.

Three runs, same deck, 200 episodes each

50%60%70%80%90%100%50100150200training episode, logged every 50plaindeckhand
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 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)

← All work