Skip to content

Python and Java examples

This page walks through two complete bots that play gorge over the network, one in Python and one in Java, for authors who want to wire their own strategy to the engine in a language other than Go. Both are single files with no dependencies beyond the language's standard library, and both are structured so that you replace one function, choose, with your own strategy. CI plays one complete game with each against a throwaway gorged (scripts/examples-smoke.sh); the transcripts below were captured on 2026-10-20 (ad3777023), and the smoke script passed again at 513a2a0c3 on 2026-10-21.

Source: examples/bots/python/gorge_agent.py (Python 3, urllib and json only) and examples/bots/java/Agent.java (Java 21, java.net.http, a 130-line embedded JSON reader and writer; runs as java Agent.java, no build step, no Maven).

Why HTTP

Both agents use the HTTP seat, not the SpellBench v2 protocol. v2's transport is a child process's stdin and stdout: the server must start the agent itself, on its own machine, once per match. The HTTP seat works from any machine that can reach the server, keeps one agent process across as many games as it likes, and is the same surface the browser client plays through, so anything the agent does can be watched in a browser. An agent that already speaks v2 should use -sb-agent instead.

Run them

Start a server with the play-vs-bot flow armed (see Start a server):

$ ./gorged -addr 127.0.0.1:8092 -dir /tmp/gorge-bots -feedback /tmp/gorge-bots/feedback \
    -tables 0 -vsbot -max-on-demand-tables 0 -pace 0 -prewarm=false -bot-policy bot

Each run creates a fresh game against the hosted bot, plays it to the end and prints a summary. -v prints every answer. --policy random or --policy simple picks the strategy; --deck, --bot-deck and --bot-policy pick the matchup (ids from GET /api/decks and GET /api/bot-policies). To play a seat you already hold instead, pass --table, --match, --seat and --token.

$ python3 gorge_agent.py --server http://127.0.0.1:8092 --policy simple \
    --deck mono-green-stompy --bot-deck mono-red-goblins -v
created table g209 match 1, seat 0 vs bot
seq 22    mulligan         -> [0]
seq 53    priority         -> [0]
...
seq 2878  priority         -> [10]
seq 2887  priority         -> pay plan
seq 2897  priority         -> [8]
seq 2907  priority         -> [8]
seq 2916  priority         -> [8]
seq 2924  attackers        -> [0, 1, 2, 3, 4, 5, 6]
seq 2935  priority         -> [6]
seq 2945  priority         -> [6]
decisions answered: 276 (0 answers refused and retried)
by kind: attackers=9, blockers=2, choose=1, mulligan=1, priority=259, target=4
intent POST round trip: median 0.14 ms, p95 0.20 ms
match finished after 24 turns in 0.7 s: winner us (seat 0, mono-green-stompy)

$ python3 gorge_agent.py --server http://127.0.0.1:8092 --policy random --seed 7
created table g210 match 1, seat 0 vs bot
decisions answered: 130 (0 answers refused and retried)
by kind: choose=3, mulligan=1, priority=126
intent POST round trip: median 0.12 ms, p95 0.18 ms
match finished after 13 turns in 0.4 s: winner opponent (seat 1, boros-moxite-burn)
$ java Agent.java --server http://127.0.0.1:8093 --policy simple \
    --deck mono-green-stompy --bot-deck mono-red-goblins -v
created table g1 match 1, seat 0 vs bot
seq 18    starting_player  -> [0]
seq 20    mulligan         -> [0]
...
seq 3596  priority         -> [11]
seq 3605  priority         -> [11]
seq 3620  priority         -> [11]
seq 3630  priority         -> [11]
seq 3634  blockers         -> []
seq 3642  priority         -> [11]
decisions answered: 347 (0 answers refused and retried)
by kind: attackers=14, blockers=4, choose=2, mulligan=1, priority=321, starting_player=1, target=4
intent POST round trip: median 0.26 ms, p95 0.87 ms
match finished after 30 turns in 1.1 s: winner opponent (seat 1, mono-red-goblins)

$ java Agent.java --server http://127.0.0.1:8093 --policy random --seed 7
created table g2 match 1, seat 0 vs bot
decisions answered: 223 (0 answers refused and retried)
by kind: attackers=1, blockers=1, choose=5, mulligan=1, priority=213, starting_player=1, target=1
intent POST round trip: median 0.28 ms, p95 0.62 ms
match finished after 18 turns in 0.6 s: winner opponent (seat 1, tron)

