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 suppliedrandomstream, so a whole game replays identically from its recorded seed (seerng.ts). - Never touch the DOM,
fetch, or the clock. - Never voluntarily return
nullwhile 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 inmain.tscatches 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
nextPlacementcall vialib.occupancy(state), and look pieces up throughlib.at(occ, position)from there -- never callgame.pieceAtinside a loop over many candidates. - Never call
game.pendingEncirclementsorgame.pendingTerritoriesinside a loop over candidate cells.bot/circles.tsis the worked example of computing an equivalent, cheaper, purely local signal instead of callingpendingEncirclements. game.pendingCutsis cheap by comparison -- it only scans stones placed this turn, not the whole board -- and is safe to probe once per candidate.bot/cuts.tsdoes 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 chasespendingCutspressure 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 callspendingEncirclements). 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 (seedocs/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/transcripton the backend (backend/src/server.ts), so the app must be served bybackend/dist/server.js(npm startinbackend/, orPORT=<n> node backend/dist/server.js) for it to land on disk -- not just the frontend's owntsc --watch. It defaults to port 3000 (PORT=3000unless 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:
- Header -- the seed (the important one), board size, roster, and a one-line reminder of how to replay this exact game.
- 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).
- 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.
- 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. - 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.