A Go-like game designed to be playe by two-to-eight players competing for territory!
bit-blossom docs bots.md
10 kB
Markdown
at main

Writing bit-blossom bot strategies #

For the Claude session iterating on frontend/src/bot/*.ts. Design: docs/tasks/2026-09-08/18-local-bot-players.md. Background on why this replaced the earlier LLM-agent-player approach: docs/memories/project/experimental-agent-branch.md and docs/memories/project/bot-strategies.md.

The permission boundary #

You may edit frontend/src/bot/*.ts and add new files there. You may NOT edit game.ts. A strategy that needs a rule change is a conversation with Arcadia, never a commit. This is the guardrail that matters most here: without it, "make the bot win" and "change the rules" become the same button, and this whole approach collapses back into what the agent-player work already failed at.

The strategy contract #

A strategy is a module at frontend/src/bot/<name>.ts exporting, at top level:

export const id = "example";       // stable, used in rosters and transcripts
export const name = "Example";     // shown in the panel and transcript
export const nextPlacement = (
  state: game.BoardState,
  random: () => number,
): game.Position | null => { ... };

nextPlacement is called once per placement, on the live board, until it returns null or the turn's placement budget is spent (bot.ts's takeTurn runs this loop). It must:

  • Never mutate state.
  • Never call Math.random -- use the supplied random stream, so a whole game replays identically from its recorded seed (see rng.ts).
  • Never touch the DOM, fetch, or the clock.
  • Never voluntarily return null while a legal placement exists. Passing is reserved for "there is genuinely no legal cell." A strategy that passes while legal placements exist is a bug -- the driver's idle guard in main.ts catches it, but it is never a game outcome. Arcadia explicitly rejected an all-pass termination rule (docs/memories/project/bot-strategies.md); the reason it's safe to leave out is exactly that well-written strategies never voluntarily stop.

A minimal example that just fills its own home base's neighbourhood, then stops:

// bot/example.ts -- placeholder that only ever plays its opening turn.
//
// Revision log:
// - 2026-09-08: initial version.

import * as game from "../game.js";
import * as lib from "./lib.js";

export const id = "example";
export const name = "Example";

export const nextPlacement = (
  state: game.BoardState,
  random: () => number,
): game.Position | null => {
  const available = lib.moves(state);
  return lib.bestBy(available, () => 0, random)?.position ?? null;
};

To register a strategy so it's importable from a roster in main.ts, add one line to strategies in bot.ts. No other file needs to change.

Legality comes from game.ts, never from a strategy #

There is no second copy of the rules anywhere in bot/*.ts. The one legality oracle is game.place(state, position) returning non-null. This is what bot/lib.ts's moves/ownBase/openingTriple helpers are built on, and it's the thing that made the abandoned LLM-agent-player's agent.firstRejection (a hand-maintained English-language copy of game.place's checks) unnecessary here. If a strategy needs to know whether a cell is legal, it asks game.place, full stop -- it never reimplements a check game.ts already makes.

Performance rules (read this before writing a scoring loop) #

game.pieceAt is a linear .find over every piece on the board, so game.pendingEncirclements (8 pieceAt lookups per piece via ringAt) is effectively O(pieces²), and game.pendingTerritories runs two full-board flood fills per call. Both are fine at the once-per-render cadence they were written for in ui.ts, but calling either inside a loop over candidate cells -- a move search, a strategy scoring every legal placement -- is roughly 100M operations on a late-game 16x16 board.

This matters more here than almost anywhere else in the codebase: turn latency is what killed the LLM agent player (seconds per turn, multiplied by dozens of turns and a retry loop -- see docs/memories/project/experimental-agent-branch.md), and this is the one way it could sneak back into a design that has no network calls in it at all.

Rules for strategy authors:

  • Build the occupancy map once per nextPlacement call via lib.occupancy(state), and look pieces up through lib.at(occ, position) from there -- never call game.pieceAt inside a loop over many candidates.
  • Never call game.pendingEncirclements or game.pendingTerritories inside a loop over candidate cells. bot/circles.ts is the worked example of computing an equivalent, cheaper, purely local signal instead of calling pendingEncirclements.
  • game.pendingCuts is cheap by comparison -- it only scans stones placed this turn, not the whole board -- and is safe to probe once per candidate. bot/cuts.ts does exactly this, plus a shallow one-ply lookahead capped at a small constant (LOOKAHEAD_CANDIDATES) to keep that lookahead's own cost bounded.

bot/lib.ts: the shared substrate #

Cheap board lookups, the candidate-move enumeration every strategy scores over, and scored/random-tiebreak selection. Not a strategy itself -- bot.ts lists strategies explicitly, so there's no ambiguity about lib.ts's role.

The important design point: lib.moves(state) is a heuristic filter, not a legality claim. It only considers empty cells within Chebyshev distance 1 of some existing piece (an isolated stone is inert under every capture rule in this game, so a cell far from any stone is never worth scoring), then evaluates each one through game.place and keeps only the legal results. That's the "no duplicated rules" property from above, made concrete: the candidate set shrinks from the whole board to a few dozen cells, and every one of them is already known-legal by construction.

Known limitation worth remembering: lib.moves never enumerates a cell that already holds exactly one doomed enemy stone, even though placing an overlay there is legal (see game.ts's place/pieceAt comments). A strategy can therefore see zero candidates and stop even when an overlay placement is technically still legal. None of the three shipped strategies chase overlays; a future one that wants to would need its own enumeration.

The three shipped strategies, in one line each #

  • bot/cuts.ts -- builds a line of own stones out from its base, then chases pendingCuts pressure with a shallow lookahead.
  • bot/circles.ts -- scores empty cells by how close they bring a 4-cell ring around an enemy stone to completion, computed locally (never calls pendingEncirclements). Never targets an enemy home base -- a base can be a ring member but is never a capturable victim.
  • bot/tentacles.ts -- the on-vision one (see docs/vision.md): grows two thick diagonal arms outward from its base with an empty wedge between them, which become territory walls once they pin a region against the border. Deliberately the simplest file, and the one to iterate on first.

Read each file's own header comment before touching it -- that's where the idea and the dated revision log live.

The revision log convention #

Every strategy file's header comment ends with a dated revision log: what changed, why, and (once transcripts exist to point at) what a transcript showed that motivated the change. This is the durable artifact this whole approach exists to produce -- the abandoned agent-player work produced nothing equivalent; every turn's reasoning evaporated on commit. Add an entry every time you change a strategy's behaviour, even a small tuning constant.

Obtaining a transcript #

A transcript writes itself in two cases: automatically, the moment a game formally ends (someone wins, or it draws), or on demand, whenever the Save transcript button in the panel is clicked mid-game. Two things worth knowing before assuming one exists or diagnosing why it doesn't:

  • The write goes through POST /api/transcript on the backend (backend/src/server.ts), so the app must be served by backend/dist/server.js (npm start in backend/, or PORT=<n> node backend/dist/server.js) for it to land on disk -- not just the frontend's own tsc --watch. It defaults to port 3000 (PORT=3000 unless overridden).
  • If that request fails for any reason (server not running, write error), the full transcript text is dumped to the browser's own console as a fallback instead of being silently lost -- check there before concluding a finished game produced nothing.

Reading a transcript #

A finished (or manually saved) game writes a Markdown file to docs/games/<timestamp>-seed<seed>.md (gitignored -- this is scratch you read from the working tree, not project history). It has five sections, in the order that actually drives iteration:

  1. Header -- the seed (the important one), board size, roster, and a one-line reminder of how to replay this exact game.
  2. Result -- winner or draw, final scores, and how the game ended (lone survivor / board full tiebreak / autoplay stopped by the idle guard while still technically in progress).
  3. Per-player summary -- the scorecard. Since Cuts/Circles/Tentacles map onto the three capture rules, a strategy racking up captures by a rule that isn't its own namesake is immediately visible here.
  4. Move list -- one line per turn: placements, bonus placements earned, the per-rule capture breakdown for that turn, and a FAULT: line if the strategy suggested something illegal or had to be forced through an incomplete opening turn. A fault is the highest-value line in a transcript -- a bot is code, not a model, so an illegal suggestion is a bug in the strategy to be shown and fixed, never silently retried.
  5. Final board -- a labelled ASCII grid: . for empty, a lowercase letter per player, uppercase for that player's own home base.

There is deliberately no board snapshot per turn -- the seed plus the move list already make any turn recoverable exactly by replaying.

Replaying a game #

Set SEED in frontend/src/main.ts to the seed printed at the top of the transcript, keep ROSTER identical, rebuild (docs/memories/project/build-and-serve.md), and reload. Home base placement and every bot decision are drawn from seeded streams (rng.seeded), so the replayed game is identical move for move.