Build and Push Docker Container / build-and-push (push) Successful in 3m6s
- Add read-only gameplay statistics with filters and opponent comparisons. - Report move-trace latency percentiles and external timeout counts. - Pair engine latency with the preceding turn to separate timing stages. - Adjust Zenith rollout deepening for its larger timeout reserve. - Bump Zenith to 1.2.0 and document the fix and snake-stats command. - Cover statistics, trace aggregation, rollout depth, and verdict budgets.
361 lines
16 KiB
Markdown
361 lines
16 KiB
Markdown
# Battlesnake Python Starter Project
|
|
An official Battlesnake template written in Python. Get started at [play.battlesnake.com](https://play.battlesnake.com).
|
|
|
|

|
|
|
|
This project is a great starting point for anyone wanting to program their first Battlesnake in Python. It can be run locally or easily deployed to a cloud provider of your choosing. See the [Battlesnake API Docs](https://docs.battlesnake.com/api) for more detail.
|
|
|
|
## Technologies Used
|
|
This project requires [Python 3.15+](https://www.python.org/) and uses [Quart](https://quart.palletsprojects.com/) with Hypercorn. The Dockerfile uses the uv Python 3.15 runtime.
|
|
|
|
## Run Your Battlesnake
|
|
Install the pinned Python runtime and locked dependencies using uv:
|
|
```sh
|
|
uv python install 3.15
|
|
uv sync --locked
|
|
```
|
|
|
|
Python modernization (2026-11-03):
|
|
- Python 3.15 `lazy` imports defer database backend loading and benchmark-only dependencies. Optional-dependency `try`/`except` imports keep their fallback behavior.
|
|
- Python 3.14 deferred annotations replace `from __future__ import annotations`. Type-only dependencies use lazy bindings to avoid loading circular imports at startup.
|
|
- Python 3.14 `Path.info` checks reuse directory-entry metadata during dataset and customization asset discovery. Paths reused after mutations retain fresh checks.
|
|
- The shared `quart_common` submodule also requires Python 3.15 and already uses Python 3.14 UUIDv7 request IDs.
|
|
|
|
Run tests with `uv run --locked pytest tests quart_common/tests/test_user_agent.py`.
|
|
Python 3.14 is **not** a supported runtime: this project uses 3.15-only syntax.
|
|
|
|
Start your Battlesnake
|
|
```sh
|
|
uv run --locked python main.py
|
|
```
|
|
|
|
You should see the following output once it is running
|
|
|
|
```sh
|
|
Running your Battlesnake at http://0.0.0.0:8000
|
|
* Serving Flask app 'My Battlesnake'
|
|
* Debug mode: off
|
|
```
|
|
|
|
Open [localhost:8000](http://localhost:8000) in your browser and you should see
|
|
|
|
```json
|
|
{"apiversion":"1","author":"","color":"#888888","head":"default","tail":"default"}
|
|
```
|
|
|
|
## Play a Game Locally
|
|
Install the [Battlesnake CLI](https://github.com/BattlesnakeOfficial/rules/tree/main/cli)
|
|
* You can [download compiled binaries here](https://github.com/BattlesnakeOfficial/rules/releases)
|
|
* or [install as a go package](https://github.com/BattlesnakeOfficial/rules/tree/main/cli#installation) (requires Go 1.18 or higher)
|
|
|
|
Command to run a local game
|
|
```sh
|
|
battlesnake play -W 11 -H 11 --name 'Python Starter Project' --url http://localhost:8000 -g solo --browser
|
|
```
|
|
|
|
## Next Steps
|
|
Continue with the [Battlesnake Quickstart Guide](https://docs.battlesnake.com/quickstart) to customize and improve your Battlesnake's behavior.
|
|
|
|
## Included Competitive Snake
|
|
This repo retains `snakes/legacy/BestBattleSnake.py`, a stronger historical snake that combines:
|
|
- collision and head-to-head risk checks
|
|
- flood-fill space evaluation to avoid traps
|
|
- food routing that gets more aggressive as health drops
|
|
- tail access checks for better long-term survival
|
|
|
|
Run it explicitly with:
|
|
```sh
|
|
SNAKE=BestBattleSnake python main.py
|
|
```
|
|
|
|
Optional duel tuning (when only 2 snakes are alive):
|
|
```sh
|
|
BATTLE_SNAKE_DUEL_STYLE=balanced python main.py
|
|
```
|
|
|
|
Allowed values: `safe`, `balanced`, `aggressive`.
|
|
|
|
## Snake package layout
|
|
The snake code is split by responsibility:
|
|
- `snakes/strategies/` — actively maintained Apex and Prism entry points
|
|
- `snakes/engine/` — reusable bitboards, spatial mixins, duel search, and survival search
|
|
- `snakes/core/` — shared base classes
|
|
- `snakes/legacy/` — historical snakes retained for compatibility and benchmarks
|
|
|
|
Snake selection still uses the existing registry names, so deployment values such
|
|
as `SNAKE=PrismBattleSnake_GPT_5_6_Sol` remain unchanged.
|
|
|
|
## PrismBattleSnake_GPT_5_6_Sol
|
|
`PrismBattleSnake_GPT_5_6_Sol` is a separate snake that keeps Apex's strategy while
|
|
accelerating hot spatial operations with a Python-integer bitboard engine. It
|
|
also shares duel transpositions across candidate moves, uses principal-variation
|
|
ordering, aspiration windows, path-aware food races, and a deeper tactical
|
|
horizon, and runs a compact adversarial multiplayer rollout with simultaneous
|
|
enemy responses and cached occupancy/evaluation states. Its filename, class, and registry
|
|
key include the model name, while its public Battlesnake API name remains
|
|
`PrismBattleSnake`.
|
|
|
|
Run it with:
|
|
```sh
|
|
SNAKE=PrismBattleSnake_GPT_5_6_Sol python main.py
|
|
```
|
|
|
|
## ZenithBattleSnake_ClaudeOpus5_5
|
|
`ZenithBattleSnake_ClaudeOpus5_5` builds on Prism and keeps its engine. Prism
|
|
only runs the exact duel search on near-tied heuristic candidates. Zenith adds a
|
|
root verdict pass in duels: every safe move is resolved against all
|
|
simultaneous enemy replies using the shared transposition table. A move whose
|
|
search proves a forced loss is replaced by the best surviving move. A move whose
|
|
search proves a forced win is played even if the heuristic preferred another
|
|
move. Its public Battlesnake API name is `ZenithBattleSnake`.
|
|
|
|
Version 1.1.0 adds permanent-trail space scoring for Constrictor (free capacity
|
|
is not compared against the already occupied body length), balanced root-search
|
|
budgets, and health-cost routes for hazard escape and food. It reserves at least
|
|
180 ms for non-search work. The HTTP move path also deducts executor queue wait
|
|
from the strategy budget and returns a legal-direction fallback instead of null
|
|
when its request budget expires. This is best-effort deadline handling, not a
|
|
hard real-time guarantee under CPU saturation.
|
|
|
|
Version 1.2.0 fixes a search-depth regression from that larger reserve. Prism
|
|
looks one turn deeper in 3-snake rollouts when more than 250 ms remain; with
|
|
Zenith's 50 ms shorter budget that gate was never reached in production, so
|
|
Zenith always stopped at depth 2. Zenith now lowers the gate by its extra
|
|
reserve and deepens after the same elapsed time as Prism.
|
|
|
|
Run it with:
|
|
```sh
|
|
SNAKE=ZenithBattleSnake_ClaudeOpus5_5 python main.py
|
|
```
|
|
|
|
Show read-only win statistics per own snake version and per opponent from the
|
|
PostgreSQL gameplay database (`GAMEPLAY_DB_PG_DSN`):
|
|
```sh
|
|
just snake-stats --since-hours 24
|
|
just snake-stats --snake-type ZenithBattleSnake --game-type duel --min-games 5
|
|
just snake-stats --since-hours 48 --compare # opponents side by side per own version
|
|
just snake-stats --traces --since-hours 24 # move-trace latency per version and mode
|
|
```
|
|
|
|
`--traces` unpacks the newest `--trace-games` (default 300) finished replays and
|
|
reports p50/p90/p99 for strategy time, server overhead (handler minus
|
|
strategy), handler time, network/outside-handler time (engine latency minus
|
|
handler) and engine latency. `ext.timeouts` counts engine timeouts where our
|
|
handler had answered at least 100 ms before the deadline.
|
|
|
|
Benchmark Apex and Prism against sampled positions from a gameplay database:
|
|
```sh
|
|
python scripts/benchmark_snakes_from_db.py \
|
|
--database /path/to/gameplay.sqlite3 \
|
|
--samples 100
|
|
```
|
|
|
|
The benchmark samples every `--stride`-th stored turn from the newest games
|
|
(SQLite is opened read-only; a `postgresql://` DSN works too) and reports mean,
|
|
median, p95, and maximum move latency. Increase `--samples` for a broader but slower comparison.
|
|
|
|
Run the deterministic CI-friendly arena benchmark without a gameplay database:
|
|
```sh
|
|
just bench-snake-arena positions=100
|
|
```
|
|
|
|
It rotates through duel, hazard, multiplayer, constrictor, and cramped-endgame
|
|
positions. It reports latency, completed duel/rollout depth, searched nodes,
|
|
cache hits, deadline exits, and move disagreements between Apex and Prism. Use
|
|
`--scenario hazard` (repeatable) when invoking the Python script to isolate a
|
|
scenario. Add `output=data/arena-report.json` to save a machine-readable report.
|
|
For representative strategy evaluation, provide recorded positions to
|
|
`scripts/benchmark_snake_arena.py --database /path/to/gameplay.sqlite3`.
|
|
|
|
Run paired seeded games through the official local Battlesnake rules engine:
|
|
```sh
|
|
just bench-snake-tournament games=20 gametype=standard map=standard
|
|
```
|
|
|
|
Pick other opponents with `snake_a=` and `snake_b=`, for example
|
|
`just bench-snake-tournament snake_a=PrismBattleSnake_GPT_5_6_Sol snake_b=ZenithBattleSnake_ClaudeOpus5_5`.
|
|
|
|
Each seed is played twice with both snakes swapping initial engine slots. The
|
|
report includes wins, draws, win rates, and average game length. Save all
|
|
per-game results with `output=data/tournament-report.json`. The tournament starts
|
|
both snake servers with gameplay persistence disabled, adds the engine identity
|
|
header required by the API, and shuts them down when finished.
|
|
|
|
## Move tracing and latency diagnosis
|
|
Every `/move` response includes `X-Move-Trace-ID`. Replay thinking contains a
|
|
small `move_trace` object: JSON parse, runtime lookup, executor queue wait,
|
|
strategy execution, handler/response encoding, bookkeeping queue wait, and
|
|
metrics publication timings. No board payloads are copied into traces.
|
|
|
|
Structured application log lines start with `move_trace` and include the same
|
|
ID, game ID and turn, plus persistence and dashboard-publication duration once
|
|
background work finishes. These last two timings cannot be included in the
|
|
record being written itself. Timed-out workers may finish later; missing
|
|
`strategy_ms`/`worker_finished` means they had not finished at that snapshot.
|
|
|
|
- `MOVE_TRACE_SAMPLE_EVERY=100`: approximately 1% of normal requests logged;
|
|
use `1` for all, `0` to disable normal sampling.
|
|
- `MOVE_TRACE_SLOW_MS=300`: always log a trace if any measured stage exceeds
|
|
this threshold. Timeouts and metrics publication failures are always logged.
|
|
- Metrics counters update locally before responding; shared metrics backend
|
|
publication now runs in ordered background bookkeeping, not on `/move`'s
|
|
response path.
|
|
|
|
These are lightweight local timings, not exported OpenTelemetry spans. Handler
|
|
time starts inside the route and excludes middleware, socket delivery and
|
|
reverse-proxy/network delay. Compare with client-observed latency. Background
|
|
stage times can include event-loop scheduling delays; production improvements
|
|
must be verified after deployment under representative concurrency.
|
|
|
|
## Compact gameplay database
|
|
Gameplay storage keeps game results in `games` and snake identity (name,
|
|
customizations) once per game in `game_snakes`. Replay data is stored compactly:
|
|
|
|
- **Running games** write one `game_turns` row per turn and one `snake_states`
|
|
row per snake and turn, keyed by small integer ids (`games.gid`,
|
|
`game_snakes.gsid`) instead of repeating text ids. Moves are 1-byte codes,
|
|
latency is an integer, bodies are a head position plus one step code per
|
|
segment, and food/hazards are only stored when they changed since the previous
|
|
turn. Reasoning fields that equal stored values (`turn`, `move`, `head`,
|
|
`health`, `length`, `food_count`, `board_size`, `snakes`) are stripped and
|
|
restored exactly on read.
|
|
- **Finished games** are packed into one zstd-compressed blob per game in
|
|
`game_replays`, and their rows are deleted. Turns arriving after a game was
|
|
packed are merged into the blob by the stale-game sweep.
|
|
|
|
Replay loading rebuilds the normal Battlesnake board payload, so the dashboard
|
|
is unchanged. Head position, length, and inferred moves are derived from the
|
|
stored bodies.
|
|
|
|
### Migrate the legacy layout
|
|
Databases written by older versions store replays in `turns`/`snake_turns`.
|
|
Migrate them in place (SQLite path or `postgresql://` DSN). Every game is read
|
|
back and compared after writing; failed games are reported and keep their
|
|
legacy rows:
|
|
```sh
|
|
python scripts/migrate_gameplay_to_compact.py --source /path/to/gameplay.sqlite3
|
|
python scripts/migrate_gameplay_to_compact.py --source /path/to/gameplay.sqlite3 --drop-legacy
|
|
```
|
|
|
|
`--destination` writes to a different database instead, for example from
|
|
SQLite into PostgreSQL. `--minimum-quality medium` re-rates replays by
|
|
structural completeness, valid moves, thinking coverage, game length, move
|
|
diversity, opponent data, and terminal outcome, and keeps only replays of that
|
|
tier or better. All game-result rows remain stored, so historical win/loss
|
|
rates stay persistent. `--drop-legacy` only drops the old tables when every
|
|
legacy replay exists in compact form.
|
|
|
|
For a live PostgreSQL database: run the migration while the old version keeps
|
|
recording, deploy the new version, run the migration again with
|
|
`--since <start time of the first run>` to pick up games recorded meanwhile,
|
|
then run `--drop-legacy`. A PostgreSQL server configured with a SQLite
|
|
`GAMEPLAY_DB_PATH` imports that legacy file automatically on startup.
|
|
|
|
### Record new games while cleanup runs
|
|
Point the running server at a temporary delta database while the old database
|
|
is being migrated or cleaned. After stopping the writer and flushing the delta database,
|
|
merge it into the cleaned copy:
|
|
```sh
|
|
python scripts/merge_gameplay_databases.py \
|
|
--base /path/to/gameplay.compact.sqlite3 \
|
|
--delta /path/to/gameplay.delta.sqlite3 \
|
|
--destination /path/to/gameplay.merged.sqlite3 \
|
|
--minimum-quality medium
|
|
```
|
|
|
|
The merger copies game by game, keeps all game results, quality-rates delta
|
|
games, assigns fresh integer ids, and verifies game, snake, turn, and snake-state
|
|
counts.
|
|
Identical game IDs are skipped; conflicting duplicates abort the merge. After
|
|
reviewing the result, `--replace-base` backs up and replaces the cleaned base.
|
|
Stop the delta writer before the final merge and file swap.
|
|
|
|
## Export Training Dataset
|
|
Game saves now include a `dataset` section with labeled move samples.
|
|
|
|
Export all stored samples to JSONL:
|
|
|
|
```sh
|
|
python -m server.DatasetExporter --input data --output data/dataset/good_moves.jsonl
|
|
```
|
|
|
|
Or with `just`:
|
|
|
|
```sh
|
|
just export-dataset
|
|
```
|
|
|
|
Curate a high-quality training subset (single file):
|
|
|
|
```sh
|
|
python -m server.DatasetCurator --input good_moves-2026-04-03.jsonl --output data/dataset/best_moves.jsonl
|
|
```
|
|
|
|
Curate from multiple JSONL sources (repeat `--input`):
|
|
|
|
```sh
|
|
python -m server.DatasetCurator \
|
|
--input good_moves-2026-04-03.jsonl \
|
|
--input good_moves-2026-04-04.jsonl \
|
|
--output data/dataset/best_moves.jsonl
|
|
```
|
|
|
|
Curate from folder or glob:
|
|
|
|
```sh
|
|
python -m server.DatasetCurator --input data/dataset --output data/dataset/best_moves.jsonl
|
|
python -m server.DatasetCurator --input "good_moves-*.jsonl" --output data/dataset/best_moves.jsonl
|
|
```
|
|
|
|
Append mode (keeps existing curated rows and deduplicates against them):
|
|
|
|
```sh
|
|
python -m server.DatasetCurator --input "good_moves-*.jsonl" --output data/dataset/best_moves.jsonl --append
|
|
```
|
|
|
|
Archive processed input files after curation:
|
|
|
|
```sh
|
|
python -m server.DatasetCurator --input "good_moves-*.jsonl" --output data/dataset/best_moves.jsonl --append --archive-input
|
|
python -m server.DatasetCurator --input "good_moves-*.jsonl" --output data/dataset/best_moves.jsonl --append --archive-input --archive-dir data/dataset/archive
|
|
```
|
|
|
|
Or with `just`:
|
|
|
|
```sh
|
|
just curate-dataset
|
|
just curate-dataset append=true
|
|
just curate-dataset append=true archive=true archive_dir=data/dataset/archive
|
|
```
|
|
|
|
Analyze dataset quality overall and by day (best game overall/day included):
|
|
|
|
```sh
|
|
python -m server.DatasetStats --input "good_moves-*.jsonl"
|
|
python -m server.DatasetStats --input data/dataset --output data/dataset/stats-report.json
|
|
```
|
|
|
|
The stats report now includes both:
|
|
- `best_game` (survival/length focused)
|
|
- `best_pressure_game` (high-pressure quality focused: fewer safe options + strong survival)
|
|
|
|
Or with `just`:
|
|
|
|
```sh
|
|
just analyze-dataset
|
|
just analyze-dataset input=data/dataset output=data/dataset/stats-report.json
|
|
```
|
|
|
|
To store compact dataset-only records (JSONL) and skip full per-game JSON files:
|
|
|
|
```sh
|
|
STORE_DATASET_ONLY=true DATASET_JSONL_PATH=data/dataset/good_moves.jsonl python main.py
|
|
```
|
|
|
|
Optional compact storage tuning:
|
|
- `DATASET_ROTATE_DAILY=true` creates one JSONL file per day (default: `true`)
|
|
- `DATASET_JSONL_MAX_MB=50` rotates when file reaches max size in MB (default: `50`)
|
|
- `DATASET_COMPRESS_ROTATED=true` gzip-compresses rotated/old JSONL files (default: `true`)
|
|
|
|
**Note:** To play games on [play.battlesnake.com](https://play.battlesnake.com) you'll need to deploy your Battlesnake to a live web server OR use a port forwarding tool like [ngrok](https://ngrok.com/) to access your server locally.
|