java Agent.java compiles the file in memory (Java 11+ source-launcher; tested on OpenJDK 21.0.11). The Java default server is port 8093; pass --server for another.

How they are built

Both files have the same four parts, in the same order, so they can be read side by side.

1. The seat client: four calls

pending() returns the decision or nothing (on 409), view() the seat's redacted view, submit() posts an intent and returns the error body on a refusal, match_state() reads the match row (state, winner, turns). Every call sends the token as Authorization: Bearer.

def pending(self):
    """The decision asked of this seat, or None (409: nothing pending)."""
    status, body = self._call("GET", self._match_path("pending?seat=%d" % self.seat))
    if status == 200:
        return body
    if status == 409:
        return None
    raise RuntimeError("pending: HTTP %d %s" % (status, body))

def submit(self, decision, answer):
    intent = {"seq": decision["seq"], "player": decision["player"]}
    if isinstance(answer, dict):
        intent.update(answer)          # {"payment": {...}}
    else:
        intent["choices"] = answer     # [option indices]
    status, body = self._call("POST", self._match_path("intent"), intent)
    return None if status == 204 else (body or {"code": str(status)})
Map<String, Object> pending() throws IOException, InterruptedException {
    Reply r = call("GET", matchPath("pending?seat=" + seat), null);
    if (r.status == 200) return Json.obj(r.body);
    if (r.status == 409) return null;
    throw new IOException("pending: HTTP " + r.status + " " + Json.write(r.body));
}

Map<String, Object> submit(Map<String, Object> d, Answer a) throws IOException, InterruptedException {
    Map<String, Object> intent = new LinkedHashMap<>();
    intent.put("seq", d.get("seq"));
    intent.put("player", d.get("player"));
    if (a.payment != null) intent.put("payment", a.payment);
    else intent.put("choices", a.choices);
    Reply r = call("POST", matchPath("intent"), intent);
    if (r.status == 204) return null;
    return r.body == null ? Map.of("code", String.valueOf(r.status)) : Json.obj(r.body);
}

The JSON reader keeps object key order (LinkedHashMap) and reads integers as long (a 64-bit seed as BigInteger), so a payment plan is echoed back exactly as the server sent it.

2. Answer helpers: the wire rules

legal_random builds a random answer that respects min and max, includes every required option, and takes at most one option per exclusivity key: the option's group, and on an attackers decision its creature. minimal is the fallback that is almost always accepted (pass at priority, otherwise the first min options). pay turns a payment action into a {"payment": …} answer.

def exclusive_keys(d, o):
    keys = []
    if o.get("group"):
        keys.append(("group", o["group"]))
    if d["kind"] == "attackers" and o.get("obj"):
        keys.append(("attacker", o["obj"]))   # one creature, one defender
    return keys

def pay(action):
    return {"payment": {"action_id": action["id"], "plan": action["plans"][0]}}
static List<String> exclusiveKeys(Map<String, Object> d, Map<String, Object> o) {
    List<String> keys = new ArrayList<>();
    if (o.get("group") != null) keys.add("group:" + o.get("group"));
    if ("attackers".equals(d.get("kind")) && o.get("obj") != null) keys.add("attacker:" + o.get("obj"));
    return keys;
}

/** Either option indices or a plan-paid cast. */
record Answer(List<Integer> choices, Map<String, Object> payment) {
    static Answer of(List<Integer> c) { return new Answer(c, null); }
    static Answer pay(Map<String, Object> action) {
        Map<String, Object> p = new LinkedHashMap<>();
        p.put("action_id", action.get("id"));
        p.put("plan", Json.arr(action.get("plans")).get(0)); // echoed verbatim
        return new Answer(null, p);
    }
}

3. The strategies

Two are included:

  • random: uniform over legal answers, except that it never concedes and passes priority half the time (a uniformly random priority answer keeps activating repeatable abilities and never ends its turn).
  • simple: keeps every hand and plays first; plays a land whenever it can, then the most expensive spell it can cast (through a payment plan when that is how the spell is offered); attacks with a creature only when its toughness exceeds the power of every untapped enemy creature; blocks only when the blocker survives; targets the opponent's things when it can; says yes to optional triggers. Everything else is legal_random.

4. The loop

