The card pipeline¶
How a Forge card script becomes behaviour in a game, and how gorge measures which cards it can play: the contributor's how-to view, for someone about to change a card, a parser or a primitive. The runtime internals (the IR types in depth, how the engine dispatches primitives during play) are in the card pipeline architecture page.
Function names and files re-checked on develop @ 513a2a0c3,
2026-10-21.
flowchart LR
fetch["forgec fetch<br/>.cards/cardsfolder/"] --> parse["cards.ParseBytes<br/>script → Card"]
parse --> link["Card.Link<br/>SVars resolved, keywords expanded"]
link --> intr["Face.ApplyIntrinsics"]
intr --> cache[".cards/ir-*.gob.gz"]
cache --> reg["cards.Registry"]
reg --> prims["Face.Primitives<br/>api: trig: stat: repl: kw: count:"]
prims --> cov["Registry.Coverage<br/>vs effects.Supported()"]
reg --> game["rules.Engine<br/>effects.Resolve dispatches by name"]
1. Fetch¶
make fetch-cards builds bin/forgec and runs forgec fetch
(engine/cards/fetch.go). It does a sparse checkout of Forge's
cardsfolder and token scripts from the fork
adams-shaun/forge at the commit
pinned by FORGE_REF in the Makefile, into the gitignored .cards/, and
writes .cards/cards.lock recording the ref, commit and license. When the
lock changes, the target clears Go's test cache, because the corpus lives
outside the module and go test cannot see that it changed.
The scripts are GPL-3.0 and gorge is Apache-2.0. Nothing under .cards/ is
ever committed, and engine/cards/boundary_test.go fails the build if a
script is tracked.
2. Parse and link¶
engine/cards is the compiler. It depends on nothing else in the module.
cards.ParseBytes(path, src)(engine/cards/parse.go) turns one script into a*cards.Cardwith one*cards.Faceper printed face, each holding itsAbilities,Triggers,Statics,Repls,Keywords,SVarsand rawParamsmaps (engine/cards/ir.go). Problems come back as non-fatalcards.Diags. The whole corpus produces only a handful.Card.Link()(engine/cards/link.go) resolves the SVar references (Execute$,SubAbility$, …) into sub-ability trees, and first runsexpandKeywords(engine/cards/keywords.go). That rewrites a keyword the engine implements through ordinary machinery into the lines Forge itself would expand it to. For example,kw_prowess.goturns Prowess into aSpellCasttrigger with aPumpeffect. Each expander is oneengine/cards/kw_*.gofile registered withregisterKeyword.Face.ApplyIntrinsics()(engine/cards/intrinsic.go) adds what the rules grant rather than the script prints, such as a basic land's mana ability.
cards.CompileDir does all three for every script, in sorted path order, so
the output is byte-identical run to run.
3. The IR cache¶
make compile-cards (forgec compile) saves the compiled registry to
.cards/ir-<fingerprint>.gob.gz (cards.CachePath, engine/cards/open.go).
The fingerprint is cards.CompilerFingerprint, a hash of the engine/cards
sources that affect the IR (engine/cards/fingerprint.go). So a branch that
changes the parser writes and reads its own cache file, and never reads one
built by a different parser. At most a few sibling caches are kept.
Programs and tests open the corpus with cards.OpenCorpus(dir) or
cards.SharedCorpus(dir). Both load the cache, or recompile from
cardsfolder when it is missing or older than cards.lock. Tests use
testutil.CorpusRegistry(t), which opens the shared registry once per test
binary and skips when .cards/ is absent.
4. The registry¶
cards.Registry (engine/cards/registry.go) holds every card and token.
Registry.Lookup(name) finds a card by name, and Registry.Tokens the token
scripts. Decks (engine/deck) resolve card names against it, and
rules.New(rules.Config{Decks: ...}) deals the resulting *cards.Cards
into a game.
5. Primitives and their implementations¶
Face.Primitives() (engine/cards/primitive.go) lists every engine symbol
a face needs, prefixed by kind, after keyword expansion and following
sub-ability chains and SVar bodies: api:DealDamage, trig:ChangesZone,
stat:Continuous, repl:Moved, kw:Flying. Card.ValueHeads()
(engine/cards/value_heads.go) adds the count: heads that its value
SVars read.
The engine side registers what it implements:
effects.Register("DealDamage", fn)in aninit()inengine/effectsinstalls anapi:effect, aneffects.Effectfunctionfunc(h Host, c *Ctx, sa *cards.SA). At resolution,effects.Resolvewalks an ability and itsSubAbility$chain and calls each node's registered function. The function reads the game and proposes events througheffects.Host, never touchingrulesdirectly.effects.RegisterNonAPI("kw:Flying", ...)declares keyword, trigger, static and replacement primitives. Those are implemented inengine/rules(combat, layers, the trigger matcher, replacement handling); the registration only records that the build supports them.count:heads are evaluated inengine/effects/count*.goand listed inmodelledValueHeads(engine/effects/value_heads.go).
effects.Supported() returns the union, in the same prefixed form.
6. Coverage¶
Registry.Unsupported(card, effects.Supported()) (engine/cards/validate.go)
is a card's missing primitives. An empty result means the card is
playable. Registry.Coverage aggregates that over the corpus.
make report # cards: 34074 playable: 32246 (94.6%) on 2026-10-21, then the top missing primitives
make coverage # the same as markdown tables in .coverage/ (gitignored), by type, colour, mana value
"Playable" means every primitive is registered. It does not mean every parameter is honoured or every interaction is right. Three things narrow that gap:
- the param census (
TestEveryRepoDeckParamsAreRead), which tracks parameters no code reads; - the oracle scenarios, which check behaviour against the printed text;
- the compliance oracles, which compare gorge with XMage and Forge.
The scenarios and the oracles live in the public companion repository gorge-compliance, which measures a pinned gorge revision; the results are summarised on the sets page.
The per-set figures are on Support by set.