A Magic: The Gathering judge built as a deterministic game state machine with semantic RAG over the Comprehensive Rules, IPG, MTR, JAR and Scryfall rulings. Optionally synthesises rulings through Claude Code (subscription) — no paid API key required from contributors.
Reference deployment: https://magic.mago.team (the tecnomancy community instance). You can run your own the same way — the stack is self-hostable on any Linux box with Docker.
- What it does
- Architecture
- Data flow
- Quick start
- Ingestion pipeline
- Claude-backed synthesis (optional)
- Workflow & tests
- Production
- Contributing
- Design notes
- License
- Rules Q&A —
/judge/ask. Ask anything, get a cited ruling.- Retrieval-only mode: concatenates top-k rule chunks from hybrid FTS5 + sqlite-vec retrieval. Works offline, zero LLM.
- Semantic mode: the same retrieval, but the chunks + resolved cards +
retrieval metadata are routed through
claude-proxy→ Claude Code → your subscription. Stateless, one round-trip. - The rendered answer is not plain text — citations like
[CR 603.3b]become cyan pills with hover popovers showing the retrieved rule chunk, card names become gold pills linking to Scryfall with oracle text on hover, and jargon (APNAP, LIFO, stack, priority, ETB, …) surfaces a short gloss on first mention. Keyboard-accessible, screen-reader-aware.
- Live arbiter —
/judge/live/<matchId>. Drives a match through a formal xstate machine. Illegal actions are rejected with the rule citation before they mutate state. - Replay analyzer —
/judge/replay. Paste a log, see illegal moves with explanations.
flowchart LR
subgraph browser [Browser]
UI[Next.js pages<br/>/judge/ask · /matches · /cards]
end
subgraph docker [Docker stack]
subgraph magic [magic container :8085]
api[Next.js API routes<br/>/api/ask · /api/matches · ...]
gsm[xstate game<br/>state machine]
rag[RAG pipeline<br/>chunks_fts + chunks_vec]
end
subgraph embedder [embedder container :8086]
emb[MiniLM<br/>all-MiniLM-L6-v2<br/>384-d, L2-norm]
end
dbrules[(data/rules/chunks.db<br/>sqlite + sqlite-vec)]
dbcards[(data/cards/oracle.db<br/>sqlite + sqlite-vec)]
end
subgraph host [Host (optional)]
proxy[claude-proxy FastAPI<br/>docker bridge :5099]
cli[claude -p<br/>subscription auth]
end
UI -->|HTTPS| api
api --> gsm
api --> rag
rag -->|embed query| emb
rag --> dbrules
api --> dbcards
api -.semantic mode.-> proxy
proxy --> cli
- Next.js 15 (magic container) — SSR pages + API routes. xstate game
engine under
lib/gamestate/. RAG underlib/rag.ts. - embedder (Python FastAPI) —
sentence-transformers/all-MiniLM-L6-v2, 384-d, L2-normalised. One shared instance for ingestion and query time. - sqlite-vec + FTS5 —
chunks.db(Comprehensive Rules) andoracle.db(Scryfall bulk) each carry both a vector index (vec0) and a full-text index (fts5). Retrieval fuses them via Reciprocal Rank Fusion. - claude-proxy (optional) — host-side FastAPI that shells out to
claude -p. Binds to the Docker bridge IP only; never exposed publicly.
sequenceDiagram
autonumber
participant U as User
participant N as Next.js /api/ask
participant R as Rules DB<br/>(sqlite-vec + FTS5)
participant C as Cards DB<br/>(sqlite-vec + FTS5)
participant E as Embedder
participant P as claude-proxy
participant K as claude -p
U->>N: POST {question}
N->>E: embed(question)
E-->>N: 384-d vector
par Rules retrieval
N->>R: FTS5 BM25 top-pool
N->>R: vec0 MATCH top-pool
R-->>N: chunks + distances + ranks
and Card resolution
N->>C: lexical name lookup (n-grams)
N->>C: vec0 MATCH on question embedding
C-->>N: resolved cards + distances
end
N->>N: RRF fuse chunks<br/>dedupe cards
N->>N: build prompt<br/>(question + meta + chunks + cards)
N->>P: POST /ask {prompt, system}
P->>K: spawn claude -p
K-->>P: answer text
P-->>N: {answer}
N-->>U: {answer, mode, citations, cards_used, retrieval_meta, hits}
When CLAUDE_PROXY_URL is unset or the proxy times out, step 9 is skipped
and the route returns mode: "retrieval-fallback" with the concatenated
top-k chunks as answer.
flowchart TD
cr[Comprehensive Rules<br/>CR.txt] --> chunker[lib/rules/chunker]
chunker --> chunks[NormalizedChunk[]<br/>id, title, body, tags]
chunks --> embed1[embedBatch<br/>→ embedder :8086]
embed1 --> upsert1[upsertChunks<br/>→ chunks + chunks_fts + chunks_vec]
upsert1 --> rulesdb[(data/rules/chunks.db)]
scry[Scryfall oracle bulk<br/>~37k cards] --> import[importOracle]
import --> cards[cards table<br/>+ cards_fts]
cards --> embed2[embedBatch<br/>(name + type + oracle_text)]
embed2 --> upsert2[upsertCardVector<br/>→ cards_vec + cards_vec_map]
upsert2 --> cardsdb[(data/cards/oracle.db)]
style rulesdb fill:#1b1b1b,stroke:#0ff
style cardsdb fill:#1b1b1b,stroke:#0ff
One-time commands:
docker compose exec magic pnpm ingest:rules # ~2 min
docker compose exec magic pnpm ingest:cards # ~2 min (downloads Scryfall bulk)
docker compose exec magic pnpm ingest:cards-vec # ~20 min (embeds all 37k cards)stateDiagram-v2
[*] --> beginning
beginning --> main_one: end untap/upkeep/draw
main_one --> combat: declare combat
main_one --> main_one: cast spell / activate
combat --> declare_attackers
declare_attackers --> declare_blockers
declare_blockers --> combat_damage
combat_damage --> end_of_combat
end_of_combat --> main_two
main_two --> end_step: pass
end_step --> cleanup
cleanup --> beginning: next turn
cleanup --> [*]: game ends
Every transition is guarded by a pure predicate (lib/gamestate/guards.ts).
Illegal transitions are rejected with a CR citation before the store
mutates. The retrieval layer never validates game state.
Everything runs in containers. No local Node or Python needed.
git clone https://github.com/tecnomancy/magic.git
cd magic
docker compose up -d --build
curl http://localhost:8085/api/health
# {"status":"ok","rules_chunks":0,"cards":0,...}docker compose exec magic pnpm ingest:rulesdocker compose exec magic pnpm ingest:cards
docker compose exec magic pnpm ingest:cards-vecAfter these three steps /api/health should show rules_chunks: ~3800,
cards: ~37000.
4. (Optional) Enable Claude-backed synthesis — see next section.
curl -s -X POST http://localhost:8085/api/ask \
-H "Content-Type: application/json" \
-d '{"question":"What is haste?"}' | jq .Or open http://localhost:8085/judge/ask.
The semantic path uses your own Claude Code subscription. There is no product-level paid API billing. Contributors who don't want to enable this still get retrieval-only answers.
# 1. Install Claude Code & log in with your subscription
# https://docs.claude.com/claude-code
claude /login
# 2. Start the proxy (binds to docker bridge only — never public)
python3 -m uvicorn server:app \
--host 172.17.0.1 --port 5099 \
--app-dir services/claude-proxy &
curl http://172.17.0.1:5099/health
# {"status":"ok","service":"claude-proxy"}The Next.js app reads CLAUDE_PROXY_URL which defaults to
http://host.docker.internal:5099 in docker-compose.yml. extra_hosts
already maps host.docker.internal:host-gateway, so the container reaches
the proxy out of the box.
If CLAUDE_PROXY_URL is empty or the proxy is unreachable or it
returns a non-2xx or it times out (default 240 s), /api/ask returns
{ "mode": "retrieval-fallback", "answer": "<concatenated chunks>", ... }No error to the client, no 5xx to monitor. The UI shows the fallback answer and the user can retry.
Spec-driven + TDD. See specs/SPEC.md and specs/WORKFLOW.md.
SPEC -> RED -> GREEN -> REFACTOR -> REVIEW
No spec = no code. No test = no impl. No CI green = no merge.
flowchart LR
spec[specs/features/*.spec.md] --> red[tests/<br/>failing]
red --> green[lib/ + app/<br/>min impl]
green --> refactor[clean diff]
refactor --> review[PR review]
review --> merge[main]
merge --> deploy[your deployment]
Run the suite:
docker compose exec magic pnpm test # vitest
docker compose exec magic pnpm test:coverage
docker compose exec magic pnpm lint # tsc + eslintCoverage gates live in vitest.config.ts (see SPEC.md for per-area
thresholds).
docker compose -f docker-compose.prod.yml up -d --buildThe production compose file binds the Next.js container to
127.0.0.1:8085 so you can front it with whatever reverse proxy you
prefer (nginx, Caddy, Traefik, Cloudflare Tunnel, ...) to terminate
TLS and map to your own domain.
flowchart LR
u[User] -->|https://your-domain| tls[Reverse proxy<br/>TLS terminator<br/>nginx · caddy · traefik · ...]
tls -->|proxy_pass| prod[magic container :8085]
prod --> emb[embedder container :8086]
prod --> rulesdb[(chunks.db)]
prod --> cardsdb[(oracle.db)]
- Open an issue describing the bug or feature. Reference a spec file (or
propose one) in
specs/. - Fork, branch from
develop. - Write the spec. Then the failing tests. Then the minimum impl.
- PR with a clear description; CI must be green.
We follow the Spec Symphony workflow. Details in specs/WORKFLOW.md.
magic/
├─ app/ # Next.js pages + API routes
├─ lib/
│ ├─ gamestate/ # xstate machine + rule engine
│ ├─ rules/ # CR chunker, DB handles
│ ├─ rag.ts # hybrid retrieval
│ ├─ embed.ts # embedder client
│ ├─ cards/ # Scryfall lookup + vector search
│ └─ semantic/ # synth, claude-synth, card-extract
├─ embedder/ # Python FastAPI with MiniLM
├─ services/
│ └─ claude-proxy/ # host-side Claude Code wrapper
├─ scripts/ # ingest-rules, ingest-cards, ingest-card-vectors
├─ tests/ # vitest suites
└─ specs/
├─ SPEC.md
├─ WORKFLOW.md
├─ adrs/ # architecture decisions
└─ features/ # per-feature specs
- Deterministic rules engine (ADR-0003). The xstate machine is the source of truth for game legality, never the LLM.
- Local embeddings (ADR-0002).
all-MiniLM-L6-v2is fast, free, runs anywhere, 384-d, well studied for rules-text similarity. - Subscription-only LLM (ADR-0007 + ADR-0010). No product-level paid API billing. Contributors without a Claude subscription keep the retrieval-only mode.
- Commander first-class (ADR-0008). Deck validation + CMDR zone handling live in the same engine; not retrofitted.
- WotC Fan Content Policy (ADR-0006 + NOTICE.md). All MTG names, rules text, mana symbols, trademarks are property of Wizards of the Coast LLC. This project is not affiliated with or endorsed by Wizards of the Coast.
Code: MIT (see LICENSE). MTG content under the WotC Fan Content
Policy (see NOTICE.md). The embedder container pulls the MiniLM model
under its upstream license.