Magic: The Gathering rules engine + online play platform, in Kotlin
See the code
Before the oil. Before the corruption. There was only perfection.
An unofficial Magic: The Gathering rules engine and online play platform. Not affiliated with, endorsed, sponsored, or specifically approved by Wizards of the Coast LLC.
Play now at magic.wingedsheep.com · Join our Discord
Argentum Engine is a modular MTG implementation composed of:
Live set completion tracker → magic.wingedsheep.com/set-completion — per-set coverage, and every card in a set marked implemented, missing, or not planned. Missing cards that Argentum Assay already reads end to end are flagged ⚡ Assay-ready: they need no new engine vocabulary, so they're the cheapest ones to pick up. The same page carries the Assay explorer as a second tab.
Distinct implemented cards, day by day since the project began. Regenerated by scripts/card-progress-graph
(an interactive version lives at card-implementation-progress.html).
# Initialize environment
just init
# Start Redis (optional, for session persistence)
just docker-up
# Start the game server
just server
# In another terminal, start the web client
just client
The client runs at http://localhost:5173 and connects to the server at http://localhost:8080.
Build & Test
| Command | Description |
|---|---|
just build | Build the entire project |
just test | Run all tests |
just test-rules | Run rules-engine tests only |
just test-server | Run game-server tests only |
just test-gym | Run gym tests only |
just test-gym-server | Run gym-server HTTP tests only |
just test-gym-trainer | Run gym-trainer (MCTS + self-play) tests only |
just clean | Clean build artifacts |
Development
| Command | Description |
|---|---|
just server | Start the game server (port 8080) |
just gym-server | Start the gym HTTP server (port 8081) — for RL/MCTS training |
just client | Start the web client dev server |
just client-install | Install web client dependencies |
Environment
| Command | Description |
|---|---|
just init | Create .env from .env.example |
just docker-up | Start local Docker services (Redis) |
just docker-down | Stop local Docker services |
just docker-logs | View Docker logs |
Copy .env.example to .env to configure:
| Variable | Default | Description |
|---|---|---|
CACHE_REDIS_ENABLED | false | Enable Redis for session persistence |
REDIS_HOST | localhost | Redis host |
REDIS_PORT | 6379 | Redis port |
GAME_AI_ENABLED | true | Enable AI opponent |
GAME_AI_MODE | engine | AI mode: engine (built-in), llm (requires API key), jev (local profile), or the mode of a registered AiControllerProvider. An unrecognised value fails startup. |
OPENROUTER_API_KEY | Shared OpenRouter API key for llm and jev modes | |
GAME_AI_MODEL | google/gemini-3.1-flash-lite-preview | LLM model (only for llm mode) |
Host booster drafts with up to 8 players. Create a draft lobby, invite friends, and build your deck from freshly opened packs.
Play Magic against friends with fully implemented MTG rules. The engine automatically handles the stack, priority, combat, triggers, and state-based actions—so you can focus on the game.
Play multiplayer Free-for-All games for 2–6 players (CR 806). From a lobby, switch the mode toggle from Tournament to Free-for-All to seat every player in a single shared game instead of a 2-player bracket — any pool-building format (sealed, draft, custom decks) composes with it.
Eliminated players are removed from the table while the remaining seats play on: conceding (or losing) takes you out, your opponents' boards keep going, and the opponent rail shows a live tombstone for anyone knocked out. Standings follow elimination order, and a "Play Again" ready loop lets the pod rematch without rebuilding the lobby.
Play against an AI opponent using the built-in engine, a chat model, or Jev (local development).
The built-in rules-engine AI runs locally with no external dependencies. It uses multi-ply game tree search with alpha-beta pruning, a composite board evaluator, and a specialized combat advisor.
Works out of the box — no API key or configuration needed:
# AI is enabled by default in engine mode
GAME_AI_ENABLED=true
GAME_AI_MODE=engine
Start the server and client, then click "Play vs AI" on the main menu.
Alternatively, you can use an LLM-powered AI that sends game state to an OpenAI-compatible API for decisions.
Setup:
.env file:GAME_AI_ENABLED=true
GAME_AI_MODE=llm
OPENROUTER_API_KEY=sk-or-v1-your-key-here
# GAME_AI_MODEL=google/gemini-3.1-flash-lite-preview # optional, change model
The LLM AI receives the same masked game state as a human player and responds through the standard game protocol. When the LLM fails to respond or returns an unparseable answer, it falls back to heuristic play.
Jev makes structured choices through OpenRouter's Decisions API.
Add these to your .env, then run just dev (which activates the local Spring profile):
GAME_AI_ENABLED=true
GAME_AI_MODE=jev
OPENROUTER_API_KEY=your-openrouter-key
Choose Just me, pick a deck source, and start playing (or use Add AI in a lobby); the opponent's name ends in (Jev).
No Ollama or TypeSafe account is needed. The default model is typesafe/jev-1.13.
Jev shares OPENROUTER_API_KEY with OpenRouter chat models and has its own endpoint, so local Ollama defaults cannot redirect its requests.
Do not put Jev in GAME_AI_MODEL: that setting uses the chat-completions protocol.
Jev chooses plays, targets, X, modes, attackers, blockers, damage assignments, card selections, ordering, piles, additional/alternative payments, mulligans, bottom cards, and draft picks. Ordinary mana payment can use the engine solver when Jev selects auto-pay. Sealed deck construction uses the existing heuristic builder. All gameplay choices use typed options from server metadata; the engine previews the completed action and supplies validation feedback for one correction. The engine AI is used only after a failed request, exhausted budget, or invalid corrected move; server logs explicitly report fallback. Jev receives the player's masked view, permitted decision metadata and known decklist. The provider rebuilds a masked view even when local debug mode reveals both hands on screen; debug event logs are excluded from requests.
| Variable | Default | Description |
|---|---|---|
GAME_AI_JEV_MODEL | typesafe/jev-1.13 | Decisions model |
GAME_AI_JEV_ENDPOINT | https://openrouter.ai/api/alpha/decisions | Decisions endpoint (also useful for a local mock) |
GAME_AI_JEV_TIMEOUT_MS | 30000 | Total budget per decision, including follow-up choices and correction; max 120000 |
The adapter caps each decision at 64 API calls and each request at 28 KB to stay conservatively
within the model context window. Choices with more than 255 options use hierarchical selection;
they are not silently truncated. Very large positions can hit the budget and fall back. This is an
experimental opponent, not a claim of competitive playing strength. Calls are billed by OpenRouter.
The jev provider is registered only under the local profile; other profiles reject that mode.
| Variable | Default | Description |
|---|---|---|
GAME_AI_ENABLED | true | Enable the AI opponent feature |
GAME_AI_MODE | engine | engine — built-in AI (no API key needed); llm — LLM-powered AI; jev — local Jev opponent; or the mode of an AiControllerProvider bean supplied by another build. An unrecognised value fails startup. |
GAME_AI_BASE_URL | https://openrouter.ai/api/v1 | LLM API endpoint (LLM mode only) |
GAME_AI_API_KEY | API key for LLM provider (LLM mode only) | |
GAME_AI_MODEL | google/gemini-3.1-flash-lite-preview | LLM model name (LLM mode only) |
GAME_AI_DECKBUILDING_MODEL | Separate model for AI deckbuilding; falls back to GAME_AI_MODEL if not set (LLM mode only) |
argentum-engine/
├── mtg-sdk/ # Shared contract — DSLs, data models, primitives
├── mtg-sets/ # Aggregator — re-exports the whole card corpus, plus the set catalog
│ ├── core/ # CardDiscovery, token art, cards belonging to no set
│ └── <era>/ # Card definitions, one module per release-year range (`just where <SET>`)
│ └── tests/ # That era's card scenario tests
├── rules-engine/ # Core MTG rules engine (no server dependencies)
├── gym/ # RL/MCTS environment wrapper (GameEnvironment, MultiEnvService)
├── gym-server/ # Spring Boot HTTP transport for gym (Python trainers)
├── gym-trainer/ # JVM-side MCTS + self-play SPI for AlphaZero-style projects
├── game-server/ # Spring Boot game server & matchmaking
├── web-client/ # React/TypeScript browser UI
└── e2e-scenarios/ # Playwright end-to-end tests
The rules engine is a standalone library with no server dependencies. It models the complete game state immutably and exposes a pure functional API:
Cards are defined as pure data using a Kotlin DSL — no card-specific logic in the engine.
For agent research and reinforcement-learning training, the engine also ships as a Gymnasium-style environment. A trainer drives many games in parallel against a stable JSON contract, without touching the game server or the browser UI.
gymA transport-agnostic Kotlin library that wraps the rules engine in a stateful reset / step / observe / legalActions API with MCTS-friendly affordances:
GameEnvironment.fork() returns a sibling env pointing at the same GameState. Because state is never mutated in place, tree expansion is free.MultiEnvService.snapshot() returns an opaque handle; restore() swaps the env back to that state in O(1). Designed to grow a byte-blob variant for cross-process MCTS workers.MultiEnvService.stepBatch() fans out per-env steps across a work-stealing pool, so vectorised rollouts run in parallel.PendingDecisions (scry, targets, search, distribute…); simple decisions fold into the numeric action-ID space, complex ones expose a structured response channel.TrainingObservation has a schemaHash so Python clients fail fast on contract drift; every observation carries a stateDigest usable as an MCTS transposition-table key.revealAll is available for debug scripts.gym-serverA thin Spring Boot shell that exposes MultiEnvService over HTTP so a Python agent can drive the engine without a JVM embedding:
| Method & path | Maps to |
|---|---|
POST /envs | MultiEnvService.create |
GET /envs | listEnvs |
DELETE /envs | dispose |
GET /envs/{id} | observe |
POST /envs/{id}/reset | reset |
POST /envs/{id}/step | step |
POST /envs/step-batch | stepBatch |
POST /envs/{id}/decision | submitDecision (structured DecisionResponse) |
POST /envs/{id}/fork?count=N | fork |
POST /envs/{id}/snapshot | snapshot |
POST /envs/{id}/restore | restore |
GET /schema-hash | observation-schema version (fail-fast on drift) |
GET /health | liveness probe |
JSON is handled end-to-end by kotlinx.serialization — sealed hierarchies (DeckSpec, DecisionResponse) round-trip via @SerialName discriminators without extra adapter code.
Start the server with just gym-server (port 8081, so it doesn't collide with the game server on 8080). Running it does not require the web client or Redis.
Deliberately out of scope for the current scaffold: authentication, env-lifetime TTLs, byte-based snapshots, metrics. Bind to localhost until you add auth.
gym-trainerFor AlphaZero-shaped projects that want tree search in the JVM and only use Python as a stateless NN inference server (MageZero-style): a small SPI + a PUCT MCTS + a self-play loop.
StateFeaturizer<T>, ActionFeaturizer (multi-head first-class), Evaluator<T>, SelfPlaySink<T>.AlphaZeroSearch with PUCT, optional Dirichlet root noise, using GameEnvironment.fork() for O(1) tree expansion.SelfPlayLoop with temperature schedule that labels training rows with the final outcome before flushing.BoardEvaluator, a structural featurizer, a hash-bucket action featurizer, a JSONL sink, and a random structured-decision resolver.RemoteHttpEvaluator POSTs features + legal slots and parses {priors, value}. Swap codec by subclassing.See gym-trainer/README.md for the full design write-up and a 30-line hello-world.
Contributions are very welcome, and I'm genuinely grateful for every PR. To keep the project healthy, there's one rule above all others:
No slop PRs. A reviewer's time is the scarcest resource here. Please open a PR only when you've built and tested it yourself, kept the change focused, and made it faithful to the actual Magic rules (no shortcuts). A polished small PR is worth far more than a large unverified one.
Using AI to implement cards is encouraged — most of the card catalog is data, and the project ships agent skills that automate the workflow correctly (Scryfall lookup, oracle errata, set registration, scenario tests, reprint handling):
add-card <CARD_NAME> <SET_CODE> — implement a specific card.add-random-card <SET_CODE> — pick a random unimplemented card from a set and implement it.If a card adds a new UI / UX element, test it manually before opening the PR — AI can build the
flow, but a human needs to confirm it actually feels right in the client. Run the app (just server
just client), set up the situation (the generate-scenario skill can inject a board state), and
click through the decision yourself.mtgish-tooling
The :mtgish-tooling module maps the mtgish oracle-IR corpus —
a wonderful project by i5jb that parses every card's oracle text into a
structured intermediate representation — onto our SDK. Huge thanks to its creator: that clean IR is
what makes this whole pipeline possible. It's a predictive, non-authoritative analyzer (never a
card loader): it triages the backlog and drafts the easy cards as a head start.
just coverage-dashboard # interactive TUI: browse sets, drill into a card's generated cardDef + missing caps
just coverage --set TMP # implemented / free-to-add / blocked, plus which feature unlocks the most cards
just coverage-generate --set TMP # draft .kt for the auto-generable cards -> mtgish-tooling/generated/<set>/
just coverage-verify --set POR # compile the drafts + diff their capabilities against the golden snapshot
Generated .kt are drafts in a staging dir — they must compile, get a passing scenario test, and
be human-reviewed before moving into a set's cards/ package. Use it to find which feature unlocks
the most cards, or for a blank-page head start; keep using add-card for the real implementation. See
mtgish-tooling/README.md for the full reference.
:oracle-assay is a first-party Oracle-text grammar: Scryfall JSON in,
mtg-sdk models out. Its trick is that every rule is written in both directions — a rule that can
parse a phrase must also print it — so the whole corpus checks the grammar without a human reading
the output. If print(parse(text)) == text, the reading was right.
That makes it an auditor before it is a generator, and the thing it audits is us. A card Assay
cannot read is a card whose meaning the SDK has no way to say, so the ranked list of declines is a
continuously-updated report of what the card vocabulary is missing, ordered by how many cards each gap
blocks. Coverage is the lagging indicator: 100% would mean mtg-sdk can express every card in Magic.
just assay parse "Serra Angel" # the normalized lines, and the SDK model each one parses to
just assay explain "Wall of Omens" # the same, with a caret on the token a decline died on
just assay-gate # the touchstone over all 34,882 cards; exits 1 on a bug
just assay-report --rank tail # the SDK gap report, ranked by what each family would unlock
just assay-differential # Assay's readings vs. the hand-written cards — the sharper gate
just assay-explore # all of it in a browser, against the grammar on your classpath
just assay-bake # re-bless the per-card verdict ledger the coverage page reads
Two gates keep it honest. The touchstone proves a reading round-trips; the differential compares
Assay's reading of a card against the hand-written cardDef we already ship, which is the only thing
that catches a parse that round-trips byte-perfectly while meaning something else. It has found real
bugs in hand-written cards — the outcome worth the most.
It is deliberately not a card loader, and won't become one: a human-authored cardDef with a
passing scenario test stays the only ground truth. The one carve-out is the Scenario Builder's
dev-gated sandbox, where you can paste a custom card and play it if — and only if — Assay reads every
line of it.
The explorer (just assay-explore, or the Assay Explorer tab on the set completion page) is the
readable version of all of the above: the ranked declines with the cards behind each one, every card's
reading beside its printed text, the wired grammar, the differential, and a box for text that was
never printed. It runs against the grammar on the classpath rather than a snapshot, so a rule you just
edited is one restart from being re-measured.
See oracle-assay/README.md for the design, the verdict table, and what the
gates have found so far.
When bringing up an entire set, this flow has proven much faster than implementing cards one at a time (it's how Invasion was done):
add-feature skill, which encodes these principles end to end.add-card skill to implement a handful
of cards, and have each open a PR when its batch is done.review-changes <PR_URL> on it — this checks for elegance and
correctness and keeps the engine/SDK clean. (Card-only PRs with no engine changes don't need it.):mtgish-tooling generator can predict and
draft — a capability entry in the bridge (coverage/bridge/) plus a rendering handler in the
emitter (coverage/emitter/*Handlers.kt). This has wider benefits than the one card: the tooling
maps the mtgish IR corpus across every set, so one bridge/emitter entry typically unlocks coverage
and auto-draft for many more cards that share the mechanic. Confirm with
just coverage-verify --set <SET> that the cards you just implemented now classify as
coverable/AUTO. (The add-feature and add-card skills both prompt for this step.)A set can also finish with a card or two deliberately left out — Antiquities' Bronze Tablet needs the
ante zone, Arabian Nights' Shahrazad needs a whole subgame. Add those to
coverage/card-exclusions.json with a reason (required) and re-run
scripts/gen-set-totals: they drop out of the denominator and show as "not planned" on the
set completion tracker, so completion means
everything we intend to build is built. It's a policy list, not a backlog — a card that's merely
hard, or waiting on a feature we do plan to build, stays a normal gap.
AGENTS.md and docs/architecture-principles.md
first — they describe the load-bearing rules (immutability, projected state, events-not-mutations,
server-authoritative client) that PRs are reviewed against.docs/card-sdk-language-reference.md in the same
change whenever you add or change anything in the SDK..txt is too large to fetch
into context — download it and grep locally.just build (simple changes) or just test (new effects/engine changes) and confirm green
before opening the PR.Questions or ideas? Join the Discord.
Argentum was a plane of mathematical perfection, created by the planeswalker Karn. Every angle intentional, every law absolute. It was governed by rules so elegant they seemed inevitable.
That's what a rules engine should be.
Kotlin
93.5%
TypeScript
5.5%
Magic: The Gathering rules engine + online play platform, in Kotlin
See the code
Before the oil. Before the corruption. There was only perfection.
An unofficial Magic: The Gathering rules engine and online play platform. Not affiliated with, endorsed, sponsored, or specifically approved by Wizards of the Coast LLC.
Play now at magic.wingedsheep.com · Join our Discord
Argentum Engine is a modular MTG implementation composed of:
Live set completion tracker → magic.wingedsheep.com/set-completion — per-set coverage, and every card in a set marked implemented, missing, or not planned. Missing cards that Argentum Assay already reads end to end are flagged ⚡ Assay-ready: they need no new engine vocabulary, so they're the cheapest ones to pick up. The same page carries the Assay explorer as a second tab.
Distinct implemented cards, day by day since the project began. Regenerated by scripts/card-progress-graph
(an interactive version lives at card-implementation-progress.html).
# Initialize environment
just init
# Start Redis (optional, for session persistence)
just docker-up
# Start the game server
just server
# In another terminal, start the web client
just client
The client runs at http://localhost:5173 and connects to the server at http://localhost:8080.
Build & Test
| Command | Description |
|---|---|
just build | Build the entire project |
just test | Run all tests |
just test-rules | Run rules-engine tests only |
just test-server | Run game-server tests only |
just test-gym | Run gym tests only |
just test-gym-server | Run gym-server HTTP tests only |
just test-gym-trainer | Run gym-trainer (MCTS + self-play) tests only |
just clean | Clean build artifacts |
Development
| Command | Description |
|---|---|
just server | Start the game server (port 8080) |
just gym-server | Start the gym HTTP server (port 8081) — for RL/MCTS training |
just client | Start the web client dev server |
just client-install | Install web client dependencies |
Environment
| Command | Description |
|---|---|
just init | Create .env from .env.example |
just docker-up | Start local Docker services (Redis) |
just docker-down | Stop local Docker services |
just docker-logs | View Docker logs |
Copy .env.example to .env to configure:
| Variable | Default | Description |
|---|---|---|
CACHE_REDIS_ENABLED | false | Enable Redis for session persistence |
REDIS_HOST | localhost | Redis host |
REDIS_PORT | 6379 | Redis port |
GAME_AI_ENABLED | true | Enable AI opponent |
GAME_AI_MODE | engine | AI mode: engine (built-in), llm (requires API key), jev (local profile), or the mode of a registered AiControllerProvider. An unrecognised value fails startup. |
OPENROUTER_API_KEY | Shared OpenRouter API key for llm and jev modes | |
GAME_AI_MODEL | google/gemini-3.1-flash-lite-preview | LLM model (only for llm mode) |
Host booster drafts with up to 8 players. Create a draft lobby, invite friends, and build your deck from freshly opened packs.
Play Magic against friends with fully implemented MTG rules. The engine automatically handles the stack, priority, combat, triggers, and state-based actions—so you can focus on the game.
Play multiplayer Free-for-All games for 2–6 players (CR 806). From a lobby, switch the mode toggle from Tournament to Free-for-All to seat every player in a single shared game instead of a 2-player bracket — any pool-building format (sealed, draft, custom decks) composes with it.
Eliminated players are removed from the table while the remaining seats play on: conceding (or losing) takes you out, your opponents' boards keep going, and the opponent rail shows a live tombstone for anyone knocked out. Standings follow elimination order, and a "Play Again" ready loop lets the pod rematch without rebuilding the lobby.
Play against an AI opponent using the built-in engine, a chat model, or Jev (local development).
The built-in rules-engine AI runs locally with no external dependencies. It uses multi-ply game tree search with alpha-beta pruning, a composite board evaluator, and a specialized combat advisor.
Works out of the box — no API key or configuration needed:
# AI is enabled by default in engine mode
GAME_AI_ENABLED=true
GAME_AI_MODE=engine
Start the server and client, then click "Play vs AI" on the main menu.
Alternatively, you can use an LLM-powered AI that sends game state to an OpenAI-compatible API for decisions.
Setup:
.env file:GAME_AI_ENABLED=true
GAME_AI_MODE=llm
OPENROUTER_API_KEY=sk-or-v1-your-key-here
# GAME_AI_MODEL=google/gemini-3.1-flash-lite-preview # optional, change model
The LLM AI receives the same masked game state as a human player and responds through the standard game protocol. When the LLM fails to respond or returns an unparseable answer, it falls back to heuristic play.
Jev makes structured choices through OpenRouter's Decisions API.
Add these to your .env, then run just dev (which activates the local Spring profile):
GAME_AI_ENABLED=true
GAME_AI_MODE=jev
OPENROUTER_API_KEY=your-openrouter-key
Choose Just me, pick a deck source, and start playing (or use Add AI in a lobby); the opponent's name ends in (Jev).
No Ollama or TypeSafe account is needed. The default model is typesafe/jev-1.13.
Jev shares OPENROUTER_API_KEY with OpenRouter chat models and has its own endpoint, so local Ollama defaults cannot redirect its requests.
Do not put Jev in GAME_AI_MODEL: that setting uses the chat-completions protocol.
Jev chooses plays, targets, X, modes, attackers, blockers, damage assignments, card selections, ordering, piles, additional/alternative payments, mulligans, bottom cards, and draft picks. Ordinary mana payment can use the engine solver when Jev selects auto-pay. Sealed deck construction uses the existing heuristic builder. All gameplay choices use typed options from server metadata; the engine previews the completed action and supplies validation feedback for one correction. The engine AI is used only after a failed request, exhausted budget, or invalid corrected move; server logs explicitly report fallback. Jev receives the player's masked view, permitted decision metadata and known decklist. The provider rebuilds a masked view even when local debug mode reveals both hands on screen; debug event logs are excluded from requests.
| Variable | Default | Description |
|---|---|---|
GAME_AI_JEV_MODEL | typesafe/jev-1.13 | Decisions model |
GAME_AI_JEV_ENDPOINT | https://openrouter.ai/api/alpha/decisions | Decisions endpoint (also useful for a local mock) |
GAME_AI_JEV_TIMEOUT_MS | 30000 | Total budget per decision, including follow-up choices and correction; max 120000 |
The adapter caps each decision at 64 API calls and each request at 28 KB to stay conservatively
within the model context window. Choices with more than 255 options use hierarchical selection;
they are not silently truncated. Very large positions can hit the budget and fall back. This is an
experimental opponent, not a claim of competitive playing strength. Calls are billed by OpenRouter.
The jev provider is registered only under the local profile; other profiles reject that mode.
| Variable | Default | Description |
|---|---|---|
GAME_AI_ENABLED | true | Enable the AI opponent feature |
GAME_AI_MODE | engine | engine — built-in AI (no API key needed); llm — LLM-powered AI; jev — local Jev opponent; or the mode of an AiControllerProvider bean supplied by another build. An unrecognised value fails startup. |
GAME_AI_BASE_URL | https://openrouter.ai/api/v1 | LLM API endpoint (LLM mode only) |
GAME_AI_API_KEY | API key for LLM provider (LLM mode only) | |
GAME_AI_MODEL | google/gemini-3.1-flash-lite-preview | LLM model name (LLM mode only) |
GAME_AI_DECKBUILDING_MODEL | Separate model for AI deckbuilding; falls back to GAME_AI_MODEL if not set (LLM mode only) |
argentum-engine/
├── mtg-sdk/ # Shared contract — DSLs, data models, primitives
├── mtg-sets/ # Aggregator — re-exports the whole card corpus, plus the set catalog
│ ├── core/ # CardDiscovery, token art, cards belonging to no set
│ └── <era>/ # Card definitions, one module per release-year range (`just where <SET>`)
│ └── tests/ # That era's card scenario tests
├── rules-engine/ # Core MTG rules engine (no server dependencies)
├── gym/ # RL/MCTS environment wrapper (GameEnvironment, MultiEnvService)
├── gym-server/ # Spring Boot HTTP transport for gym (Python trainers)
├── gym-trainer/ # JVM-side MCTS + self-play SPI for AlphaZero-style projects
├── game-server/ # Spring Boot game server & matchmaking
├── web-client/ # React/TypeScript browser UI
└── e2e-scenarios/ # Playwright end-to-end tests
The rules engine is a standalone library with no server dependencies. It models the complete game state immutably and exposes a pure functional API:
Cards are defined as pure data using a Kotlin DSL — no card-specific logic in the engine.
For agent research and reinforcement-learning training, the engine also ships as a Gymnasium-style environment. A trainer drives many games in parallel against a stable JSON contract, without touching the game server or the browser UI.
gymA transport-agnostic Kotlin library that wraps the rules engine in a stateful reset / step / observe / legalActions API with MCTS-friendly affordances:
GameEnvironment.fork() returns a sibling env pointing at the same GameState. Because state is never mutated in place, tree expansion is free.MultiEnvService.snapshot() returns an opaque handle; restore() swaps the env back to that state in O(1). Designed to grow a byte-blob variant for cross-process MCTS workers.MultiEnvService.stepBatch() fans out per-env steps across a work-stealing pool, so vectorised rollouts run in parallel.PendingDecisions (scry, targets, search, distribute…); simple decisions fold into the numeric action-ID space, complex ones expose a structured response channel.TrainingObservation has a schemaHash so Python clients fail fast on contract drift; every observation carries a stateDigest usable as an MCTS transposition-table key.revealAll is available for debug scripts.gym-serverA thin Spring Boot shell that exposes MultiEnvService over HTTP so a Python agent can drive the engine without a JVM embedding:
| Method & path | Maps to |
|---|---|
POST /envs | MultiEnvService.create |
GET /envs | listEnvs |
DELETE /envs | dispose |
GET /envs/{id} | observe |
POST /envs/{id}/reset | reset |
POST /envs/{id}/step | step |
POST /envs/step-batch | stepBatch |
POST /envs/{id}/decision | submitDecision (structured DecisionResponse) |
POST /envs/{id}/fork?count=N | fork |
POST /envs/{id}/snapshot | snapshot |
POST /envs/{id}/restore | restore |
GET /schema-hash | observation-schema version (fail-fast on drift) |
GET /health | liveness probe |
JSON is handled end-to-end by kotlinx.serialization — sealed hierarchies (DeckSpec, DecisionResponse) round-trip via @SerialName discriminators without extra adapter code.
Start the server with just gym-server (port 8081, so it doesn't collide with the game server on 8080). Running it does not require the web client or Redis.
Deliberately out of scope for the current scaffold: authentication, env-lifetime TTLs, byte-based snapshots, metrics. Bind to localhost until you add auth.
gym-trainerFor AlphaZero-shaped projects that want tree search in the JVM and only use Python as a stateless NN inference server (MageZero-style): a small SPI + a PUCT MCTS + a self-play loop.
StateFeaturizer<T>, ActionFeaturizer (multi-head first-class), Evaluator<T>, SelfPlaySink<T>.AlphaZeroSearch with PUCT, optional Dirichlet root noise, using GameEnvironment.fork() for O(1) tree expansion.SelfPlayLoop with temperature schedule that labels training rows with the final outcome before flushing.BoardEvaluator, a structural featurizer, a hash-bucket action featurizer, a JSONL sink, and a random structured-decision resolver.RemoteHttpEvaluator POSTs features + legal slots and parses {priors, value}. Swap codec by subclassing.See gym-trainer/README.md for the full design write-up and a 30-line hello-world.
Contributions are very welcome, and I'm genuinely grateful for every PR. To keep the project healthy, there's one rule above all others:
No slop PRs. A reviewer's time is the scarcest resource here. Please open a PR only when you've built and tested it yourself, kept the change focused, and made it faithful to the actual Magic rules (no shortcuts). A polished small PR is worth far more than a large unverified one.
Using AI to implement cards is encouraged — most of the card catalog is data, and the project ships agent skills that automate the workflow correctly (Scryfall lookup, oracle errata, set registration, scenario tests, reprint handling):
add-card <CARD_NAME> <SET_CODE> — implement a specific card.add-random-card <SET_CODE> — pick a random unimplemented card from a set and implement it.If a card adds a new UI / UX element, test it manually before opening the PR — AI can build the
flow, but a human needs to confirm it actually feels right in the client. Run the app (just server
just client), set up the situation (the generate-scenario skill can inject a board state), and
click through the decision yourself.mtgish-tooling
The :mtgish-tooling module maps the mtgish oracle-IR corpus —
a wonderful project by i5jb that parses every card's oracle text into a
structured intermediate representation — onto our SDK. Huge thanks to its creator: that clean IR is
what makes this whole pipeline possible. It's a predictive, non-authoritative analyzer (never a
card loader): it triages the backlog and drafts the easy cards as a head start.
just coverage-dashboard # interactive TUI: browse sets, drill into a card's generated cardDef + missing caps
just coverage --set TMP # implemented / free-to-add / blocked, plus which feature unlocks the most cards
just coverage-generate --set TMP # draft .kt for the auto-generable cards -> mtgish-tooling/generated/<set>/
just coverage-verify --set POR # compile the drafts + diff their capabilities against the golden snapshot
Generated .kt are drafts in a staging dir — they must compile, get a passing scenario test, and
be human-reviewed before moving into a set's cards/ package. Use it to find which feature unlocks
the most cards, or for a blank-page head start; keep using add-card for the real implementation. See
mtgish-tooling/README.md for the full reference.
:oracle-assay is a first-party Oracle-text grammar: Scryfall JSON in,
mtg-sdk models out. Its trick is that every rule is written in both directions — a rule that can
parse a phrase must also print it — so the whole corpus checks the grammar without a human reading
the output. If print(parse(text)) == text, the reading was right.
That makes it an auditor before it is a generator, and the thing it audits is us. A card Assay
cannot read is a card whose meaning the SDK has no way to say, so the ranked list of declines is a
continuously-updated report of what the card vocabulary is missing, ordered by how many cards each gap
blocks. Coverage is the lagging indicator: 100% would mean mtg-sdk can express every card in Magic.
just assay parse "Serra Angel" # the normalized lines, and the SDK model each one parses to
just assay explain "Wall of Omens" # the same, with a caret on the token a decline died on
just assay-gate # the touchstone over all 34,882 cards; exits 1 on a bug
just assay-report --rank tail # the SDK gap report, ranked by what each family would unlock
just assay-differential # Assay's readings vs. the hand-written cards — the sharper gate
just assay-explore # all of it in a browser, against the grammar on your classpath
just assay-bake # re-bless the per-card verdict ledger the coverage page reads
Two gates keep it honest. The touchstone proves a reading round-trips; the differential compares
Assay's reading of a card against the hand-written cardDef we already ship, which is the only thing
that catches a parse that round-trips byte-perfectly while meaning something else. It has found real
bugs in hand-written cards — the outcome worth the most.
It is deliberately not a card loader, and won't become one: a human-authored cardDef with a
passing scenario test stays the only ground truth. The one carve-out is the Scenario Builder's
dev-gated sandbox, where you can paste a custom card and play it if — and only if — Assay reads every
line of it.
The explorer (just assay-explore, or the Assay Explorer tab on the set completion page) is the
readable version of all of the above: the ranked declines with the cards behind each one, every card's
reading beside its printed text, the wired grammar, the differential, and a box for text that was
never printed. It runs against the grammar on the classpath rather than a snapshot, so a rule you just
edited is one restart from being re-measured.
See oracle-assay/README.md for the design, the verdict table, and what the
gates have found so far.
When bringing up an entire set, this flow has proven much faster than implementing cards one at a time (it's how Invasion was done):
add-feature skill, which encodes these principles end to end.add-card skill to implement a handful
of cards, and have each open a PR when its batch is done.review-changes <PR_URL> on it — this checks for elegance and
correctness and keeps the engine/SDK clean. (Card-only PRs with no engine changes don't need it.):mtgish-tooling generator can predict and
draft — a capability entry in the bridge (coverage/bridge/) plus a rendering handler in the
emitter (coverage/emitter/*Handlers.kt). This has wider benefits than the one card: the tooling
maps the mtgish IR corpus across every set, so one bridge/emitter entry typically unlocks coverage
and auto-draft for many more cards that share the mechanic. Confirm with
just coverage-verify --set <SET> that the cards you just implemented now classify as
coverable/AUTO. (The add-feature and add-card skills both prompt for this step.)A set can also finish with a card or two deliberately left out — Antiquities' Bronze Tablet needs the
ante zone, Arabian Nights' Shahrazad needs a whole subgame. Add those to
coverage/card-exclusions.json with a reason (required) and re-run
scripts/gen-set-totals: they drop out of the denominator and show as "not planned" on the
set completion tracker, so completion means
everything we intend to build is built. It's a policy list, not a backlog — a card that's merely
hard, or waiting on a feature we do plan to build, stays a normal gap.
AGENTS.md and docs/architecture-principles.md
first — they describe the load-bearing rules (immutability, projected state, events-not-mutations,
server-authoritative client) that PRs are reviewed against.docs/card-sdk-language-reference.md in the same
change whenever you add or change anything in the SDK..txt is too large to fetch
into context — download it and grep locally.just build (simple changes) or just test (new effects/engine changes) and confirm green
before opening the PR.Questions or ideas? Join the Discord.
Argentum was a plane of mathematical perfection, created by the planeswalker Karn. Every angle intentional, every law absolute. It was governed by rules so elegant they seemed inevitable.
That's what a rules engine should be.
Kotlin
93.5%
TypeScript
5.5%