Poll /pending, back off from 2 ms to 50 ms while nothing is pending, check the match state every tenth empty poll, and on a decision: read the view if the strategy wants it, then submit the candidates in order (the strategy's answer, eight random legal answers, the minimal answer) until one is accepted. A stale-seq refusal means the decision moved on, so it polls again. It also remembers the last seq it answered and skips a pending decision that is not newer: the server refuses a second answer to one decision with a 409 anyway, so this only saves a round trip.

answered = -1
while True:
    d = client.pending()
    if d is not None and d["seq"] <= answered:
        d = None
    if d is None:
        ...                      # back off; every 10th poll, check match_state()
        continue
    view = client.view() if agent.needs_view else None
    accepted = False
    for choices in agent.candidates(d, view):
        err = client.submit(d, choices)
        if err is None:
            accepted = True
            break
        if "pending decision seq" in err.get("message", ""):
            break                # the decision moved on; poll again
    if not accepted:
        continue                 # nothing admitted: read the decision again
    answered = d["seq"]
long answered = -1;
while (true) {
    Map<String, Object> d = client.pending();
    if (d != null && Json.num(d.get("seq")) <= answered) d = null;
    if (d == null) { /* back off; every 10th poll, check matchState() */ continue; }
    Map<String, Object> view = policy.needsView() ? client.view() : null;
    List<Answer> tries = new ArrayList<>();
    tries.add(policy.choose(d, view, rng));
    for (int i = 0; i < 8; i++) tries.add(Answer.of(legalRandom(d, rng, null)));
    tries.add(Answer.of(minimal(d)));
    boolean accepted = false;
    for (Answer ans : tries) {
        Map<String, Object> err = client.submit(d, ans);
        if (err == null) { accepted = true; break; }
        if (String.valueOf(err.get("message")).contains("pending decision seq")) break;
    }
    if (!accepted) continue; // nothing admitted: read the decision again
    answered = Json.num(d.get("seq"));
}

Write your own strategy

Override one method. It gets the decision and (if you ask for it) the view, and returns either a list of option indices or a payment answer. Returning something illegal is safe: the server refuses it with a 409 and the loop falls back.

class BurnFace(ga.Agent):
    """Point every target at the opponent; otherwise play like SimpleAgent."""
    needs_view = True

    def __init__(self, seed=1):
        super().__init__(seed)
        self.simple = ga.SimpleAgent(seed)

    def choose(self, d, view):
        if d["kind"] == "target":
            face = [o for o in d["options"] if o["kind"] == "player" and o["player"] != d["player"]]
            if face:
                return [face[0]["index"]]
        return self.simple.choose(d, view)

Then pass an instance to play(client, BurnFace()), or add it to the --policy choices in main(). (Tested 2026-10-20: a Burn deck, 152 decisions, none refused.)

/** Point every target at the opponent; otherwise play like SimplePolicy. */
static final class BurnFace implements Policy {
    final SimplePolicy simple = new SimplePolicy();
    public boolean needsView() { return true; }
    public Answer choose(Map<String, Object> d, Map<String, Object> view, Random rng) {
        if ("target".equals(d.get("kind"))) {
            for (Map<String, Object> o : options(d)) {
                if ("player".equals(kind(o)) && Json.num(o.get("player")) != Json.num(d.get("player")))
                    return Answer.of(List.of(idx(o)));
            }
        }
        return simple.choose(d, view, rng);
    }
}

Then select it in main() where policy is chosen. (Tested 2026-10-20 in a copy of Agent.java with a --policy burn branch: 164 decisions, none refused.)

The decision kinds and what an answer to each means are on Playing a seat over HTTP.

How well they play

Neither is meant to be strong; they are scaffolding. Measured on 2026-10-20 against the hosted bot playing UW Control, our deck dealt at random, one game per seed:

Agent Games Wins Answers refused
Python simple 40 20 0
Python random 40 3 2
Java simple 20 9 0
Java random 20 3 0
$ for i in $(seq 1 40); do python3 gorge_agent.py --policy simple --seed $i --bot-deck uw-control | tail -1; done

All 120 games ran to a result. Over whole games the agents spend 1.6 ms (random, no view) to 3.2 ms (simple, a view per decision) per decision, mostly in polling back-off; Latency breaks that down. For fair comparisons between strategies (seats traded, seeded deck pairs, enough games), see Bot-vs-bot games.