Skip to content

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 (this repository) The full rules engine whole Forge corpus: 34,074 card scripts up to 8 seats, Constructed and Commander state.Game: an object arena with pointers, slices and maps; clone = deep copy event-sourced: 144 event kinds, hash-chained log, replay by re-execution 13 decision kinds, redacted per-seat views Job: correctness for every card, hosting real games, the reference dirty-dirac (companion module) A 2-player kernel, still growing 47% of the corpus lowers; FDN, BLB verified exactly two players State is one fixed-size value: no pointers, maps, slices or strings; clone = assignment Step / Apply state machine over a generated table of card operations no event log; equal positions project == Job: raw speed for self-play and search on its verified pools lockstep same seed, same options, equal projections

gorge is the authority. dirty-dirac imports nothing from gorge at run time; its oracle package plays the same decks, seed and answers through both kernels and requires the same canonical option list at every decision and the same canonical board projection after every action.
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.

cmd/ gorged forgec repro mtgsim pkgmap ... 18 packages, 11k lines: thin mains; nothing imports a command server/ gorged host host/httpapi protocol divergence manabrew spellbench/... 30 packages, 42k lines: tables, sessions, SSE bots/ botpolicy heuristic registry hosted bots, search seats 22 packages, 51k lines: every bot a table can seat engine/ 19 packages, 294k lines (559k lines of tests) seat view replay rules (+ 7 sub-packages) effects (+ params) events decision deck state cards (+ oracletext)

Package counts from 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:

  1. effects never imports rules. Card effects reach the engine only through the effects.Host interface, which *rules.Engine implements. An effect that needs something Host does not offer is doing rules work in the wrong place.
  2. The rules sub-packages read the game through narrow Board interfaces and never hold a *rules.Engine. They compute; rules orchestrates and emits. internal/archtest pins each package's allowed imports.
  3. view and seat never import rules. A seat is handed a view.View, never a *state.Game or 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.