# Battlesnake Python Starter Project An official Battlesnake template written in Python. Get started at [play.battlesnake.com](https://play.battlesnake.com). ![Battlesnake Logo](https://media.battlesnake.com/social/StarterSnakeGitHubRepos_Python.png) 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 ` 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.