Mana Duel: Building a Magic-Inspired Card Game With Coding Agents

I like Magic: The Gathering more than I get to play it. Between finding an opponent, shuffling, and the arithmetic of tapping lands, an evening disappears before the third game. So I built Mana Duel: a minimalist card duel on Magic’s rules that runs in the browser, installs on your phone as a PWA, and plays offline, with no account and no busywork. The code is on GitHub, and I built most of it with AI coding agents in about four weeks.
This post is the story of that side project: the need it came from, the problems I hit along the way, and what shipped.
The need: Magic without the busywork
Magic has a paper shuffling problem. The rules are wonderful, but a real game wants an opponent, a table, and a lot of small bookkeeping: paying mana, passing priority, tracking triggers. Playing digitally solves the bookkeeping, but the official clients want accounts and the homemade ones usually want more of my time than a quick duel does.
So the brief wrote itself:
- Runs in the browser, installs as a PWA, plays offline.
- Real rules: the stack, instants in response, the London mulligan, flying, trample, deathtouch, lifelink and a dozen more keywords.
- No account, no collection, no deck you have to grind for. Pick a deck and play.
- In one hand, on a phone.
I had no intention of shipping 200 cards of someone else’s IP, either. Every card in the game is original: fourteen decks, one per color pair plus mono-colors and a five-color pile, each with its own plan.
How it got built: agents, one commit at a time
I built it with AI coding agents (mostly Claude Code) over about four weeks and 44 commits. Not as autocomplete, as a team: the repo carries an AGENTS.md file that is effectively the project’s engineering handbook, written for both people and coding agents. Every commit tells one story: [feat/landfall-prowess-five-colors], [fix/ai-lethal-blocks], [refactor/one-file-per-card].
The layout reflects one rule I cared about from commit one: the engine is pure functions over a serializable game state. applyAction(state, action) returns a new state or throws; randomness lives in a seeded RNG inside the state itself. That one decision paid for itself over and over: any game replays exactly from its seed and its action list, the AI can play thousands of games headlessly in a simulator script, and the agents could test every rule with a createScenario() factory that builds any position from a spec.
src/engine/ rules: pure functions over a serializable state
src/ai/ chooseAction(state) via forward rollouts
src/llm/ optional LLM player, legal moves as a zod schema
src/app/ controller, modes (Expedition, Draft, my decks)
src/ui/ Preact views, animations, backdrops
src/audio/ music and sfx synthesized with the Web Audio APIThe layers form a strict line: everything reads the engine, the engine reads nothing but itself, and a lint rule keeps it that way.
The problems that were actually interesting
Making the AI honest
The obvious way to make a card-game AI strong is to let it read the opponent’s hand. It is also the way that ruins the game, because humans can tell when the computer plays around cards it should not know about. Mana Duel’s AI only ever reads public state: the battlefields, graveyards, life totals and hand sizes. And there is a test that proves it: it shuffles the hidden cards of a real game and asserts the AI’s decisions do not change. The AI is strong the honest way: it weighs each move by playing the game forward through rollouts.
Lethal blocks and dying blockers
The bugs I am proudest of fixing were in combat. Blocking against trample is a small optimization puzzle: blocking a 6/6 trampler with a 2/2 saves 2 life, with three chump blockers it saves none because trample carries the rest over. The AI piles blockers wrong and you lose games you should win. Worse fun: a deathtouch blocker that drains life when it dies should only get to win the exchange if its owner survives the damage. Two commits named [fix/ai-lethal-blocks] did nothing but pile up scenario tests until the combat math was right.
Mana payment that respects your future
Another one from a commit title: pay costs “the way that leaves the most other cards castable”. With lands that tap for multiple colors, there are several ways to pay the same spell, and paying greedily locks you out of the instant you are holding. It is a tiny search problem that every digital card game has solved before, but it is the kind of detail that separates a playable engine from a good one.
Balance by simulation, not vibes
Fourteen decks against each other is 196 matchups, and nobody can play enough games to tune them by feel. The answer was a simulator: headless AI-vs-AI games in every matchup, run in parallel across CPUs, with invariant checks along the way. After changing any card, deck or AI behavior, a run of 3000 games per matchup keeps win rates between roughly 0.42 and 0.58, and comparing the same seed before and after a change isolates its effect. The whole verification pipeline (npm run verify) runs format, lint, unused-code detection, typecheck, unit tests, 196 games, and the production build in one command.
Letting an LLM play the second seat
The built-in AI is fast and free, but I also wanted the option of an opponent that reasons in public. So the AI seat can be played by any OpenRouter model with your own key: the game builds a prompt from the model’s seat view, the legal moves arrive as a zod schema with strict JSON schema enforcement, and every answer is checked against the rules before it is played. An illegal answer is retried with the engine’s refusal reason, and if the model still fails, the classic AI plays the move and the log says so. A test rebuilds the prompt with the hidden cards shuffled and asserts the prompt never changes: the model cannot cheat either.
One card, one file, twice the languages
Cards started as one big typed registry and got refactored into one file per card (214 of them, tokens included). Each card has its own SVG illustration on a 120x80 viewBox that fills the card face. Every player-facing string exists in Spanish and English, from rules text to the log, with types ensuring a missing English key fails the build. Bilingual from the start was a gift to the agents: writing the structure once and adding a language later is far cheaper than bolting i18n onto a finished game.
The final product
Mana Duel is live. Fourteen decks, 213 original cards each with its own illustration, and three ways to play: single duels against a classic AI or an LLM, the Expedition (seven duels in a row with card and relic rewards against rivals that get stronger, one loss ends the run), and Draft or Sealed with pack passing and a best-of-three against the AI’s deck. There is a deck builder with search, filters and a mana curve, if you would rather build your own.

It installs to your home screen and plays offline thanks to a service worker, the music and sound effects are synthesized live with the Web Audio API (each deck has a theme that turns tense when you are low on life), and behind the menus there are five painted landscapes, one per mana color, that shift between day and night. Everything is animated, in both languages, on a UI designed for one thumb: tap to act, long-press to read.
What building it with agents taught me
- Purity first. A seeded, serializable, pure engine made testing, replaying and simulating cheap. Every other good decision in the repo leaned on that one.
- Agents are only as good as the handbook. The
AGENTS.mdfile, with its layout map, engine model, and verify checklist, is why an agent could add a new card, deck or keyword without breaking the layering. - Simulation beats intuition. I cannot feel whether a deck sits at 44% or 58% win rate. A simulator that runs thousands of games in under a minute can.
- Fairness is a testable property. “The AI does not cheat” is not a hope, it is an assertion that shuffles the hidden cards and asserts nothing changes.
- The verification command is the project. If
npm run verifyexits 0, the change ships. Agents thrive inside a boundary like that.
Quick takeaways
- A pure, seeded, serializable rules engine pays for itself in replay, simulation and testing.
- An AI that plays fair is stronger design, and provable with one well-built test.
- Balance 196 matchups with a simulator, not with intuition.
- LLM opponents work best as checked players: schema-constrained answers, engine-validated, with a classical AI as fallback.
- A good
AGENTS.mdturns a codebase into something coding agents can extend safely.
If you give Mana Duel a try, I would love to hear which deck gives you the most trouble: play it here, and the code is on GitHub.


