Bot Battle is a small package, botbattle, with a game engine, a bot loader,
and a text renderer, plus a bots/ folder of one-file bots that anyone can
add to by PR. uv run botbattle loads every bot, plays a seeded match on a
10×10 grid, and animates it in the terminal.
Modules (src/botbattle/):
| Module | Job |
|---|---|
view.py |
The read-only data a bot sees, plus the list of valid actions |
game.py |
The rules: the board, moves, attacks, rounds, and the end of the match |
loader.py |
Finds and loads every bot file in bots/ |
display.py |
Turns the game state into a text frame (a pure function) |
cli.py |
Command-line options, the animation loop, and the final result |
Data model:
# One table drives both moves and attacks: "attack_up" uses DIRECTIONS["up"].
DIRECTIONS = {"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0)}
ACTIONS = {
"up",
"down",
"left",
"right",
"attack_up",
"attack_down",
"attack_left",
"attack_right",
"wait",
}
@dataclass(frozen=True)
class BotInfo: # what anyone can see about a bot
name: str
x: int
y: int
hp: int
@dataclass(frozen=True)
class BotView: # what a bot receives each turn ((0, 0) is top-left)
me: BotInfo
others: tuple[BotInfo, ...] # living opponents only
width: int
height: int
round: int
# A bot file defines: def act(view: BotView) -> strInside game.py, a mutable Bot dataclass (name, act function, x, y,
hp, letter) holds the real state, and bots never see it. (0, 0) is the
top-left corner and y grows downward, so "up" means y - 1.
Main flow:
cli.pyreads the options:--bots(defaultbots/),--seed,--delay(default 0.3 s), and--no-animate. Without--seed, it picks a random seed and prints it, so any match can be replayed.loader.load_bots(folder)returns(name, act)pairs, one per*.pyfile, sorted by name. A file that fails to import or has noactis skipped with a warning.Game(bots, size=10, max_rounds=200, seed=...)seedsrandomitself and places bots on random empty squares. The round limit is aGamesetting, not a command-line option.- Each round,
game.play_round()shuffles the living bots, then for each one: build a freshBotView, callact()safely (an error or invalid action becomes"wait"), and apply the action. A bot at 0 HP is removed at once and doesn't act later that round. - After each round,
cli.pyprintsdisplay.render(game)(clear the screen and redraw) and sleeps for--delay. - When
game.is_over(one or no bots left, or the round limit reached), the final frame shows the result,Winner: chaserorDraw.
- Adds: renames the project to
botbattle, addsuv_buildand thebotbattlescript, and addsview.py(BotInfo,BotView,ACTIONS). For nowcli.pyonly prints a placeholder line. - Files:
pyproject.toml,uv.lock,src/botbattle/__init__.py,src/botbattle/view.py,src/botbattle/cli.py,tests/test_view.py - Tests: the dataclasses are frozen (assigning to a field raises an
error),
ACTIONScontains the 9 actions, andmain()runs. - Size: about 50 lines
- Priority: core
- Model: Opus 5.5 (me). It sets the API every later unit uses.
- Note: it mixes tooling (rename, build backend) with the API, and the commit message says both.
- Adds:
Gamesetup (random, non-overlapping start positions, 3 HP each) andapply_action(bot, action)for moves, attacks, waiting, and removing bots at 0 HP.DIRECTIONS(inview.py) drives both moves and attacks, which keeps the unit under 100 lines. - Files:
src/botbattle/game.py,tests/test_rules.py - Tests: a move to an open square works; a move off the edge or into another bot is ignored; an attack hits only the adjacent square in its direction; a hit costs 1 HP; a bot at 0 HP is removed; an attack on an empty square does nothing.
- Size: about 100 lines
- Priority: core
- Model: Opus 5.5 (me). This is the core logic, where a subtle bug would be expensive.
- Adds:
play_round()(shuffled order, a freshBotViewper call,act()wrapped intry/except),is_over,winner, and seeding. - Files:
src/botbattle/game.py,tests/test_match.py - Tests: one bot left means it's the winner; the round limit means a draw;
the same seed gives the same result twice; a bot that raises, returns
"fly", or returnsNonejust waits; a bot that triesview.me.hp = 99just waits and its real HP is unchanged; a bot removed mid-round doesn't act. - Size: about 90 lines
- Priority: core
- Model: Opus 5.5 (me). The determinism and error handling need care.
- Adds:
load_bots(folder)usingimportlib.util(with a 2-line comment explaining it), which warns about and skips files that fail to import or have noactfunction, and skips names starting with_. Also addsbots/random_walker.py,bots/chaser.py(moves toward the nearest bot and attacks when next to it), andbots/coward.py(moves away from the nearest bot). - Files:
src/botbattle/loader.py,bots/*.py,tests/test_loader.py - Tests: loads bots from a temporary folder; skips a file without
act; skips a file with a syntax error; the three real bots load and return valid actions for a sample view. - Size: about 90 lines
- Priority: core
- Model: Sonnet 5.5 subagent. The spec is clear and the work is routine. I review it before committing.
- Adds:
render(game) -> str, which shows the grid (.for an empty square, a capital letter for each bot) with an HP panel next to it, such asA chaser ♥♥♡, a defeated bot marked as out, and a header likeRound 12/200. Once the match is over, the frame ends with the result. - Files:
src/botbattle/display.py,tests/test_display.py - Tests: a small hand-built game renders exactly as expected; a removed
bot is missing from the grid and shown as out in the panel; a finished
game's frame shows the winner or
Draw. - Size: about 70 lines
- Priority: core
- Model: Sonnet 5.5 subagent. It's routine string building. I review it before committing.
- Adds: a real
cli.main(): theargparseoptions, the redraw loop with ANSI clear plustime.sleep,--no-animate, and the final frame. With fewer than 2 bots, it prints a clear message and exits. It also picks a demo seed that ends in a winner with the shipped bots. - Files:
src/botbattle/cli.py,tests/test_cli.py - Tests:
main(["--no-animate", "--seed", ...])prints aWinner:orDrawline; an end-to-end run over the realbots/folder with the demo seed ends in a winner; a run without--seedprints the seed it used; an empty bots folder gives the friendly error. - Size: about 70 lines
- Priority: core
- Model: Opus 5.5 (me). It's the integration point and the demo.
- Adds: a README section on how to write a bot (the
act(view)signature, theBotViewfields, the coordinates, and the action names), how to try it locally, and how to open a PR with one file inbots/. The sample bot is a real file,bots/example.py, quoted in the README. It also states the known limit: a bot that loops forever hangs the game. - Files:
README.md,bots/example.py - Tests: none new. The existing loader and end-to-end tests already
cover
bots/example.py, so the sample can't go stale. - Size: about 60 lines
- Priority: core
- Model: Sonnet 5.5 subagent. It's documentation, and I review it before committing.
- Adds:
.github/workflows/check.yml, triggered onpull_requestand pushes tomain:actions/checkout@v7,astral-sh/setup-uv@v10,uv sync, ruff check, ruff format check, pytest, anduv run botbattle --no-animatewith the demo seed from Unit 6. The job hastimeout-minutes: 5, so a bot that loops forever fails fast. - Files:
.github/workflows/check.yml - Tests: the workflow passes on the pushed commit (checked with
gh run watch). - Size: about 30 lines
- Priority: optional
- Model: Haiku 4.5 subagent. It's mechanical YAML. I review it before committing.
| Definition of Done item | Unit |
|---|---|
uv run botbattle runs a match and announces the winner |
1 (entry point), 6 |
| A bot is a plain function with a read-only view | 1, 3 |
| Rules enforced and tested | 2 |
| Winner, turn-limit draw, seeded replay | 3 |
| A misbehaving bot just waits | 3 |
| One file per bot, at least 3 examples | 4 |
Terminal animation with HP panel, --delay, --no-animate |
5, 6 |
| README explains how to submit a bot | 7 |
| (Optional) PR check | 8 |
The models available now: Fable 5.1, Opus 5.5 (me, the session model), Sonnet 5.5, and Haiku 4.5.
- Opus 5.5 (me): the API, the rules engine, the match loop, and the CLI (units 1, 2, 3, and 6), where design matters most.
- Sonnet 5.5 subagent: routine units with a clear spec (4, 5, and 7) and
the plan review (the
plan-revieweragent already runs on Sonnet). - Haiku 4.5 subagent: the mechanical CI YAML (unit 8).
- Fable 5.1: not needed. Nothing here is hard enough to justify the cost, but it's the fallback if a unit gets stuck.
Delegated units get a brief with the relevant parts of CLAUDE.md and this
plan. I review their work and run the quality gates before committing. Nobody
needs to switch with /model.
Reviewed by a Sonnet 5.5 subagent (using the plan-reviewer brief) and
by the host.
- Final screen not specified: fixed.
rendershows the result, and Units 5 and 6 test it. - No test for a bot that edits its view: fixed with a Unit 3 test.
- A bot file with a syntax error would crash the loader: fixed. It's skipped with a warning and tested in Unit 4.
- Global
random.seedand the default seed: fixed.Gameseeds itself, and without--seedthe CLI prints the seed it picked. - Demo might end in a dull draw: fixed. Unit 6 picks a demo seed that gives a winner, and CI uses it.
- A bot that loops forever hangs the game: accepted as a known limit. It's
documented in the README, and CI has
timeout-minutes: 5. - Parsing the README to test the sample bot is fragile: fixed. The sample is
bots/example.py, quoted in the README. --max-roundsisn't needed: removed from the CLI and kept as aGamesetting.importlibneeds a comment, action parsing was unspecified, and the coordinates were only in the plan: fixed with the comment, theDIRECTIONStable, and docs in theBotViewdocstring and the README.- Split the rules into
rules.py: won't fix.DIRECTIONSkeeps Unit 2 small, and one rules file is easier to follow. - Unit 1 mixes tooling with the API: kept as one unit, and the commit message says both.
- Move Unit 6 to Sonnet: host decided to keep it on Opus.
- Host decisions: bots act one at a time in a shuffled order,
"up"meansy - 1, theBotViewstays as designed, only Windows Terminal is supported (not the oldcmd.execonsole), and a bot that loops forever is a known limit.
Status: Approved