# 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/.ts` exporting, at top level: ```ts 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: ```ts // 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= 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/-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.