Writing a native Go bot¶
This page walks a Go developer through a complete bot policy for gorge: the
interface it implements, registering it with options, playing it against the
default bot in-process, testing it with the harness gorge runs on its own
policies, and serving it from a gorged binary. The code is in
examples/bots/go,
and every command below was run on 2026-10-21 at 513a2a0c3.
A native seat is the fastest way to play (tens of microseconds per decision, engine included) and the only one that can search, because it lives in the same process as the engine.
The interface¶
A bot is a seat.Seat
(engine/seat/seat.go):
type Seat interface {
Decide(ctx context.Context, v view.View, d decision.Decision) (decision.Intent, error)
}
v is the seat's redacted view: its own hand and library contents (never
the order), the public board, counts for every hidden zone. d lists every
legal option; the answer is an Intent{Seq, Player, Choices} naming option
indices, and d.Validate(in) tells you before you return whether the engine
will accept it. A bot never errors in normal play: when it has nothing better
to say it should delegate.
Optional interfaces a seat can add, all detected by type assertion:
| Interface | Package | What it buys |
|---|---|---|
seat.PaymentPlanConsumer (WantsPaymentActions() bool) |
engine/seat |
On a table with automatic mana payment, the decision carries the engine's payment plans, so the bot casts "this spell, paid this way" instead of tapping lands one decision at a time. Every registered bot answers true or forwards what it wraps. |
heuristic.BoardSeat (DecideBoard) |
bots/heuristic |
The cheaper botpolicy.Board instead of a projected view. |
bots.EnvSeat (WantsEnv, DecideEnv) |
bots |
For a search seat: the host builds an honest root, a redeal of the hidden cards from the seat's own observations, at the decisions the seat asks for. The policy must declare Info.Env. |
bots.MatchSeat / bots.MatchEnd |
bots |
Lifecycle hooks: match context before the first decision, the result after the last. |
bots.RefusalAnswerer |
bots |
A second attempt when the engine refuses an answer, before the host's own fallbacks. |
bots.BudgetedSeat |
bots |
Declares a per-decision wall-clock budget; the host arms it as the ctx deadline. |
What a native seat is trusted with
A native seat runs inside the server, so it is trusted code. The plain
Decide path hands it only a View. But bots.MatchSeat.AttachMatch
receives MatchContext.Engine, the live engine, to be read, never
written; nothing in the type system stops a seat that holds it from
reading hidden cards. Use only the View (or an honest root through
EnvSeat) in a policy that will play people.
A complete policy¶
The example policy allin is the default heuristic bot with one change: it
attacks with every creature of at least min-power power, every combat. It
shows the three things every native policy does: answer from the View,
delegate what it does not specialise in, and register itself with its
options.
const Name = "allin"
func init() {
bots.Register(bots.Entry{
Info: bots.Info{
Name: Name,
Label: "All-in (example)",
Description: "The heuristic bot, but it attacks with every creature of at least min-power power, every combat.",
Tier: bots.Experimental,
Formats: []string{"constructed", "commander"},
Options: []bots.OptionSpec{{
Key: "min-power", Type: "int", Default: "1",
Help: "attack only with creatures of at least this power",
}},
},
New: New,
})
}
func New(o bots.Options) (seat.Seat, error) {
b := heuristic.NewBot(o.Seed)
if o.AutoPayMana {
b.EnableAutoPayMana()
}
return &Seat{fallback: b, minPower: int32(o.Int("min-power"))}, nil
}
func (s *Seat) WantsPaymentActions() bool { return s.fallback.WantsPaymentActions() }
func (s *Seat) Decide(ctx context.Context, v view.View, d decision.Decision) (decision.Intent, error) {
if d.Kind != decision.KAttackers {
return s.fallback.Decide(ctx, v, d)
}
power := map[state.ObjID]int32{}
for _, p := range v.Players {
for _, c := range p.Battlefield {
power[c.ID] = c.Power
}
}
in := decision.Intent{Seq: d.Seq, Player: d.Player}
attacking := map[state.ObjID]bool{} // one defender per creature
groups := map[string]bool{} // at most one option per Group
for _, o := range d.Options {
if attacking[o.Obj] || (o.Group != "" && groups[o.Group]) {
continue
}
if !o.Required && power[o.Obj] < s.minPower {
continue
}
in.Choices = append(in.Choices, o.Index)
attacking[o.Obj] = true
if o.Group != "" {
groups[o.Group] = true
}
}
if d.Validate(in) != nil { // a whole-declaration rule we did not model
return s.fallback.Decide(ctx, v, d)
}
return in, nil
}
Points worth copying:
- An attackers decision has one option per (creature, defender) pair.
With a planeswalker or battle on the other side, one creature has several
options; declare at most one per creature.
Option.Requiredmarks a creature that must attack (goad, "attacks each combat"). Validatebefore returning. It runs the same wire rules the host applies, so a seat finds out it was wrong here rather than in a refusal.- Deterministic. Everything a policy does must be a pure function of its
seed and its inputs: seeded
math/rand/v2only, no wall clock, nomapiteration order reaching a choice. (The maps above are only looked up.)
The registry entry¶
bots.Register panics at init on a malformed entry, so a typo fails the
build's first test rather than a server start. The fields that matter:
| Field | Meaning |
|---|---|
Name |
the registry key: lower case, digits and dashes |
Tier |
bots.Production or bots.Experimental |
Formats, MaxSeats |
tables the policy accepts (MaxSeats 0 = no bound) |
Env, ClosedLists, Search |
an Env policy builds search over every seat's real decklist and is never seated against a human; ClosedLists marks one that closes opponents' lists and may be; Search lets the host bound concurrent searches |
NeedsAutoPay |
the policy has no manual-tap mode; a table without payment plans refuses it |
Caretaker |
the policy the host builds to answer for a disconnected human |
Strength, Cost |
measurement metadata GET /api/bot-policies publishes |
Options |
the keys the factory reads (next section) |
Options¶
Each bots.OptionSpec declares a key, a type (string, int, float,
bool, path), a default, help text and whether it is required. bots.New
fills every default into Options.Config before calling the factory, so
o.Int("min-power") is always set; reading an undeclared key panics, because
it is a programming error. A host supplies values from, highest first:
-bot-opt <policy>.<key>=<value>(repeatable)GORGED_BOT_<POLICY>_<KEY>(upper case,-becomes_)-bot-config <policy>=<file.json>(a flat JSON object; repeatable)- the declared default
gorged validates every value before it opens a listener, and its -help
lists every linked policy's options:
$ ./gorged-allin -help
...
Bot policy options (set with -bot-opt <policy>.<key>=<value>, -bot-config <policy>=<file.json>, or GORGED_BOT_<POLICY>_<KEY>):
allin (All-in (example)):
allin.min-power (int; default "1") attack only with creatures of at least this power
$ ./gorged-allin -bot-opt allin.min-power=two ...
gorged: -bot-opt "allin.min-power=two": option "min-power": want int, got "two"
$ ./gorged-allin -bot-opt allin.speed=2 ...
gorged: -bot-opt: bot policy "allin" has no option "speed" (known: min-power)
Option values never appear in /api/bot-policies.
Its own module¶
The example is a Go module of its own, as a bot author's would be:
module github.com/adams-shaun/gorge/examples/bots/go
go 1.25.8
require github.com/adams-shaun/gorge v0.0.0
replace github.com/adams-shaun/gorge => ../../..
Inside this repository the replace points at the checkout. Outside it, drop
the replace and require a gorge commit (go get
github.com/adams-shaun/gorge@<commit>). gorge has no third-party
dependencies, so that is the whole dependency graph.
Play it against the default bot¶
cmd/duel plays seeded games between two registered policies on real decks,
seats traded each game. It is the in-process path: the engine, both seats and
the loop in one process, no network, no clock in any decision. It needs the
card corpus (make fetch-cards compile-cards in the repository root).
$ cd examples/bots/go
$ go run ./cmd/duel -cards ../../../.cards -a allin -b bot -games 40
allin (mono-green-stompy) 17 - 23 bot (mono-red-goblins), 0 draws, 0 stalls
40 games, 12279 decisions in 0.63s: 51.7 µs per decision, both seats, engine included
$ go run ./cmd/duel -cards ../../../.cards -a bot -b bot -games 40
bot (mono-green-stompy) 28 - 12 bot (mono-red-goblins), 0 draws, 0 stalls
40 games, 14045 decisions in 0.45s: 32.3 µs per decision, both seats, engine included
Always run the control: on the same seeds and decks, bot takes 28 of 40
with the green deck and allin only 17, so attacking with everything is
worse than bot's combat simulation. (These timings were taken on a shared
machine under load; unloaded, on 2026-10-20, the same two runs took 34 and
21 µs per decision.) Forty games is a smoke test
(about ±15 percentage points); Bot-vs-bot games
covers how many games a real comparison needs.
duel is a thin loop over
testkit/bench.PlayGame,
which takes a rules.Config and one seat.Seat per seat and returns the
outcome; bench.RunPairs runs a deck-pair matrix with seats traded across
worker goroutines.
Test it¶
allin_test.go
has three tests a policy should copy. None needs the card corpus:
testutil.SampleDecks builds two synthetic 40-card decks.
TestRegistered: the init ran andbots.Newbuilds the policy with its declared defaults.TestPlaysAgainstTheBot: ten seeded games againstheuristic.NewBotthroughbench.PlayGame, failing on any stall.TestHostContract: seats the policy at a real host table withhost.RunContractTable, plays the match to the end and replays its log to the recorded head. This is the harness gorge runs on its own hosted policies (server/host/contract.go); its siblingsRunEnvLeakContractandRunClosedPrivacyContractcheck that an Env seat's answers never depend on hidden cards.
$ go test -v ./allin/
=== RUN TestRegistered
--- PASS: TestRegistered (0.00s)
=== RUN TestPlaysAgainstTheBot
allin_test.go:58: allin won 0 of 10 against bot
--- PASS: TestPlaysAgainstTheBot (1.15s)
=== RUN TestHostContract
allin_test.go:67: allin: finished turn 20 intents 634 events 3310 rootRefusals 0 feeds false reason ""
--- PASS: TestHostContract (0.03s)
PASS
ok github.com/adams-shaun/gorge/examples/bots/go/allin 1.189s
(Inside the gorge repository, run tests under the test budget: one package, capped memory and CPUs.)
Serve it¶
A server is gorged.Main plus blank imports: gorge's own bots and yours.
package main
import (
"os"
_ "github.com/adams-shaun/gorge/bots/all"
_ "github.com/adams-shaun/gorge/examples/bots/go/allin"
"github.com/adams-shaun/gorge/server/gorged"
)
func main() { os.Exit(gorged.Main(os.Args[1:], gorged.Extensions{})) }
$ go build -o gorged-allin ./cmd/gorged-allin
$ ./gorged-allin -addr 127.0.0.1:8093 -cards ../../../.cards -decks ../../../testkit/testutil/decks \
-dir /tmp/gorge-allin -feedback /tmp/gorge-allin/feedback -tables 0 -vsbot -pace 0 -prewarm=false \
-bot-policy bot -bot-opt allin.min-power=2
$ curl -s http://127.0.0.1:8093/api/bot-policies | jq -r '.policies[].name'
bot
allin
forge-heuristic
lethal-pressure
allin is now offered to POST /api/games ("bot_policy": "allin") and to
the browser's play-vs-bot picker. The Python example agent played it:
$ python3 gorge_agent.py --server http://127.0.0.1:8093 --bot-policy allin --policy simple
created table g2 match 1, seat 0 vs allin
decisions answered: 446 (0 answers refused and retried)
...
match finished after 34 turns in 2.0 s: winner us (seat 0, mono-black-aggro)
gorged.Extensions also carries extra flags (Flags), a draft-event
runtime (Events) and a match-review loader (Review); gorged.Serve runs the same server on a listener you
supply, which is how a test drives it. The full Go embedding surface is on
the Go API reference.
Rules for bot code that will ship¶
- Determinism. Seeded
math/rand/v2only, nomaporder in anything that reaches a decision or a checkpoint, notimein library code: inject a clock hook the waysearchseatandazmctsdo. Only the host may read the clock, and a search seat's deadline arrives as itsctx. - Pure Go. No cgo and no third-party modules in anything gorge links.
- Honesty. A policy that plays people reads the redacted View, or an honest root, and nothing else. Clairvoyant worlds and oracle feature sets are for diagnostic ceilings in benches.
- Measure before you claim. Record the command, seeds, game count and a confidence interval against a same-seed control.
Training and adoption covers the gates a policy passes on its way from a bench to the default bot.