A Discord bot that plays Magic: The Gathering with you — full Commander / Modern / Legacy / Vintage / Pioneer / Pauper / Limited / Brawl / Oathbreaker / Cube support. Plays both sides if you have nobody else to play with.
Includes:
- A tiered rules engine with ~500 card-specific templates (160 of them plain JSON) plus ~90 oracle-text pattern families for the long tail
- An XMage card-database bridge (87,000+ cards) for novel-card support
- An LLM-backed judge for genuinely complex interactions
- An
!undosnapshot stack so bugs in obscure rules don't ruin your game - A
!coveragecommand that classifies every card in a deck by how the engine will handle it - An autoplay loop for batch playtesting (Claude, DeepSeek, or Qwen as both players)
- A persona layer for chat flavor in game threads
For a Discord companion bot with distress support / memory / tarot / YouTube transcription, see the sibling discord-companion-bot repo. That bot can optionally import this MTG engine if you want both in one deployment.
Magic: The Gathering is Turing complete — Churchill, Biderman & Herrick (2019) showed a legal two-player game can encode an arbitrary Turing machine, which means a fully general rules engine that always knows what happens next is mathematically impossible. Every digital Magic implementation is a choice about where to spend that impossibility: MTG Arena closes the card pool (a card doesn't exist until it's implemented), MTGO hand-implements everything and wears the bugs.
This engine takes the third road: accept any deck, and be honest about how each card will be handled. Deterministic tiers cover the head of the distribution (templates, pattern families, a regex spell resolver, the XMage bridge), an LLM judge catches the long tail, !coverage tells you the split for your exact deck before you play, and !undo/!fix are the escape hatches the math says every open-pool engine must have. The tiers aren't a workaround for an unfinished engine — they're the only architecture an open card pool permits.
# 1. Clone + create your config
git clone https://github.com/VIXAL-OS/discord-mtg-bot.git
cd discord-mtg-bot
cp config.json.example config.json
cp .env.example .env
# Edit .env with your Discord token + Anthropic API key
# 2. Install Python deps
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
# 3. (Optional) XMage bridge — 87k-card coverage via a prebuilt JAR.
# One curl, no Java build. See "The XMage bridge" below.
# Skip it if you like: the engine works fine without it.
# 4. Run it
python bot.pyconfig.json has three knobs:
| Setting | What it does |
|---|---|
bot_persona |
Character layer (a file under personas/). Default: plain (no roleplay). Try ressapanda for whimsy. |
mtg_channel_id |
Discord channel where the bot auto-responds to every message. Set to null to require @-mentions in other channels. |
excluded_channels |
Channel IDs where the bot never responds. |
That's it — the bot is intentionally minimal in scope.
In a Discord channel the bot can see:
!game @SomeoneElse commander # Start a 2-player commander game
!game claude modern # Play against the bot
!mydeck surrak # Load one of the bundled test decks
!deck <archidekt-url> # Load your own deck from Archidekt
!play Lightning Bolt # Cast a card from hand
!attack all # Declare all available attackers
!block Grizzly Bears with Wall of Omens
!pass # Pass priority
!state # Show the board
!hand # See your hand (DM'd to you)
!undo # Roll back the last risky action (depth 5)
!coverage surrak # See how the engine handles each card in the deck
!judge <question> # Ask a rules judge — applies state changes if needed
!fix <natural-language> # Manual state surgery for bug recovery
!card <name> # Pretty Scryfall card display
!xmage <name> # Raw XMage rules-engine data
!cost # Lifetime API usage + costs
See mtg/cog.py for the full command list. The bot plays as the AI opponent when you !game claude or !game @BotName.
The rules engine resolves effects through a tiered cascade — start fast/cheap, escalate only when needed:
| Tier | What | Cost | Coverage |
|---|---|---|---|
| Tier 1 | Hardcoded handlers in mtg/triggers.py + mtg/spells.py |
Free, instant | ~15 specific cards |
| Tier 1.5 | Templates in data/card_templates.json + patterns in rules/effect_templates.py |
Free, instant | ~500 cards + ~90 pattern families |
| Tier 2 | SpellResolver (regex → JSON action) |
Free, instant | ~40% of remaining oracle text |
| Tier 2.5 | XMage bridge (Java subprocess, 87k-card DB) | ~10-50ms | Catches what regex misses |
| Tier 3 | LLM judge (mtg/judge.py) |
Tokens + ~2s | Genuinely novel effects |
| Tier 4 | Manual: !judge, !resolve, !fix, !undo |
Human | Last resort |
Run !coverage <deckname> to see how the engine will classify each card in a deck before you play. See ARCHITECTURE.md for the deeper tech overview, the mtg/ and rules/ package layouts, the effect-action JSON format, and the per-batch audit playbook contributors use to catch regressions.
The engine can consult XMage's card database (87,000+ cards) for effects the templates and regex passes miss. It's genuinely optional — without it the engine falls back to template + LLM resolution and nothing crashes, it just leans on Tier 3 slightly more often.
The easy way — download the prebuilt JAR (no Java build required):
mkdir -p rules/xmage-bridge/target
curl -L -o rules/xmage-bridge/target/xmage-bridge-1.0.0.jar https://github.com/VIXAL-OS/discord-mtg-bot/releases/download/xmage-bridge-v1.0.0/xmage-bridge-1.0.0.jarRestart the bot and you'll see [XMAGE] lines in the console. First start
rescans the card DB (~13s) and writes a ~250MB cache under db/. You need a
JRE 11+ on PATH — the Docker image already bundles one.
Building it yourself is a chore, which is why the JAR is published.
rules/xmage-bridge/pom.xml depends on org.mage:mage, mage-server, and
mage-sets at version 1.4.58, and those artifacts are not published to
Maven Central. You'd have to build XMage from source first so they land in
your local ~/.m2:
git clone --depth 1 --branch xmage_1.4.58 https://github.com/magefree/mage.git
cd mage && mvn install -DskipTestsThat's a large multi-module Java build — budget real time and a few GB of RAM.
Only then does cd rules/xmage-bridge && mvn package work, producing the same
~90MB shaded JAR the release gives you for free.
If you'd rather not: skip it. Run !coverage <deck> to see what your decks
actually need — most Commander decks are covered by Tier 1.5 templates.
XMage is MIT licensed,
so a JAR that shades it can be redistributed — provided XMage's copyright and
license notice travel with it. The build bundles that notice at
META-INF/LICENSE-xmage.txt; if you distribute a built JAR, don't strip it.
Note also that the bundle includes XMage's card implementations (~42,000 classes named after Magic cards). Magic: The Gathering and card names are Wizards of the Coast property; this project is unaffiliated fan tooling, as is XMage. Nothing here is legal advice — if you plan to redistribute builds publicly rather than run your own, that's worth your own look.
The bot is a normal long-running Python process; docker compose is the
turnkey path. A small VPS is plenty — 2GB RAM is fine without the XMage
bridge, 4GB if you want it. These instructions were walked end-to-end on a
fresh Ubuntu 24.04 box; if something here is wrong, that's a bug worth an
issue.
curl -fsSL https://get.docker.com | shgit clone https://github.com/VIXAL-OS/discord-mtg-bot.git && cd discord-mtg-botcp config.json.example config.json && cp .env.example .envNow edit .env (Discord token, Anthropic key, optional DeepSeek key) and
config.json (channel IDs).
Create those two files before your first
docker compose up.docker-compose.ymlbind-mountsconfig.jsonas a file. If it doesn't exist yet, Docker helpfully creates a directory with that name and the bot then fails in a way that doesn't mention the real problem. If you hit it:docker compose down && rm -rf config.json, then copy the example properly.
docker compose up -d --builddocker compose logs -fYou're waiting for the Discord ready line. Then sanity-check in Discord:
!card Lightning Bolt (Scryfall works), !game claude commander +
!mydeck surrak (deck loading works), !state (rendering works). If you set DEEPSEEK_API_KEY or DASHSCOPE_API_KEY, a single
!autoplay commander surrak aminatou exercises the engine, provider
selection, logging, and Discord rate limiting end to end.
State lives in host bind mounts (data/, logs/, config.json), so
docker compose down and rebuilds don't lose your decks, saved games, or
lifetime cost tracking. To update: git pull && docker compose up -d --build.
If you'd rather not run a box, Fly.io works well here and the
repo ships a fly.toml. It builds the same Dockerfile, so you get
the same image — you're just swapping who runs it.
fly launch --no-deploy --copy-configDecline the HTTP service and health check when it offers them. This bot listens on no ports — it dials out to the Discord gateway. A health check against a port nothing serves will fail forever and restart-loop the bot.
Secrets are environment variables (the same ones .env holds), so they go in
Fly's secret store rather than a file:
fly secrets set DISCORD_TOKEN=xxx ANTHROPIC_API_KEY=xxx DEEPSEEK_API_KEY=xxx DASHSCOPE_API_KEY=xxxSaved games need a volume — one gigabyte is plenty, and it must be in the same
region as primary_region:
fly volumes create mtg_games --size 1fly deployfly logsThen run the same Discord sanity checks as the Docker path above.
Four things worth knowing before you commit to it:
You are paying for an always-on machine. Fly's headline "scale to zero when idle" is a proxy feature for apps that serve requests. A Discord bot holds a persistent gateway connection and must never suspend, so those savings don't apply — price this against a small always-on VPS, not against Fly's idle tier.
Never scale past one machine. Each instance opens its own gateway session,
so two machines answer every command twice and run every !autoplay twice.
fly.toml sets strategy = "immediate" for this reason: the default rolling
deploy briefly runs old and new together, which is that same double-answer
state on every deploy. Keep fly scale count 1.
Don't mount a volume at /app/data. That path ships 39 files in git —
decks, card templates, the Scryfall cache — and an empty volume mounted over it
hides all of them, leaving you with a bot that has no decks and an error that
doesn't mention mounts. fly.toml mounts /app/data/games instead, which is
untracked and is the only path that actually needs to survive a redeploy. The
card cache re-fetches from Scryfall, and logs go to stdout for fly logs.
config.json won't be in the image — it's .dockerignored, so the bot
starts on defaults and responds only to @-mentions. That's a fine first deploy.
To pin it to a channel, delete the config.json line from .dockerignore and
redeploy so it gets baked in. That's safe in this fork specifically because
config.json holds no secrets — just bot_persona, mtg_channel_id, and
excluded_channels; every credential lives in an environment variable.
The XMage bridge (Tier 2.5) is the awkward part. Its card DB is hundreds of
megabytes that the Dockerfile deliberately keeps out of the image, and on a VPS
you just scp it into a bind mount. On Fly you'd have to bake it in (a much
larger image) or seed a second volume. The engine degrades gracefully without
it — you lose one resolution tier, not the bot — so the simplest Fly deploy
skips it. If you want the bridge, a VPS is the easier home.
Plenty of hosts that are best known for Minecraft also sell Discord bot
hosting — PebbleHost, Sparked Host and BisectHosting all do, and
bot-hosting.net has a free tier that's popular for small bots. Railway and
Render occupy similar ground as PaaS. Any of them will run this.
Rather than name plans that go stale, here's the checklist that actually decides fit. The one that matters most is root:
| Need | Why | If you don't have it |
|---|---|---|
| Python 3.11+ | Everything in requirements.txt is a pure-pip wheel — no compilers needed |
— |
| root or Docker | The XMage bridge needs a JRE (apt-get install openjdk) |
No Tier 2.5. The engine falls back to templates + LLM. Graceful, not fatal |
| Disk that survives restarts | data/ holds saved games and the card-image cache |
Saves vanish on restart; images re-download |
| Always-on (no idle sleep) | The bot holds a persistent gateway WebSocket | It drops offline and misses commands |
| ~1GB RAM | See sizing below | Rendering spikes can OOM you |
So: a locked-down panel where you upload code and pick a Python version runs the whole bot except the XMage bridge. That's the honest dividing line. If you want Tier 2.5, you want root — a VPS or their VPS tier.
On a panel you skip Docker entirely: clone, pip install -r requirements.txt,
set DISCORD_TOKEN / ANTHROPIC_API_KEY (and optionally DEEPSEEK_API_KEY or DASHSCOPE_API_KEY)
in the panel's environment-variable UI, and run python bot.py. Panels are
genuinely well shaped for this — they're built around always-on processes
with restart-on-crash, so you avoid the scale-to-zero trap that makes
serverless platforms awkward for a gateway client.
Size for concurrent games, not for autoplay batches. !autoplay is a
development harness; batch log volume varies with game length and instrumentation.
Normal play produces a tiny fraction of a full regression matrix.
What production actually looks like: games are keyed by Discord thread, so any number can run at once across any number of servers. Concurrency is cheap on CPU, because a game spends nearly all its time waiting on the Discord and LLM APIs. Three things do scale, though:
- Memory. Budget a couple hundred MB of baseline (Python,
discord.py, the in-memory card cache) plus a few MB per live game — then leave headroom for board rendering, which allocates in bursts. 512MB works for a quiet server; 1GB is the comfortable number once several games overlap. - Disk, via card images.
data/card_cache/holds a PNG of every card art the renderer has ever fetched. It grows with the variety of cards played, not the number of games, and it's the one directory that quietly gets large on a busy multi-server bot. It's pure cache — safe to delete, it re-downloads. - CPU, only for rendering. Board images are composited with Pillow on the
event loop, so a heavy
!staterender briefly pauses every game in the process. On a throttled shared-CPU plan that's the thing you'd notice first.
Multi-server configuration: mtg_channel_id accepts either one legacy
channel ID for every guild or a {guild_id: channel_id} mapping, optionally
with a "*" fallback. Unlisted guilds do not inherit another guild's channel.
The practical ceiling is almost never the host. It's your LLM API spend and rate limits, which are identical wherever you run.
Two separate things grow, and they want different treatments.
The container's stdout is capped in docker-compose.yml (max-size: 10m,
max-file: 5 → a 50MB ceiling). Nothing to do.
The bot's per-game logs under logs/ are not capped, and full autoplay
matrices add up. They are plain text and compress well, so use rotation or the
nightly gzip policy below if you run batches regularly.
The simplest policy that keeps everything is a nightly job that gzips anything older than 30 days:
(crontab -l 2>/dev/null; echo "0 4 * * * find ~/discord-mtg-bot/logs -name 'game_*.log' -mtime +30 -exec gzip {} +") | crontab -Compressed archives keep old logs inexpensive while preserving every line for
zgrep. Recent logs stay uncompressed so the audit
workflow's normal grep still works on them.
Archiving whole batches compresses better and matters more than it looks.
Batch logs are highly similar to each other, so a single tar.gz per batch
beats per-file gzip (~12.7x vs ~10.3x measured), and — the bigger win — it
collapses hundreds of files into one. File count is the real cost on any
filesystem with large allocation units: on a 1MB-cluster volume, 12,829 log
files averaging 57KB occupied 13GB for 721MB of actual content, and
per-file gzip would have saved almost nothing because each compressed file
still burns a full cluster. Archiving those same logs to one tarball per batch
brought it to 429MB.
cd ~/discord-mtg-bot/logs && for p in $(ls game_*.log | sed -E 's/game_([0-9]{5}).*/�/' | sort -u); do
tar -czf "archives/batch_${p}.tar.gz" game_${p}*.log && rm game_${p}*.log
done(Verify the archive before deleting if you're scripting this unattended —
check tar -tzf entry count against the original file count first.)
Note that either policy still grows without bound, just far more slowly — usually the right trade for a personal bot, since old game logs are the raw material for debugging regressions. For a hard cap, delete instead:
(crontab -l 2>/dev/null; echo "0 4 * * * find ~/discord-mtg-bot/logs -name 'game_*.log*' -mtime +90 -delete") | crontab -Why cron rather than logrotate? logrotate is built for a handful of
continuously growing files, and most of its machinery exists to rotate a file
a process is still writing to. These are many small files that are final the
moment a game ends, so none of that applies and a find one-liner is a better
fit. Use logrotate anyway if you already run it across all your services and
want one policy everywhere — a logs/*.log glob with copytruncate works.
- Create a Discord application at https://discord.com/developers/applications
- Add a Bot user; grab the bot token (goes in
.envasDISCORD_TOKEN) - Enable these Privileged Gateway Intents on the Bot tab:
- Server Members Intent
- Message Content Intent
- Generate an OAuth2 invite URL with the
bot+applications.commandsscopes, plus permissions:Send Messages,Read Message History,Attach Files,Embed Links,Add Reactions,Use Slash Commands,Manage Threads,Create Public Threads,Send Messages in Threads. - Invite the bot to your server.
- Get your Anthropic API key from https://console.anthropic.com, put it in
.envasANTHROPIC_API_KEY. - (Optional) For autoplay batches, set
DEEPSEEK_API_KEYand/orDASHSCOPE_API_KEY. The preflight probe selects a healthy provider; without either, autoplay falls back to Claude on both sides. - Run
python bot.py.
personas/plain.json is the default — no roleplay, just Claude being friendly and direct. Switch to ressapanda.json by setting "bot_persona": "ressapanda" in config.json if you want a whimsical red-panda character. Write your own by copying either file and editing the fields described in personas/README.md.
Personas only affect the voice the bot uses in chat (during game threads). All MTG capabilities are built into the code and don't change with persona.
Roughly per usage pattern:
| Use case | ~Cost |
|---|---|
| Casual chat in game threads (Sonnet) | $0.003 per message round-trip |
| One Commander MTG game (Claude on both sides) | $0.15-$0.30 |
| One Commander autoplay game (DeepSeek or Qwen) | Varies by provider/model; see per-game stats |
| One Modern / Pauper game | Varies with turn count and provider |
!cost shows the lifetime running total; persisted in data/api_costs.json.
Provider-specific counters keep future Qwen Flash, Plus, and Max usage separate;
historical pre-split Qwen usage remains honestly labeled in the legacy aggregate.
The latest strict 160-entry regression matrix used 74 DeepSeek games, 85 Qwen games, and the cube pipeline. It consumed 42,525,805 prompt tokens and 2,740,136 completion tokens at an estimated $4.0776. Treat that as a measured mixed- provider run, not a fixed budget: rates, cache behavior, and game length vary.
Pre-1.0. The rules engine is well-exercised — the post-batch audit playbook runs independent audit passes against the 160-entry autoplay matrix to catch regressions. See ARCHITECTURE.md for the open known limitations.
PRs welcome — and adding support for a card needs no API keys and no
Discord bot. Most cards resolve from a name-keyed template table, so
contributing one is an entry in data/card_templates.json
plus python -m pytest tests -q (1,675 tests in the current public gate;
runs offline in roughly two minutes on the reference workstation). The loader
is strict, so the test suite doubles as the schema
check, and CI validates every card name against Scryfall.
Engine changes are the other path: write a failing test first, keep the
suite green, and mind the debt ratchets in tests/test_ratchets.py.
Full details, the template schema, and the PR checklist are in CONTRIBUTING.md.
MIT — see LICENSE.