Architecture¶
This section explains how gorge works inside: how a match is represented, how card text becomes behaviour, how the rules core applies the Comprehensive Rules (CR), and how the server drives all of it for players and bots. It is written for Go developers who want to read or change the engine. If you only want to run a game, start at Getting started; if you want to fix your first card, see Your first card fix.
Figures on this page were measured on develop @ 513a2a0c3, 2026-10-21.
Every number names the command that produced it, so you can re-measure.
The idea in one paragraph¶
gorge does not hand-write cards. It compiles Forge's card scripts (fetched at
build time, never committed, because they are GPL-3.0) into an intermediate
representation (IR), and implements the small set of named primitives that
IR refers to: effects such as api:DealDamage, trigger modes such as
trig:ChangesZone, keywords such as kw:Flying. A card is playable when
every primitive it uses is implemented. Every change to the game is an
event folded into state by one function, events.Apply, and appended to a
SHA-256 hash-chained log. With a seeded RNG and no clock reads, a match is a
pure function of its starting configuration and the answers its players gave,
so replay, history and undo, bug reproduction and search all reuse the same
machinery. Players never touch the engine: each seat gets a redacted
view and answers decisions drawn from a closed set of kinds.
Two kernels¶
gorge contains one rules engine. A second, much smaller kernel, dirty-dirac, lives in a private companion module and plays the same game much faster, for the cards its generator can lower. They are designed for different jobs, and the smaller one is held to the larger one.
| gorge | dirty-dirac | |
|---|---|---|
| Card pool | every Forge script; 32,246 of 34,074 (94.6 %) are playable (make report, 2026-10-21) |
16,044 of 34,072 corpus cards lower (47.1 %); FDN and BLB are verified in lockstep (per set) |
| Players | up to 8 per hosted table (server/host/table.go); the golden games cover 2, 4, 6 and 8 |
2 |
| Game state | state.Game: an arena of state.Object values plus maps and slices |
a single fixed-size struct |
| Mutation | events proposed by the engine, folded by events.Apply, hash-chained |
Apply(action) on the value |
| Copying a game | Engine.Clone (deep copy, shared log prefix) |
plain assignment |
| Comparing two games | the chain head: the same head means the same history | its Projection (fixed arrays): equal positions project ==; two States in the same position may differ in bookkeeping |
| Who checks it | its own golden replays, the compliance oracles | gorge, in lockstep |
Why two? A general engine that handles every card in the corpus needs open-ended data: arbitrary card text, variable-length zones, continuous effects that come and go. That shape is what makes it correct for everything and also what makes it slower to copy and step. A kernel that keeps every card's behaviour in a read-only generated table can lay the whole game out as a fixed-size value and step it cheaply, which is what large-scale self-play and tree search want. Keeping gorge as the oracle means the fast kernel never becomes a second source of rules truth. See dirty-dirac for its design: its fixed-size State, the Step/Apply loop, how cards are compiled for it and the lockstep oracle that holds it to gorge.
The tier stack¶
The repository is grouped by audience, and each group imports only the groups
below it in this picture. The engine imports nothing outside engine/, and
the root go.mod has no require lines at all.
go list ./<group>/...; line counts are
wc -l over non-test .go files (develop @ 513a2a0c3,
2026-10-21). An upper box may import a lower one, never the reverse.Inside engine/ the import graph is strictly layered. This is the real graph
(go list -f '{{.Imports}}' per package, in-repo edges only, transitive edges
dropped for readability). Arrows point from a package to the package it
imports:
flowchart BT
cards["cards<br/>(IR, compiler)"]
state["state<br/>(Game, Object, zones)"] --> cards
decision["decision<br/>(Decision, Option, Intent)"] --> state
events["events<br/>(Event, Apply, Log)"] --> decision
cost["rules/cost<br/>(cost vocabulary)"] --> state
params["effects/params"] --> decision
effects["effects<br/>(api: primitives, filters, counts)"] --> events
effects --> cost
effects --> params
chars["rules/chars<br/>(CR 613 layers)"] --> effects
trig["rules/trigmatch"] --> effects
combat["rules/combat"] --> effects
pay["rules/pay"] --> chars
resolve["rules/resolve<br/>(resolution kernel)"] --> events
scriptfacts["rules/scriptfacts"] --> cost
deck["deck"] --> cards
rules["rules<br/>(Engine)"] --> pay
rules --> trig
rules --> combat
rules --> resolve
rules --> scriptfacts
rules --> deck
view["view<br/>(per-seat projection)"] --> effects
view --> deck
seat["seat<br/>(the Seat contract)"] --> view
replay["replay"] --> rules
Three things in that graph carry the design:
effectsnever importsrules. Card effects reach the engine only through theeffects.Hostinterface, which*rules.Engineimplements. An effect that needs somethingHostdoes not offer is doing rules work in the wrong place.- The
rulessub-packages read the game through narrowBoardinterfaces and never hold a*rules.Engine. They compute;rulesorchestrates and emits.internal/archtestpins each package's allowed imports. viewandseatnever importrules. A seat is handed aview.View, never a*state.Gameor an*Engine, so a bot sees exactly what a remote client would.
The Packages page lists every package with its size, and Invariants lists the rules above with the test that enforces each one.
How one decision flows¶
Everything the engine does happens between two decisions. This is the loop the server, the test harness and every bot driver run:
sequenceDiagram
autonumber
participant D as Driver (host / test / sim)
participant E as rules.Engine
participant L as events.Log
participant G as state.Game
participant S as Seat
D->>E: Advance()
loop until a decision is needed or the game ends
E->>E: step(): turn structure, SBAs, triggers, resolution
E->>L: append Event (hash-chained)
E->>G: events.Apply(Event)
end
D->>E: Pending()
E-->>D: decision.Decision (every legal Option)
D->>S: Decide(view.View, Decision)
S-->>D: decision.Intent (option indices)
D->>E: Submit(Intent)
E->>E: validate against the posed Decision, or reject
E->>L: record Intent
Each step of that loop has its own page.
The gorge kernel, page by page¶
| Page | What it covers |
|---|---|
| Events and replay | events.Apply as the only mutation path, the 144 event kinds, the hash chain and golden heads, replay by re-execution, clones and the resolution kernel's checkpoints |
| Game loop and decisions | turns, steps and priority; the 13 decision kinds; how a decision is posed, answered and validated; mulligans, the starting player and concede; the Seat contract |
| Cards and primitives | Forge script to IR, keyword expansion, the IR cache and its fingerprint, how primitives are registered and how an unsupported one is caught; SVars, costs and payment |
| The rules core | casting (CR 601), the stack, state-based actions, triggers, replacement effects, the layer system (CR 613), combat, zones and object identity |
| Views and hidden information | what each seat may see, how the view is projected and redacted, determinism and the RNG, why the server is authoritative |
| The server tier | how server/host and gorged drive engines: tables, matches, seats, persistence and event fan-out |
Further reading¶
- Packages: every package with its purpose, size and status.
- Invariants: the design rules and what enforces each.
- ADR 0001: repository layout.
- The design notes these pages draw on live in
docs/design/; they are working documents and are often ahead of or behind the code, so treat the code as the authority.