- C++ 80%
- GDScript 12.1%
- Python 6.3%
- Shell 0.8%
- CMake 0.5%
- Other 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| cards | ||
| cmake | ||
| core | ||
| decks | ||
| drivers | ||
| packaging | ||
| research/mtgo | ||
| rules | ||
| tests | ||
| text | ||
| .gitignore | ||
| BURNDOWN.md | ||
| CMakeLists.txt | ||
| DECLINED.md | ||
| HANDOFF.md | ||
| LICENSE | ||
| M145-STALENESS-SWEEP.md | ||
| M146-STUDY.md | ||
| M150-REVEAL-STUDY.md | ||
| M155-TWO-BLOCKER-SWEEP.md | ||
| README.md | ||
| TESTING.md | ||
dirus
A Magic: The Gathering rules engine in C++17. (The source tree, binaries and
CMake project are still named mtg; dirus is the public/package name --
see packaging/arch/README.md. All rights reserved: see LICENSE.)
Every complete open-source MTG engine is on the JVM — Forge, XMage and Magarena are all Java. The two C++ projects in the space either simplify the rules (Wagic) or enforce none at all (Cockatrice). This one aims at the Comprehensive Rules in C++, with a pure core you can embed.
Status: M1b. A game shuffles, deals, mulligans and passes turns; lands are played and tapped for mana; spells are cast with targets and paid for; the stack resolves; creatures attack, block and deal combat damage; and things die to state-based actions rather than to the spell or the creature that damaged them.
Not yet: continuous effects and the layer system (CR 613), replacement effects (CR 614), triggered abilities (CR 603), and all 771 rules of keyword abilities — so no flying, no first strike, no trample. A few percent of the Comprehensive Rules by rule count, and none of the hard subsystems. Be honest with yourself about what "implemented" means.
Where things live
~/mtg/engine this repository
~/mtg/resources downloads -- rules text, card data, art. In no repository.
Downloads sit outside the repo deliberately: they are large, they come from
several sources, they are somebody else's copyright, and more than one project
will want the same copy. rules/fetch.sh and rules/check-citations.sh find
the tree by walking up from git's common directory (so they work from a worktree
too); MTG_RESOURCES overrides it.
The rules text is the source of truth
rules/fetch.sh downloads the current Comprehensive Rules; it is not committed,
because it is Wizards' copyright. Implement against the text, not against
memory. Thirteen wrong CR n.n citations were written into this codebase in its
first days — one naming a rule that had been renumbered, one naming a rule that
no longer exists, and one describing a decision the game removed years ago — and
a wrong comment becomes a wrong implementation. rules/check-citations.sh
prints every citation in the source beside the rule it names, and ctest runs
it. It only proves a rule exists: three of the worst errors named a real rule
that said something else entirely, and would have passed. Read the text.
The shape of it
A pure core with imperative shells. The core is a resumable step function:
Request advance(GameState&, const CardDatabase&, const Choice& answer);
apply(state, action) -> state would be the obvious signature and it is the wrong
one, because it assumes the engine only ever consumes decisions. Magic constantly
has to ask: who has priority, what does this target, which mode, what is X, in
what order do these three simultaneous triggers go on the stack, which replacement
effect applies first. So advance applies your answer, runs the game forward, and
stops the moment it needs another decision — returning a Request naming the player,
the reason, and every legal option.
Every consumer is then the same loop:
text protocol: loop { req = advance(st, db, choice); print(req); choice = parse(stdin) }
scenario test: loop { req = advance(st, db, choice); choice = script[req] }
AI: loop { req = advance(st, db, choice); choice = search(clone(st)) }
server: loop { req = advance(st, db, choice); choice = await socket }
One core, many shells, rules logic never duplicated. The server is a shell added later, not the foundation — tree search has to clone a position and play it out thousands of times per decision, and that has to be a copy, not an IPC round trip.
Because the engine enumerates legality, an AI driver is "pick an index", and illegal actions are unrepresentable rather than validated.
Three invariants
- The RNG seed lives inside
GameState, never in a global. Shuffles are a pure function of state, so replays are exact. - The action log is the source of truth:
state = fold(advance, initial, log). Save files, replays, bug reports, network resync and reconnect are one mechanism. - State is complete internally; players get filtered views.
view(state, player)is the single chokepoint for hidden information.
One deliberate omission
GameObject caches no current power, toughness, types or colours. They are derived
from the printed card plus the continuous effects in play (CR 613) every time they
are needed. Caching them is the most common way a homebrew engine paints itself into
a corner, and it is why the layer system gets built early rather than bolted on.
Layout
core/ libmtgcore — pure. No I/O, no globals, no threads.
types.h ids, enums, mana costs
cards.* the static card database; cards are DATA, loaded from text files
state.* GameState, Request/Option/Choice, zones
rng.* splitmix64, seeded from state
log.* action log + state hashing
engine.* new_game() / advance()
view.* hidden-information filtering
text/ structured data -> wire text, shared by every client
cards/ card data files (.cards)
decks/ deck lists
drivers/
proto/ the line-oriented text protocol on stdin/stdout
jev/ Jev decision driver (Python; legal choices only)
tests/
scenarios/ declarative rules tests (.scen)
The Jev driver connects to the table protocol, sends each seat's filtered view
and the engine's already-enumerated legal options to a Jev model — by default
the local OpenAI-compatible OpenJev server (an SSH tunnel on port 8689), or
TypeSafe's cloud Choice primitive with --backend typesafe — and writes the
typed choice back as a PICK. It does not evaluate card rules or bypass the
server's hidden-information filter. See drivers/jev/README.md for setup.
The text protocol
The primary interface, in the spirit of GNU Chess: a human can drive it, and so can a bot, with no client library.
> newgame 12345 decks/unlimited-green.deck decks/unlimited-red.deck
REQ 1 P0 mulligan-keep min 1 max 1
OPT 0 keep
OPT 1 mulligan
ENDREQ
> choose 1 0
state <player> dumps that player's filtered view, log dumps the action log,
save/load round-trip it, quit exits.
Build
cmake -S . -B build -G Ninja
cmake --build build
ctest --test-dir build
Reading a build's version
Every binary in this tree links core/version.h's build_version(), stamped
fresh from git on every build (cmake/GenerateVersion.cmake, run as a custom
target so moving HEAD between two builds with no source edit — a git checkout or git pull — is never missed). This exists because a user's
mtg-served was once built from an older commit than the checkout beside it,
nothing recorded that anywhere, and a replay's divergence took a long
investigation to trace back to stale object code rather than an engine bug.
There are four places to read it:
- A binary: every executable answers
--version, e.g.mtg-served --versionormtg-tui --version. Prints the full commit hash,-dirtyif the build had uncommitted changes to tracked files, the short hash, andgit describewhen one was available.unknownin all of those means git or.gitwas unavailable at build time (a release tarball). mtg-served's startup line (stderr) leads with the same tag:mtg-served <hash>[-dirty]: table '<name>' on port <n>, ....- A saved log (
--save,core/log.h) carriesversion <hash>[-dirty]andcards <pool-hash>header lines beside itsseed/decklines: which commit wrote the log, and a content hash of thecards/pool it played against. Both are absent on a log saved before this existed, and load fine either way. - The wire protocol (
drivers/PROTOCOL.md1.10): a client seesVERSION <hash>right after the connection banner, unprompted.
The mismatch warning. Any tool that replays a saved log (mtg-resolutions,
the proto REPL's load) compares the log's version/cards lines against
the running build's own and prints a WARNING to stderr on either
difference — never a refusal, since a divergent replay is still useful
evidence, just evidence that needs the caveat. Seeing it means either the
binary doing the replay is not the one that produced the log (rebuild with
cmake --build build), or cards/ has changed since the log was recorded
(a local edit, or a different checkout's cards). drivers/jev/jev_arena.sh
and jev_suite.sh run the identical commit-vs-checkout check once per run,
before any game is played, and write it to <out>/version.txt.
Card data
Cards are data, not code — adding one is a data edit, never a rebuild:
CARD Grizzly Bears
COST 1G
TYPE Creature
SUB Bear
PT 2/2
END
Card text is Oracle text, never the printed 1993 wording, so the 1993 cards are implemented against current rules with no legacy concepts (no mana burn, no damage-on-the-stack). From M2 a card also carries a list of abilities, each a tree of enum-tagged effect nodes; the struct grows, the principle does not change.
Checking a set
A set directory is held to an accounting invariant: every printed card in the set
appears exactly once across its .cards files — as a live CARD block, as an
# UNPROVEN block, as a # LIVE-ELSEWHERE pointer, or as a
# REFUSED <name> -- <blocker> line. Nothing missing, nothing twice, and no refusal
without a stated blocker. cards/check_cmm.py
enforces that for Commander Masters, and also compares every live block's mana cost,
type line, power/toughness and ORACLE text against the oracle extract:
python3 cards/check_cmm.py . # from the repo root
It needs the set's oracle extract, which is bulk card data and is not distributed
with this repository. Point MTG_ORACLE_DIR at the directory holding
cmm-oracle.json if you keep it somewhere other than ~/mtg/resources/cards.
Two things worth knowing before trusting a green result. The checker matches a
CARD <name> block in the file, so a card the loader later REJECTS still counts as
live here — a passing accounting run is not evidence the card works. And a .cards
parse failure aborts CardDatabase construction outright, which kills every scenario
rather than just the offending one, so after editing a .cards file run a scenario
unfiltered to confirm the pool still constructs.
What each ledger state claims
Shipping a card is a ladder of three rungs, and cards/v09/accounting.cards states
it outright: a card is live "only if EVERY clause of it is said exactly, and only if
a scenario proves it PLAYS — loading is rung two of three."
- written — every clause said exactly (the M9a White Ward rule: approximation, strictly-better and strictly-worse all disqualify)
- loads — the block parses and the pool constructs
- proved — a scenario compares its behaviour against its printed text
The ledger's vocabulary is how far up that ladder a name has got. live means
rung three and nothing less — the word is load-bearing across ~2000 blocks, and
widening it is the decay DECLINED.md was written to stop.
| marker | rung | what it asserts |
|---|---|---|
live CARD block |
3 | every clause exact, and a scenario proves it plays |
# UNPROVEN <name> -- <file>; <evidence> |
2 | it loads and it ran, and no scenario has compared it to its printed text |
UNTESTED (derived, not written) |
2 | it loads, and nothing has ever run it — weaker than UNPROVEN |
# LIVE-ELSEWHERE <name> -- <file> |
— | a pointer; another set holds the block. The database is keyed by name, so a second block with a loaded name is a load error |
# REFUSED <name> -- <blocker>[, ...] |
— | measured, not encodable today. Means "not yet" |
DECLINED.md |
— | "not ever" (excluded by design) or "sized and not scheduled" (declined for now) |
UNTESTED and UNPROVEN exist because rung two previously had no name at all, so a
card that loads but carries no scenario had nowhere honest to sit. Both name the
missing evidence rather than the work done — deliberately unlike the other
markers, because the absent evidence is the whole content of the row. They are two
stops on one road:
UNTESTED --(play)--> UNPROVEN --(scenario)--> live
nothing has the harness ran it, a scenario judged it
ever run it nothing judged it against printed text
DERIVE WHAT IS COMPUTABLE; RECORD ONLY JUDGEMENTS. A fact written in two
places drifts, and the written copy wins by being the more visible one — which is
the duplication DECLINED.md exists to undo. So the states split:
- Derived, never written down —
live(a block with behaviour lines that some scenario names) andUNTESTED(the same, that no scenario names). A written# UNTESTEDrow would go stale the moment somebody added a scenario naming that card. - Recorded, because nothing can compute them —
# REFUSED(a judgement carrying a measured blocker),# LIVE-ELSEWHERE(a pointer),# UNPROVEN(an assertion that a harness run happened), andDECLINED.md's two categories.
An UNPROVEN row therefore names the evidence that does exist, the way a
# REFUSED row names its blocker:
# UNPROVEN Chameleon Colossus -- cards/v13/creatures.cards;
# mtg-probe 40 games clean, jev-arena 12 games, no flags.
Two tools read the derived half:
drivers/probe/uncovered.py— theUNTESTEDset, with the reasoning for what counts as implemented. A card carrying an# UNPROVENrow (in its set'saccounting.cards, or beside its block where the set keeps no ledger) has play evidence instead and is listed separately. Measured today: 6 of 2049 blocks. The original 30 were all played by the firstdrivers/jev/jev_suite.shrun and now carry UNPROVEN rows; the 6 are three cards the pre-fix classifier misread as stubs and the three cards shipped after that run. UNTESTED is the harness's cheapest target set, because moving a card toUNPROVENneeds no engine work at all, only a deck containing it and a run — the suite builds that deck, points the strong seat at it and scores the run (drivers/jev/untested_play.py).drivers/probe/ledger.py— every state for every set in one table, which is the only place the whole ledger is visible at once. It decomposes theblocksnumber an accounting header calls "live" into what it is actually made of.
An UNPROVEN row is not a weaker live, it is a different claim. Play evidence
finds engine defects — drivers/probe audits the client contract across tens of
thousands of requests, and the arena's loop guard catches positions that stall. None
of it can find card-text infidelity, where the engine does exactly what the card
file said and the card file describes the wrong card. STATIC costmod any: +3 loads
clean, plays clean, breaks no invariant, and is not Trinisphere;
movezone battlefield chosen:sub:Goblin picks a Goblin already in play and is not
Goblin Lackey. cards/v09/accounting.cards calls this "the loads-but-plays-wrong
shape the Iron Rule exists to refuse", and only rung three rules it out.
The Iron Rule binds live only. A block below rung three — UNTESTED or
UNPROVEN — may be a best-effort encoding, because nothing yet claims it plays as
printed. What it may not be is silently inexact: every clause the lines do not say
exactly carries a # INEXACT: <clause> -- <why> comment above the block, so the
scenario that would promote the card knows what it has to fix first. A card with an
# INEXACT note cannot become live until the note is gone.
Promotion is one-way and retraction is cheap: when a scenario is written, the row
becomes a live block; if the scenario disagrees with the card, the block comes out.
The nine accounting.cards headers that spell "in three shapes" name four once a
set has its first UNPROVEN row, and their COUNTS line grows a term.
Target pool: Unlimited first, then Kaladesh. Unlimited has no planeswalkers and no modern mechanics, so it exercises the core loop gently; Kaladesh then forces the hard modern machinery — type-changing Vehicles, a player-level Energy resource, loyalty abilities and ETB modal choices.
Style
Procedural and data-oriented. Plain structs, free functions, enums instead of strings. No class hierarchies, no ABCs, no virtuals.