A Python REST API that enforces the full Magic: The Gathering Comprehensive Rules, enabling AI agents to play complete games against each other and generate structured training data for downstream MTG model fine-tuning.
The engine acts as a rules referee — it enforces legality, resolves actions, and reports state. Strategic decisions are entirely the responsibility of the calling AI agent.
Each completed game produces four types of training data:
- Snapshots — board state + legal actions + chosen action at every priority grant
- Transcript — play-by-play annotated game log
- Rules Q&A pairs — derived from actual rule triggers during play, with CR citations
- Outcome — win/loss record for reinforcement learning
- Python 3.11+
- uv (recommended) or pip
- MongoDB (optional — training data export; engine runs without it)
- Internet access for first-time card data fetches (Scryfall API)
# Clone and enter the project
git clone <repo-url>
cd mtg_ai_engine
# Install dependencies
uv pip install fastapi uvicorn pydantic httpx pymongo pytest pytest-asyncio
# Download the MTG Comprehensive Rules (required for rules references)
curl -L "https://media.wizards.com/2025/downloads/MagicCompRules_20250404.txt" -o cr.txt
# Start the server
uvicorn mtg_engine.api.main:app --reloadThe API will be available at http://localhost:8000. Interactive docs at http://localhost:8000/docs.
PYTHONPATH=. uv run python -m pytest tests/ -vAll 2678+ tests should pass.
curl -X POST http://localhost:8000/game \
-H "Content-Type: application/json" \
-d '{
"player1_name": "Alice",
"player2_name": "Bob",
"player1_deck": ["Lightning Bolt", "Lightning Bolt", ...],
"player2_deck": ["Counterspell", "Island", ...],
"seed": 42
}'Response includes the initial GameState with game_id.
curl http://localhost:8000/game/{game_id}/legal-actionsReturns all legal actions for the current priority holder: pass, play-land, cast spells, activate abilities, declare attackers, or put triggers on the stack.
Pass priority:
curl -X POST http://localhost:8000/game/{game_id}/pass \
-H "Content-Type: application/json" \
-d '{}'Cast a spell:
curl -X POST http://localhost:8000/game/{game_id}/cast \
-H "Content-Type: application/json" \
-d '{
"card_id": "<card-uuid>",
"targets": ["<target-permanent-id>"],
"mana_payment": {"R": 1}
}'Play a land:
curl -X POST http://localhost:8000/game/{game_id}/play-land \
-H "Content-Type: application/json" \
-d '{"card_id": "<card-uuid>"}'Declare attackers:
curl -X POST http://localhost:8000/game/{game_id}/declare-attackers \
-H "Content-Type: application/json" \
-d '{
"attack_declarations": [
{"attacker_id": "<creature-id>", "defending_id": "<player-name>"}
]
}'Declare blockers:
curl -X POST http://localhost:8000/game/{game_id}/declare-blockers \
-H "Content-Type: application/json" \
-d '{
"block_declarations": [
{"blocker_id": "<creature-id>", "attacker_id": "<attacker-id>"}
]
}'Assign combat damage:
curl -X POST http://localhost:8000/game/{game_id}/assign-combat-damage \
-H "Content-Type: application/json" \
-d '{
"assignments": [
{"source_id": "<attacker-id>", "target_id": "<player-or-creature>", "damage": 3}
]
}'Make a choice (e.g. replacement effect ordering):
curl -X POST http://localhost:8000/game/{game_id}/choice \
-H "Content-Type: application/json" \
-d '{"choice_id": "<choice-id>", "selection": "<option>"}'Add "dry_run": true to any action request body to validate and preview the resulting state without committing it.
curl -X POST http://localhost:8000/game/{game_id}/cast \
-H "Content-Type: application/json" \
-d '{"card_id": "<id>", "mana_payment": {"R": 1}, "dry_run": true}'After a game ends, retrieve training data:
# Game state snapshots (JSONL)
curl http://localhost:8000/export/{game_id}/snapshots
# Play-by-play transcript
curl http://localhost:8000/export/{game_id}/transcript
# Rules Q&A pairs
curl http://localhost:8000/export/{game_id}/rules-qa
# Win/loss outcome
curl http://localhost:8000/export/{game_id}/outcomeAutomatically construct a legal, optimized deck from a card pool using strategy-aware scoring and format rules:
curl -X POST http://localhost:8000/ai/deck/build \
-H "Content-Type: application/json" \
-d '{
"cards": [
{"name": "Lightning Bolt", "mana_cost": "{R}", "type_line": "Instant", "cmc": 1.0},
{"name": "Mountain", "mana_cost": "", "type_line": "Basic Land — Mountain", "cmc": 0.0},
{"name": "Goblin Warrior", "mana_cost": "{1}{R}", "type_line": "Creature — Goblin Warrior", "cmc": 2.0}
],
"format": "standard",
"strategy": "aggro",
"seed": 42
}'Response includes main_deck (list of card entries with quantities) and optional sideboard. Supports all 8 formats (Standard, Pioneer, Modern, Legacy, Vintage, Commander, Brawl, Pauper) and strategies (aggro, control, midrange, combo). For Commander format, include "commanders": ["Card Name"] to enforce color identity filtering.
Replay completed games with full board state reconstruction from stored snapshots and transcripts:
# Get replay info (total events, turns, phases)
curl http://localhost:8000/replay/{game_id}/info
# List events (paginated)
curl "http://localhost:8000/replay/{game_id}/events?page=1&page_size=25"
# Step forward/backward through the game with board state reconstruction
curl -X POST http://localhost:8000/replay/{game_id}/step \
-H "Content-Type: application/json" \
-d '{"direction": "forward", "from_event_seq": 10}'
# Get timeline overview (events grouped by turn/phase)
curl http://localhost:8000/replay/{game_id}/timelineBoard state reconstruction uses a two-tier approach: snapshot anchors (full GameState dumps at priority grants) provide exact state, and incremental transcript event replay fills in intermediate states. All endpoints are stateless — the client controls navigation via from_event_seq.
Watch a live game in real-time via WebSocket without affecting game state:
# Connect as spectator (wscat or any WS client)
wscat -c ws://localhost:8000/ws/game/{game_id}On connect, the server sends an initial_state message with the full GameState dump. Thereafter, every transcript event (casts, resolves, damage, phase changes, etc.) is streamed as JSON messages in real time. When the game ends, a game_end notification includes winner/loser info and the reason.
The endpoint is read-only — incoming non-pong messages are silently ignored. Connections to non-existent or completed games are rejected with close code 4004 before accepting.
Message types:
| Type | Description |
|---|---|
initial_state |
Full GameState dump on connect |
cast, resolve, damage, phase_change, … |
Transcript events as they happen |
game_end |
Winner/loser + reason when game finishes |
Python example:
import asyncio, websockets
async def spectate(game_id):
uri = f"ws://localhost:8000/ws/game/{game_id}"
async with websockets.connect(uri) as ws:
while True:
msg = await ws.recv()
print(msg) # {"type": "cast", "data": {...}, ...}
asyncio.run(spectate("your-game-id"))Track persistent player statistics and ELO ratings across games via MongoDB:
# Create or update player stats (idempotent — HTTP 201 on create, 200 on update)
curl -X POST http://localhost:8000/stats/player/Alice \
-H "Content-Type: application/json" \
-d '{}'
# Get full player stats with computed total_games and win_rate
curl http://localhost:8000/stats/player/Alice
# Get leaderboard (top players by ELO descending)
curl "http://localhost:8000/leaderboard?limit=10"
# Filter leaderboard by format
curl "http://localhost:8000/leaderboard?format=commander&limit=5"
# Get per-player matchup history
curl http://localhost:8000/stats/player/Alice/matchupsStats are automatically updated when a game completes (via DELETE /game/{id}). The ELO system uses the standard formula with K=32. All endpoints return HTTP 503 if MongoDB is not configured.
The canonical agent loop:
import httpx
BASE = "http://localhost:8000"
# Create game
r = httpx.post(f"{BASE}/game", json={
"player1_name": "agent_a",
"player2_name": "agent_b",
"player1_deck": [...], # 60+ card names
"player2_deck": [...],
"seed": 1234
})
game_id = r.json()["data"]["game_id"]
while True:
# Get current state
state = httpx.get(f"{BASE}/game/{game_id}").json()["data"]
if state["players"][0]["has_lost"] or state["players"][1]["has_lost"]:
break
# Get legal actions
actions = httpx.get(f"{BASE}/game/{game_id}/legal-actions").json()["data"]["actions"]
# Pick an action (your AI logic here)
action = pick_action(state, actions)
# Execute the action
httpx.post(f"{BASE}/game/{game_id}/{action['type']}", json=action["params"])
# Export training data
snapshots = httpx.get(f"{BASE}/export/{game_id}/snapshots").text
transcript = httpx.get(f"{BASE}/export/{game_id}/transcript").json()
rules_qa = httpx.get(f"{BASE}/export/{game_id}/rules-qa").json()
outcome = httpx.get(f"{BASE}/export/{game_id}/outcome").json()
# Clean up (writes to MongoDB if configured)
httpx.delete(f"{BASE}/game/{game_id}")The repository root is mtg_ai_engine/. The Python package lives inside it at mtg_engine/ and is divided into four sub-packages plus an API layer.
mtg_ai_engine/ ← repository root
│
├── mtg_engine/ ← importable Python package
│ │
│ ├── models/ ← Pydantic v2 data models (no logic)
│ │ ├── game.py ← Core domain types: Card, Permanent,
│ │ │ PlayerState, GameState, StackObject,
│ │ │ ManaPool, CombatState, PendingTrigger
│ │ ├── stats.py ← PlayerStats, FormatRecord, MatchupRecord,
│ │ │ StatsResponse, LeaderboardEntry, MatchupResult
│ │ └── actions.py ← API request/response types: CastRequest,
│ │ DeclareAttackersRequest, LegalAction, …
│ │
│ ├── ai/ ← Deck Building AI (APP-02)
│ │ └── deck_builder.py ← Stateless deck construction: Filter → Score → Select
│ │ → Validate pipeline with strategy weights (aggro/control/
│ │ midrange/combo), CMC curve targeting, format-aware
│ │ filtering (banned lists, singleton dedup per CR 905.2
│ │ with basic land exemption, Commander color identity)
│ │
│ ├── card_data/ ← Card data retrieval and parsing
│ │ ├── scryfall.py ← ScryfallClient: fetches card JSON from the
│ │ │ Scryfall API and caches results in SQLite
│ │ │ (mtg_engine/card_data/cache.db). Rate-limited
│ │ │ to 100ms between requests.
│ │ ├── ability_parser.py ← Parses oracle text into structured ability
│ │ │ objects: TriggeredAbility, ActivatedAbility,
│ │ │ KeywordAbility, SpellEffect. Used at deck-load
│ │ │ time so the engine never re-parses mid-game.
│ │ └── deck_loader.py ← Accepts a list of card names, fetches each
│ │ from Scryfall, validates 60-card minimum,
│ │ assigns a fresh UUID per copy.
│ │
│ ├── engine/ ← Rules enforcement — one file per concern
│ │ ├── zones.py ← Zone management (CR 400-407): move_card_to_zone,
│ │ │ move_permanent_to_zone, draw_card, zone-change
│ │ │ event emitter. Tokens cease to exist on leaving
│ │ │ the battlefield (CR 704.5d).
│ │ ├── turn_manager.py ← Turn structure (CR 500-514): 13-step TURN_SEQUENCE,
│ │ │ begin_step / advance_step / pass_priority. Handles
│ │ │ untap, draw, and cleanup step side effects.
│ │ ├── mana.py ← Mana pool arithmetic: parse_mana_cost,
│ │ │ can_pay_cost, pay_cost, add_mana. Supports
│ │ │ generic (X/N), colored, and colorless symbols.
│ │ ├── stack.py ← Spell casting and stack resolution (CR 601-608):
│ │ │ timing enforcement, split-second check, target
│ │ │ validation, mana payment, resolve_top. Permanents
│ │ │ enter the battlefield; instants/sorceries resolve
│ │ │ their effect then go to the graveyard.
│ │ ├── sba.py ← State-based actions (CR 704): loops check-and-apply
│ │ │ until no SBAs fire. Covers 704.5a–q: lethal damage,
│ │ │ zero toughness, legend rule, planeswalker loyalty,
│ │ │ aura/equipment validity, token removal, poison, etc.
│ │ ├── triggers.py ← Triggered ability detection (CR 603): registers a
│ │ │ zone-change listener, matches events against ability
│ │ │ conditions, checks phase triggers (upkeep/end/combat).
│ │ │ Simultaneous triggers ordered by APNAP (CR 603.3b).
│ │ ├── layers.py ← Continuous effect layer system (CR 613): seven
│ │ │ layers (copy → control → type → color → ability →
│ │ │ P/T set → P/T modify) applied in timestamp order
│ │ │ with dependency override. CDA (characteristic-
│ │ │ defining ability) P/T handled in layer 7a.
│ │ ├── replacement.py ← Replacement and prevention effects (CR 614-616):
│ │ │ process_event intercepts GameEvents and applies
│ │ │ "instead" modifications. Handles infect (-1/-1
│ │ │ counters), shield counters, regeneration shields,
│ │ │ and damage prevention/reduction.
│ │ ├── stats.py ← Player statistics & ELO: calculate_new_elo()
│ │ │ (standard formula, K=32), update_player_stats()
│ │ │ (pure transform via model_copy with deep copy of
│ │ │ nested dicts for formats and matchups)
│ │ └── combat.py ← Full combat phase (CR 508-511): declare_attackers
│ │ (summoning sickness, vigilance, defender checks),
│ │ declare_blockers (flying/reach enforcement),
│ │ order_blockers, assign_combat_damage with trample
│ │ overflow, deathtouch 1-damage lethal, and lifelink.
│ │
│ ├── export/ ← Training data generation (one recorder per type)
│ │ ├── store.py ← GameExportStore: per-game-id dict holding all four
│ │ │ recorders. get_export_store / delete_export_store
│ │ │ provide global access without singleton coupling.
│ │ ├── snapshots.py ← SnapshotRecorder: called at every priority grant.
│ │ │ Captures compressed board state, legal action set,
│ │ │ and — once an action is taken — the chosen action
│ │ │ and who took it. Serializes to JSONL.
│ │ ├── transcript.py ← TranscriptRecorder: event-driven log of every
│ │ │ meaningful game event (phase changes, casts, resolves,
│ │ │ SBAs, zone changes, damage, choices). Each entry has
│ │ │ seq, event_type, description, turn, phase, step. Also
│ │ │ provides pub/sub listener system (register_listener /
│ │ │ unregister_listener) for real-time spectator streaming.
│ │ ├── rules_qa.py ← RulesQARecorder: 24 Q&A template functions triggered
│ │ │ by engine events (SBAs, damage, trample, layers,
│ │ │ replacement effects). Each pair includes the question,
│ │ │ answer, and the CR citation it demonstrates.
│ │ ├── outcome.py ← build_outcome: assembles the final GameOutcome record
│ │ │ (winner, loser, turn count, snapshot count, how the
│ │ │ game ended) after a player has_lost.
│ │ └── replay_engine.py ← Stateless replay (APP-03): two-tier board state
│ │ reconstruction from snapshot anchors + incremental
│ │ event replay; timeline generation; pagination helpers
│ │
│ └── api/ ← FastAPI application
│ ├── main.py ← App factory: creates FastAPI instance, mounts both
│ │ routers, exposes GET /health.
│ ├── game_manager.py ← GameManager singleton: in-memory dict of game_id →
│ │ GameState. Provides create_game (seeded RNG shuffle,
│ │ 7-card opening hands), get, update, delete, and
│ │ snapshot (deep copy for dry_run support).
│ └── routers/
│ ├── game.py ← 16 game endpoints: POST /game, GET/DELETE /game/{id},
│ │ pass, play-land, cast, activate, put-trigger,
│ │ special-action, declare-attackers, declare-blockers,
│ │ order-blockers, assign-combat-damage, legal-actions,
│ │ pending-triggers, stack, choice. All success responses
│ │ wrapped in {"data": ...}; errors return HTTP 422 with
│ │ {"error": ..., "error_code": ...}.
│ ├── export.py ← 4 export endpoints: GET /export/{id}/snapshots (JSONL),
│ │ /transcript (JSON array), /rules-qa (JSON array),
│ │ /outcome (single object).
│ ├── replay.py ← 4 replay endpoints at /replay/{game_id}/*: GET /info,
│ │ /events (paginated), POST /step (forward/backward with
│ │ board state reconstruction), GET /timeline. Stateless —
│ │ reads from export store snapshots/transcript only.
│ ├── player_stats.py ← Player stats & ELO (APP-06): POST/GET /stats/player/{name},
│ │ GET /leaderboard, GET /stats/player/{name}/matchups; async
│ │ game completion hook with atomic MongoDB $inc/$set updates;
│ │ returns HTTP 503 when MongoDB unconfigured
│ └── spectate.py ← WebSocket endpoint WS /ws/game/{game_id}: real-time
│ spectator streaming via pub/sub listener pattern on
│ TranscriptRecorder; sends initial_state, streams events,
│ game_end notification; read-only (APP-04)
│
├── tests/
│ ├── conftest.py ← Adds project root to sys.path
│ ├── rules/ ← Pure rules engine unit tests (no HTTP)
│ │ ├── test_mana.py ← Mana parsing and cost payment
│ │ ├── test_zones.py ← Zone transitions and token rules
│ │ ├── test_stack.py ← Casting timing, mana, stack resolution
│ │ ├── test_sba.py ← State-based action scenarios
│ │ ├── test_combat.py ← Attacker/blocker declaration, damage assignment
│ │ ├── test_layers.py ← Layer ordering and Humility interactions
│ │ ├── test_replacement.py ← Replacement/prevention effect scenarios
│ │ ├── test_ability_parser.py ← Oracle text parsing
│ │ ├── test_actions.py ← Action model validation
│ │ └── test_rules_interactions.py ← 50 complex multi-system interaction tests
│ └── api/ ← API integration tests
│ ├── test_api.py ← Core endpoint contract tests
│ ├── test_scryfall.py ← Scryfall cache and fetch tests
│ ├── test_export.py ← Export endpoint tests
│ ├── test_bot_games.py ← Scripted bot games (TASK-25) and concurrent
│ │ isolation with 10 simultaneous games (TASK-27)
│ ├── test_spectate_websocket.py ← WebSocket spectator streaming (APP-04): connect,
│ │ event broadcast, game-end notification, cleanup
│ └── test_performance.py ← p99 latency benchmarks: empty board, 20 permanents,
│ stack-heavy scenarios — all well under 200ms
│
├── spec.md ← Architecture, goals, and out-of-scope rules
├── requirements.md ← Numbered requirements (REQ-XXX)
├── tasks.md ← Phase-by-phase implementation task list
└── cr.txt ← MTG Comprehensive Rules (downloaded separately)
POST /game/{id}/cast
│
▼
game_manager.py ← snapshot for dry_run isolation
│
▼
engine/stack.py ← validate timing, targets, cost; move card to stack
│
├─▶ engine/mana.py ← deduct mana payment from pool
│
(after all players pass)
│
▼
engine/stack.py ← resolve_top: permanent → zones.py; spell → effect
│
├─▶ engine/sba.py ← loop SBAs until none fire
├─▶ engine/triggers.py ← queue any triggered abilities
└─▶ export/ ← record snapshot, transcript entry, rules Q&A
- One file per concern in
engine/— each rules section (stack, SBAs, layers, combat, …) is isolated so changes to one cannot silently break another. - Seeded
random.Random— all shuffle and randomness goes through a seeded instance created at game creation, never the globalrandommodule. Games are fully reproducible from the seed. - deep copy for dry runs —
game_manager.snapshot()returnscopy.deepcopy(state). Dry-run actions operate on the copy; the live state is never touched. - Export recorders are event-driven — the engine calls
recorder.record_*()methods inline; there is no post-hoc log parsing. - MongoDB is best-effort — the
_write_to_mongodb()call in the DELETE handler is wrapped intry/exceptso the engine is fully usable without a running MongoDB instance.
The engine implements the following MTG Comprehensive Rules sections:
| Area | CR Section | Notes |
|---|---|---|
| Zones | 400-407 | All 7 zones; tokens cease to exist outside battlefield |
| Turn structure | 500-514 | All phases and steps in correct sequence |
| State-based actions | 704 | Full CR 704.5a–q coverage, looping until none apply |
| Casting spells | 601-608 | Timing, targets, split-second, copy effects |
| Triggered abilities | 603 | APNAP ordering for simultaneous triggers |
| Layer system | 613 | All 7 layers with timestamp and dependency ordering |
| Replacement effects | 614-616 | Shield counters, regeneration, "instead" effects |
| Combat | 508-511 | First/double strike, trample, deathtouch, lifelink, infect |
| Keywords | 702 | Flash, haste, vigilance, flying, reach, defender, and more |
Illegal actions return HTTP 422:
{
"detail": {
"error": "Cannot cast sorcery during opponent's turn",
"error_code": "INVALID_TIMING"
}
}Unknown game IDs return HTTP 404.
| Setting | Default | Description |
|---|---|---|
| SQLite cache | mtg_engine/card_data/cache.db |
Card data cache path |
| MongoDB URI | mongodb://localhost:27017 |
Training data export target |
| MongoDB DB | mtg_training |
Database name |
| MongoDB collection | games |
Game training data collection |
| MongoDB collection | player_stats |
Player stats & ELO ratings (APP-06) |
MongoDB writes are best-effort — if unavailable, the DELETE endpoint still succeeds and export endpoints still return data.