ai-code-battle/docs/plan/plan.md
jedarden a41728e99b docs(plan): note CF Pages serves replay data at initial volumes (<20k files)
R2 warm cache tier removed. Cloudflare Pages holds replay data directly
until file count approaches 20k, at which point storage strategy should
be reassessed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:36:50 -04:00

5440 lines
226 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# AI Code Battle — Implementation Plan
## 1. Overview
AI Code Battle is a competitive bot programming platform where participants write
HTTP servers that control units on a grid world. The game engine orchestrates
matches asynchronously, stores replays, and serves a web platform where visitors
watch rendered game replays and browse leaderboards. Matches are never live —
they are evaluated offline by match workers and presented as completed replays.
The platform ships with several built-in strategy bots, each deployed as its own
container, serving as both opponents for new participants and reference
implementations for the HTTP protocol.
---
## 2. System Architecture
The platform uses a **static-first** architecture. The public-facing product
is a **Cloudflare Pages** static site — all data visitors see (leaderboards,
match history, bot profiles, replays) is pre-computed JSON served from the CDN.
All compute runs in the **apexalgo-iad** Kubernetes cluster (Rackspace Spot),
which acts as a **match factory**: it runs battles, generates replays, and
periodically publishes the updated site to Pages.
Replay files are stored in and served directly from **Backblaze B2** (via Cloudflare CDN / Bandwidth Alliance). B2 is the single storage layer — all replays, thumbnails, bot cards, match metadata, and evolution status files live in B2. Free egress via Cloudflare Bandwidth Alliance (zero egress fees).
### Cloudflare (Static Tier)
- **Cloudflare Pages** (`ai-code-battle.pages.dev`): Hosts the static SPA
shell and **all** pre-computed JSON data — leaderboards, bot profiles, match
indexes, series, seasons, evolution data, blog posts. Updated every ~90
minutes by the K8s index builder via `wrangler pages deploy`. Global CDN,
zero-config TLS, instant cache invalidation on deploy.
### Backblaze B2 (Storage)
- **B2 bucket**: Permanent storage for **all** replay files, match metadata,
thumbnails, bot cards, and evolution status files. Match workers upload
directly to B2 after each match. Served to browsers via Cloudflare CDN
(Bandwidth Alliance = zero egress fees). S3-compatible API.
### apexalgo-iad (Compute Tier)
All backend compute runs in the `ai-code-battle` namespace:
- **Matchmaker Deployment**: Internal scheduler. Queries active bots from
PostgreSQL, computes pairings, enqueues job IDs into Valkey. Also handles
health checking and stale job reaping. No external exposure.
- **PostgreSQL (CNPG)**: Source of truth for all structured data — bots,
matches, jobs, ratings, predictions, series, seasons.
- **Valkey**: Job queue for match jobs, ephemeral caching.
- **Match Worker Deployment**: Dequeues jobs from Valkey (BRPOP), runs
matches, uploads replay JSON to B2 (cold archive), writes results to
PostgreSQL.
- **Strategy Bot Deployments** (x6): Built-in bots as HTTP servers on
cluster-internal Services.
- **Evolved Bot Deployments** (0-50): LLM-generated bots, same pattern.
- **Evolver Deployment**: LLM evolution pipeline. Reads match data from
PostgreSQL, generates candidates, tests them, deploys successful bots as
new K8s Deployments. Writes evolution metadata to PostgreSQL. Self-restarts
every 4h.
- **Index Builder Deployment**: Sleep-loop (15 min cycle). Reads PostgreSQL,
generates all JSON index files, deploys them to Cloudflare Pages via
`wrangler pages deploy`. Self-restarts every 4h.
### Go API (deferred)
A public Go API at `api.aicodebattle.com` is planned for social features
(predictions, commenting, voting) and third-party bot registration. This is
**not required for the core match loop** — the v1 system is fully static.
The API will be added when interactive features are needed.
### Data Architecture
Data is split across three tiers by access pattern and volume:
**Cloudflare Pages** (SPA + pre-computed indexes, deployed by index builder):
```
Pages project (ai-code-battle.pages.dev):
├── index.html, app.html, ... (SPA shell)
├── js/ (bundled TypeScript application)
│ ├── app.js (SPA router, data fetching)
│ ├── replay-viewer.js (Canvas replay renderer)
│ └── charts.js (win probability, meta charts)
├── css/ (stylesheets)
├── docs/ (protocol spec, replay format, guides)
├── img/ (logos, icons, UI assets)
├── embed.html (lightweight embeddable replay player)
├── data/ (pre-computed JSON indexes, rebuilt every ~15 min)
│ ├── leaderboard.json
│ ├── bots/
│ │ ├── index.json
│ │ └── {bot_id}.json
│ ├── matches/
│ │ └── index.json (recent matches, paginated)
│ ├── series/
│ │ ├── index.json
│ │ └── {series_id}.json
│ ├── seasons/
│ │ ├── index.json
│ │ └── {season_id}.json
│ ├── playlists/
│ │ └── {slug}.json
│ ├── meta/
│ │ ├── archetypes.json
│ │ └── rivalries.json
│ ├── evolution/
│ │ ├── lineage.json
│ │ └── meta.json
│ └── blog/
│ ├── index.json
│ └── posts/{slug}.json
└── maps/
├── index.json
└── {map_id}.json
```
**Backblaze B2** (storage — all replay data, served via Cloudflare CDN):
```
B2 bucket:
├── replays/
│ └── {match_id}.json.gz (ALL replay files, forever)
├── matches/
│ └── {match_id}.json (ALL per-match metadata)
├── thumbnails/
│ └── {match_id}.png (ALL thumbnails)
├── cards/
│ └── {bot_id}.png (ALL bot card images)
└── evolution/
└── live.json (evolver status, updated each cycle)
```
**Data loading pattern in the SPA:**
```js
// SPA shell + index data from Cloudflare Pages (same origin)
const PAGES = '' // relative — same origin as the SPA
const B2 = 'https://b2.aicodebattle.com' // B2 via Cloudflare CDN
// Leaderboard, bot profiles, match indexes — all from Pages (same origin):
const lb = await fetch(`${PAGES}/data/leaderboard.json`).then(r => r.json())
// Replay viewer — fetches directly from B2:
async function fetchReplay(matchId) {
return fetch(`${B2}/replays/${matchId}.json.gz`)
}
// Match metadata — fetches directly from B2:
const meta = await fetch(`${B2}/matches/${matchId}.json`).then(r => r.json())
```
**Cache behavior:**
- **Pages assets**: Cloudflare Pages handles caching automatically. Deploys
via `wrangler pages deploy` invalidate the cache globally. Index data is
at most ~90 minutes stale (the index builder's cycle time).
- **B2 objects**: Served via Cloudflare CDN with appropriate `Cache-Control` headers:
- `replays/*.json.gz`: `immutable, max-age=31536000` (content-addressed)
- `matches/*.json`: `immutable, max-age=31536000` (content-addressed)
- `thumbnails/`, `cards/`: `max-age=86400` (regenerated rarely)
- `evolution/live.json`: `max-age=10` (updated each evolver cycle)
B2 egress through Cloudflare Bandwidth Alliance = zero egress fees. Cloudflare CDN
caches B2 responses so frequently accessed replays perform well globally.
**Data flow:**
1. Match worker completes a match → uploads `replays/{match_id}.json.gz`
and `matches/{match_id}.json` to **B2**
2. Worker writes match result to **PostgreSQL** (scores, ratings, metadata)
3. Index builder (every ~15 min) reads new results from PostgreSQL, rebuilds
all JSON index files, deploys to **Pages** via `wrangler pages deploy`
4. Browser loads SPA + indexes from Pages, fetches replays and match data
directly from **B2** (via Cloudflare CDN)
**Storage budget:**
- **B2**: First 10 GB free, $0.006/GB/month after. At 60 matches/hour,
~2.2 GB/month for replays. Year one: ~26 GB ≈ $0.10/month.
Free egress via Cloudflare Bandwidth Alliance.
- **Pages**: 20K file limit per deployment. Only SPA + JSON indexes — well
within limits (replays and match data are on B2, not Pages).
```
┌────────── Cloudflare ──────────────────────────────────┐
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Cloudflare Pages (static site) │ │
│ │ │ │
│ │ SPA shell (HTML/JS/CSS) │ │
│ │ data/*.json indexes │ │
│ │ maps/*.json │ │
│ │ docs/, img/ │ │
│ └─────────────────────────────────────────────────┘ │
│ ▲ wrangler deploy │
└────────┼──────────────────────────────────────────────────┘
┌────────┼──────────────────────────────────────────────────┐
│ │ apexalgo-iad cluster — ai-code-battle ns │
│ │ │
│ ┌─────┴──────────────────┐ │
│ │ Index Builder Dep. │ │
│ │ Reads PostgreSQL, │ │
│ │ generates JSON indexes,│ │
│ │ deploys to Pages │ │
│ └─────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Matchmaker Dep. (internal only — no ingress) │ │
│ │ Pairings, job enqueue, health check, stale reaper │ │
│ └────────┬─────────────────────────────────────────────┘ │
│ │ │
│ ┌────────▼──────────────────────────────────────────────┐ │
│ │ CNPG PostgreSQL (cnpg-apexalgo) │ │
│ │ bots, matches, jobs, ratings, etc. │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Valkey (Redis-compatible) │ │
│ │ Job queue (acb:jobs:pending) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Match Workers (Deployment, 1-10 pods) │ │
│ │ BRPOP from Valkey, run matches, upload replays to B2, │ │
│ │ write results to PostgreSQL │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────────────────────────┼───────┐ │
│ │ Bot Containers (Deployments) │ │ │
│ │ Strategy (×6) + Evolved (050) │ │ │
│ └────────────────────────────────────────────────┼───────┘ │
│ │ │
│ ┌────────────────────────────────────────────────┼───────┐ │
│ │ Evolver (Deployment) │ │ │
│ │ LLM pipeline, writes evolution data to PG │ │ │
│ └────────────────────────────────────────────────┼───────┘ │
│ │ │
│ ┌─────────────────────────────────────────────┐ │ │
│ │ ArgoCD — syncs K8s manifests from git │ │ │
│ │ Argo Workflows — CI builds, image pushes │ │ │
│ └─────────────────────────────────────────────┘ │ │
│ ▼ │
└───────────────────────────────────────────────────┼──────────┘
┌─────────────▼──────────┐
│ Backblaze B2 │
│ (cold replay archive) │
│ │
│ replays/*.json.gz │
│ matches/*.json │
│ thumbnails/*.png │
│ cards/*.png │
│ ALL data, forever │
└─────────────────────────┘
```
### Component Summary
| Component | Where | Role |
|-----------|-------|------|
| **Cloudflare Pages** | Cloudflare | Static site: SPA (HTML/JS/CSS) and pre-computed JSON index files. Updated every ~90 min by the index builder via `wrangler pages deploy`. Global CDN with automatic cache invalidation. |
| **Backblaze B2** | Backblaze | Storage: ALL replays, match metadata, thumbnails, bot cards, and evolution status. Workers upload directly after each match. Served via Cloudflare CDN (Bandwidth Alliance = zero egress fees). |
| **Matchmaker** | Deployment (ai-code-battle ns) | Internal scheduler: computes pairings, enqueues jobs to Valkey, health checks bots, reaps stale jobs. No external exposure. |
| **PostgreSQL** | CNPG cluster (cnpg ns, `cnpg-apexalgo`) | Relational database — bot registry, match queue, ratings, results, series, seasons. Source of truth for structured data. |
| **Valkey** | Cluster service | Job queue (`acb:jobs:pending`), ephemeral caching. |
| **Match Workers** | Deployment (ai-code-battle ns) | Stateless match execution — BRPOP from Valkey, run simulation, upload replay to B2, write result to PostgreSQL. |
| **Bot Containers** | Deployments + Services (ai-code-battle ns) | Strategy bots (x6) + evolved bots (0-50) — HTTP servers called by workers during matches via cluster-internal Service DNS. |
| **Evolver** | Deployment (ai-code-battle ns) | Evolution pipeline — reads lineage/meta from PostgreSQL, generates candidates, writes evolution data to PostgreSQL. |
| **Index Builder** | Deployment (ai-code-battle ns) | Sleep-loop (15 min cycle). Reads PostgreSQL, generates JSON indexes, deploys to Pages. Self-restarts every 4h. |
| **Go API** | Deferred | Social features (predictions, comments, voting) and third-party bot registration. Not required for v1. |
| **ArgoCD** | Cluster (argocd ns) | GitOps: syncs all K8s manifests from git. All deployments are declarative. |
| **Argo Workflows** | Cluster (argo ns) | CI pipelines: builds container images, pushes to Forgejo registry, builds static site. |
---
## 3. Game Mechanics
### 3.1 Map & Grid
The game plays on a **toroidal grid** — a rectangular grid that wraps both
horizontally and vertically (no edges, no corners). This eliminates
positional advantages from map boundaries.
**Tile types:**
| Tile | Symbol | Description |
|------|--------|-------------|
| Open | `.` | Passable empty tile |
| Wall | `#` | Impassable barrier |
| Energy | `*` | Collectible resource (respawns) |
| Core | `C` | Player spawn point (owned by a player) |
**Grid parameters (configurable per match):**
| Parameter | Default | Range | Description |
|-----------|---------|-------|-------------|
| `rows` | 60 | 30120 | Grid height |
| `cols` | 60 | 30120 | Grid width |
| `wall_density` | 0.15 | 0.050.30 | Fraction of tiles that are walls |
| `energy_nodes` | 20 | 850 | Number of energy spawn locations |
| `cores_per_player` | 1 | 12 | Starting cores per player |
### 3.2 Units (Bots)
Each player controls **bots** — mobile units on the grid.
- Bots move one tile per turn in a cardinal direction: `N`, `E`, `S`, `W`
- Bots that do not receive a move order hold position
- Bots are binary — alive or dead, no hit points
- A bot ordered into a wall tile stays in place (order ignored)
- Two friendly bots ordered to the same tile: **both die** (self-collision)
- A bot ordered onto a tile occupied by a stationary enemy: **both die**
Each player starts with one bot spawned at each of their cores.
### 3.3 Energy & Economy
Energy is the sole resource. It is used to spawn new bots.
**Energy nodes:**
- Fixed positions on the map that periodically produce collectible energy
- Energy appears on a node every `energy_interval` turns (default: 10)
- When energy is present on a node, it is visible to any player who can see the tile
**Collection:**
- A bot adjacent to (or on) an energy tile collects it if no enemy bot is also
adjacent to that energy
- If bots from multiple players are adjacent to the same energy, the energy is
**destroyed** — nobody gets it (contested resources are denied)
- Collection happens after combat resolution each turn
**Spawning:**
- Cost: **3 energy** per bot
- Spawning happens automatically when a player has ≥3 energy and an unoccupied,
unrazed core
- One bot spawns per core per turn maximum
- If a player has multiple cores and enough energy, one bot spawns at each
eligible core simultaneously
- Spawn priority: core that has been idle longest
### 3.4 Combat
Combat uses a **focus fire** algorithm inspired by the aichallenge ants system.
This rewards formations and positioning over raw unit count.
**Attack radius:** squared Euclidean distance ≤ `attack_radius2`. Tuned per player count
to achieve target combat density (65-80% for 2-player, 100% for 3+):
| Player Count | `attack_radius2` | Distance | Rationale |
|--------------|-----------------|----------|-----------|
| 2-player | 25 | ~5 tiles | Tuned with zone_min_radius=2 to achieve 65-80% combat density; zone forces contact |
| 3+ player | 12 | ~3.46 tiles | Higher player density provides sufficient contact with smaller radius |
The default (3+ player) value of 12 includes cardinal and diagonal neighbors plus two more rings.
**Resolution (simultaneous):**
```
for each bot B on the grid:
enemies_of_B = count of enemy bots within attack_radius2 of B
for each enemy E within attack_radius2 of B:
enemies_of_E = count of E's enemies within attack_radius2 of E
if enemies_of_B >= enemies_of_E:
mark B as dead
break (B is already dead, no need to check further)
```
All deaths are resolved **simultaneously** — no cascading within a single turn.
**Key properties:**
- 2v1: the lone bot dies, the pair survives (superior numbers win cleanly)
- 1v1: both die (mutual destruction)
- Tight formations are defensive — a cluster facing scattered enemies takes
fewer losses because each bot in the cluster has a lower enemy count
- Multi-player battles create emergent alliances and third-party exploitation
### 3.5 Fog of War
Each player has limited visibility. Only tiles within `vision_radius2`
(default: **49**, ~7 tiles) of any owned bot are visible.
**What players see within their vision:**
- All tile types (open, wall, energy, core)
- Enemy bots and their owner IDs
- Dead bots (for one turn after death)
**What players do NOT see:**
- Anything outside their collective vision radius
- How much energy opponents have
- Total number of opponents (discovered through play)
**Walls** are sent every turn they are visible (no incremental discovery state —
keeps the protocol stateless-friendly for HTTP bots).
### 3.6 Scoring & Win Conditions
**Scoring:**
- Each player starts with **1 point per core** owned
- **Capturing a core** (enemy bot moves onto an undefended enemy core): **+2 points** to capturer, **1 point** to owner; core is razed
- **Razed cores** stop spawning but the player continues with remaining bots
- **Energy collected**: tracked as a tiebreaker statistic (not added to score)
- **Bots eliminated**: tracked as a statistic
**Win conditions (checked in order):**
| Condition | Trigger | Resolution |
|-----------|---------|------------|
| **Sole Survivor** | Only one player has living bots | That player wins; bonus +2 per surviving enemy core |
| **Annihilation** | All players eliminated simultaneously | Draw |
| **Dominance** | One player controls ≥80% of all bots for 100 consecutive turns | That player wins |
| **Turn Limit** | Turn count reaches `max_turns` (default: 500) | Highest score wins; ties broken by energy collected, then bots alive |
### 3.7 Turn Structure
Each turn executes in a strict, deterministic sequence:
```
1. Send game state to all players (HTTP POST, filtered by fog of war)
2. Await responses (up to 3-second timeout per player, in parallel)
3. Validate all responses against schema
4. Phase: MOVE — execute valid movement orders
5. Phase: COMBAT — resolve focus-fire algorithm, remove dead bots
6. Phase: ZONE — shrinking zone kills bots outside the safe radius
(Forces bots into contact range for combat engagement.
Zone starts at a configured turn and shrinks over time
until reaching a minimum radius. Bots outside the zone
are killed immediately, creating pressure to fight.)
7. Phase: CAPTURE — enemy bots on undefended cores raze them
(A core is undefended if no bot belonging to
the core's owner occupies the core's tile after
the attack phase resolves. An enemy bot on an
undefended core's tile razes it.)
8. Phase: COLLECT — uncontested energy adjacent to bots is collected
9. Phase: SPAWN — players with ≥3 energy spawn bots at eligible cores
10. Phase: ENERGY_TICK — energy nodes on their interval produce new energy
11. Phase: ENDGAME — check win conditions
11. Record turn state for replay
```
All player requests in step 1 are sent **concurrently**. Responses are collected
with the 3-second deadline. The engine does not proceed to step 3 until all
responses are in or timed out.
All player requests in step 1 are sent **concurrently**. Responses are collected
with the 3-second deadline. The engine does not proceed to step 3 until all
responses are in or timed out.
#### 3.7.1 Zone Parameters
The shrinking zone forces bots into contact range, ensuring combat engagement
rather than pure energy farming. Zone parameters are tuned per player count:
| Parameter | 2-Player | 3+ Player | Description |
|-----------|----------|-----------|-------------|
| ZoneStartTurn | 10 | 10 | Turn when zone begins shrinking |
| ZoneShrinkInterval | 1 | 1 | Turns between shrink steps |
| ZoneShrinkStep | 1 | 1 | Tiles to shrink each step |
| ZoneMinRadius | 2 | 1 | Minimum zone radius (stops shrinking) |
**Design rationale:**
- **ZoneStartTurn = 10**: Starts early to force combat before energy farming dominates.
Both 2-player and 3+ use the same start turn for consistent forcing function timing.
- **ZoneShrinkInterval = 1**: Shrinks every turn creates steady, predictable pressure.
Faster than the original 2-turn interval to ensure bots reach contact range before
the match is decided by energy alone.
- **ZoneShrinkStep = 1**: Zone shrinks at the same rate as bot movement (1 tile/turn).
A value of 2 caused the zone to shrink faster than bots could move, killing them
before combat could occur. The value of 1 ensures bots have time to reach each other
and engage in combat while still being forced into contact range.
- **ZoneMinRadius = 2 (2-player)**: Final zone diameter (4 tiles) ≤ 2 × attack radius (10 tiles),
ensuring bots at opposite zone edges are within attack range (5 tiles).
- **ZoneMinRadius = 1 (3+ player)**: Final zone diameter (2 tiles) is smaller than attack
radius (3.5 tiles), guaranteeing any two bots in the final zone are within attack range.
This is necessary because 3+ player maps have higher player density and a smaller attack
radius (3.5 tiles vs 5 tiles for 2-player).
**Combat density metrics** (verified with local testing):
- 2-player: ~65-80% of matches have combat_deaths; ~1 death per 20 turns
- 6-player: 100% of matches have combat_deaths; ~1 death per 5-6 turns
The zone achieves its forcing function: bots must fight or die, while maintaining
strategic depth (early game positioning matters, not just pure chaos).
### 3.8 Map Generation
Maps are generated offline and stored in the map library. They are not generated
on-the-fly during matches.
**Symmetry requirements:**
- 2-player maps: 180° rotational symmetry (point symmetry through center)
- 3-player maps: 120° rotational symmetry
- 4-player maps: 90° rotational symmetry
- 6-player maps: 60° rotational symmetry
**Generation algorithm:**
1. Generate one **sector** (1/N of the map for N players)
2. Place walls using cellular automata (random seed → smooth with neighbor rules)
3. Place cores and energy nodes within the sector
4. Validate connectivity: BFS from core must reach all energy nodes and the
sector boundary
5. Mirror/rotate the sector to fill the full map
6. Validate full-map connectivity: all cores must be reachable from each other
7. Store the map with metadata (player count, dimensions, wall density)
**Map library:**
- Pre-generated pool of 50+ maps per player count (2, 3, 4, 6)
- Maps are curated — auto-generated then play-tested with strategy bots
- Matchmaking selects the least-recently-used map for each match
---
## 4. Communication Protocol
### 4.1 HTTP Interface
The game engine communicates with bots via HTTP POST requests. Each bot exposes
a single endpoint.
**Bot endpoint:** `POST {bot_base_url}/turn`
The engine sends the game state as a JSON body. The bot responds with its moves
as a JSON body. No other endpoints are required from the bot (though `/health` is
recommended for registration validation).
**Request flow per turn:**
```
Engine Bot
│ │
│ POST /turn │
│ Headers: auth + metadata │
│ Body: game state JSON │
│─────────────────────────────►│
│ │ (bot computes moves)
│ 200 OK │
│ Body: moves JSON │
│◄─────────────────────────────│
│ │
```
### 4.2 Game State Schema (Engine → Bot)
```json
{
"match_id": "m_7f3a9b2c",
"turn": 42,
"config": {
"rows": 60,
"cols": 60,
"max_turns": 500,
"vision_radius2": 49,
"attack_radius2": 12,
"spawn_cost": 3,
"energy_interval": 10
},
"you": {
"id": 0,
"energy": 7,
"score": 3
},
"bots": [
{ "row": 10, "col": 15, "owner": 0 },
{ "row": 12, "col": 15, "owner": 0 },
{ "row": 30, "col": 40, "owner": 1 }
],
"energy": [
{ "row": 20, "col": 25 }
],
"cores": [
{ "row": 5, "col": 5, "owner": 0, "active": true },
{ "row": 55, "col": 55, "owner": 1, "active": true }
],
"walls": [
{ "row": 10, "col": 10 },
{ "row": 10, "col": 11 }
],
"dead": [
{ "row": 15, "col": 20, "owner": 1 }
]
}
```
**Schema rules:**
- `bots`, `energy`, `cores`, `walls`, `dead` -- only includes tiles within the
player's collective vision
- `owner` IDs are consistent within a match but randomized per match (player 0
is always "you")
- `config` is identical for all players and does not change between turns
- `walls` are sent every turn they are visible (stateless -- bot does not need to
track previously seen walls, though smart bots will)
- `dead` contains bots that died on the previous turn (visible for one turn)
**Future additive fields:** the game state schema is designed for forward
compatibility. Future seasons may add optional fields to `config` (e.g.,
`season_id`, `rules_version`, `special_tiles`, `terrain`) without breaking
existing bots. See the seasonal backward compatibility rules in §14.9. Bots
that do not read new fields continue to function normally.
### 4.3 Move Schema (Bot -> Engine)
```json
{
"moves": [
{ "row": 10, "col": 15, "direction": "N" },
{ "row": 12, "col": 15, "direction": "E" }
],
"debug": {
"reasoning": "3 energy within 5 tiles east; enemy cluster north — avoiding",
"targets": [
{ "row": 20, "col": 25, "label": "energy", "priority": 0.9 }
]
}
}
```
The `debug` field is entirely optional. When present, it is stored in the
replay for visualization in the replay viewer but is never parsed or acted
upon by the engine. See §13.1 for the full debug telemetry specification.
**Validation rules:**
- `moves` must be an array (may be empty -- all bots hold position)
- Each move must reference a `(row, col)` where the player owns a bot
- `direction` must be one of: `"N"`, `"E"`, `"S"`, `"W"`
- Duplicate `(row, col)` entries: first valid entry wins, rest ignored
- Moves referencing tiles with no owned bot: ignored
- Moves into walls: ignored (bot stays)
- Any response that fails top-level schema validation: entire response
discarded, all bots hold
- **The engine never parses, evaluates, or interprets any field beyond
`moves[].row`, `moves[].col`, `moves[].direction`** (and the optional
`debug` field, which is pass-through to the replay)
### 4.4 Authentication (HMAC Shared Secret)
Each registered bot has a **shared secret** generated at registration time. The
secret is known only to the bot owner and the game engine. It authenticates both
directions — the bot can verify requests came from the real game engine, and the
engine can verify responses came from the real bot.
**Engine → Bot (request signing):**
Headers sent with every request:
```
X-ACB-Match-Id: m_7f3a9b2c
X-ACB-Turn: 42
X-ACB-Timestamp: 1711200000
X-ACB-Bot-Id: b_4e8c1d2f
X-ACB-Signature: <hex-encoded HMAC-SHA256>
```
Signature computation:
```
signing_string = "{match_id}.{turn}.{timestamp}.{sha256(request_body)}"
signature = HMAC-SHA256(shared_secret, signing_string)
```
The bot verifies:
1. Compute the expected signature from the headers and request body
2. Compare with `X-ACB-Signature` (constant-time comparison)
3. Verify `X-ACB-Timestamp` is within ±30 seconds of current time (prevents
replay attacks)
4. If verification fails: bot should return 401 and ignore the request
**Bot → Engine (response signing):**
Response headers:
```
X-ACB-Signature: <hex-encoded HMAC-SHA256>
```
Signature computation:
```
signing_string = "{match_id}.{turn}.{sha256(response_body)}"
signature = HMAC-SHA256(shared_secret, signing_string)
```
The engine verifies the response signature. If invalid, the response is
discarded (bots hold position). This prevents man-in-the-middle from
injecting moves.
**Why HMAC over OAuth/JWT/mTLS:**
- Minimal complexity — no token refresh, no certificate management
- Bot developers add a single header computation, not an auth library
- Symmetric: both sides can verify the other with the same secret
- Sufficient for the threat model (prevent impersonation and tampering)
**Secret management:**
- Secrets are generated as 256-bit random values, hex-encoded (64 characters)
- Displayed once at registration time; bot owner must save it
- Can be rotated via the web platform (old secret invalidated immediately)
- Stored encrypted (AES-256-GCM) in the database. The master encryption key
is held in an environment variable (from SealedSecret), never in the
database. HMAC
verification requires the raw secret, so hashing is not viable -- the
engine decrypts on each request.
### 4.5 Timeout & Error Handling
| Scenario | Behavior |
|----------|----------|
| Bot responds within 3s | Moves validated and applied normally |
| Bot responds after 3s | Response discarded; bots hold position for that turn |
| Bot returns non-200 status | Treated as timeout; bots hold position |
| Bot returns invalid JSON | Treated as timeout; bots hold position |
| Bot returns valid JSON failing schema | Entire response discarded; bots hold position |
| Bot connection refused | Bots hold position; engine retries next turn |
| Bot connection timeout (TCP) | Engine uses 2s connect timeout within the 3s budget |
| 10 consecutive failures | Bot marked as **crashed** for this match; bots become inert for remaining turns |
The bot is **never killed or disconnected**. Even after being marked crashed, the
match continues -- the crashed bot's units simply hold position every turn until
they are destroyed or the match ends.
**Rating impact of crashes:** Matches where a bot crashes or times out still
count toward Glicko-2 ratings. The crashed bot receives a loss. This prevents
intentional crashing as a loss-avoidance strategy.
---
## 5. Strategy Bots
Six built-in strategy bots serve as reference implementations and permanent
ladder opponents. Each is implemented in a **different programming language**
to demonstrate that the HTTP protocol is truly language-agnostic and to
provide starter code for participants across the most popular ecosystems.
Each bot is deployed as its own container running a lightweight HTTP server.
| Bot | Language | Complexity | Expected Rank |
|-----|----------|------------|---------------|
| RandomBot | Python | Trivial | 6th (floor) |
| GathererBot | Go | Low | 4th5th |
| RusherBot | Rust | Low | 4th5th |
| GuardianBot | PHP | Medium | 3rd4th |
| SwarmBot | TypeScript | Medium | 1st2nd |
| HunterBot | Java | High | 1st2nd |
### 5.1 RandomBot — Python
**Language rationale:** Python is the most accessible language for newcomers.
The random bot doubles as the simplest possible starter template — a
participant can fork it and have a working bot in minutes.
**Strategy:** Makes uniformly random valid moves each turn.
**Behavior:**
- For each owned bot, pick a random direction (N/E/S/W) or hold (20% chance)
- No pathfinding, no memory, no awareness of enemies
- Serves as the absolute baseline — any reasonable bot should beat this
**Value:** Ensures new participants have an easy opponent to test against.
Rating floor anchor.
**Implementation:** Flask or bare `http.server`. ~50 lines of strategy code.
HMAC verification via `hmac` stdlib module.
### 5.2 GathererBot — Go
**Language rationale:** Go is the same language as the game engine and
platform services, making this the canonical "how to build a bot" reference.
Demonstrates idiomatic Go HTTP server patterns.
**Strategy:** Maximize energy collection, avoid combat entirely.
**Behavior:**
- BFS from each owned bot to the nearest visible energy
- Assign each bot to the closest uncontested energy (greedy matching)
- If an enemy bot is within vision, move away from it
- Never voluntarily enters attack range of an enemy
- Spawns bots as fast as energy allows
**Value:** Tests whether aggressive bots can actually close games or whether
passive resource hoarding is dominant (it shouldn't be).
**Implementation:** `net/http` stdlib server. Shared `game/` package with
grid utilities, BFS, and distance calculations that participants can reuse.
### 5.3 RusherBot — Rust
**Language rationale:** Rust participants get maximum compute within the
3-second timeout. This bot demonstrates that Rust's performance advantage
matters less than strategy — a dumb fast bot still loses to a smart slow one.
**Strategy:** Identify and rush the nearest enemy core as fast as possible.
**Behavior:**
- BFS from each owned bot toward the nearest known enemy core
- If no enemy core is known, spread out to explore (random walk with
bias toward unexplored territory)
- Ignores energy except incidentally (walks over it)
- Ignores enemy bots unless they block the path
- Spawns bots immediately and sends all toward the target
**Value:** Punishes bots that neglect defense. Tests whether the combat
system allows pure aggression to dominate (it shouldn't — rusher bots will
walk into defensive formations and die).
**Implementation:** `axum` or `actix-web`. Serde for JSON. HMAC via `hmac`
and `sha2` crates. Demonstrates Rust's zero-copy deserialization.
### 5.4 GuardianBot — PHP
**Language rationale:** PHP is often overlooked in competitive programming
but is widely known and trivially deployable. This demonstrates that even
PHP — without async, without frameworks — can compete on equal footing
when the interface is HTTP. Lowers the barrier for the large PHP developer
community.
**Strategy:** Defend own core, gather nearby energy, cautious expansion.
**Behavior:**
- Maintain a perimeter of bots within 5 tiles of each owned core
- Assign excess bots (beyond perimeter needs) to gather energy within
10 tiles of a core
- If enemy bots are spotted approaching, consolidate defenders between
the enemy and the core
- Only sends scouts (lone bots) to explore beyond the safe zone
- Very conservative spawning — maintains energy reserve of 6
**Value:** Tests whether turtling is viable. Should beat rushers but lose to
gatherers/swarms in the long game (inferior economy due to limited territory).
**Implementation:** PHP built-in server (`php -S`) with a single router
script. `hash_hmac()` for HMAC. JSON via `json_decode`/`json_encode`.
BFS implemented with `SplQueue`.
### 5.5 SwarmBot — TypeScript
**Language rationale:** TypeScript (Node.js) is the most popular language
for web developers entering the platform. This bot demonstrates maintaining
complex state across turns — the swarm's formation tracking, rally points,
and center-of-mass calculation benefit from TypeScript's type system.
**Strategy:** Keep units in tight formations, advance as a group toward enemies.
**Behavior:**
- All bots maintain cohesion — no bot moves if it would be >3 tiles from the
nearest friendly bot
- The swarm moves as a unit toward the nearest enemy presence
- BFS-based center-of-mass steering: average position of all owned bots
is the swarm center; steer toward enemy center of mass
- Energy collection is incidental (pass over it during advance)
- New spawns rally to the swarm before advancing
**Value:** Exploits the focus combat system — a tight group defeats scattered
enemies. But slow expansion means inferior economy. Should dominate combat
but can be outscored by gatherers on large maps.
**Implementation:** Express.js or Fastify. State persisted in-process across
turns (the HTTP server stays alive between requests). HMAC via Node.js
`crypto` module. Typed interfaces for game state and moves.
### 5.6 HunterBot — Java
**Language rationale:** Java is dominant in competitive programming (Battlecode
is Java-only). This is the most sophisticated strategy bot, demonstrating
that Java's verbosity is offset by mature data structures (`PriorityQueue`,
`HashMap`) and predictable GC behavior within the timeout window.
**Strategy:** Target isolated enemy bots for efficient kills.
**Behavior:**
- Identify enemy bots that are ≥4 tiles from their nearest friendly bot
(isolated targets)
- Send pairs of bots to intercept isolated enemies (2v1 wins cleanly)
- If no isolated targets, default to gatherer behavior
- Maintain a map of known enemy positions across turns, predict movement
based on last-seen direction and speed
- Avoid engaging formations of 3+ enemy bots
- Opportunistic energy collection when not actively hunting
**Value:** Sophisticated target selection and prediction. Represents an
intermediate-to-advanced-skill bot. Should beat random/gatherer/rusher but
struggle against swarm formations.
**Implementation:** Javalin or `com.sun.net.httpserver`. `javax.crypto.Mac`
for HMAC. Maintains a `HashMap<Position, EnemyTracker>` across turns for
movement prediction. Hungarian algorithm for optimal bot-to-target assignment.
### 5.7 Container Templates
Each language has its own container structure. All share the same contract:
listen on port 8080, serve `POST /turn` and `GET /health`.
**Go (GathererBot):**
```
strategy-gatherer/
├── Dockerfile
├── main.go # HTTP server, HMAC verification
├── strategy.go # Gatherer-specific logic
├── game/
│ ├── state.go # Game state types
│ ├── grid.go # Grid utilities (BFS, distance, wrapping)
│ └── moves.go # Move response types
└── go.mod
```
**Python (RandomBot):**
```
strategy-random/
├── Dockerfile
├── main.py # HTTP server, HMAC verification, strategy
├── game.py # Game state types and grid utilities
└── requirements.txt # (minimal — stdlib only for random bot)
```
**Rust (RusherBot):**
```
strategy-rusher/
├── Dockerfile
├── Cargo.toml
└── src/
├── main.rs # HTTP server, HMAC verification
├── strategy.rs # Rusher-specific logic
└── game.rs # Game state types, grid utilities
```
**PHP (GuardianBot):**
```
strategy-guardian/
├── Dockerfile
├── index.php # Router + HMAC verification
├── strategy.php # Guardian-specific logic
├── game.php # Game state types, BFS, grid utilities
└── composer.json # (optional — no external deps needed)
```
**TypeScript (SwarmBot):**
```
strategy-swarm/
├── Dockerfile
├── package.json
├── tsconfig.json
└── src/
├── index.ts # HTTP server, HMAC verification
├── strategy.ts # Swarm-specific logic
└── game.ts # Game state types, grid utilities
```
**Java (HunterBot):**
```
strategy-hunter/
├── Dockerfile
├── pom.xml
└── src/main/java/com/acb/hunter/
├── App.java # HTTP server, HMAC verification
├── Strategy.java # Hunter-specific logic
├── GameState.java # Game state deserialization
└── Grid.java # Grid utilities, BFS, distance
```
**Shared contract (all languages):**
- Listen on port 8080
- `POST /turn` — receives game state, runs strategy, returns moves
- `GET /health` — returns 200 (used for registration health check)
- HMAC signature verification on incoming requests
- HMAC signature on outgoing responses
- Request logging (turn number, compute time, move count)
**Container specs:**
| Bot | Build Image | Runtime Image | Memory Limit | CPU Limit |
|-----|-------------|---------------|-------------|-----------|
| RandomBot | `python:3.13-slim` | `python:3.13-slim` | 64MB | 0.1 cores |
| GathererBot | `golang:1.24-alpine` | `alpine:3.21` | 128MB | 0.25 cores |
| RusherBot | `rust:1.85-alpine` | `alpine:3.21` | 128MB | 0.25 cores |
| GuardianBot | `php:8.4-cli-alpine` | `php:8.4-cli-alpine` | 128MB | 0.25 cores |
| SwarmBot | `node:22-alpine` | `node:22-alpine` | 128MB | 0.25 cores |
| HunterBot | `eclipse-temurin:21-alpine` | `eclipse-temurin:21-jre-alpine` | 256MB | 0.5 cores |
Java gets a higher resource allocation due to JVM overhead. All others are
intentionally constrained — strategy bots should be lightweight.
### 5.8 Starter Kit & SDK Libraries
To lower the barrier for participants writing their own bots, the platform
provides **starter kits** for each supported language. Each starter kit is a
minimal, forkable repository containing:
- A working HTTP server with HMAC verification already implemented
- Type definitions for the game state and move schemas
- Grid utility functions (toroidal distance, BFS, neighbor enumeration)
- A stub strategy function that holds all bots in place (participant fills in)
- A Dockerfile that builds and runs the bot
- A README with quickstart instructions
**Starter kit languages (matching strategy bots):**
| Kit | Repository | Notes |
|-----|-----------|-------|
| `acb-starter-python` | Template repo | Flask-based, ~100 lines total |
| `acb-starter-go` | Template repo | Shares `game/` package with GathererBot |
| `acb-starter-rust` | Template repo | `axum` + `serde`, strongly typed |
| `acb-starter-php` | Template repo | Zero dependencies, built-in server |
| `acb-starter-typescript` | Template repo | Fastify, full type definitions |
| `acb-starter-java` | Template repo | Javalin, Maven-based |
| `acb-starter-javascript` | Template repo | Node.js built-in http, zero dependencies |
| `acb-starter-csharp` | Template repo | ASP.NET Core minimal API, zero external dependencies |
Participants are not limited to these languages. Any language that can serve
HTTP and compute HMAC-SHA256 can compete. The starter kits simply eliminate
boilerplate for the most common choices.
---
## 6. Tournament System
### 6.1 Matchmaking
Matches are created continuously by the **tournament scheduler**, a
process that runs on a fixed interval (default: every 10 seconds).
**Algorithm:**
1. **Select seed bot**: the registered bot with the most time since its last
match (tiebreak: lowest bot ID)
2. **Determine match size**: based on the seed bot's least-played format
(2-player, 3-player, 4-player, or 6-player)
3. **Select opponents**: from the eligible pool, preferring:
a. Closest skill rating to seed (Pareto distribution: 80% within 16 ranks)
b. Least recently paired with the seed
c. Fewest games played in the last 24 hours (keeps game counts even)
4. **Select map**: least recently used map for the chosen player count
5. **Assign player slots**: random
6. **Create match job**: push to Redis queue with match config + bot endpoints
**Eligibility:**
- Bot must be registered and active (passed health check within last hour)
- Bot must not be in a match currently (one match at a time per bot)
- Bot must not have been marked crashed in its last 3 consecutive matches
(cooldown: 30 minutes)
### 6.2 Rating System
**Algorithm: Glicko-2**
Glicko-2 is preferred over TrueSkill for this platform because:
- No licensing concerns (TrueSkill is patented by Microsoft)
- Includes a volatility parameter (σ) that adapts to inconsistent performance
- Well-suited to multi-player games via pairwise decomposition
- Established in competitive gaming (chess, Go, online games)
**Parameters per bot:**
- `mu` (μ): rating estimate (default: 1500)
- `phi` (φ): rating deviation / uncertainty (default: 350)
- `sigma` (σ): rating volatility (default: 0.06)
**Display rating:** `mu - 2*phi` (conservative estimate shown on leaderboard)
**Update frequency:** after every match. Ratings converge quickly — a new bot
reaches a stable rating within ~30 matches.
**Multi-player adaptation:**
- A 4-player match produces 6 pairwise results (every pair of players)
- Each pairwise result is: win/loss based on relative score, or draw if equal
- Glicko-2 update is applied once per match using all pairwise outcomes
### 6.3 Continuous Tournament
The tournament runs indefinitely with no seasons or resets (initially).
**Match throughput target:** enough matches that every active bot plays at
least 10 matches per day. With N active bots and M match workers:
- 2-player matches: each match involves 2 bots, takes ~3 minutes (500 turns × 3s max + overhead)
- One worker produces ~20 matches/hour
- 3 workers: ~60 matches/hour, ~1440/day — supports ~288 active bots at 10 games/day
**Scaling:** add more match worker replicas to increase throughput.
---
## 7. Replay System
### 7.1 Replay Data Format
Replays are JSON files optimized for compact storage while supporting full
client-side reconstruction of every game turn.
```json
{
"version": 1,
"match_id": "m_7f3a9b2c",
"date": "2026-03-23T14:30:00Z",
"players": [
{ "bot_id": "b_4e8c1d2f", "name": "SwarmBot", "owner": "alice" },
{ "bot_id": "b_9a1b3c4d", "name": "HunterBot", "owner": "bob" }
],
"result": {
"winner": 0,
"condition": "turn_limit",
"final_scores": [7, 3],
"final_energy": [12, 4],
"final_bots": [18, 6]
},
"config": {
"rows": 60,
"cols": 60,
"max_turns": 500,
"vision_radius2": 49,
"attack_radius2": 12,
"spawn_cost": 3,
"energy_interval": 10
},
"map": {
"walls": [[10,10], [10,11], [10,12]],
"energy_nodes": [[20,25], [40,35]],
"cores": [
{ "pos": [5,5], "owner": 0 },
{ "pos": [55,55], "owner": 1 }
]
},
"turns": [
{
"moves": {
"0": [{"from":[10,15],"dir":"N"},{"from":[12,15],"dir":"E"}],
"1": [{"from":[50,45],"dir":"S"}]
},
"spawns": [[5,5,0]],
"deaths": [[30,40,1]],
"captures": [],
"energy_collected": {"0": [[20,25]]},
"energy_spawned": [[35,15]],
"scores": [3, 1]
}
]
}
```
**Size estimate:** a 500-turn, 4-player match with ~50 bots total produces
a replay of ~200500 KB uncompressed, ~3080 KB gzipped.
**Optimization:** for very long matches, the `turns` array can use delta
encoding — only recording events that changed from the previous turn.
### 7.2 Storage
All replay files, match metadata, thumbnails, bot cards, and evolution status
files are stored in **Backblaze B2** and served via Cloudflare CDN (Bandwidth
Alliance). Pre-computed JSON index files are deployed to **Cloudflare Pages**
by the index builder. No PersistentVolumes are used for web-facing data.
**B2 data layout** (all data — served via Cloudflare CDN):
```
replays/{match_id}.json.gz # ALL replay files
matches/{match_id}.json # ALL per-match metadata
thumbnails/{match_id}.png # ALL match thumbnails
cards/{bot_id}.png # ALL bot profile card images
evolution/live.json # evolver status (updated each cycle)
```
**Pages data layout** (static site):
```
data/leaderboard.json # current leaderboard snapshot
data/bots/index.json # bot directory
data/bots/{bot_id}.json # per-bot profile (rating history, recent matches)
data/matches/index.json # paginated match list (last 1000)
data/series/index.json # series directory
data/seasons/index.json # seasons directory
data/playlists/{slug}.json # auto-curated collections
data/evolution/lineage.json # evolution lineage graph
data/evolution/meta.json # current meta/Nash snapshot
data/blog/index.json # blog post directory
data/blog/posts/{slug}.json # individual blog posts
maps/index.json # map directory
maps/{map_id}.json # map definitions
```
**How data flows:**
1. Match worker completes a match → uploads `replays/{match_id}.json.gz`
and `matches/{match_id}.json` to **B2** (via S3-compatible API)
2. Worker writes match result to **PostgreSQL** (scores, ratings, metadata)
3. Index builder (every ~15 min) reads new results from PostgreSQL, rebuilds
all JSON index files, deploys to **Pages** via `wrangler pages deploy`
4. Browser loads SPA + indexes from Pages, fetches replays and match data
directly from **B2** (via Cloudflare CDN)
**Retention:**
- **B2**: All replays retained permanently. No pruning. The canonical
store.
- **PostgreSQL**: Match metadata retained indefinitely (rows are small).
- Index files are append-with-rotation: `index.json` holds the last 1000;
older pages at `index-{page}.json`.
**Storage costs:**
- **B2**: First 10 GB free, $0.006/GB/month after. At 60 matches/hour,
~2.2 GB/month for replays. Year one: ~26 GB ≈ $0.10/month. Free
egress via Cloudflare Bandwidth Alliance.
- **Pages**: No per-file storage costs. 20K file limit per deployment — only
SPA + JSON indexes, well within limits.
### 7.3 Browser Replay Viewer
The replay viewer is a client-side TypeScript application rendered on
HTML5 Canvas.
**Rendering pipeline:**
1. Fetch `replay.json.gz` from B2 (via Cloudflare CDN); browser handles
gzip decompression via `Accept-Encoding`
2. Parse and index: build per-turn game state by replaying events from turn 0
3. Render the current turn to canvas
4. User controls advance/rewind the turn index
No API invocations — the viewer is a static page (served from Pages) loading
a replay file from B2 (via Cloudflare CDN).
**Visual design:**
| Element | Rendering |
|---------|-----------|
| Grid | Subtle grid lines on dark background |
| Walls | Dark gray filled squares |
| Open tiles | Transparent (background shows through) |
| Energy nodes | Small yellow diamond; pulse animation when energy is present |
| Cores | Large player-colored circle with ring; X overlay when razed |
| Bots | Player-colored filled circles; brief trail showing last move direction |
| Dead bots | Fading red X for one turn |
| Fog of war | Dark semi-transparent overlay on tiles outside selected player's vision |
| Combat | Flash effect on tiles where kills occurred |
**Controls:**
| Control | Function |
|---------|----------|
| Play / Pause | Toggle automatic playback |
| Speed slider | 1x, 2x, 4x, 8x, 16x (turns per second: 2, 4, 8, 16, 32) |
| Turn scrubber | Drag to any turn; displays turn number |
| Perspective dropdown | "All" (omniscient) or per-player fog of war view |
| Zoom | Scroll to zoom; drag to pan |
| Score overlay | Per-player score, energy, bot count — updates each turn |
| Minimap | Small overview of full grid in corner (for large maps) |
**Shareable URLs:** `https://aicodebattle.com/replay/{match_id}` — the
replay viewer is the landing page for any match. No login required to watch.
---
## 8. Web Platform
The web platform spans **Cloudflare** (static site and data files) and the
**apexalgo-iad** Kubernetes cluster (API, compute, databases). **Cloudflare
Pages** serves the SPA globally. **Backblaze B2** (via Cloudflare CDN) stores
replays and all per-match data. The **Go API Deployment** on K8s handles
registration, job coordination, and scheduling logic. **CNPG PostgreSQL** stores
all relational data. **Valkey** provides the job queue and caching. No
PersistentVolumes are used for web-facing data.
### 8.1 Cloudflare Pages (Static Site)
The website is a static SPA hosted on **Cloudflare Pages**. The SPA shell
(HTML/JS/CSS/WASM) and all pre-computed JSON data files are deployed as a
single Pages project. The index builder on K8s updates the data files every
~90 minutes via `wrangler pages deploy`.
```
/ → Landing page, featured replays, leaderboard summary
/leaderboard → Full leaderboard (fetches data/leaderboard.json from Pages)
/matches → Match history (fetches data/matches/index.json from Pages)
/replay/{match_id} → Replay viewer (fetches replays/{match_id}.json.gz from B2)
/bot/{bot_id} → Bot profile (fetches data/bots/{bot_id}.json from Pages)
/evolution → Evolution dashboard (fetches data/evolution/*.json from Pages + evolution/live.json from B2)
/register → Bot registration form (submits to Go API on K8s)
/docs → Protocol spec, starter kit links, getting started
```
**Build:** Vite + TypeScript. Code changes are built by an Argo Workflow
(triggered by Argo Events on git push), which runs `npm run build`. The
build output is stored as a container artifact; the index builder merges it
with the latest data files and deploys to Pages via `wrangler pages deploy`.
No build-time data fetching -- all data loaded at runtime.
**Data loading pattern:**
```js
// SPA shell + index data from Pages (same origin)
const leaderboard = await fetch('/data/leaderboard.json').then(r => r.json())
// Replays from B2 (cross-origin, CORS enabled on B2 bucket)
const replay = await fetch(`https://b2.aicodebattle.com/replays/${matchId}.json.gz`)
// Dynamic operations from K8s API
const result = await fetch('https://api.aicodebattle.com/api/register', { method: 'POST', body: ... })
```
Index JSON files are rebuilt and deployed to Pages every ~15 minutes by
the index builder (with actual Pages deploys batched every ~90 minutes to
stay well within Cloudflare's deploy limits). Visitors see index data that
is at most ~90 minutes old. Replays and per-match metadata are uploaded to
B2 in real time by match workers and available immediately.
### 8.2 Go API Service
A single Go HTTP service (`acb-api`) handles all server-side logic. It runs
as a Deployment in the `ai-code-battle` namespace with a ClusterIP Service.
Traefik routes `api.aicodebattle.com` to it via an IngressRoute (TLS via
cert-manager). The API serves only dynamic endpoints -- no static files.
It connects to CNPG PostgreSQL for persistent state and Valkey for the job
queue.
**API endpoints (HTTP routes):**
```
POST /api/register → register a new bot
POST /api/rotate-key → rotate a bot's shared secret
GET /api/status/{bot_id} → check bot health status
POST /api/jobs/{id}/result → worker submits match result metadata (authenticated)
```
**Internal scheduling (goroutine tickers, not external crons):**
| Ticker | Interval | What It Does |
|--------|----------|--------------|
| Matchmaker | Every 1 min | Queries active bots from PostgreSQL, computes pairings, enqueues jobs in Valkey |
| Health checker | Every 15 min | Pings each active bot's `/health` endpoint via cluster-internal Service DNS, updates status in PostgreSQL |
| Stale job reaper | Every 5 min | Marks jobs running >15 min as abandoned, re-enqueues in Valkey |
Index building runs as a separate Deployment (see below) that reads directly
from PostgreSQL and deploys generated JSON indexes to Cloudflare Pages.
**Match worker coordination:**
Match workers dequeue jobs from Valkey (BRPOP on a job queue list). The Go
API enqueues job IDs; workers fetch full job config from PostgreSQL. Workers
submit results back via `POST /api/jobs/{id}/result`. All communication is
cluster-internal (no external API calls needed).
**Authentication:**
The `/api/jobs/{id}/result` endpoint is called by match workers within the
cluster. Workers authenticate with a shared API key stored in a SealedSecret
and mounted as an environment variable. External-facing endpoints
(`/api/register`, `/api/rotate-key`, `/api/status`) are public.
### 8.3 PostgreSQL (CNPG)
The platform uses the existing CNPG PostgreSQL cluster (`cnpg-apexalgo`) in
the `cnpg` namespace. A dedicated database `acb` is created within the
cluster. The Go API connects via the CNPG-managed Service
(`cnpg-apexalgo-rw.cnpg.svc.cluster.local`). Credentials are stored in a
SealedSecret.
**Consolidated schema** (all tables referenced throughout this plan):
```sql
-- §8.3: Core tables
CREATE TABLE bots (
bot_id VARCHAR(16) PRIMARY KEY, -- e.g. 'b_4e8c1d2f'
name VARCHAR(32) UNIQUE NOT NULL,
owner VARCHAR(128) NOT NULL,
endpoint_url TEXT NOT NULL,
shared_secret VARCHAR(64) NOT NULL, -- encrypted, see §4.4
status VARCHAR(16) NOT NULL DEFAULT 'pending',
rating_mu DOUBLE PRECISION NOT NULL DEFAULT 1500.0,
rating_phi DOUBLE PRECISION NOT NULL DEFAULT 350.0,
rating_sigma DOUBLE PRECISION NOT NULL DEFAULT 0.06,
evolved BOOLEAN NOT NULL DEFAULT FALSE,
island VARCHAR(16),
generation INTEGER,
parent_ids JSONB, -- array of parent bot_ids for lineage tracking
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_active TIMESTAMPTZ
);
CREATE TABLE matches (
match_id VARCHAR(32) PRIMARY KEY,
map_id VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
winner INTEGER,
condition VARCHAR(32),
turn_count INTEGER,
scores_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE TABLE match_participants (
match_id VARCHAR(32) NOT NULL REFERENCES matches(match_id),
bot_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id),
player_slot INTEGER NOT NULL,
score INTEGER,
status VARCHAR(16),
PRIMARY KEY (match_id, bot_id)
);
CREATE TABLE jobs (
job_id VARCHAR(32) PRIMARY KEY,
match_id VARCHAR(32) NOT NULL REFERENCES matches(match_id),
status VARCHAR(16) NOT NULL DEFAULT 'pending',
config_json JSONB NOT NULL,
claimed_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
CREATE TABLE rating_history (
bot_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id),
match_id VARCHAR(32) NOT NULL REFERENCES matches(match_id),
rating DOUBLE PRECISION NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_rating_history_bot ON rating_history(bot_id, recorded_at);
-- §13.5: Prediction system
CREATE TABLE predictions (
prediction_id VARCHAR(32) PRIMARY KEY,
match_id VARCHAR(32) NOT NULL REFERENCES matches(match_id),
predictor_id VARCHAR(36) NOT NULL, -- localStorage-generated UUID
predictor_name VARCHAR(64), -- optional display name
predicted_bot_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id),
correct BOOLEAN, -- null until resolved
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE predictor_stats (
predictor_id VARCHAR(36) PRIMARY KEY,
predictor_name VARCHAR(64),
correct INTEGER NOT NULL DEFAULT 0,
incorrect INTEGER NOT NULL DEFAULT 0,
streak INTEGER NOT NULL DEFAULT 0,
best_streak INTEGER NOT NULL DEFAULT 0,
rating DOUBLE PRECISION NOT NULL DEFAULT 1000.0
);
-- §13.6: Map voting
CREATE TABLE map_votes (
vote_id VARCHAR(32) PRIMARY KEY,
map_id VARCHAR(32) NOT NULL,
voter_id VARCHAR(36) NOT NULL, -- localStorage UUID
vote SMALLINT NOT NULL CHECK (vote IN (-1, 1)),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(map_id, voter_id)
);
-- §13.6: Community replay feedback
CREATE TABLE replay_feedback (
feedback_id VARCHAR(32) PRIMARY KEY,
match_id VARCHAR(32) NOT NULL REFERENCES matches(match_id),
turn INTEGER NOT NULL,
type VARCHAR(16) NOT NULL CHECK (type IN ('insight', 'mistake', 'idea', 'highlight')),
body TEXT NOT NULL,
author VARCHAR(128) NOT NULL, -- free text (no accounts, like registration)
upvotes INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_feedback_match ON replay_feedback(match_id, turn);
-- §14.7: Multi-game series
CREATE TABLE series (
series_id VARCHAR(32) PRIMARY KEY,
bot_a_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id),
bot_b_id VARCHAR(16) NOT NULL REFERENCES bots(bot_id),
format SMALLINT NOT NULL CHECK (format IN (3, 5, 7)),
status VARCHAR(16) NOT NULL DEFAULT 'pending',
a_wins INTEGER NOT NULL DEFAULT 0,
b_wins INTEGER NOT NULL DEFAULT 0,
season_id VARCHAR(32),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE TABLE series_games (
series_id VARCHAR(32) NOT NULL REFERENCES series(series_id),
game_number INTEGER NOT NULL,
match_id VARCHAR(32) REFERENCES matches(match_id), -- null until played
map_id VARCHAR(32) NOT NULL,
winner INTEGER,
PRIMARY KEY (series_id, game_number)
);
-- §14.9: Seasonal rotations
CREATE TABLE seasons (
season_id VARCHAR(32) PRIMARY KEY,
name VARCHAR(64) NOT NULL,
theme VARCHAR(64) NOT NULL,
rules_version INTEGER NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
champion_id VARCHAR(16) REFERENCES bots(bot_id),
status VARCHAR(16) NOT NULL DEFAULT 'active'
);
```
**PostgreSQL advantages over SQLite (D1):**
- JSONB columns with indexing for structured data (scores, parent_ids, config)
- Foreign key constraints enforced at the database level
- TIMESTAMPTZ for proper timezone-aware timestamps
- CHECK constraints for enum-like fields
- Concurrent writes without locking issues
- CNPG provides automatic backups, failover, and point-in-time recovery
### 8.4 Index Builder (Deployment)
The index builder runs as a Kubernetes **Deployment** in the `ai-code-battle`
namespace. It is a long-running process with a **sleep loop**: run the index
build, sleep 15 minutes, repeat. Every ~6 cycles (~90 minutes), it deploys
the accumulated index files to Cloudflare Pages via `wrangler pages deploy`.
After a configurable lifetime (default: 4 hours), the process exits cleanly
and Kubernetes restarts it — preventing memory leaks and stale state.
**Process lifecycle:**
```
start → build indexes → sleep 15m → build indexes → sleep 15m → ...
→ (every ~6 cycles) deploy to Pages
→ (after 4 hours) exit 0
→ K8s restarts pod
```
**Each cycle:**
1. **Read:** Queries PostgreSQL directly (via the CNPG Service) for current
match results, bot stats, ratings, series, seasons, predictions,
playlists, community feedback, and evolution lineage data.
2. **Generate:** Computes all pre-computed JSON index files in a local
staging directory:
- `data/leaderboard.json` — sorted bot rankings with stats
- `data/bots/index.json` and `data/bots/{bot_id}.json` — bot directory and profiles
- `data/matches/index.json` — paginated match list (last 1000)
- `data/series/index.json` and `data/series/{series_id}.json`
- `data/seasons/index.json` and `data/seasons/{season_id}.json`
- `data/playlists/{slug}.json` — auto-curated collections
- `data/predictions/leaderboard.json` and `data/predictions/open.json`
- `data/meta/archetypes.json` and `data/meta/rivalries.json`
- `data/evolution/lineage.json` and `data/evolution/meta.json`
- `data/blog/index.json` and `data/blog/posts/{slug}.json` (weekly blog generation)
3. **Deploy to Pages:** Every ~6 cycles (~90 minutes), merges the generated
data files with the SPA shell (HTML/JS/CSS/WASM from the latest site
build) and deploys to Cloudflare Pages via `wrangler pages deploy`. Only
the `data/` directory changes between deploys; the SPA shell updates
only when a new site build is triggered.
**Environment:** The Deployment Pod has PostgreSQL credentials and a
**Cloudflare API token** (for `wrangler pages deploy`) stored as
SealedSecrets. No PersistentVolumes needed — all output goes to Cloudflare.
### 8.5 Bot Registration
**Registration flow:**
1. Participant fills out the form on the static site (`/register`)
2. Form POSTs to the Go API: `POST /api/register`
- **Bot name** (unique, alphanumeric + hyphens, 3-32 chars)
- **Endpoint URL** (HTTPS required for competitive; HTTP allowed for dev)
- **Owner name** (free text, shown on leaderboard)
- **Description** (optional)
3. Go API generates:
- `bot_id`: `b_` + 8 hex chars (from `crypto/rand`)
- `shared_secret`: 256-bit random, hex-encoded (`crypto/rand`)
4. Go API performs a **health check**: `http.Get(endpoint_url + "/health")`
- Must return 200 within 5 seconds
5. Go API performs a **protocol test**: sends mock game state to
`POST {endpoint_url}/turn` with valid HMAC
- Must return valid moves JSON within 3 seconds
6. Go API inserts bot record into PostgreSQL
7. Go API returns `bot_id` and `shared_secret` to the participant
(displayed once -- they must save it)
**No user accounts.** Registration is bot-level. The owner name is
self-reported. The shared secret is the only authentication -- whoever has
it can rotate the key or retire the bot. No OAuth, no sessions, no
password storage.
**Bot status lifecycle:**
```
PENDING -> ACTIVE -> INACTIVE (health check failed)
-> RETIRED (by owner via /api/rotate-key with retire flag)
```
Only `ACTIVE` bots participate in matchmaking. The health checker ticker
pings each active bot every 15 min. Three consecutive failures -> `INACTIVE`.
Bots automatically return to `ACTIVE` when health checks pass again.
### 8.6 Leaderboard
The leaderboard is a **JSON file** on Cloudflare Pages (`data/leaderboard.json`)
rebuilt by the index builder Deployment every ~15 minutes and deployed to
Pages every ~90 minutes.
```json
{
"updated_at": "2026-03-23T14:35:00Z",
"entries": [
{
"rank": 1,
"bot_id": "b_4e8c1d2f",
"name": "SwarmBot",
"owner": "alice",
"rating": 1820,
"games": 142,
"wins": 98,
"losses": 40,
"draws": 4,
"evolved": false,
"last_match": "2026-03-23T14:30:00Z"
}
]
}
```
The SPA fetches this file directly from Pages (same origin, no API
invocation). Client-side sorting and filtering (by player count tier,
time range, human-only vs all). Auto-refresh every 60 seconds. Public
-- no login.
### 8.7 Match History & Profiles
**Bot profile** (`/bot/{bot_id}`) -- fetches `data/bots/{bot_id}.json` from Pages:
- Current rating + rating history (array of `[timestamp, rating]` pairs
rendered as a chart client-side)
- Recent matches (last 50) with links to replay viewer
- Win/loss/draw breakdown
- Bot description, owner, registration date
- If evolved: lineage, generation, island
**Match list** (`/matches`) -- fetches `data/matches/index.json` from Pages:
- Paginated list of recent matches
- Each entry: match_id, participants, scores, date, link to replay
**Match detail** (`/replay/{match_id}`):
- Fetches `matches/{match_id}.json` from B2 for metadata
- Fetches `replays/{match_id}.json.gz` from B2 for the replay
- Embedded replay viewer (auto-plays)
- Score breakdown, participants, match duration
---
## 9. Deployment & Infrastructure
### 9.1 Design Principles
Compute runs in the **apexalgo-iad** Kubernetes cluster (Rackspace Spot) in
a dedicated `ai-code-battle` namespace. The public-facing product is a
**Cloudflare Pages** static site. All replay files and match data are stored
in **Backblaze B2** and served via Cloudflare CDN (Bandwidth Alliance).
The cluster is existing infrastructure shared with other workloads — it
already provides PostgreSQL (CNPG), Valkey, Traefik ingress, cert-manager,
ArgoCD, Argo Workflows, Argo Events, Forgejo (git + container registry),
SATA (Cinder CSI) storage, and Sealed Secrets.
Key principles:
- **Static-first architecture** — the public product is a static site on
Pages. All data visitors see is pre-computed JSON. K8s is the factory
that generates data and publishes it to Pages.
- **B2 as single storage layer** — B2 stores all replays, match metadata,
thumbnails, bot cards, and evolution status. Served directly via Cloudflare
CDN (Bandwidth Alliance = zero egress fees). No warm cache tier needed.
Replay files are served via Cloudflare Pages at initial volumes (<20k files);
reassess if object count approaches that threshold.
- **GitOps via ArgoCD** all K8s manifests are committed to git and synced
by ArgoCD. Never apply manifests directly with `kubectl`.
- **Argo Workflows for CI** container image builds and static site builds
run as Argo Workflows triggered by Argo Events.
- **Shared infrastructure** PostgreSQL, Valkey, Traefik, and cert-manager
are cluster-level services. The ai-code-battle namespace consumes them
but does not manage them.
- **No public API initially** the Go API for social features and third-
party registration is deferred. The v1 system is fully static.
### 9.2 Kubernetes Namespace Layout
All ai-code-battle resources live in the `ai-code-battle` namespace.
Cross-namespace dependencies:
- `cnpg` namespace: CNPG PostgreSQL cluster (`cnpg-apexalgo`) the Go API
and index builder connect via `cnpg-apexalgo-rw.cnpg.svc.cluster.local`
- `valkey` namespace: Valkey StatefulSet the Go API and match workers
connect via `valkey-master.valkey.svc.cluster.local`
- `traefik` namespace: Traefik ingress controller IngressRoute CRDs in
the `ai-code-battle` namespace reference Services in the same namespace
- `argocd` namespace: ArgoCD an Application resource points to the
manifests directory in the git repo
**Cloudflare infrastructure requirements:**
- **Cloudflare Pages project**: `ai-code-battle` (`ai-code-battle.pages.dev`)
hosts the static SPA and data indexes. Deployed by the index builder
via `wrangler pages deploy`.
- **DNS** (when custom domain is desired): `aicodebattle.com` CNAME to Pages.
**Backblaze B2 infrastructure requirements:**
- **B2 bucket**: Cold archive for ALL replays and match data, permanently.
Match workers upload directly via S3-compatible API. Free egress via
Cloudflare Bandwidth Alliance.
**K8s manifests directory structure** (flat per cluster CLAUDE.md norms):
```
declarative-config/k8s/apexalgo-iad/ai-code-battle/
├── namespace.yml
├── acb-database.yml (ext-postgres-operator Postgres + PostgresUser)
├── acb-schema-init.yml (ConfigMap + Deployment for schema migration)
├── acb-matchmaker-deployment.yml (matchmaker: pairings, job enqueue, health, reaper)
├── acb-worker-deployment.yml (match workers: run matches, upload to B2)
├── acb-index-builder-deployment.yml (index builder: generate JSON, deploy to Pages)
├── acb-evolver-deployment.yml (LLM evolution pipeline)
├── acb-strategy-random-deployment.yml (RandomBot — Python)
├── acb-strategy-random-service.yml
├── acb-strategy-gatherer-deployment.yml (GathererBot — Go)
├── acb-strategy-gatherer-service.yml
├── acb-strategy-rusher-deployment.yml (RusherBot — Rust)
├── acb-strategy-rusher-service.yml
├── acb-strategy-guardian-deployment.yml (GuardianBot — PHP)
├── acb-strategy-guardian-service.yml
├── acb-strategy-swarm-deployment.yml (SwarmBot — TypeScript)
├── acb-strategy-swarm-service.yml
├── acb-strategy-hunter-deployment.yml (HunterBot — Java)
├── acb-strategy-hunter-service.yml
├── acb-secret.yml.template (template: B2/Cloudflare/bot secrets)
└── acb-sealedsecret.yml (sealed version of above)
```
Secrets already provisioned in the namespace: `acb-app-credentials-acb-app`
(PostgreSQL), `keydb-secret` (Valkey), `backblaze-secret` (B2),
`cloudflare-pages-secret` (wrangler), `docker-hub-registry` (image pulls),
`openai-secret` (evolver LLM).
### 9.3 Container Images
All container images are built by Argo Workflows and pushed to the Forgejo
container registry (`forgejo.ardenone.com/ai-code-battle/<image>`).
| Image | Base | Purpose | K8s Resource |
|-------|------|---------|--------------|
| `acb-matchmaker` | Go binary on Alpine | Matchmaking, health checks, stale job reaping | Deployment (1 replica) |
| `acb-worker` | Go binary on Alpine | Match execution, B2 upload | Deployment (2-10 replicas) |
| `acb-evolver` | Go binary on Alpine | Evolution pipeline | Deployment (1 replica) |
| `acb-index-builder` | Go binary on Alpine (includes `wrangler` CLI) | Reads PostgreSQL, generates JSON indexes, deploys to Pages | Deployment (sleep-loop, 15 min cycle, Pages deploy every ~90 min, self-restarts every 4h) |
| `acb-strategy-random` | Python 3.13 slim | RandomBot | Deployment (1 replica) |
| `acb-strategy-gatherer` | Go on Alpine | GathererBot | Deployment (1 replica) |
| `acb-strategy-rusher` | Rust on Alpine | RusherBot | Deployment (1 replica) |
| `acb-strategy-guardian` | PHP 8.4 CLI Alpine | GuardianBot | Deployment (1 replica) |
| `acb-strategy-swarm` | Node 22 Alpine | SwarmBot (TypeScript) | Deployment (1 replica) |
| `acb-strategy-hunter` | Temurin 21 JRE Alpine | HunterBot (Java) | Deployment (1 replica) |
| `acb-evolved-*` | Varies by language | LLM-generated bots | Deployments (0-50) |
### 9.4 Match Job Coordination
Match workers coordinate via **Valkey** (job queue) and **PostgreSQL**
(persistent state). The matchmaker Deployment is the coordination point.
**Job flow:**
1. Matchmaker Deployment queries active bots from PostgreSQL, computes
pairings, inserts match + job rows in PostgreSQL, enqueues job IDs
into a Valkey list (`acb:jobs:pending`)
2. Match worker pod BRPOPs from the Valkey list (blocking dequeue)
3. Worker fetches full job config from PostgreSQL (map data, bot
endpoints + shared secrets for HMAC signing, match config)
4. Worker executes the full match (500 turns, HTTP calls to bot Services
via cluster DNS, e.g. `acb-strategy-rusher.ai-code-battle.svc:8080`)
5. Worker uploads replay file to **B2** via S3-compatible API
(`replays/{match_id}.json.gz`)
6. Worker uploads match metadata to **B2**
(`matches/{match_id}.json`)
7. Worker writes result directly to **PostgreSQL** (scores, winner, turn
count, condition) and updates ratings (Glicko-2)
8. Index builder (next ~15-min cycle) reads new results from PostgreSQL,
rebuilds all index JSON files, deploys to Pages
**Stale job recovery:**
- Reaper ticker (in matchmaker) checks PostgreSQL every 5 minutes for
jobs `running` >15 minutes
- Assumed abandoned (worker pod crashed or was evicted)
- Re-enqueues the job ID in Valkey for retry
### 9.5 Resilience & Pod Failure
**If a match worker pod is evicted or crashes:**
- The match in progress is lost. The stale job reaper detects it within
5 minutes and re-enqueues it.
- Other worker pods continue draining the queue.
- The Deployment controller restarts the pod.
**If a bot Deployment pod is evicted:**
- The bot goes offline. Health checker detects failure within 15 minutes,
marks it `INACTIVE` in PostgreSQL.
- Matchmaker skips inactive bots.
- Kubernetes restarts the pod; health checks pass; bot returns to `ACTIVE`.
- Matches in progress where a bot disappeared: that bot times out on each
turn, its units hold position, it effectively loses.
**If the matchmaker pod restarts:**
- Matchmaker and health checker tickers restart with the pod.
- No state lost — all state is in PostgreSQL and Valkey.
- Workers continue BRPOPing from Valkey (the queue persists).
- Brief gap in matchmaking (~1 min) while the pod starts.
**If PostgreSQL or Valkey is temporarily unavailable:**
- Workers block on BRPOP. Matchmaker retries on its next tick.
- CNPG handles PostgreSQL failover automatically (3-node cluster).
- When service resumes, everything recovers without intervention.
### 9.6 Networking & Security
**External traffic:**
- `ai-code-battle.pages.dev` (or custom domain) → Cloudflare Pages
(static SPA + data indexes)
- B2 public URL (via Cloudflare CDN) → Backblaze B2 (replay/match data storage)
- No K8s services are exposed externally in v1. The Go API IngressRoute
at `api.aicodebattle.com` is planned for when social features are added.
- TLS: Pages handles TLS automatically. B2 via Cloudflare CDN gets TLS
from the CDN layer.
**Cluster-internal traffic:**
- Matchmaker -> PostgreSQL: `cnpg-apexalgo-rw.cnpg.svc.cluster.local:5432`
- Matchmaker -> Valkey: `valkey-master.valkey.svc.cluster.local:6379`
- Workers -> Valkey: same
- Workers -> PostgreSQL: same
- Workers -> Bot Services: `acb-strategy-*.ai-code-battle.svc:8080`
- Workers -> External bots: outbound HTTPS to registered URLs
- Workers -> B2: HTTPS (S3-compatible API, for replay upload)
- Index Builder -> PostgreSQL: same
- Index Builder -> Cloudflare Pages: HTTPS (wrangler CLI)
- Index Builder -> B2: HTTPS (S3-compatible API, read stats for index building)
- Evolver -> PostgreSQL: same
- All cluster-internal traffic is plaintext (trusted network)
**Security boundaries:**
- The game engine (workers) never executes bot code — HTTP only
- All bot responses are schema-validated before processing
- HMAC authentication prevents request/response forgery
- PostgreSQL credentials in SealedSecrets (encrypted in git, decrypted
in-cluster)
- Valkey access is cluster-internal only (no external exposure)
- Pages and B2 serve public-read data only — no secrets stored there
- B2 write credentials (SealedSecret) are scoped to the acb bucket
- Cloudflare API token (SealedSecret) is scoped to Pages deploy only
- NetworkPolicy can restrict egress from bot pods to prevent data
exfiltration (future hardening)
### 9.7 ArgoCD & GitOps
All K8s manifests for the `ai-code-battle` namespace are stored in the
declarative-config repo (ardenone-cluster):
```
declarative-config/k8s/apexalgo-iad/ai-code-battle/
```
An ArgoCD Application watches this directory and syncs changes
automatically:
```yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: ai-code-battle
namespace: argocd
spec:
project: default
source:
repoURL: https://forgejo.ardenone.com/infra/ardenone-cluster.git
path: declarative-config/k8s/apexalgo-iad/ai-code-battle
targetRevision: main
destination:
server: https://kubernetes.default.svc
namespace: ai-code-battle
syncPolicy:
automated:
prune: true
selfHeal: true
```
**Workflow for infrastructure changes:**
1. Edit manifests in `declarative-config/k8s/` (ardenone-cluster repo)
2. `git push` to origin
3. ArgoCD detects the change and syncs within ~3 minutes
4. Verify via: `kubectl --server=http://kubectl-apexalgo-iad:8001 get pods -n ai-code-battle`
### 9.8 CI/CD Pipelines (Argo Workflows + Events)
**Argo Events sensor:** A GitHub webhook sensor listens for push events
on the `ai-code-battle` repo. On push to `main`, it triggers the
appropriate Argo Workflow(s).
**Image build workflow (`build-images`):**
1. Triggered by git push to `main` (when Go/container source changes)
2. Clones the repo
3. Builds container images (Kaniko — no Docker daemon needed)
4. Pushes to Forgejo registry: `forgejo.ardenone.com/ai-code-battle/<image>:<sha>`
5. Tags as `latest`
6. ArgoCD detects the image tag change and rolls out new pods
**Site build workflow (`build-site`):**
1. Triggered by git push to `main` (when `web/` directory changes)
2. Clones the repo
3. Runs `npm ci && npm run build` in the `web/` directory
4. Stores the build output as a container image artifact
(`acb-site-build:<sha>`) pushed to Forgejo registry
5. The index builder picks up the latest site build artifact, merges it
with generated data files, and deploys to Cloudflare Pages on its
next cycle
**Evolved bot deploy workflow:**
1. Triggered by the evolver when a candidate is promoted
2. Receives bot source code and language as parameters
3. Builds a container image (Kaniko)
4. Pushes to Forgejo registry
5. Creates a Deployment + Service manifest
6. Commits the manifest to the declarative-config repo
7. ArgoCD syncs the new bot into the cluster
### 9.9 Monitoring
| Signal | Method | Alert |
|--------|--------|-------|
| Pod health | Kubernetes liveness/readiness probes | Auto-restart |
| Pages up | Cloudflare Pages analytics + synthetic checks | Cloudflare handles failover |
| PostgreSQL health | CNPG operator monitoring | Auto-failover |
| Valkey health | Kubernetes probes | Auto-restart |
| Match throughput | PostgreSQL query: completions per hour | <10/hour for >1 hour |
| Worker queue depth | Valkey LLEN on `acb:jobs:pending` | >50 pending for >30 min |
| Bot health failures | Matchmaker health checker ticker | >50% failing |
| Stale jobs | Matchmaker reaper ticker count | >10 stale in a cycle |
| B2 usage | B2 dashboard / API metrics | Informational only (no cap) |
Alerts via matchmaker -> webhook to Discord/Slack. Cluster-level monitoring
(Prometheus, if deployed) can scrape the matchmaker's `/metrics` endpoint.
---
## 10. LLM-Driven Bot Evolution
The platform includes an autonomous evolution pipeline that uses LLMs to
continuously generate, evaluate, and promote new bot strategies. Evolved bots
compete on the same ladder as human-written bots — visitors see an ever-changing
meta where strategies emerge, dominate, and get countered without human
intervention.
### 10.1 Architecture Overview
The evolution system combines two proven approaches:
- **FunSearch/AlphaEvolve island model** — maintains diverse, independent
populations of bot code that cross-pollinate. Prevents premature convergence
to a single dominant strategy.
- **LLM-PSRO (Policy Space Response Oracle)** — uses Nash equilibrium as the
promotion gate. A new bot must beat the optimal mixed strategy over the
current population, not just one specific opponent. This provides
mathematically grounded regression prevention.
```
┌──────────────────────────────────────────────────────────┐
│ Programs Database │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌──────────┐ │
│ │ Island 1 │ │ Island 2 │ │ Island 3 │ │ Island 4 │ │
│ │ (Python) │ │ (Go) │ │ (Rust) │ │ (mixed) │ │
│ │ pop: 20 │ │ pop: 20 │ │ pop: 20 │ │ pop: 20 │ │
│ └───────────┘ └───────────┘ └───────────┘ └──────────┘ │
└──────────────────────────┬───────────────────────────────┘
sample 2-3 parents + match replays
┌──────────▼───────────┐
│ Prompt Builder │
│ • Parent source code │
│ • Recent loss replay │
│ • Win/loss analysis │
│ • Current meta desc │
│ • "Beat this mix" │
└──────────┬───────────┘
┌──────────▼───────────┐
│ LLM Ensemble │
│ • Fast model (×8) │
│ exploration/breadth │
│ • Strong model (×2) │
│ exploitation/depth │
└──────────┬───────────┘
│ generates candidate bot code
┌──────────▼───────────┐
│ Validation Gate │
│ 1. Syntax check │
│ 2. Compile/lint │
│ 3. Schema test │
│ 4. Sandbox smoke run │
└──────────┬───────────┘
│ passes validation
┌──────────▼───────────┐
│ Evaluation Arena │
│ • 10 matches vs │
│ population sample │
│ • Compute win rate │
│ • Build payoff row │
└──────────┬───────────┘
┌──────────▼───────────┐
│ Promotion Gate │
│ • Compute Nash eq. │
│ over population │
│ • Candidate must │
│ beat Nash mixture │
│ • Or: fill empty │
│ MAP-Elites niche │
└──────────┬───────────┘
│ promoted
┌──────────▼───────────┐
│ Deploy & Register │
│ • Build container │
│ • Push to registry │
│ • Register on ladder │
│ • Enter island DB │
└──────────────────────┘
```
### 10.2 Programs Database (Island Model)
The programs database stores all evolved bot code, organized into **islands**
that evolve independently to maintain strategic diversity.
**Island structure:**
- **4 islands**, one per primary language (Python, Go, Rust, mixed)
- Each island holds up to **20 programs** ranked by fitness
- Programs are clustered by **behavior signature** — a vector of outcomes
across a fixed set of benchmark matches (e.g., win/loss/score against each
of the 6 built-in strategy bots)
- Sampling favors high-scoring clusters; within a cluster, favors shorter/simpler
code (Occam pressure prevents bloat)
**Cross-pollination:**
- Every 50 generations, the top program from each island is copied to a random
other island (translated to that island's language by the LLM if needed)
- This spreads successful strategies across languages without homogenizing
the populations
**Behavior dimensions for MAP-Elites diversity:**
| Dimension | Low | High |
|-----------|-----|------|
| Aggression | Never enters enemy territory | Rushes enemy core immediately |
| Economy | Ignores energy entirely | Maximizes energy per turn |
| Exploration | Stays near core | Covers >80% of visible map |
| Formation | Units always scattered | Units always in tight groups |
Each dimension is binned into 3 levels, creating a 3⁴ = 81-cell behavior grid.
The database tries to fill every cell with the highest-scoring bot for that
behavioral profile. This ensures the evolved population contains turtles,
rushers, economists, swarmers, and everything in between — not just one
dominant archetype.
### 10.3 Prompt Construction
The LLM prompt is the critical interface between match performance data and
code generation. Each prompt is constructed from:
**Parent code (23 programs):**
- Sampled from the island's high-scoring clusters
- Included as full source code with inline comments noting their rating and
behavioral profile
- The LLM sees concrete working examples, not abstract descriptions
**Match analysis (from recent losses):**
- The replay of the parent's worst recent loss is summarized:
- Turn-by-turn narrative of critical moments (when the bot lost a formation,
missed energy, walked into a trap)
- Final score breakdown
- Opponent's apparent strategy (inferred from replay)
- This gives the LLM specific failure modes to address
**Meta description:**
- Current Nash equilibrium mixture over the population (e.g., "the optimal
counter-strategy is 40% swarm, 30% hunter, 30% gatherer")
- The candidate should beat this mixture, not just one opponent
- Weaknesses in the current meta are highlighted (e.g., "no bot currently
exploits the east-side energy clusters on 4-player maps")
**Constraints:**
- Target language for this island
- Must implement the HTTP bot interface (`POST /turn`, `GET /health`)
- Must include HMAC verification
- Maximum source code size (10 KB — prevents bloat)
- Must respond within 3-second timeout with reasonable compute
**Prompt template (simplified):**
```
You are evolving a competitive bot for AI Code Battle, a grid-based
strategy game. Your bot must be an HTTP server that receives game state
and returns moves.
## Game Rules
{game_rules_summary}
## HTTP Protocol
{protocol_spec}
## Parent Bots (these work — improve on them)
### Parent A — Rating: 1650, Style: aggressive-gatherer
```{language}
{parent_a_source}
```
### Parent B — Rating: 1580, Style: defensive-swarm
```{language}
{parent_b_source}
```
## Parent A's Worst Loss (Replay Summary)
{replay_analysis}
## Current Meta
The Nash equilibrium mixture is:
{nash_mixture_description}
Known weaknesses in current population:
{meta_weaknesses}
## Your Task
Write a new bot in {language} that:
1. Addresses Parent A's failure mode shown in the replay
2. Incorporates Parent B's strongest tactical element
3. Can beat the Nash mixture described above
4. Fits in a single file under 10 KB
Return the complete source code.
```
### 10.4 LLM Ensemble
The evolution system uses two model tiers, inspired by AlphaEvolve:
**Exploration tier (fast model, 80% of generations):**
- Cheaper, faster model (e.g., Claude Haiku, GPT-4o-mini, Gemini Flash)
- Generates 8 candidates per cycle
- High temperature (0.91.0) for diversity
- Purpose: broad search across strategy space; most candidates will fail,
but occasional novel approaches emerge
**Exploitation tier (strong model, 20% of generations):**
- More capable model (e.g., Claude Sonnet/Opus, GPT-4o, Gemini Pro)
- Generates 2 candidates per cycle
- Lower temperature (0.30.5) for refinement
- Purpose: take the best current strategies and make them better; refine
tactical details, optimize pathfinding, improve edge-case handling
**Total throughput:** 10 candidates per evolution cycle. With a cycle time
of ~15 minutes (generation + validation + 10 evaluation matches), the system
produces ~96 candidates/day, of which ~515% pass the promotion gate.
### 10.5 Validation Pipeline
Every LLM-generated candidate passes through a multi-stage validation
before it touches the evaluation arena:
**Stage 1: Syntax & Compilation**
- Language-specific: `python -m py_compile`, `go build`, `cargo check`,
`php -l`, `tsc --noEmit`, `javac`
- Reject: syntax errors, missing imports, type errors
- ~40% of candidates fail here (expected — LLMs produce broken code often)
**Stage 2: Schema Compliance**
- Start the bot container
- Send a mock turn-0 game state to `POST /turn`
- Verify response parses as valid moves JSON
- Verify `GET /health` returns 200
- Verify HMAC signature is present and valid
- Reject: bots that can't speak the protocol
- ~20% of remaining candidates fail here
**Stage 3: Sandbox Smoke Test**
- Run a 50-turn match against RandomBot inside nsjail
- Verify the bot doesn't crash, timeout on every turn, or produce
identical moves every turn (degenerate)
- Verify the bot scores ≥ 0 (doesn't actively self-destruct)
- Reject: bots that crash, hang, or do nothing
- ~10% of remaining candidates fail here
**Net yield:** ~3040% of generated candidates survive to the evaluation
arena. At 10 candidates/cycle, that's 34 evaluated candidates per cycle.
**Sandboxing (nsjail):**
- All LLM-generated code executes inside nsjail containers
- No network access (game state is piped via the engine, not fetched)
- No filesystem access beyond the bot's own directory
- CPU time limit: 5 seconds per turn (generous; 3-second HTTP timeout is
enforced by the engine separately)
- Memory limit: 512 MB
- Process limit: 10 (prevents fork bombs)
### 10.6 Evaluation Arena
Candidates that pass validation enter a mini-tournament:
**Evaluation protocol:**
1. Play 10 matches against opponents sampled from the current population:
- 2 matches vs each of the 3 closest-rated bots in the candidate's island
- 2 matches vs a random bot from a different island
- 2 matches vs the current island champion
2. Record results → compute win rate and per-opponent scores
3. Build the candidate's **payoff row** in the population's payoff matrix
**Match configuration:**
- 2-player matches only (faster evaluation; multi-player tested post-promotion)
- Standard maps, standard timeout
- Evaluation matches are **not** counted toward ladder ratings (they use a
separate evaluation queue)
### 10.7 Promotion Gate (Nash Equilibrium / PSRO)
The promotion gate determines whether a candidate enters the population and
gets deployed to the ladder.
**Primary gate: Nash equilibrium (LLM-PSRO)**
1. Compute the Nash equilibrium mixture σ* over the current island population
using the existing payoff matrix
2. Compute the candidate's expected payoff against σ* (using the payoff row
from the evaluation arena)
3. **Promote if** the candidate's expected payoff against σ* is positive
(i.e., the candidate beats the current optimal mixed strategy)
4. If promoted, add the candidate to the island population, recompute Nash
This ensures the population's game-theoretic strength monotonically increases.
A bot that just exploits one opponent's weakness but loses to the overall mix
is rejected.
**Secondary gate: MAP-Elites niche filling**
Even if a candidate doesn't beat the Nash mixture, it may fill an **empty
cell** in the behavior grid (section 10.2). If the candidate's behavior
signature maps to an unoccupied cell, it is promoted anyway. This maintains
strategic diversity even when the Nash gate is tight.
**Replacement policy:**
- If the candidate's behavior cell already has an occupant, the candidate
replaces it only if the candidate's fitness is higher
- Island population size is capped at 20; if full and no cell is improved,
the candidate is discarded
- The worst-performing program in an over-populated cluster is evicted first
### 10.8 Deployment Pipeline
Promoted bots are automatically containerized and registered on the ladder:
**Build:**
1. Write the bot's source code to a temporary directory
2. Copy the language-appropriate Dockerfile from the starter kit template
3. Build the container image: `acb-evolved-{island}-{generation}-{hash}`
4. Push to container registry
**Register:**
1. Generate a new `bot_id` and `shared_secret`
2. Deploy the container to the always-on strategy bot instance pool
3. Register the bot via the platform API with metadata:
- `owner`: "evolution-system" (system account)
- `name`: auto-generated (e.g., `evo-py-g42-7f3a`)
- `description`: auto-generated from the LLM's strategy summary
- `lineage`: parent bot IDs + generation number
- `island`: which island produced it
4. Health check → mark ACTIVE → enters matchmaking
**Lifecycle management:**
- Evolved bots are tagged with `evolved: true` in the database
- The evolution system tracks the **lineage** of every bot (parent IDs,
generation number, island of origin)
- Evolved bots that drop below rating 800 (bottom 10% of ladder) for 7
consecutive days are **retired** automatically to prevent population bloat
- Maximum active evolved bots: 50 (configurable). When the cap is reached,
the lowest-rated evolved bot is retired before a new one is promoted.
- Retired evolved bots remain in the programs database for future sampling
(their code may still contain useful tactics) but are removed from the
ladder and their containers are stopped
### 10.9 Evolution Cycle Timing
| Phase | Duration | Notes |
|-------|----------|-------|
| Parent sampling + prompt construction | ~10 seconds | CPU-bound, fast |
| LLM generation (10 candidates) | ~3060 seconds | Parallel across ensemble |
| Validation (syntax, schema, smoke) | ~2 minutes | Parallel per candidate |
| Evaluation arena (10 matches) | ~10 minutes | Sequential matches, 3s/turn × 500 turns worst case; but most against weak bots end faster |
| Nash computation + promotion | ~5 seconds | Small matrix, fast |
| Container build + deploy | ~2 minutes | Docker build + push |
| **Total cycle time** | **~15 minutes** | |
**Daily output:** ~96 candidates generated, ~10-15 promoted, ~5-10 survive
on the ladder after the 7-day retirement window.
**Throughput is configurable** and depends on match worker capacity. The
ratio of ladder matches to evolution evaluation matches is tunable (default:
70/30 -- 70% of match worker capacity goes to ladder matches, 30% to
evolution evaluation matches). When worker pods are scaled down, the ratio
can be adjusted to prioritize ladder matches over evolution. When excess
capacity is available, evolution throughput increases automatically.
**Container lifecycle:** the evolver Deployment runs as a long-lived
container that intentionally exits after a configurable time period
(default: 4 hours), causing Kubernetes to redeploy the pod. This prevents
memory leaks and stale state accumulation across hundreds of evolution
cycles.
### 10.10 Test Harnesses
Three test harness suites validate correctness across the game engine, bot
protocol, and evolution pipeline. These run as part of CI and as part of
the evolution validation pipeline.
**Game engine test suite:**
- Unit tests for combat resolution (focus fire algorithm), fog of war
computation, movement and collision, scoring, and endgame condition
detection
- Property-based tests for determinism: given the same input state and
moves, the engine must produce the same output state. Random seeds
generate thousands of input combinations; any nondeterminism is a
failing test.
- Edge case tests: toroidal wrapping, simultaneous multi-player death,
contested energy, core capture during spawn phase
**Bot protocol test suite:**
- Schema validation: verify that game state JSON conforms to the
documented schema (section 4.2) and that move responses are validated
correctly (section 4.3)
- HMAC verification: test correct signature generation and verification,
timestamp replay rejection, and constant-time comparison
- Timeout handling: verify that the engine correctly handles bots that
respond after the 3-second deadline, return non-200 status codes,
return invalid JSON, or refuse connections
- Malformed response handling: verify graceful degradation for partial
JSON, missing fields, extra fields, and oversized payloads
**Evolution validation test suite:**
- Syntax checking per language: verify that the validation pipeline
correctly accepts valid code and rejects invalid code for each
supported language (Python, Go, Rust, PHP, TypeScript, Java, JavaScript, C#)
- Schema compliance: verify that generated bots correctly implement
`POST /turn` and `GET /health` with valid HMAC signatures
- Sandbox smoke test: verify that nsjail isolation works correctly
(no network access, filesystem isolation, resource limits enforced)
- End-to-end: generate a known-good bot from a template, run it through
the full validation pipeline, and verify it passes all stages
### 10.11 Evolution Dashboard
The web platform includes a dedicated evolution section visible to all visitors:
**Lineage viewer:**
- Interactive tree/graph showing the ancestry of every evolved bot
- Click a node to see the bot's source code, rating history, and match record
- Color-coded by island/language
- Animated timeline showing which bots were active at which point
**Meta tracker:**
- Current Nash equilibrium mixture visualization (pie chart of strategy archetypes)
- How the meta has shifted over time (stacked area chart)
- Which behavioral niches are filled vs empty in the MAP-Elites grid
**Generation log:**
- Stream of recent evolution attempts: generated, validated, evaluated, promoted/rejected
- For each attempt: the prompt summary, the LLM's output, validation results,
evaluation match results, and promotion decision with reasoning
**Statistics:**
- Total generations run, candidates generated, promotion rate
- Average rating of evolved bots vs human-written bots over time
- Island diversity metrics (how different are the islands from each other)
### 10.12 Separation from Human Ladder
Evolved bots compete on the **same ladder** as human-written bots — there is
no separate tier. This is a deliberate design choice:
**Why mix them:**
- The entire point is to see if LLM-evolved strategies can compete with or
surpass human-written ones
- Humans can study evolved bot replays and learn new tactics, then write
better bots that push the meta further — a human-AI co-evolution dynamic
- Separate ladders would remove the competitive pressure that drives evolution
**Identification:**
- Evolved bots are clearly tagged on the leaderboard (`[EVO]` prefix or badge)
- Their lineage and source code are publicly viewable (transparency)
- Human participants can opt to filter the leaderboard to show human-only rankings
- Match history shows whether opponents were evolved or human-written
**Fair play:**
- Evolved bots follow the same rules: same timeout, same schema, same HMAC
- No special treatment in matchmaking — rated and matched identically
- The evolution system is rate-limited (max 50 active evolved bots) to prevent
flooding the ladder
---
## 11. Artifact Inventory
Every deliverable that gets built, deployed, or published. Grouped by
where it runs.
### 11.1 Monorepo Structure
All platform code lives in a single repository (`ai-code-battle`):
```
ai-code-battle/
├── engine/ # Go library — game simulation core
│ ├── grid.go # Toroidal grid, tile types, wrapping
│ ├── bot.go # Bot state, movement, collision
│ ├── combat.go # Focus-fire algorithm
│ ├── energy.go # Energy nodes, collection, spawning
│ ├── fog.go # Fog of war computation
│ ├── capture.go # Core capture/razing
│ ├── scoring.go # Score tracking, win conditions
│ ├── match.go # Turn loop, phase orchestration
│ ├── replay.go # Replay JSON serialization
│ ├── winprob.go # Monte Carlo win probability rollout
│ └── engine_test.go # Property-based + unit tests
├── cmd/
│ ├── acb-local/ # CLI: run a match locally (stdin/stdout bots)
│ ├── acb-mapgen/ # CLI: generate symmetric maps
│ ├── acb-worker/ # Container: match execution worker
│ ├── acb-evolver/ # Container: LLM evolution pipeline
│ ├── acb-index-builder/ # Container: PostgreSQL -> JSON -> Pages
│ # replay pruning is handled by acb-index-builder (weekly cycle)
├── cmd/acb-api/ # Go API service (replaces Cloudflare Worker)
│ ├── main.go # HTTP server + ticker scheduler
│ ├── routes/
│ │ ├── register.go # POST /api/register, /api/rotate-key
│ │ ├── jobs.go # POST /api/jobs/{id}/result
│ │ ├── predict.go # POST /api/predict
│ │ ├── feedback.go # POST /api/feedback
│ │ └── status.go # GET /api/status/{bot_id}
│ ├── tickers/
│ │ ├── matchmaker.go # Every 1 min: create match jobs, enqueue in Valkey
│ │ ├── health.go # Every 15 min: ping bot endpoints
│ │ └── reaper.go # Every 5 min: reclaim stale jobs
│ ├── lib/
│ │ ├── hmac.go # HMAC-SHA256 signing/verification
│ │ ├── glicko2.go # Glicko-2 rating computation
│ │ └── schema.go # Request/response JSON schema validators
│ ├── db/
│ │ ├── postgres.go # PostgreSQL connection + queries
│ │ └── valkey.go # Valkey (Redis) connection + queue ops
│ └── Dockerfile
├── migrations/ # PostgreSQL schema migrations
│ └── 0001_initial.sql
├── web/ # Static site (TypeScript + Vite)
│ ├── src/
│ │ ├── app.ts # SPA router, data fetching, layout
│ │ ├── pages/
│ │ │ ├── home.ts # Homepage: hero replay, leaderboard, playlists
│ │ │ ├── watch.ts # /watch: replay browser, playlists
│ │ │ ├── replay.ts # /watch/replay/{id}: full viewer
│ │ │ ├── series.ts # /watch/series/{id}: series page
│ │ │ ├── compete.ts # /compete: sandbox, registration, docs
│ │ │ ├── sandbox.ts # /compete/sandbox: WASM sandbox
│ │ │ ├── leaderboard.ts # /leaderboard
│ │ │ ├── bot-profile.ts # /bot/{id}: public bot profile
│ │ │ ├── evolution.ts # /evolution: live observatory
│ │ │ ├── blog.ts # /blog: meta reports + chronicles
│ │ │ ├── season.ts # /season/{id}: season archive
│ │ │ ├── predictions.ts # /watch/predictions
│ │ │ └── embed.ts # /embed/{id}: lightweight embed player
│ │ ├── components/
│ │ │ ├── replay-canvas.ts # Canvas renderer: bots, grid, animations
│ │ │ ├── territory.ts # Voronoi + influence overlay renderers
│ │ │ ├── particles.ts # Particle pool + death/energy animations
│ │ │ ├── follow-camera.ts # Bounding box tracking + lerp viewport
│ │ │ ├── pip.ts # Picture-in-picture manager
│ │ │ ├── director.ts # Adaptive auto-speed controller
│ │ │ ├── win-prob.ts # Win probability sparkline graph
│ │ │ ├── event-timeline.ts# Event icon ribbon
│ │ │ ├── clip-export.ts # GIF/MP4 export via MediaRecorder
│ │ │ ├── annotation.ts # Spatial + text replay annotations
│ │ │ ├── leaderboard-table.ts
│ │ │ ├── bot-card.ts # Bot profile card renderer (Canvas PNG)
│ │ │ ├── match-card.ts # Match summary card
│ │ │ ├── playlist-row.ts # Horizontal scrollable playlist
│ │ │ ├── prediction-widget.ts
│ │ │ ├── observatory-feed.ts # Live evolution status
│ │ │ ├── blog-post.ts # Markdown renderer for blog content
│ │ │ └── skeleton.ts # Per-page skeleton screens
│ │ ├── lib/
│ │ │ ├── data.ts # Data fetching from Pages (same origin) + B2 (cross-origin), caching
│ │ │ ├── preload.ts # Hover preload + route cache
│ │ │ ├── disclosure.ts # Progressive feature revelation (XP)
│ │ │ ├── accessibility.ts # Color palettes, keyboard shortcuts
│ │ │ ├── ambient.ts # Favicon badges, tab titles, haptic
│ │ │ └── season-theme.ts # Background hue shift per season
│ │ └── styles/
│ │ ├── base.css # Dark theme, typography, reset
│ │ ├── components.css # Component styles
│ │ └── mobile.css # Responsive breakpoints, bottom tab bar
│ ├── public/
│ │ ├── docs/ # Static documentation pages
│ │ └── img/ # Logos, icons, UI assets
│ ├── vite.config.ts
│ └── tsconfig.json
├── wasm/ # WASM builds for the browser sandbox
│ ├── engine/ # Go game engine → WASM
│ │ ├── main_wasm.go # WASM exports: runMatch, loadState, step
│ │ └── build.sh # GOOS=js GOARCH=wasm go build
│ └── bots/ # Built-in bot WASM builds
│ ├── gatherer/ # Go → WASM
│ ├── rusher/ # Rust → WASM (wasm32-unknown-unknown)
│ ├── swarm/ # TypeScript → WASM (AssemblyScript)
│ ├── random/ # Go → WASM (lightweight reimpl)
│ ├── guardian/ # Go → WASM (reimpl from PHP)
│ └── hunter/ # Go → WASM (reimpl from Java)
├── bots/ # Production bot HTTP servers (6 languages)
│ ├── random/ # Python — Flask, ~50 lines strategy
│ │ ├── Dockerfile
│ │ ├── main.py
│ │ ├── game.py
│ │ └── requirements.txt
│ ├── gatherer/ # Go — net/http, BFS pathfinding
│ │ ├── Dockerfile
│ │ ├── main.go
│ │ ├── strategy.go
│ │ └── game/
│ ├── rusher/ # Rust — axum, BFS to enemy core
│ │ ├── Dockerfile
│ │ ├── Cargo.toml
│ │ └── src/
│ ├── guardian/ # PHP — built-in server, perimeter defense
│ │ ├── Dockerfile
│ │ ├── index.php
│ │ ├── strategy.php
│ │ └── game.php
│ ├── swarm/ # TypeScript — Fastify, formation advance
│ │ ├── Dockerfile
│ │ ├── package.json
│ │ └── src/
│ └── hunter/ # Java — Javalin, target isolation
│ ├── Dockerfile
│ ├── pom.xml
│ └── src/
├── starters/ # Forkable starter kit template repos
│ ├── python/
│ ├── go/
│ ├── rust/
│ ├── php/
│ ├── typescript/
│ ├── javascript/
│ ├── java/
│ └── csharp/
├── docs/ # Project documentation
│ ├── plan/
│ │ └── plan.md # This document
│ ├── research/
│ │ ├── ants-ai-challenge.md
│ │ └── llm-bot-evolution.md
│ └── notes/
│ └── requirements.md
├── CLAUDE.md
└── README.md
```
### 11.2 Deployable Artifacts
**Container images (Forgejo registry: `forgejo.ardenone.com/ai-code-battle/`):**
| Image | Source | Base | Purpose | K8s Resource |
|-------|--------|------|---------|--------------|
| `acb-api` | `cmd/acb-api/` | Go on Alpine | API + scheduling | Deployment (1 replica) |
| `acb-worker` | `cmd/acb-worker/` | Go on Alpine | Match execution | Deployment (2-10 replicas) |
| `acb-evolver` | `cmd/acb-evolver/` | Go on Alpine | LLM evolution pipeline | Deployment (1 replica, self-restarts every 4h) |
| `acb-index-builder` | `cmd/acb-index-builder/` | Go on Alpine (includes wrangler) | PostgreSQL -> JSON -> Cloudflare Pages | Deployment (sleep-loop) |
| `acb-strategy-random` | `bots/random/` | Python 3.13 slim | RandomBot | Deployment (1 replica) |
| `acb-strategy-gatherer` | `bots/gatherer/` | Go on Alpine | GathererBot | Deployment (1 replica) |
| `acb-strategy-rusher` | `bots/rusher/` | Rust on Alpine | RusherBot | Deployment (1 replica) |
| `acb-strategy-guardian` | `bots/guardian/` | PHP 8.4 CLI Alpine | GuardianBot | Deployment (1 replica) |
| `acb-strategy-swarm` | `bots/swarm/` | Node 22 Alpine | SwarmBot | Deployment (1 replica) |
| `acb-strategy-hunter` | `bots/hunter/` | Temurin 21 JRE Alpine | HunterBot | Deployment (1 replica) |
| `acb-evolved-*` | Generated by evolver | Varies | LLM-evolved bots | Deployments (0-50) |
**K8s manifests (ArgoCD-managed):**
| Resource | Source | Namespace |
|----------|--------|-----------|
| All Deployments, Services, Deployments, PVCs, IngressRoutes, SealedSecrets | `declarative-config/k8s/apexalgo-iad/ai-code-battle/` | `ai-code-battle` |
| ArgoCD Application | `declarative-config/k8s/apexalgo-iad/ai-code-battle/argocd-application.yaml` | `argocd` |
| PostgreSQL database `acb` | Created in existing CNPG cluster `cnpg-apexalgo` | `cnpg` |
**WASM artifacts (built at compile time, deployed to Cloudflare Pages):**
| Artifact | Source | Target | Size |
|----------|--------|--------|------|
| `engine.wasm` | `wasm/engine/` | `GOOS=js GOARCH=wasm` | ~15 MB |
| `gatherer.wasm` | `wasm/bots/gatherer/` | Go → WASM | ~12 MB |
| `rusher.wasm` | `wasm/bots/rusher/` | Rust → `wasm32-unknown-unknown` | ~3 MB |
| `swarm.wasm` | `wasm/bots/swarm/` | AssemblyScript → WASM | ~5 MB |
| `random.wasm` | `wasm/bots/random/` | Go → WASM | ~10 MB |
| `guardian.wasm` | `wasm/bots/guardian/` | Go → WASM (reimpl) | ~12 MB |
| `hunter.wasm` | `wasm/bots/hunter/` | Go → WASM (reimpl) | ~12 MB |
**Published template repos (one per language):**
| Repo | Language | Contents |
|------|----------|----------|
| `acb-starter-python` | Python | Flask server, HMAC, game types, stub strategy, Dockerfile |
| `acb-starter-go` | Go | net/http, shared game/ package, Dockerfile |
| `acb-starter-rust` | Rust | axum + serde, HMAC crate, Dockerfile |
| `acb-starter-php` | PHP | Built-in server, hash_hmac, Dockerfile |
| `acb-starter-typescript` | TypeScript | Fastify, typed interfaces, Dockerfile |
| `acb-starter-javascript` | JavaScript | Node.js built-in http, HMAC, zero dependencies, Dockerfile |
| `acb-starter-java` | Java | Javalin, javax.crypto.Mac, Maven, Dockerfile |
| `acb-starter-csharp` | C# | ASP.NET Core minimal API, System.Security.Cryptography HMAC, Dockerfile |
**CLI tools (built from monorepo, used locally):**
| Tool | Source | Purpose |
|------|--------|---------|
| `acb-local` | `cmd/acb-local/` | Run a match between two local bots (stdin/stdout), output replay JSON |
| `acb-mapgen` | `cmd/acb-mapgen/` | Generate symmetric maps with configurable parameters |
### 11.3 Build & Deploy Pipeline
```
Source (git push to main)
├──► Argo Events sensor (GitHub webhook)
│ │
│ ├──► Argo Workflow: build-images
│ │ ├── go test ./engine/... (game engine tests)
│ │ ├── go test ./cmd/... (API/worker/evolver tests)
│ │ ├── npm test (web) (SPA tests)
│ │ ├── Kaniko image builds (all container images)
│ │ └── Push to Forgejo registry
│ │
│ └──► Argo Workflow: build-site
│ ├── npm ci && npm run build (Vite build)
│ ├── WASM builds (engine + 6 bots)
│ └── Push site build artifact to Forgejo registry
├──► ArgoCD (watches declarative-config repo)
│ └── Syncs K8s manifests -> ai-code-battle namespace
└──► PostgreSQL migrations
└── Run via init container or migration Job on deploy
Index builder Deployment (every ~15 min build, ~90 min deploy):
├── Query PostgreSQL directly
├── Generate JSON index files
└── Deploy to Cloudflare Pages via wrangler pages deploy
```
### 11.4 Shared Libraries & Code Reuse
Several components share code. The monorepo structure avoids duplication:
| Shared Code | Used By | Language |
|-------------|---------|---------|
| `engine/` | `acb-worker`, `acb-evolver`, `acb-local`, `acb-mapgen`, WASM engine | Go |
| `engine/replay.go` | `acb-worker` (write), `acb-index-builder` (read for stats) | Go |
| `engine/winprob.go` | `acb-worker` (post-match computation) | Go |
| `engine/auth.go` | Match workers, matchmaker (HMAC verification) | Go |
| `engine/glicko2.go` | Match workers (rating updates on result write) | Go |
| `web/src/components/replay-canvas.ts` | Full viewer, embed, sandbox, homepage | TypeScript |
| `web/src/lib/data.ts` | All pages (data fetching + caching) | TypeScript |
The game engine is the foundational shared artifact — it compiles to:
1. A Go library (imported by worker, evolver, CLI tools)
2. A WASM module (loaded by the browser sandbox and embed viewer)
3. A test binary (run in CI)
---
## 12. Implementation Phases
### Phase 1: Core Engine (Foundation)
Build the game simulation as a standalone Go library with a CLI runner.
**Deliverables:**
- `engine/` package: grid, bots, energy, combat, fog of war, turn execution
- `cmd/acb-local/` CLI: run a match between two local bot processes
(stdin/stdout for dev convenience) and output a replay JSON file
- Replay JSON writer
- Comprehensive unit tests for combat resolution, fog of war, wrapping,
collision, scoring, endgame conditions
- Map generation tool: `cmd/acb-mapgen/`
**Exit criteria:** can run a complete 500-turn match between two bots locally
and produce a valid replay file.
### Phase 2: HTTP Protocol & Strategy Bots
**Deliverables:**
- HTTP bot interface in the engine (replaces stdin/stdout for production)
- HMAC signing and verification library (Go, reusable by GathererBot)
- GathererBot (Go) and RandomBot (Python) — validate the protocol works
across languages before building the remaining four
- RusherBot (Rust), GuardianBot (PHP), SwarmBot (TypeScript), HunterBot (Java)
- All 6 bots containerized with language-appropriate Dockerfiles
- Starter kit template repos for each language (fork-ready)
- Integration test: engine runs a full match between bots in different
languages over HTTP
**Exit criteria:** can run a complete match between any two strategy bot
containers (in different languages) over HTTP, with HMAC authentication,
producing a valid replay.
### Phase 3: Replay Viewer
**Deliverables:**
- TypeScript Canvas-based replay viewer
- Play/pause, scrub, speed control
- Fog of war perspective toggle
- Score overlay
- Loads replay JSON from local file or URL
**Exit criteria:** can open a replay file in a browser and watch a complete
match with all visual elements rendering correctly.
### Phase 4: Match Orchestration
**Deliverables:**
- Matchmaker Deployment (`acb-matchmaker`): internal tickers for pairing
bots (1 min), health checking (15 min), stale job reaping (5 min).
Enqueues job IDs into Valkey. No external exposure.
- PostgreSQL schema (CNPG): `bots`, `matches`, `match_participants`, `jobs`,
`rating_history` tables in the `acb` database
- Index builder Deployment (`acb-index-builder`): reads PostgreSQL directly,
generates index JSON files every ~15 min, deploys to Cloudflare Pages
every ~90 min
- Match worker Deployment (`acb-worker`): BRPOPs jobs from Valkey, runs
matches, uploads replays to B2, writes results + Glicko-2 ratings to
PostgreSQL
- Glicko-2 rating update logic in the match worker (runs on result write)
**Exit criteria:** matchmaker creates jobs and enqueues them in Valkey,
worker pods dequeue and execute them, replays land on B2, results flow
into PostgreSQL, ratings update, and leaderboard.json rebuilds automatically.
System recovers from worker pod failure via the stale job reaper.
### Phase 5: Web Platform
**Deliverables:**
- Cloudflare Pages static SPA (`ai-code-battle.pages.dev`): leaderboard,
match history, bot profiles, replay viewer, docs/getting-started page
- SPA fetches replay files directly from B2 (via Cloudflare CDN)
- Index builder deploying leaderboard, bot profiles, playlists to Pages
- Match workers uploading replays and per-match metadata to B2
**Exit criteria:** anyone can browse matches, view leaderboards, and watch
replays — SPA from Pages, replay/match data from B2.
All data is static and pre-computed.
### Phase 6: Deployment & Production
**Deliverables:**
- K8s manifests committed to `declarative-config/k8s/apexalgo-iad/ai-code-battle/`:
namespace, Deployments, Services, SealedSecrets (flat directory structure)
- Cloudflare Pages project (`ai-code-battle`, already exists at
`ai-code-battle.pages.dev`)
- Backblaze B2 bucket (replay/match data storage)
- ArgoCD Application syncing the manifests directory
- Argo Events sensor: GitHub webhook triggers on push to `ai-code-battle` repo
- Argo Workflows: image build (Kaniko -> Forgejo registry), site build
(npm build -> artifact for Pages deploy)
- SealedSecrets for PostgreSQL, Valkey, B2, Cloudflare API token
(most already provisioned in the namespace)
- Monitoring: matchmaker metrics endpoint + Discord/Slack alerting webhooks
**Exit criteria:** platform is publicly accessible — SPA from Pages, replay/match
data from B2. All K8s manifests are GitOps-managed by ArgoCD, CI pipelines
rebuild images and site on git push, matches run autonomously, and the
leaderboard updates every ~90 minutes.
### Phase 7: LLM-Driven Evolution
**Deliverables:**
- Programs database with island model (4 islands, MAP-Elites behavior grid)
- Prompt builder: parent sampling, replay analysis, meta description
- LLM ensemble integration (fast + strong model tiers)
- Validation pipeline: syntax → schema → sandbox smoke test (nsjail)
- Evaluation arena: 10-match mini-tournament per candidate
- Promotion gate: Nash equilibrium computation (PSRO) + MAP-Elites niche fill
- Automated container build + deploy + register pipeline for promoted bots
- Retirement policy: auto-retire low-rated evolved bots, enforce population cap
- Evolution dashboard: lineage viewer, meta tracker, generation log
- Seed the programs database with the 6 built-in strategy bots as initial
population
**Exit criteria:** evolution system runs autonomously — generates candidates,
validates, evaluates, promotes, deploys, and retires bots without human
intervention. At least one evolved bot reaches the top 50% of the ladder
within the first week of operation.
### Phase 8: Enhanced Features
**Deliverables:**
- WASM game engine build (`GOOS=js GOARCH=wasm`) with `loadState()`,
`step()`, and `runMatch()` API for browser use
- WASM bot interface spec: `init()`, `compute_moves()`, `free_result()`
exports for bot-to-engine communication
- Pre-compiled WASM builds of the 6 built-in strategy bots (Go/Rust/TS
natively; PHP/Java reimplemented in Go for WASM)
- In-browser sandbox: Monaco editor (TS quick-start) + WASM upload mode +
opponent selector + replay viewer integration
- Win probability computation in the match worker (Monte Carlo rollout) +
critical moments detector + replay viewer sparkline graph
- Replay enrichment pipeline: selective AI commentary for featured matches
- Clip maker: GIF + MP4 export with 5 social media format presets
(landscape, square, portrait, compact GIF, square GIF)
- Rival detection query + rivalry pages with template-generated narratives
- Community replay feedback system: tagged annotations feeding evolution
- PostgreSQL schema additions: `replay_feedback` table
- Go API addition: `POST /api/feedback` for submitting replay annotations
**Exit criteria:** users can write and test bots in the browser (TS
quick-start or uploaded WASM) without deploying anything, watch enriched
replays with commentary and win probability, export clips for social
sharing, view rivalries, and submit tactical feedback that influences the
evolution pipeline.
### Phase 9: Platform Depth
**Deliverables:**
- Bot debug telemetry: optional `debug` field in move response schema,
stored in replay, rendered in viewer side panel + grid overlays
- Replay view modes: dots (default), Voronoi territory, influence gradient
— all computed client-side, toggled via viewer toolbar
- Embeddable replay widget: `/embed/{match_id}` route on the static site, minimal
Chrome, auto-play, ~50KB, Open Graph tags
- Replay playlists: auto-curated collections rebuilt by index builder
Deployment, deployed to Pages, browsable on the static site
- Prediction system: PostgreSQL `predictions` table, Go API endpoints for
submit + resolve, prediction leaderboard JSON deployed to Pages
- Map evolution pipeline: engagement scoring, breeding/mutation, symmetry
validation, positional fairness monitoring, user map voting
- Multi-game series: PostgreSQL `series` table, series scheduler, unified
replay presentation, spoiler toggle
- Match event timeline: client-side event extraction, icon ribbon in
replay viewer, click-to-jump
- Seasonal system: PostgreSQL `seasons` table, ladder reset logic, season
archive pages, versioned game rules with backward compatibility
- Bot profile cards: Canvas-rendered PNG, shareable URL with OG tags
**Exit criteria:** the platform supports seasonal competition with map
evolution, multi-game series, predictions for non-coders, embeddable
replays, curated playlists, three replay view modes, bot debug telemetry,
event timelines, and shareable bot profile cards.
### Phase 10: Ecosystem & Polish
**Deliverables:**
- Weekly meta report: auto-generated blog post deployed to Pages,
rendered on `/blog` with LLM-enhanced narrative sections
- Public match data: documented static JSON file paths on Pages and B2,
OpenAPI-style documentation at `/docs/api`, versioned replay format spec
- Accessibility suite: Tol color-blind palette + shape-per-player, keyboard
shortcuts for replay viewer, high contrast mode, reduced motion, screen
reader transcript, focus indicators
- Live evolution observatory: evolver writes `live.json` to B2
every cycle, observatory page polls and renders live feed + lineage tree
+ meta shift chart
- Narrative engine: weekly story arc detection, LLM-generated 200-word
chronicles, published alongside meta reports on `/blog`
**Exit criteria:** the platform publishes weekly editorial content (meta
report + story arcs) as blog posts, exposes all match data as documented
static JSON, meets WCAG accessibility standards for color and keyboard
navigation, and streams the evolution process as a live observatory.
---
## 13. Enhanced Features
### 13.1 In-Browser WASM Game Sandbox
The game engine and bots compile to WebAssembly, enabling users to develop
and test bots entirely in the browser against real opponents — zero
deployment, zero server setup.
**Architecture — WASM per module, not JS functions:**
A meaningful bot needs pathfinding, state tracking across turns, spatial
data structures, and threat assessment. That's a real program, not a
20-line JavaScript function. Limiting bots to JS callbacks would undermine
the platform's multi-language premise.
Instead, the sandbox loads **separate WASM modules** for the game engine
and each bot:
```
Browser
├── Game Engine (Go → WASM, ~15 MB)
│ ├── loadState(json) → set engine to a specific turn state
│ ├── step(moves[]) → advance one turn, return events
│ └── runMatch(config, map) → run full match coordinating bot WASMs
├── Bot WASMs (pre-compiled, loaded on demand)
│ ├── gatherer.wasm (Go → WASM, ~12 MB)
│ ├── rusher.wasm (Rust → WASM, ~3 MB)
│ ├── swarm.wasm (TypeScript → WASM via wasm-pack, ~5 MB)
│ ├── random.wasm (Go → WASM, ~10 MB) -- lightweight reimpl
│ ├── guardian.wasm (Go → WASM, ~12 MB) -- reimpl from PHP
│ └── hunter.wasm (Go → WASM, ~12 MB) -- reimpl from Java
├── User's Bot WASM (compiled locally, uploaded as .wasm file)
│ └── or: user writes Go/Rust/TS, compiles in-browser via toolchain
├── Monaco Editor (code editing for quick-start JS/TS mode)
└── Replay Viewer (Canvas, renders result)
```
**WASM communication interface:**
Each bot WASM exports a standard interface:
```
// Exported by every bot WASM module
fn init(config_json: *const u8, config_len: u32)
fn compute_moves(state_json: *const u8, state_len: u32) -> *const u8
fn free_result(ptr: *const u8)
```
The engine WASM orchestrates the match: each turn, it serializes the
fog-filtered game state as JSON, calls each bot WASM's `compute_moves`,
deserializes the moves, and advances the simulation. Bots maintain their
own internal state across turns inside their WASM linear memory.
**Language support in the sandbox:**
| Language | WASM Compilation | Sandbox Support |
|----------|-----------------|-----------------|
| Go | `GOOS=js GOARCH=wasm` (native) | Full |
| Rust | `wasm32-unknown-unknown` (native) | Full |
| TypeScript | AssemblyScript or wasm-pack | Full |
| Python | Pyodide (~20 MB runtime) | Heavy but feasible |
| PHP | Not practical for WASM | HTTP ladder only |
| Java | Not practical for WASM | HTTP ladder only |
For the built-in opponents, GuardianBot (PHP) and HunterBot (Java) are
**reimplemented in Go** as sandbox-only WASM builds. They are behaviorally
equivalent — same BFS, same combat logic, same heuristics — not identical
code.
**Memory budget:**
| Configuration | Memory |
|--------------|--------|
| Engine + 1 user bot + 1 opponent | ~3040 MB |
| Engine + 1 user bot + 3 opponents (4-player) | ~5575 MB |
| Engine + 1 user bot + 5 opponents (6-player) | ~75105 MB |
| With Pyodide (Python user bot) | Add ~20 MB |
Desktop browsers typically have 24 GB available. Even the heaviest
configuration is <5% of available memory. Mobile is tighter but the
sandbox is a desktop-first dev tool.
A 500-turn 2-player match simulates in ~23 seconds (WASM-to-WASM calls
have overhead vs native, but each turn's computation is trivial).
**User flows (two modes):**
*Quick-start mode (JS/TS in Monaco):*
1. User visits `/sandbox`
2. Monaco editor pre-loaded with a TypeScript starter bot
3. User writes strategy code with full type hints and autocomplete
4. Code compiles to WASM in-browser via AssemblyScript
5. Selects opponent and map, clicks "Run Match"
6. Engine orchestrates match between user WASM and opponent WASM (~23s)
7. Replay viewer renders result inline
8. Modify, re-run — instant feedback loop
*Full mode (upload compiled WASM):*
1. User develops a bot locally in Go, Rust, or any WASM-targeting language
2. Compiles to `.wasm` using their own toolchain
3. Uploads the `.wasm` file to the sandbox page
4. Sandbox validates the exported interface (`init`, `compute_moves`)
5. Runs match against selected opponents
6. When ready for the real ladder, deploys the same bot logic as an HTTP
server using a starter kit
**Why this matters:** The sandbox preserves the platform's multi-language
strength while eliminating the deployment barrier. Users can develop
substantial, stateful bots in real languages — not toy JS functions —
and iterate locally before committing to the HTTP ladder.
### 13.2 Win Probability Graph + Critical Moments
Every match replay includes a **win probability curve** — a per-turn estimate
of each player's chance of winning — and a set of **critical moments** where
the game's outcome shifted decisively.
**Win probability computation:**
After each match, the worker computes win probability using Monte Carlo
rollout:
```
for each turn T in the match:
state = game_state_at_turn_T
wins = [0, 0, ..., 0] // per player
for i in 1..100:
result = simulate_random_play(state, remaining_turns)
wins[result.winner] += 1
win_prob[T] = wins / 100
```
`simulate_random_play` runs the game engine with random valid moves for all
players from the given state to completion. 100 rollouts × 500 turns is
~50,000 engine steps — the Go engine handles this in <1 second.
The result is stored in the replay JSON as a `win_prob` array:
```json
"win_prob": [
[0.50, 0.50],
[0.51, 0.49],
[0.48, 0.52],
...
]
```
Size: ~4 KB for a 500-turn, 2-player match. Negligible.
**Critical moments:**
A critical moment is any turn where `|Δwin_prob|` exceeds 0.15 (15%) for
any player. Typically 35 per match. Stored in the replay JSON:
```json
"critical_moments": [
{ "turn": 87, "delta": 0.22, "description": "SwarmBot loses 6 units in eastern engagement" },
{ "turn": 203, "delta": -0.31, "description": "GathererBot's core captured" }
]
```
The `description` is auto-generated from the turn's events (deaths, captures,
large position changes). No LLM needed — template-based.
**Replay viewer integration:**
- **Sparkline graph** below the main canvas: one line per player, color-coded
- Horizontal axis: turns. Vertical axis: 0%100% win probability
- **Critical moment markers**: vertical dashed lines on the graph with labels
- **Click to jump**: clicking any point on the graph scrubs to that turn
- **Quick nav buttons**: "Next critical moment" / "Previous critical moment"
to skip between turning points
This transforms replay viewing from "press play and wait 5 minutes" to
"click the 3 interesting moments and watch 30 seconds of decisive action."
### 13.3 Replay Enrichment (Selective AI Commentary)
Select replays receive AI-generated natural language commentary — a
play-by-play narration that makes matches accessible to casual viewers.
**Not all replays are enriched.** Commentary is generated selectively for:
- **Featured matches**: matches flagged by the system as particularly
interesting (high win probability variance, close finishes, upsets where
a lower-rated bot wins)
- **Rivalry matches**: matches between detected rivals (§13.5)
- **Evolution milestones**: first match of a newly promoted evolved bot,
or matches where an evolved bot breaks into the top 10
- **User-requested**: participants can request enrichment for their own
matches (rate-limited: 5 per day per bot)
**Selection criteria (automatic):**
```
enrich if:
- win_prob crossed 0.5 at least 3 times (back-and-forth match)
- final score difference ≤ 2
- winner's rating was ≥100 lower than loser's (upset)
- match involved a newly promoted evolved bot
- match is between detected rivals
```
At ~60 matches/hour, roughly 1015% qualify — about 69 enriched replays
per hour.
**Commentary generation:**
Enrichment is performed by a **coding agent in the cluster** that takes the
replay JSON + match metadata as input and generates a Markdown-formatted
play-by-play output. The agent uses an LLM to analyze the replay data and
produce structured commentary. This runs as a post-processing step on a
dedicated container (not on the match worker itself).
**Agent input:**
- Full replay JSON (turn-by-turn game state)
- Match metadata (players, ratings, map size, win condition)
- Win probability curve (sampled every 10 turns)
- Critical moments array
**Agent output:** a Markdown file (`{match_id}-commentary.md`) stored
alongside the replay on the PV:
```markdown
# SwarmBot vs GathererBot — 60x60 Grid
## Turn 1: Opening
Both bots spawn at opposite corners of a 60x60 grid with heavy wall
cover in the center. SwarmBot immediately sends all units east in a
tight cluster.
## Turn 42: First Contact
GathererBot's scout stumbles into SwarmBot's formation near the central
energy cluster. The scout is outnumbered 8-to-1 and eliminated instantly.
## Turn 87: The Turning Point
SwarmBot pushes through the eastern corridor but GathererBot has quietly
amassed 14 units behind the western wall line — a force SwarmBot doesn't
know exists.
```
**Cost:** ~$0.01-0.03 per enriched match at Haiku-class pricing. At 9
enriched matches/hour: ~$2-6/day, ~$60-180/month. Reasonable.
**Replay viewer integration:**
- The replay viewer fetches the companion `.md` file from Nginx and renders
the Markdown as commentary subtitles below the canvas, synchronized to
turn playback
- Commentary sections are keyed to turn numbers; the viewer displays the
relevant section as playback progresses
- Toggle on/off via a "Commentary" button
- Enriched replays are badged on the match list ("Featured" / "Narrated")
### 13.4 Shareable Replay Clips
One-click export of a replay segment as a GIF or video, formatted for
major social media platforms. This is the viral growth engine.
**Export formats:**
| Preset | Resolution | Aspect | Format | Target |
|--------|-----------|--------|--------|--------|
| Landscape | 1920×1080 | 16:9 | MP4 | YouTube, Twitter, Discord |
| Square | 1080×1080 | 1:1 | MP4 | Twitter, Instagram feed |
| Portrait | 1080×1920 | 9:16 | MP4 | TikTok, YouTube Shorts, IG Stories |
| GIF (compact) | 640×360 | 16:9 | GIF | Discord embeds, forums |
| GIF (square) | 480×480 | 1:1 | GIF | Twitter, Slack |
**User flow:**
1. While watching a replay, click "Clip" (scissors icon)
2. Drag handles on the turn scrubber to select a segment (default: 20 turns
centered on the current turn, or the nearest critical moment)
3. Select format preset from dropdown
4. Optional: toggle overlays (score, win probability, commentary subtitles)
5. Click "Export"
6. Browser records the Canvas replay segment using `OffscreenCanvas` +
`MediaRecorder` API (MP4/WebM) or gif.js (GIF)
7. Processing happens entirely client-side — no server cost
8. Download button appears, plus "Share" buttons:
- **Twitter/X**: opens compose with the clip attached + auto-generated
text ("SwarmBot pulls off a comeback against HunterBot! 🎮
aicodebattle.com/replay/{id}")
- **Reddit**: copies a markdown link with embedded video
- **Discord**: downloads the file (under Discord's 25MB upload limit)
- **Copy link**: shareable URL to the replay at the specific turn range
**Clip overlay:** the exported clip includes:
- Player names + colors in a header bar
- Score overlay (bottom-left)
- Win probability mini-graph (bottom strip, if enabled)
- "aicodebattle.com" watermark (small, bottom-right)
**GIF optimization:** GIFs are limited to 256 colors and can be large.
The clip maker uses:
- Reduced frame rate (10 fps for GIF vs 30 fps for MP4)
- Color quantization optimized for the grid art style
- Max 10-second duration for GIFs (longer clips → MP4 only)
- Target size: <5 MB for GIFs, <15 MB for MP4
**Implementation:** ~200 lines of TypeScript. `MediaRecorder` for MP4,
`gif.js` for GIF, `OffscreenCanvas` for headless rendering. All runs in
the browser. The share buttons use Web Share API where available, fallback
to window.open() with pre-composed URLs.
### 13.5 Automatic Rival Detection
The platform automatically identifies **rivalries** — pairs of bots that
frequently play each other with close results — and surfaces them as
narrative-driven content.
**Detection algorithm:**
```sql
-- Run by the index builder Deployment
SELECT
a.bot_id AS bot_a,
b.bot_id AS bot_b,
COUNT(*) AS matches,
SUM(CASE WHEN winner = a.player_slot THEN 1 ELSE 0 END) AS a_wins,
SUM(CASE WHEN winner = b.player_slot THEN 1 ELSE 0 END) AS b_wins
FROM match_participants a
JOIN match_participants b ON a.match_id = b.match_id AND a.bot_id < b.bot_id
JOIN matches m ON m.match_id = a.match_id
WHERE m.status = 'completed'
GROUP BY a.bot_id, b.bot_id
HAVING COUNT(*) >= 10
ORDER BY COUNT(*) * (1.0 - ABS(CAST(a_wins - b_wins AS REAL) / COUNT(*))) DESC
LIMIT 20
```
The ranking formula: `matches_played × (1 - |win_rate_imbalance|)`.
High-scoring pairs have many matches with near-50/50 results — the
definition of a rivalry.
**Rivalry page** (`/rivalry/{bot_a_id}/{bot_b_id}`):
```json
{
"bot_a": { "id": "b_4e8c1d2f", "name": "SwarmBot", "owner": "alice" },
"bot_b": { "id": "b_9a1b3c4d", "name": "HunterBot", "owner": "bob" },
"matches": 23,
"record": { "a_wins": 11, "b_wins": 11, "draws": 1 },
"closest_match": "m_abc123",
"longest_streak": { "holder": "b_4e8c1d2f", "length": 4 },
"recent_matches": ["m_abc123", "m_def456", ...],
"narrative": "SwarmBot and HunterBot have met 23 times — the series is dead even at 11-11-1. SwarmBot held a 4-match winning streak from Mar 15-18, but HunterBot answered with 3 straight victories. Their last match was decided by a single point."
}
```
The narrative is template-generated from the stats (no LLM needed):
```
"{bot_a} and {bot_b} have met {n} times — {record_description}.
{streak_description}. {recent_trend_description}."
```
**Platform integration:**
- Rivalry widget on the landing page: "Top Rivalries" with head-to-head
records and links to key matches
- Bot profile pages show "Rivals" section listing detected rivalries
- Rivalry matches are auto-flagged for replay enrichment (§13.3)
- Leaderboard can show "rivalry mode" — filter to matches between two
specific bots
### 13.6 Community Replay Feedback
Users can leave **tagged feedback** on specific moments in replays.
Feedback is anchored to a `(replay_id, turn)` pair and is visible to
other viewers. High-signal feedback is fed into the evolution pipeline
as strategic hints.
**Feedback types:**
| Type | Icon | Purpose |
|------|------|---------|
| Tactical insight | 💡 | "This flanking move was brilliant because..." |
| Mistake spotted | ⚠️ | "Bot should have retreated here — outnumbered 3:1" |
| Strategy idea | 🧪 | "What if a bot used this wall corridor as a chokepoint?" |
| Highlight | ⭐ | "Amazing play" (lightweight, like a star/upvote) |
**PostgreSQL schema:**
```sql
CREATE TABLE replay_feedback (
feedback_id TEXT PRIMARY KEY,
match_id TEXT NOT NULL,
turn INTEGER NOT NULL,
type TEXT NOT NULL, -- 'insight', 'mistake', 'idea', 'highlight'
body TEXT NOT NULL,
author TEXT NOT NULL, -- free text (no accounts, like registration)
upvotes INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX idx_feedback_match ON replay_feedback(match_id, turn);
```
**Replay viewer integration:**
- Small markers appear on the turn scrubber at turns with feedback
- Hovering shows a preview count: "3 comments at turn 87"
- Clicking opens a side panel showing all feedback for that turn
- Users can add their own feedback via a form in the side panel
- Upvote button on each feedback item (1 per visitor via localStorage)
**Feeding into evolution:**
The evolution pipeline's prompt builder (§10.3) consumes community
feedback as an additional signal:
1. The index builder Deployment aggregates high-upvote feedback of type `idea`
and `mistake` into `data/evolution/community_hints.json` (written to the Nginx PV)
2. The evolver reads this file and includes the top-voted recent hints
in the prompt:
```
## Community Tactical Insights (from replay annotations)
Replay m_abc123, Turn 87 (12 upvotes):
"The bot should have used the narrow wall corridor at (30,42)-(30,48)
as a chokepoint instead of engaging in the open. A defensive line of
3 units there could have held off the 8-unit swarm."
Replay m_def456, Turn 203 (8 upvotes):
"When outnumbered 3:1, retreating toward the nearest energy cluster
and spawning reinforcements is better than fighting — the focus combat
system guarantees you lose the 3v1."
```
3. If a resulting evolved bot performs well, the feedback items that
contributed to its prompt are credited on the evolution dashboard:
"Feedback from user 'tactician42' on replay m_abc123 contributed to
evo-py-g42-7f3a (rating: 1720)"
**Moderation:**
- Feedback is plain text, max 500 characters
- No accounts means no banning — but feedback is public and upvote-ranked,
so low-quality content sinks
- A simple word filter catches obvious spam
- The evolution pipeline only consumes feedback with ≥3 upvotes, filtering
noise automatically
- Admin can delete feedback via a PostgreSQL query (no UI needed initially)
**Why this matters:** It creates a human-AI collaboration loop. Spectators
contribute strategic insight, the AI translates it into code, the platform
evaluates it, and successful feedback is credited. This gives non-coders a
way to participate meaningfully in the competition.
---
## 14. Platform Depth Features
### 14.1 Bot Debug Telemetry + Reasoning Visualization
Bots can optionally include a `debug` field in their move response. The
engine stores it in the replay without interpreting it. The replay viewer
renders it.
**Extended move response schema:**
```json
{
"moves": [
{ "row": 10, "col": 15, "direction": "N" }
],
"debug": {
"reasoning": "3 energy within 5 tiles east; enemy cluster north — avoiding",
"targets": [
{ "row": 20, "col": 25, "label": "energy", "priority": 0.9 },
{ "row": 8, "col": 30, "label": "threat", "priority": 0.7 }
],
"values": {
"energy_reserves": 7,
"threat_level": "medium",
"mode": "gathering"
},
"heatmap": {
"name": "threat",
"data": [[0, 0, 0.2, 0.8], [0, 0.1, 0.5, 0.9]]
}
}
}
```
**Schema rules for `debug`:**
- Entirely optional — bots that omit it behave identically
- Max size: 10 KB per turn (prevents replay bloat; excess is truncated)
- The engine never reads or acts on debug data — it's pass-through to replay
- No fields inside `debug` are validated beyond size — bots can put anything
- Only the bot's owner sees debug data by default; owners can toggle public
visibility per-bot in their bot profile
**Replay viewer rendering:**
| Debug field | Rendering |
|-------------|-----------|
| `reasoning` | Text in a collapsible side panel, one entry per turn |
| `targets` | Colored markers on the grid (green = high priority, red = low) with labels |
| `values` | Key-value table in the side panel, updates each turn |
| `heatmap` | Semi-transparent color overlay on the grid (blue→red gradient) |
All debug rendering is toggled via a "Debug" button in the viewer toolbar.
When off, no debug data is shown (default for spectators). When on, the
viewer shows the selected player's debug output.
**Replay size impact:**
A bot sending 5 KB of debug data per turn across 500 turns adds 2.5 MB
to the replay. With gzip compression (~90% on structured JSON), that's
~250 KB. Acceptable alongside the ~50 KB base replay.
**Why it matters:** This is a visual debugger for distributed bot code.
Instead of reading logs, developers watch their bot's thought process
alongside its actions. For spectators who opt in, seeing "the bot is
scared of the northern cluster" while watching it move south creates
narrative that no commentary system can match.
### 14.2 Territory Control Heatmap Overlay
The replay viewer supports three visualization modes, toggled via a toolbar
dropdown. All computed client-side from bot positions — no server cost.
**Mode 1: Dots (default)**
The current view — bots as colored circles on the grid. Minimal, clean,
fast.
**Mode 2: Voronoi Territory**
Each tile on the grid is colored by which player's nearest bot is closest.
Creates clean territorial borders that shift each turn.
```
Computation per turn:
for each visible tile (row, col):
min_dist = infinity
owner = none
for each bot on the grid:
d = toroidal_distance_squared(tile, bot)
if d < min_dist:
min_dist = d
owner = bot.owner
tile_color = player_colors[owner] at 30% opacity
```
For a 60×60 grid with 50 bots, that's 3,600 × 50 = 180,000 distance
calculations per turn — trivial for modern JS (~1ms). The result is a
per-tile color array rendered as a single full-grid Canvas `fillRect` pass
underneath the bot sprites.
**Mode 3: Influence Gradient**
Force projection based on bot count and distance. Each player's influence
at a tile is the sum of `1 / (1 + distance)` across all their bots.
Rendered as a smooth gradient:
```
for each visible tile:
influence = [0, 0, ..., 0] // per player
for each bot:
d = toroidal_distance(tile, bot)
influence[bot.owner] += 1.0 / (1.0 + d)
dominant = argmax(influence)
strength = influence[dominant] / sum(influence)
tile_color = player_colors[dominant] at (strength × 50%) opacity
```
The gradient creates a softer, more organic visualization than Voronoi —
you can see where influence is strong (dense, saturated) vs weak (faint,
contested). Frontlines appear as narrow bands where no player dominates.
**Performance:** both modes compute in <5ms per turn on a 60×60 grid.
The replay viewer caches the overlay bitmap per turn and only recomputes
on turn change. At 32 turns/second (16× speed), this stays within frame
budget.
**Toolbar UI:**
```
View: [Dots ▼] [Dots | Territory | Influence]
```
Switching modes is instant — the underlying replay data doesn't change,
only the rendering pipeline.
### 14.3 Embeddable Replay Widget
A lightweight, standalone replay player that works in an iframe anywhere.
**URL format:**
```
https://aicodebattle.com/embed/{match_id}
https://aicodebattle.com/embed/{match_id}?start=87&speed=4&mode=territory
```
**Query parameters:**
| Param | Default | Description |
|-------|---------|-------------|
| `start` | 0 | Starting turn |
| `speed` | 2 | Playback speed (1, 2, 4, 8, 16) |
| `mode` | dots | Visualization mode (dots, territory, influence) |
| `autoplay` | true | Start playing on load |
| `controls` | true | Show play/pause and speed controls |
**Widget design:**
Stripped-down replay viewer: canvas + minimal controls bar. No scrubber,
no side panel, no fog-of-war toggle. Just the match playing.
```
┌──────────────────────────────┐
│ │
│ [Canvas] │
│ │
├──────────────────────────────┤
│ ▶ 2x SwarmBot 3 — 1 Hunter │
│ Watch full ↗ │
└──────────────────────────────┘
```
"Watch full" links to the main replay page on aicodebattle.com.
**Implementation:**
- Separate route served by Nginx: `/embed/{match_id}`
- Loads the same replay JSON from Nginx
- Renders with the same Canvas engine, minus chrome
- Total bundle: ~50 KB (JS + CSS)
- Open Graph tags for rich previews when pasting the URL:
```html
<meta property="og:title" content="SwarmBot vs HunterBot — AI Code Battle" />
<meta property="og:description" content="SwarmBot wins 3-1 in 342 turns" />
<meta property="og:image" content="https://aicodebattle.com/thumbnails/m_7f3a9b2c.png" />
```
- Thumbnail: auto-generated PNG of the final turn state, created by the
index builder Deployment or pre-rendered by the match worker
**Infrastructure impact:** embed loads are static Nginx requests (cached by
Cloudflare at the edge). Negligible additional load.
### 14.4 Replay Playlists + Auto-Curation
Automatically curated collections of replays, browsable from the static
site's landing page.
**Playlist definitions:**
| Playlist | Query Criteria | Rebuild Frequency |
|----------|---------------|-------------------|
| "Closest Finishes" | `final_score_diff <= 1` sorted by `win_prob_crossings DESC` | Every ~15 min (index builder deploy) |
| "Biggest Upsets" | `winner_rating - loser_rating <= -150` | Every ~15 min |
| "Best Comebacks" | `min(win_prob) < 0.2 AND winner = underdog` | Every ~15 min |
| "Evolution Breakthroughs" | Evolved bot's first win against a top-10 bot | Every ~15 min |
| "Rivalry Classics" | Matches between detected rivals, sorted by closeness | Every ~15 min |
| "This Week's Highlights" | Top 10 by community upvote count (from §13.6) | Every ~15 min |
| "New Bot Debuts" | First match of each newly registered bot | Every ~15 min |
| "Season Highlights" | Top 20 matches of the current season by engagement | Every ~15 min |
**PV storage:** `data/playlists/{slug}.json`
```json
{
"name": "Closest Finishes",
"description": "Matches decided by a single point or less",
"updated_at": "2026-03-23T14:35:00Z",
"matches": [
{
"match_id": "m_7f3a9b2c",
"players": ["SwarmBot", "HunterBot"],
"scores": [3, 2],
"date": "2026-03-23T14:30:00Z",
"thumbnail_url": "https://aicodebattle.com/thumbnails/m_7f3a9b2c.png",
"enriched": true
}
]
}
```
**Static site UI:** landing page shows playlists as horizontal scrollable
rows (Netflix-style). Each card shows a thumbnail, player names, and score.
Click opens the replay.
**Infrastructure impact:** playlist JSONs are tiny (<50 KB each).
They're rebuilt by the index builder Deployment and written to the Nginx PV --
just additional PostgreSQL queries within the existing index build cycle.
### 14.5 Prediction System
Visitors predict outcomes of upcoming notable matches. Correct predictions
earn reputation. A prediction leaderboard tracks the best analysts.
**Which matches get predictions:**
The matchmaker flags a match as "predictable" when:
- Both bots are in the top 20
- It's a rivalry match
- It's a series match (§14.7)
- An evolved bot faces a top-10 human-written bot
At ~60 matches/hour, roughly 510% are flagged — about 36 per hour.
**Flow:**
1. Scheduler creates a match job with `predictable: true`
2. Go API writes the match to a `predictions_open` state in PostgreSQL
3. Static site shows "Upcoming Matches" with a predict button
4. Visitor clicks a player to predict (stored via `POST /api/predict`)
5. Prediction window: open from job creation until the match starts
executing (typically 15 minutes)
6. Match executes normally
7. On result submission, Go API resolves predictions in PostgreSQL
8. Index builder Deployment updates the prediction leaderboard JSON on the PV
(next ~90-min deploy cycle)
**PostgreSQL schema:** (see §8.3 for the consolidated schema)
```sql
CREATE TABLE predictions (
prediction_id TEXT PRIMARY KEY,
match_id TEXT NOT NULL,
predictor_id TEXT NOT NULL, -- localStorage-generated UUID
predictor_name TEXT, -- optional display name
predicted_bot_id TEXT NOT NULL, -- bot_id of the predicted winner
correct INTEGER, -- null until resolved
created_at TEXT NOT NULL
);
CREATE TABLE predictor_stats (
predictor_id TEXT PRIMARY KEY,
predictor_name TEXT,
correct INTEGER NOT NULL DEFAULT 0,
incorrect INTEGER NOT NULL DEFAULT 0,
streak INTEGER NOT NULL DEFAULT 0,
best_streak INTEGER NOT NULL DEFAULT 0,
rating REAL NOT NULL DEFAULT 1000.0
);
```
Predictions are tied to **bot identity** (`predicted_bot_id`), not player
slot. Resolution matches the winning bot's `bot_id` against the predicted
`bot_id`. This avoids ambiguity when the same bot appears in different
player slots across matches.
Predictor rating uses a simplified Elo: correct prediction on a balanced
match (close ratings) = small gain; correct prediction on a heavy underdog
= large gain.
**Resource usage:**
| Metric | Usage | Notes |
|--------|-------|-------|
| PostgreSQL writes | ~6 predictions/match x 6 matches/hour x 24h = ~864/day | Negligible |
| PostgreSQL reads | ~50 leaderboard reads/day | Negligible |
| Go API requests | `POST /api/predict` ~864/day | Negligible |
Comfortably within cluster capacity at any realistic scale.
**Static site UI:**
- "Predictions" page showing upcoming predictable matches with bot profiles
and head-to-head records
- One-click predict button (no login required — UUID from localStorage)
- After match: result shown with "You were right/wrong" + points earned
- Prediction leaderboard: top 50 analysts ranked by prediction rating
### 14.6 Map Evolution
Maps evolve alongside bots. High-engagement maps breed to produce new maps.
Low-engagement maps retire. User feedback and positional fairness monitoring
ensure quality.
**Engagement scoring:**
After each match, the map receives an engagement score:
```
engagement = (
win_prob_crossings × 3.0 +
critical_moments × 2.0 +
map_coverage_pct × 1.0 +
closeness × 2.0 +
avg_turn_count / max_turns × 1.0
)
where:
closeness = 1.0 - (abs(score_diff) / max(total_score, 1))
map_coverage_pct = tiles_visited_by_any_bot / total_open_tiles
```
The map's engagement score is the rolling average across its last 20 matches.
**Positional fairness monitoring:**
A map is **positionally fair** if no starting position has a systematic
advantage. Monitored by tracking win rate per player slot:
```sql
SELECT
map_id,
player_slot,
COUNT(*) AS games,
AVG(CASE WHEN winner = player_slot THEN 1.0 ELSE 0.0 END) AS win_rate
FROM match_participants mp
JOIN matches m ON m.match_id = mp.match_id
GROUP BY map_id, player_slot
HAVING COUNT(*) >= 80
```
If any player slot's win rate deviates from the expected rate (1/N for
N-player maps) by more than **10 percentage points** across 80+ matches,
the map is flagged as **unfair** and removed from the competitive pool.
At 80 matches with a 10pp threshold, the false positive rate from random
variance drops to ~2% (compared to ~15% at 20 matches).
Example: on a 2-player map, if player slot 0 wins 58% of the time after
80 matches, the map is flagged (58% - 50% = 8% -- close to threshold,
monitored). At 60%, it is flagged and removed.
**User map voting:**
After watching a replay, visitors can upvote or downvote the map (not the
match -- the map). Stored in PostgreSQL:
```sql
CREATE TABLE map_votes (
vote_id TEXT PRIMARY KEY,
map_id TEXT NOT NULL,
voter_id TEXT NOT NULL, -- localStorage UUID
vote INTEGER NOT NULL, -- +1 or -1
created_at TEXT NOT NULL,
UNIQUE(map_id, voter_id)
);
```
Map voting influences the evolution system:
- Maps with net negative votes get a 0.5× engagement multiplier (less likely
to breed)
- Maps with >10 net positive votes get a 1.5× multiplier
- Maps with >20 net negative votes are force-retired regardless of engagement
The replay viewer shows a simple 👍/👎 widget for the map (not the bots)
alongside map metadata (name, dimensions, wall density, energy count).
**Breeding algorithm:**
Runs weekly on the evolver Deployment. Produces ~5 new maps per
player-count tier.
```
1. Select parents:
- Top 5 maps by engagement × vote_multiplier for this player count
- Weighted random: higher engagement = more likely to be selected
2. Crossover:
- Divide parent maps into quadrants (or thirds for 3/6-player)
- Randomly select quadrants from each parent
- Compose into a new map
3. Apply symmetry:
- Generate one sector from the composed quadrants
- Mirror/rotate to fill the full map for the target player count
- This guarantees positional fairness by construction
4. Mutate:
- Randomly flip 5-10% of tiles (wall ↔ open)
- Shift 1-3 energy node positions by 1-3 tiles
- Apply cellular automata smoothing (2 iterations) to avoid
jagged walls
5. Validate:
- BFS from every core must reach every other core
- BFS from every core must reach ≥3 energy nodes
- Open area per player must be between 900 and 5000 tiles
- Wall density must be between 5% and 30%
6. Smoke-test:
- Run 3 matches with built-in bots on the candidate map
- Engagement score must exceed 50th percentile of current pool
- If failed: discard and retry (max 3 attempts per candidate)
7. Add to pool:
- Store map JSON on the Nginx PV (`maps/{map_id}.json`)
- Insert into PostgreSQL maps table with `status: 'active'`
- Available for matchmaking in the next scheduler cycle
```
**Lifecycle:**
| Status | Meaning |
|--------|---------|
| `active` | In the matchmaking pool, eligible for competitive play |
| `probation` | Fairness flag triggered — under review, still playable |
| `retired` | Removed from pool (low engagement, unfair, or force-retired) |
| `classic` | Top 5 all-time maps, immune from retirement |
- Active pool: 50 maps per player count (2, 3, 4, 6)
- New maps: ~5 per week per player count
- Retirement: bottom 10% by engagement score pruned monthly
- Classic promotion: maps that sustain top-5 engagement for 3+ months
### 14.7 Multi-Game Series
Best-of-N matches between two bots across different maps. Series produce
more meaningful ratings than single matches and create narrative arcs.
**Series types:**
| Type | Games | Trigger |
|------|-------|---------|
| Best-of-3 | 3 | Auto-scheduled for top-20 bots, 1 per day per bot |
| Best-of-5 | 5 | Weekly featured series between top rivalries |
| Best-of-7 | 7 | Season championship bracket (§14.9) |
**Map selection for series:**
Each game in a series uses a different map, selected to test different
strategic dimensions:
```
Game 1: Map with highest engagement score (the "classic")
Game 2: Map with highest wall density in pool (corridors/chokepoints)
Game 3: Map with lowest wall density in pool (open field)
Game 4: Most recently evolved map (untested terrain)
Game 5+: Random from remaining pool
```
This ensures series test bot adaptability, not just performance on one
map type.
**PostgreSQL schema:**
```sql
CREATE TABLE series (
series_id TEXT PRIMARY KEY,
bot_a_id TEXT NOT NULL,
bot_b_id TEXT NOT NULL,
format INTEGER NOT NULL, -- 3, 5, or 7
status TEXT NOT NULL DEFAULT 'pending',
a_wins INTEGER NOT NULL DEFAULT 0,
b_wins INTEGER NOT NULL DEFAULT 0,
season_id TEXT,
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE series_games (
series_id TEXT NOT NULL,
game_number INTEGER NOT NULL,
match_id TEXT, -- null until played
map_id TEXT NOT NULL,
winner INTEGER,
PRIMARY KEY (series_id, game_number)
);
```
**Execution:**
The scheduler creates all games in a series as pending jobs with sequential
ordering. Workers execute them in order (game 2 doesn't start until game 1
completes). If either bot reaches the winning threshold (2 for bo3, 3 for
bo5, 4 for bo7), remaining games are skipped.
**Rating impact:**
Series results contribute to Glicko-2 ratings as follows:
- Each individual game in the series contributes to the pairwise rating
update (same as a single match)
- The series winner gets a bonus rating adjustment of +10 mu (small but
meaningful — rewards series consistency)
**Replay presentation:**
The series page (`/series/{series_id}`) shows all games as a unified
experience:
```
SwarmBot vs HunterBot — Best of 5 (Season 4 Semifinals)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Game 1 ✓ SwarmBot 3-1 Map: The Labyrinth [Watch]
Game 2 ✓ HunterBot 2-4 Map: Open Expanse [Watch]
Game 3 ✓ HunterBot 1-3 Map: Coral Reef [Watch]
Game 4 ??? [Reveal]
Game 5 ??? [Reveal]
Series: HunterBot leads 2-1
```
**Spoiler toggle:** by default, future games are hidden ("???"). Viewers
click "Reveal" to show the result — or "Watch All" to experience the
series sequentially with auto-advancing between games.
### 14.8 Match Event Timeline
A horizontal event ribbon below the replay canvas showing significant
events as colored, clickable icons.
**Event types:**
| Icon | Event | Trigger |
|------|-------|---------|
| ⚔️ | Combat | 2+ bots died this turn |
| 🏰 | Core captured | A core was razed |
| 💎 | Energy milestone | Player collected 3+ energy in one turn |
| 💀 | Mass death | 5+ bots died this turn |
| 📈 | Momentum shift | Win probability crossed 50% |
| 🌟 | Critical moment | Win probability shifted >15% |
| 🐣 | Spawn wave | 3+ bots spawned this turn |
**Implementation:**
Events are extracted client-side from the replay data on load. For each
turn, check the events array (deaths, captures, spawns, energy_collected)
against the trigger thresholds. Win probability events come from the
`win_prob` and `critical_moments` arrays already in the replay.
**Rendering:**
```
┌──────────────────────────────────────────────────┐
│ [Canvas] │
├──────────────────────────────────────────────────┤
│ Win Prob: ~~~~~~~~~/\~~~~~/\~~~~/\~~~~~~ │ ← sparkline
├──────────────────────────────────────────────────┤
│ Events: ·💎·····⚔️··💎···🏰⚔️···💎···⚔️💀··🏰🌟│ ← timeline
├──────────────────────────────────────────────────┤
│ ◄ ▶ ⏸ Turn 203/500 Speed: 4x View: [Dots]│ ← controls
└──────────────────────────────────────────────────┘
```
- Icons are positioned proportionally along the timeline by turn number
- Hovering an icon shows a tooltip: "Turn 87: 3 bots killed in eastern
corridor"
- Clicking an icon scrubs the replay to that turn
- Dense clusters of icons indicate "hot zones" of activity — visually
obvious even at a glance
- The timeline is rendered as an HTML element overlaid on the viewer
(not Canvas) for accessibility and hover interactions
The event timeline and win probability graph work together: the graph
shows the *trend*, the timeline shows the *moments*. A viewer can scan
the timeline for icon clusters, then check the win probability graph to
see if those moments mattered.
### 14.9 Seasonal Rotations
The platform runs in **seasons** — 4-week competitive periods with a fresh
map pool, a new ladder, and a theme. Seasons provide urgency, freshness,
and a reason to come back.
**Season structure:**
| Week | Phase | Description |
|------|-------|-------------|
| 1 | Discovery | New map pool + theme released. All bots start at default rating. Exploration matches. |
| 23 | Competition | Main ladder. Matchmaking intensifies. Mid-season stats published. |
| 4 | Championship | Top 8 bots by rating enter a best-of-7 bracket. Season champion crowned. |
| Between | Break (3 days) | New maps bred via map evolution. Season archive published. |
**What resets each season:**
- Glicko-2 ratings (mu/phi/sigma reset to defaults)
- Map pool (evolved maps from previous season + new generated maps)
- Prediction standings
- Playlist contents
**What persists:**
- Bot registrations and endpoints (bots don't re-register)
- All-time records and historical season archives (browsable)
- Evolution population (continues across seasons, adapts to new maps)
- Community feedback and replay annotations
**PostgreSQL schema:**
```sql
CREATE TABLE seasons (
season_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
theme TEXT NOT NULL,
rules_version INTEGER NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT,
champion_id TEXT,
status TEXT NOT NULL DEFAULT 'active'
);
```
**Season themes and game rule versioning:**
Each season can introduce **minor rule variations** that keep the meta
fresh. The critical constraint: **existing bots must continue to work
without modification.** This is achieved through additive, optional
changes only.
**Backward compatibility rules:**
```
ALLOWED per-season changes (additive, non-breaking):
✓ New tile types that bots can ignore (treated as open by old bots)
✓ New optional fields in the game state JSON (old bots ignore them)
✓ Adjusted numeric parameters within the existing schema:
- vision_radius2, attack_radius2, spawn_cost, energy_interval
- These are sent in the config object each match — bots that read
config adapt automatically; bots that hardcode values still work
but may be suboptimal
✓ New scoring bonuses (additive to existing scoring)
✓ Map pool changes (different maps, not different map format)
FORBIDDEN (would break existing bots):
✗ Removing or renaming existing fields in game state / move schema
✗ Changing the meaning of existing fields
✗ New required fields in the move response
✗ Changing the coordinate system or grid topology
✗ Removing movement directions (N/E/S/W)
✗ Changing the turn structure (phases must remain in the same order)
```
**Example seasonal themes:**
| Season | Theme | Rule Variation |
|--------|-------|---------------|
| 1 | "The Labyrinth" | High wall density maps, `vision_radius2: 36` (reduced from 49) |
| 2 | "Energy Rush" | `energy_interval: 5` (doubled production), `spawn_cost: 2` (cheaper bots) |
| 3 | "Fog of War" | `vision_radius2: 25` (heavily reduced), new optional `sonar` field in game state showing approximate enemy count per quadrant |
| 4 | "The Colosseum" | `attack_radius2: 8` (extended range), open maps, aggressive meta |
| 5 | "Shifting Sands" | New tile type `quicksand` in game state (bots that don't handle it treat it as open — they can enter but movement costs 2 turns) |
For season 5's `quicksand` example: the game state sends
`{ "row": 15, "col": 20, "type": "quicksand" }` in a new `terrain` array.
Old bots that don't read `terrain` still function — they walk through
quicksand unknowingly (and get slowed). New bots that parse `terrain` can
avoid quicksand tiles, gaining a strategic edge. This creates an incentive
to update bots each season without *forcing* anyone to.
**Season config in the match protocol:**
The game state's `config` object already includes all tunable parameters.
Seasonal changes are just different values:
```json
{
"config": {
"season_id": "s4",
"season_name": "The Colosseum",
"rules_version": 4,
"rows": 60,
"cols": 60,
"max_turns": 500,
"vision_radius2": 49,
"attack_radius2": 8,
"spawn_cost": 3,
"energy_interval": 10,
"special_tiles": ["quicksand"]
}
}
```
Bots that read `config.attack_radius2` adapt automatically. Bots that
hardcode `attack_radius2 = 12` still work but use stale assumptions.
`special_tiles` is a new array listing any non-standard tile types in
play — old bots that don't read it are unaffected.
**Season archive:**
Each completed season gets an archive page (`/season/{season_id}`):
- Champion + top 10 + bracket results
- Most improved bot (biggest rating gain)
- Best newcomer (highest-rated bot registered this season)
- Most watched match (by replay view count)
- Evolution highlights (best evolved bot, most creative strategy)
- Map of the season (highest engagement score)
- All replays preserved and browsable
**Season championship bracket:**
In week 4, the top 8 bots enter a single-elimination bracket of best-of-7
series (§14.7). The bracket is published on the season page with live
updates as series complete.
```
Quarterfinals:
#1 SwarmBot vs #8 NewBot → SwarmBot (4-1)
#4 GathererBot vs #5 RusherBot → RusherBot (4-3)
#3 HunterBot vs #6 evo-go-g12 → HunterBot (4-2)
#2 GuardianBot vs #7 evo-py-g8 → GuardianBot (4-0)
Semifinals:
SwarmBot vs RusherBot → SwarmBot (4-2)
HunterBot vs GuardianBot → HunterBot (4-3)
Finals:
SwarmBot vs HunterBot → ???
```
### 14.10 Bot Profile Cards
Auto-generated visual cards summarizing a bot's identity, stats, and
character in a single shareable image.
**Card generation:**
The card is rendered as a PNG via OffscreenCanvas (in the browser on
demand, or pre-rendered by the index builder Deployment for top-50 bots).
**Card content:**
```
┌─────────────────────────────────┐
│ │
│ SwarmBot #3 │
│ by alice Rating: 1820 │
│ │
│ ┌─────────────────────────┐ │
│ │ Archetype: │ │
│ │ FORMATION SWARM │ │
│ │ │ │
│ │ Season 4 · 142 games │ │
│ └─────────────────────────┘ │
│ │
│ Win Rate 69% ████████░░ │
│ vs Rushers 82% █████████░ │
│ vs Turtles 45% ████░░░░░░ │
│ │
│ Signature: Eastern corridor │
│ push on 4-player maps │
│ │
│ Rival: HunterBot (11-11-1) │
│ │
│ ⚔️ 847 kills 💎 2.1k energy │
│ 🏰 23 captures 📈 +320 Elo │
│ │
│ aicodebattle.com │
└─────────────────────────────────┘
```
**Data sources (all from existing bot profile JSON):**
| Field | Source |
|-------|--------|
| Rating, rank | Leaderboard |
| Archetype | Strategy classifier from behavioral features (§10.2 MAP-Elites behavior grid) |
| Win rate breakdown | PostgreSQL query: wins vs each archetype cluster |
| Signature | Most statistically distinctive behavior vs population average |
| Rival | From rival detection (§13.5) |
| Kill/energy/capture stats | Aggregate from match_participants |
**"Signature" computation:**
For each bot, compare its behavioral features (aggression, economy,
exploration, formation) to the population mean. The dimension where the
bot deviates most is its signature. Combined with map-type analysis:
```
if bot.aggression is 2σ above mean AND best_map_type == "4-player":
signature = "Aggressive multi-front warfare on 4-player maps"
if bot.economy is 1.5σ above mean AND bot.exploration > 80%:
signature = "Full-map economic dominance"
```
Template-generated from ~20 signature patterns.
**Sharing:**
- "Share Card" button on the bot profile page generates a PNG download
- Direct URL: `https://aicodebattle.com/card/{bot_id}.png`
- Served as a static PNG from Nginx PV (pre-rendered for top-50 bots)
- Or rendered on-demand by the Go API endpoint that reads the bot profile
from PostgreSQL, draws to Canvas (using Go image libraries or a
pre-built image template), and returns the PNG
- Open Graph tags on the URL so pasting it into Twitter/Discord/Slack
shows the card as a rich preview:
```html
<meta property="og:image" content="https://aicodebattle.com/card/b_4e8c1d2f.png" />
<meta property="og:title" content="SwarmBot — #3 Rated — AI Code Battle" />
```
- The card image includes the platform URL as a watermark, driving traffic
---
## 15. Ecosystem & Polish
### 15.1 Weekly Meta Report (Blog Posts)
Every Monday, the platform publishes a "State of the Game" blog post — an
auto-generated analysis of the competitive landscape for the current season.
**Published to:** `/blog/meta-week-{N}-season-{S}` on the static site.
**Blog infrastructure:**
Blog posts are JSON files on Cloudflare Pages (`data/blog/posts/{slug}.json`),
each containing:
```json
{
"slug": "meta-week-12-season-4",
"title": "Week 12 Meta Report — Season 4: The Colosseum",
"date": "2026-03-23",
"type": "meta-report",
"content_md": "# Week 12 Meta Report\n\n## Dominant Strategies\n...",
"summary": "Swarm tactics dominate as attack_radius2 increase favors formations...",
"tags": ["meta-report", "season-4"]
}
```
The static site's `/blog` page fetches `data/blog/index.json` (list of all
posts) and renders them client-side with a Markdown renderer.
**Report contents:**
| Section | Data Source | Generation |
|---------|------------|------------|
| Dominant Strategies | Archetype distribution of top-20 bots | PostgreSQL query -> template |
| Rising / Falling Bots | Biggest rating movers (+/-) this week | PostgreSQL query -> template |
| Counter-Strategy Spotlight | Under-represented archetypes in top 20 | PostgreSQL query -> LLM narrative |
| Map of the Week | Highest engagement map | PostgreSQL query -> template |
| Evolution Highlights | Promotion count, best evolved bot, most novel attempt | PostgreSQL query -> LLM narrative |
| Prediction Standings | Top 5 predictors, accuracy rates | PostgreSQL query -> template |
| Season Progress | Weeks remaining, championship seedings | PostgreSQL query -> template |
**Generation pipeline:**
1. The index builder Deployment runs a weekly blog generation pass (triggered
when `dayOfWeek == Monday` during its regular cycle)
2. Queries PostgreSQL directly for all data points above
3. Template-fills the structured sections (strategy distribution, ratings,
maps, predictions)
4. Sends the free-text sections (counter-strategy spotlight, evolution
highlights) to a cheap LLM with the data context + a journalism-style
prompt
5. Assembles the full Markdown post
6. Writes the blog JSON file to the staging directory (`data/blog/posts/{slug}.json`)
7. Updates `data/blog/index.json` — deployed to Pages on the next cycle
**Cost:** one LLM call per week (~$0.05). Negligible.
**Why blog posts:** Blog posts are indexable by search engines (driving
organic traffic), shareable as URLs, and accumulate into a historical
record of the platform's competitive evolution. They also give the
platform a human-feeling editorial voice even though the content is
auto-generated.
### 15.2 Public Match Data (Static JSON)
All platform data is pre-computed and stored as static JSON files, split
between **Cloudflare Pages** (indexes) and **Backblaze B2** (replays and
per-match data, served via Cloudflare CDN). Index files are rebuilt every ~15 min
by the index builder and deployed to Pages every ~90 min. Replays and per-match
data are uploaded to B2 in real time by match workers. The "API" is simply
**documented file paths** -- no dynamic endpoints, no query parameters, no
rate limiting needed.
**Documented data paths:**
```
PAGES = https://aicodebattle.com (Cloudflare Pages)
B2 = https://b2.aicodebattle.com (Backblaze B2 via Cloudflare CDN)
API = https://api.aicodebattle.com (K8s Go API, dynamic only)
--- Index files on Pages (deployed every ~90 min by index builder) ---
Leaderboard:
GET {PAGES}/data/leaderboard.json
Bot directory:
GET {PAGES}/data/bots/index.json
GET {PAGES}/data/bots/{bot_id}.json
Match index:
GET {PAGES}/data/matches/index.json
GET {PAGES}/data/matches/index-{page}.json (older pages)
Series:
GET {PAGES}/data/series/index.json
GET {PAGES}/data/series/{series_id}.json
Seasons:
GET {PAGES}/data/seasons/index.json
GET {PAGES}/data/seasons/{season_id}.json
Playlists:
GET {PAGES}/data/playlists/{slug}.json
Meta:
GET {PAGES}/data/meta/archetypes.json
GET {PAGES}/data/meta/rivalries.json
Evolution (indexes):
GET {PAGES}/data/evolution/lineage.json
GET {PAGES}/data/evolution/meta.json
GET {PAGES}/data/evolution/community_hints.json
Blog:
GET {PAGES}/data/blog/index.json
GET {PAGES}/data/blog/posts/{slug}.json
Predictions:
GET {PAGES}/data/predictions/leaderboard.json
GET {PAGES}/data/predictions/open.json
Maps:
GET {PAGES}/maps/index.json
GET {PAGES}/maps/{map_id}.json
--- Data on B2 (written by workers/evolver, served via Cloudflare CDN) ---
Individual match metadata:
GET {B2}/matches/{match_id}.json
Replays:
GET {B2}/replays/{match_id}.json.gz
Evolution (live feed):
GET {B2}/evolution/live.json
Thumbnails:
GET {B2}/thumbnails/{match_id}.png
Bot cards:
GET {B2}/cards/{bot_id}.png
```
**Replay format specification:**
Published at `/docs/replay-format` on the static site. Contains:
- JSON Schema file (`replay-schema-v{N}.json`) served by Pages --
third-party tools can validate replays programmatically
- Field-by-field documentation with types, semantics, and examples
- Versioning policy: additive changes only, matching the seasonal backward
compatibility rules (§14.9). New fields may appear in future versions;
old fields are never removed or renamed.
- Example replays for each version (downloadable)
- Changelog of schema changes per season
**Documentation page** (`/docs/data`):
A static page listing every data path above with descriptions, update
frequency, and example `curl` commands. No authentication, no API keys,
no rate limiting -- it's just static files served by Cloudflare.
**Why static JSON, not a dynamic API:**
All this data already exists as static files on Pages and B2. The index
builder Deployment already produces leaderboard.json, bot profiles, match
indexes, playlists, etc. Adding a dynamic API layer would add complexity
for data that's already pre-computed and publicly readable. Cloudflare
serves these globally with automatic CDN caching.
Third-party tools just `fetch()` the URLs. If they need to poll for
updates, they check the `updated_at` field in each JSON file. Index files
on Pages refresh every ~90 minutes. B2 cache headers guide freshness for
replays (immutable) and the evolution live feed (10s).
### 15.3 Accessibility Suite
**Color-blind safe palettes:**
The platform ships with two palette options. Users toggle between them
via a dropdown in the replay viewer toolbar. Preference persists in
localStorage.
| Players | Default | Color-Blind Safe (Tol) |
|---------|---------|----------------------|
| Player 1 | Blue (#2196F3) | Blue (#0077BB) |
| Player 2 | Red (#F44336) | Orange (#EE7733) |
| Player 3 | Green (#4CAF50) | Cyan (#009988) |
| Player 4 | Yellow (#FFEB3B) | Magenta (#EE3377) |
| Player 5 | Purple (#9C27B0) | Grey (#BBBBBB) |
| Player 6 | Teal (#009688) | Black (#000000) |
The Tol palette is designed by Paul Tol for maximum distinguishability
under protanopia, deuteranopia, and tritanopia.
**Shape-per-player (redundant encoding):**
Each player's bots are rendered with a distinct shape in addition to
color, ensuring identification without color vision:
| Player | Shape |
|--------|-------|
| 1 | Circle ● |
| 2 | Square ■ |
| 3 | Triangle ▲ |
| 4 | Diamond ◆ |
| 5 | Pentagon ⬠ |
| 6 | Hexagon ⬡ |
Shapes are visible in all three view modes (dots, territory, influence).
In territory/influence mode, bot sprites retain their shapes on top of
the colored overlay.
**Keyboard shortcuts:**
| Key | Action |
|-----|--------|
| `Space` | Play / Pause |
| `` / `` | Step back / forward one turn |
| `Shift+←` / `Shift+→` | Jump 10 turns |
| `[` / `]` | Previous / Next critical moment |
| `1``5` | Speed preset (1×, 2×, 4×, 8×, 16×) |
| `V` | Cycle view mode (dots → territory → influence) |
| `F` | Cycle fog of war perspective |
| `T` | Toggle debug telemetry panel |
| `E` | Toggle event timeline |
| `C` | Toggle commentary subtitles |
| `?` | Show keyboard shortcuts overlay |
A "⌨️" icon in the toolbar opens the shortcuts reference as an overlay.
**High contrast mode:**
Toggled via toolbar or `H` key. Changes:
- Grid lines: thin grey → bold white
- Background: dark grey → pure black
- Bot sprites: add 2px white outline
- Territory/influence overlays: increase opacity from 30% to 50%
- Energy nodes: yellow → bright white with yellow border
- Walls: dark grey → medium grey with white border
- Dead bots: fading red → solid white X
**Reduced motion:**
Respects the `prefers-reduced-motion` CSS media query automatically.
When active:
- Energy node pulse animation → static icon
- Dead bot fade effect → instant removal
- Bot movement trails → disabled
- Combat flash → static highlight for one turn
- Replay speed presets remain (this is user-controlled motion, not
decorative)
**Screen reader transcript:**
A "Transcript" button in the toolbar opens a text panel showing a
turn-by-turn summary generated from replay events:
```
Turn 87: Player 1 (SwarmBot) moved 8 bots east. Player 2 (HunterBot)
moved 3 bots south. Combat at (30,42): 2 SwarmBot units and 1 HunterBot
unit killed. SwarmBot collected energy at (25,38). Win probability:
SwarmBot 62%, HunterBot 38%.
```
Generated client-side from the replay data. ARIA live region announces
each turn's summary during auto-playback.
**Focus management:**
- All interactive elements have visible focus indicators (2px blue
outline, offset by 2px for contrast)
- Tab order follows a logical flow: toolbar → canvas (focusable for
keyboard shortcuts) → scrubber → controls
- Canvas receives focus on click; keyboard shortcuts only activate when
canvas is focused (prevents conflicts with page-level shortcuts)
- Skip-to-content link at page top for screen reader users
### 15.4 Live Evolution Observatory
The evolution dashboard becomes a real-time observatory where visitors
watch the AI evolution system work — candidates being generated, tested,
rejected, and promoted.
**Data flow:**
The evolver Deployment writes a status file to B2 at each stage of every
evolution cycle:
```
Upload to B2: evolution/live.json
Served as: https://b2.aicodebattle.com/evolution/live.json
```
Updated at every state transition: generation start, validation
complete, each evaluation match result, promotion decision. At ~15
minutes per cycle with ~5 state transitions, that's ~20 B2 writes
per hour. B2 serves the file via Cloudflare CDN with `Cache-Control: max-age=10`.
**`live.json` schema:**
```json
{
"updated_at": "2026-03-23T14:32:15Z",
"cycle": {
"generation": 847,
"started_at": "2026-03-23T14:20:00Z",
"phase": "evaluating",
"candidate": {
"id": "go-847-3",
"island": "go",
"language": "Go",
"parents": [
{ "id": "go-831-1", "rating": 1580 },
{ "id": "go-839-2", "rating": 1540 }
],
"community_hint": "try retreating when outnumbered 3:1",
"validation": {
"syntax": { "passed": true, "time_ms": 120 },
"schema": { "passed": true, "time_ms": 450 },
"smoke": { "passed": true, "time_ms": 3200 }
},
"evaluation": {
"matches_total": 10,
"matches_played": 4,
"results": [
{ "opponent": "strategy-random", "won": true, "score": "5-1" },
{ "opponent": "strategy-swarm", "won": false, "score": "2-3" },
{ "opponent": "evo-go-g840", "won": true, "score": "4-2" },
{ "opponent": "strategy-hunter", "won": true, "score": "3-1" }
]
}
}
},
"recent_activity": [
{
"time": "2026-03-23T14:32:00Z",
"generation": 847,
"candidate": "go-847-2",
"island": "go",
"result": "rejected",
"reason": "Nash gate: expected payoff -0.12 vs Nash mixture",
"stage": "promotion"
},
{
"time": "2026-03-23T14:28:00Z",
"generation": 846,
"candidate": "py-846-5",
"island": "python",
"result": "rejected",
"reason": "Smoke test: crashed on turn 12",
"stage": "validation"
},
{
"time": "2026-03-23T14:25:00Z",
"generation": 846,
"candidate": "rs-846-1",
"island": "rust",
"result": "promoted",
"bot_id": "evo-rs-g846",
"initial_rating": 1500,
"stage": "deployment"
}
],
"islands": {
"python": { "population": 18, "best_rating": 1580, "best_bot": "evo-py-g820" },
"go": { "population": 20, "best_rating": 1650, "best_bot": "evo-go-g831" },
"rust": { "population": 17, "best_rating": 1520, "best_bot": "evo-rs-g846" },
"mixed": { "population": 20, "best_rating": 1710, "best_bot": "evo-mx-g802" }
},
"totals": {
"generations_total": 847,
"candidates_today": 96,
"promoted_today": 12,
"promotion_rate_7d": 0.12,
"highest_evolved_rating": 1710,
"evolved_in_top_10": 3
}
}
```
**Observatory page (`/evolution`):**
The static site polls `/evolution/live.json` every 10 seconds and renders:
**Top bar: island overview**
```
┌────────────┬────────────┬────────────┬────────────┐
│ 🐍 Python │ 🔵 Go │ 🦀 Rust │ 🔀 Mixed │
│ pop: 18 │ pop: 20 │ pop: 17 │ pop: 20 │
│ best: 1580│ best: 1650│ best: 1520│ best: 1710│
└────────────┴────────────┴────────────┴────────────┘
```
**Center: current cycle status**
Shows the current candidate's progress through the pipeline as a
step indicator: `[Generate] [✓ Syntax] [✓ Schema] [✓ Smoke]
[Evaluating 4/10] [Promotion?]`
Below that, a mini-results table showing the candidate's evaluation
matches as they complete: opponent, result, score.
If a community hint influenced this candidate's prompt, it's shown:
`💡 Community hint: "try retreating when outnumbered 3:1" (by tactician42)`
**Bottom: activity feed**
A scrolling log of recent evolution events, color-coded:
- 🟢 Promoted (green)
- 🔴 Rejected at validation (red)
- 🟡 Rejected at Nash gate (yellow)
Each entry shows the candidate ID, island, result, and reason.
**Tabs: lineage tree + meta chart**
- **Lineage tree**: interactive d3.js force-directed graph. Each node is
a bot (evolved or built-in). Edges connect parents to children. Nodes
are colored by island. Size proportional to rating. Click a node to
see the bot's profile. The tree grows as new bots are promoted.
- **Meta shift chart**: stacked area chart (d3.js or Chart.js) showing
the archetype distribution of the evolved population over generations.
X-axis: generation number. Y-axis: percentage. Each archetype is a
colored band. Watch strategies emerge, dominate, and get countered
over time.
Both visualizations are built from `data/evolution/lineage.json` and
`data/evolution/meta.json` (served from Pages, produced by the index builder
Deployment). The live feed overlay is the only component that polls
`evolution/live.json` (written by the evolver to B2).
### 15.5 Narrative Engine (Chronicles)
Auto-generated storylines from match data, published alongside the weekly
meta report as blog posts on `/blog`.
**Story arc detection:**
The weekly index builder pass (same as the meta report, §15.1) scans
PostgreSQL for active story arcs:
| Arc Type | PostgreSQL Query Trigger |
|----------|------------------------|
| **Rise** | Bot gained >=200 rating in the last 7 days |
| **Fall** | Bot lost >=200 rating in the last 7 days |
| **Rivalry Intensifies** | Rivalry pair played 5+ matches this week with alternating wins |
| **Upset of the Week** | Biggest single-match rating gap where the underdog won |
| **Evolution Milestone** | Evolved bot reached a new all-time-high rating or entered top 5 |
| **Comeback** | Bot recovered >=150 rating after a decline |
| **Season Narrative** | End of season (championship results, final standings) |
**Generation pipeline:**
1. Detect 3-5 active arcs from PostgreSQL queries
2. For each arc, compile context: bot profiles, rating history, key
match IDs with scores, archetype data, rival relationships
3. Prompt a cheap LLM (Haiku-class):
```
Write a 200-word sports-journalism narrative about this event in the
AI Code Battle platform. Be dramatic but factual. Reference specific
matches. Write in present tense. Do not use emojis.
Arc type: Rise
Bot: evo-go-g31
Season: 4 (The Colosseum)
Rating: 1320 → 1580 over 7 days
Key matches:
- Beat SwarmBot (#1, 1820) on "The Labyrinth" — score 4-2, turn 287
- Won bo3 series vs HunterBot (#4, 1650) 2-1
- Lost to GuardianBot (#2, 1720) by 1 point on "Open Expanse"
Archetype: hybrid swarm-gatherer
Origin: evolved, generation 31, Go island
Parents: evo-go-g28 (gatherer archetype) × evo-go-g25 (swarm archetype)
Community hint that influenced it: "combine tight formations with
energy-first opening"
```
4. Assemble output as a blog post JSON file with:
- Headline (generated by LLM)
- 200-word narrative
- Embedded replay links for key matches
- Bot profile card image (§14.10)
- Rating chart (data for client-side rendering)
5. Write to staging directory: `data/blog/posts/{slug}.json`
6. Update `data/blog/index.json` — deployed to Pages on the next cycle
**Blog page (`/blog`):**
- Lists all posts reverse-chronologically
- Post types: `meta-report` and `chronicle` (story arcs)
- Each post renders as a full page with embedded replay widgets (§13.3)
at key moments
- Tags for filtering: `meta-report`, `rise`, `fall`, `rivalry`, `upset`,
`evolution`, `comeback`, `season-recap`
**Weekly output:** 1 meta report + 35 chronicles = 46 blog posts/week.
**Cost:** ~$0.05 per LLM call × 6 posts/week = ~$0.30/week, ~$1.30/month.
**Why it matters:** Chronicles transform raw match data into stories that
people share, discuss, and follow. "The Rise of evo-go-g31" is a headline
someone posts on Hacker News. "GathererBot's Decline" is a cautionary
tale that sparks strategy discussion. The narrative engine gives the
platform a *voice* — it feels alive, with characters and plot arcs, not
just numbers on a leaderboard.
---
## 16. User Experience Design
The platform serves three distinct audiences with different needs. The UX
must be simple enough that a first-time visitor understands the platform
in 10 seconds, and deep enough that a regular user can access all features
without friction. Mobile and desktop are both first-class.
### 16.1 Audiences
| Audience | What They Want | Frequency |
|----------|---------------|-----------|
| **Spectator** | Watch cool bot battles, browse leaderboard, follow stories | Daily, 515 min sessions |
| **Participant** | Build and improve bots, track performance, iterate | Several times/week, 3060 min sessions |
| **Visitor** | Understand what this is, see something impressive, maybe come back | Once, 13 minutes |
The default experience is optimized for **spectators** — the largest
audience. Participants have dedicated sections. First-time visitors get a
clear value proposition immediately.
### 16.2 Information Architecture
```
/ Home (hero + featured replay + highlights)
├── /watch Spectator hub
│ ├── /watch/replays Browse all replays (playlists, search, filters)
│ ├── /watch/replay/{id} Full replay viewer
│ ├── /watch/series/{id} Series replay page
│ └── /watch/predictions Predict upcoming matches
├── /compete Participant hub
│ ├── /compete/sandbox In-browser WASM sandbox
│ ├── /compete/register Register a bot
│ ├── /compete/bot/{id} Your bot's dashboard (owner view)
│ └── /compete/docs Protocol spec, starter kits, guides
├── /leaderboard Rankings (current season)
├── /evolution Evolution observatory (live feed + lineage)
├── /blog Meta reports + chronicles
├── /season/{id} Season archive (past) or current season status
└── /bot/{id} Bot public profile (anyone can view)
```
**Three entry points, three audiences:**
- Spectators enter through `/watch` or the homepage highlights
- Participants enter through `/compete` or `/compete/sandbox`
- Visitors land on `/` and are guided to one of the above
### 16.3 Homepage
The homepage answers three questions in 10 seconds:
1. **What is this?** (headline)
2. **What does it look like?** (auto-playing featured replay)
3. **What can I do?** (two clear CTAs)
**Layout:**
```
┌──────────────────────────────────────────────────┐
│ AI Code Battle │
│ Bots compete. Strategies evolve. You watch. │
│ │
│ [Watch Battles] [Build a Bot] │
├──────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Featured Replay (auto-playing, muted) │ │
│ │ Territory view, win probability graph │ │
│ │ Commentary subtitles if enriched │ │
│ └──────────────────────────────────────────┘ │
│ "SwarmBot vs HunterBot — Season 4 Semifinals" │
│ │
├──────────────────────────────────────────────────┤
│ Top 5 Leaderboard │ Latest Stories │
│ #1 SwarmBot 1820 │ "The Rise of evo-go-g31"│
│ #2 GuardianBot 1720 │ "Week 12 Meta Report" │
│ #3 evo-mx-g802 1710 │ "Rivalry: Swarm v Hunt" │
│ #4 HunterBot 1650 │ │
│ #5 evo-go-g831 1650 │ │
│ [Full leaderboard →] │ [All stories →] │
├─────────────────────────┴─────────────────────────┤
│ Playlists │
│ [Closest Finishes] [Biggest Upsets] [Comebacks] │
│ ← scrollable cards with thumbnails → │
├──────────────────────────────────────────────────┤
│ Season 4: The Colosseum — Week 3 of 4 │
│ Championship bracket starts in 8 days │
│ [Predictions open →] │
├──────────────────────────────────────────────────┤
│ Evolution Observatory (mini) │
│ Gen #847 · 12 bots promoted today · 3 in top 10 │
│ [Watch evolution live →] │
└──────────────────────────────────────────────────┘
```
**Key decisions:**
- The featured replay auto-plays in territory view (most visually
impressive mode) — the visitor sees something immediately interesting
without clicking anything
- Two CTAs, not five — "Watch" for spectators, "Build" for participants
- The leaderboard is a compact summary, not the whole page — the platform
is about *watching*, not *reading tables*
- Playlists are below the fold but above the season/evolution sections
- Everything above the fold fits on a 1080p screen
### 16.4 Navigation
**Desktop: persistent top bar**
```
┌──────────────────────────────────────────────────┐
│ ⚔️ AI Code Battle Watch Compete Leaderboard │
│ Evolution Blog Season 4 │
└──────────────────────────────────────────────────┘
```
- Logo + name (links to home)
- Primary nav: Watch, Compete, Leaderboard
- Secondary nav: Evolution, Blog, current Season
**Mobile: bottom tab bar + hamburger**
```
┌──────────────────────────────────────────┐
│ [content area] │
│ │
│ │
├──────────────────────────────────────────┤
│ 🏠 Home 👀 Watch ⚔️ Compete 🏆 Board │
└──────────────────────────────────────────┘
```
Four bottom tabs for the primary actions. Evolution, Blog, Season, and
secondary pages are accessed via the hamburger menu (☰) in the top bar.
The bottom tab bar is the standard mobile pattern (iOS tab bar, Android
bottom nav). Thumb-reachable, always visible, no scrolling needed to
navigate.
### 16.5 Responsive Design
The platform is designed **mobile-first** for spectating and
**desktop-first** for participating (writing bots).
**Breakpoints:**
| Breakpoint | Target | Layout |
|-----------|--------|--------|
| <640px | Phone | Single column, bottom tab bar, touch-optimized |
| 6401024px | Tablet | Two column where useful, top nav |
| >1024px | Desktop | Full layout, sidebar where appropriate |
**Replay viewer on mobile:**
The replay viewer is the most complex component. On mobile:
```
┌────────────────────┐
│ │
│ [Canvas] │ ← full width, 1:1 aspect ratio
│ │
├────────────────────┤
│ ▶ 4x Score: 3-1 │ ← compact controls bar
├────────────────────┤
│ ··⚔️··💎··🏰··⚔️·· │ ← event timeline (scrollable)
├────────────────────┤
│ Win Prob ~~~~~~~~~ │ ← sparkline (tap to scrub)
├────────────────────┤
│ Commentary text... │ ← if enriched, scrollable
└────────────────────┘
```
- Canvas renders at full phone width with 1:1 aspect ratio (square grid
maps fit naturally)
- Pinch-to-zoom on the canvas (native gesture handling)
- Tap to play/pause (large touch target — the entire canvas)
- Swipe left/right on the timeline to scrub turns
- View mode toggle (dots/territory/influence) via a floating button
- Debug telemetry panel is a slide-up sheet (collapsed by default)
**Leaderboard on mobile:**
Simplified table with just rank, name, rating, and trend arrow (↑/↓).
Tap a row to expand inline with games played, win rate, archetype.
"Full stats" link goes to the bot profile page.
**Sandbox on mobile:**
The WASM sandbox is **desktop-only**. On mobile, the `/compete/sandbox`
page shows a clear message: "The sandbox requires a desktop browser.
On mobile, you can watch replays, browse the leaderboard, make
predictions, and read the docs." With a QR code or "Send to desktop"
link.
Bot registration (`/compete/register`) works on mobile — it's just a
form.
### 16.6 First-Time Visitor Flow
A visitor who has never seen the platform:
```
1. Lands on homepage
→ Sees headline + auto-playing featured replay (territory view)
→ Understands "this is a platform where bots fight on a grid"
2. Watches for 1030 seconds
→ Sees win probability shift, commentary subtitles, territory change
→ Understands "this is dynamic and strategic, not random"
3. Clicks "Watch Battles" or a playlist card
→ Enters /watch with curated playlists
→ Picks a replay that sounds interesting ("Biggest Upsets")
4. Watches a full replay (13 minutes at 4x speed)
→ Uses event timeline to skip to the action
→ Maybe makes a prediction on an upcoming match
5. Returns later
→ Checks predictions results
→ Browses new blog posts / chronicles
→ Eventually clicks "Build a Bot" → sandbox → starter kit → ladder
```
The funnel is: **see** (homepage) → **watch** (replays) → **engage**
(predictions, feedback) → **build** (sandbox, compete). Each step has a
lower barrier than the next.
### 16.7 Page Load Performance
The SPA should feel instant. Performance budget:
| Metric | Target | How |
|--------|--------|-----|
| First Contentful Paint | <1s | Cloudflare CDN, minimal critical CSS |
| Largest Contentful Paint | <2s | Defer replay loading, hero image as CSS gradient |
| Time to Interactive | <2s | Small JS bundle (<200KB gzipped), code-split per route |
| Replay load | <3s | Replay JSON gzipped (~50KB), streamed parse |
| WASM engine load | <5s | Lazy-loaded only on sandbox/embed pages |
**Code splitting strategy:**
```
app.js (~30KB gz) Core SPA router, leaderboard, blog, nav
replay.js (~80KB gz) Replay viewer + territory renderer + charts
sandbox.js (~20KB gz) Sandbox orchestrator (loads WASM on demand)
engine.wasm (~3MB) Game engine (loaded only in sandbox/embed)
bot-*.wasm (~312MB ea) Built-in bots (loaded only in sandbox)
```
The homepage loads only `app.js`. Replay viewer code is loaded when a
user navigates to a replay. WASM is loaded only when the sandbox is
opened. A visitor who just browses the homepage and leaderboard never
downloads replay viewer or WASM code.
**Data fetching:**
All data files are served by Cloudflare (Pages for indexes, B2 via Cloudflare CDN for replays and match data) with appropriate cache behavior.
Subsequent visits hit the edge cache. The SPA uses `stale-while-revalidate`
pattern: show cached data immediately, fetch fresh data in the background,
update the UI when it arrives. The leaderboard never shows a loading
spinner on repeat visits — it shows the cached version instantly and
refreshes within seconds.
### 16.8 Design Language
**Visual principles:**
- **Dark theme by default** — grid-based games look better on dark
backgrounds. White text, subtle grid lines, colored elements pop.
- **Minimal chrome** — let the replay canvas, leaderboard data, and
content speak. No decorative borders, shadows, or gradients.
- **Color is information** — player colors, archetype colors, win
probability (green/red), and status indicators use color purposefully.
Everything else is grey/white on dark.
- **Typography** — monospace for data (ratings, scores, turn counts),
sans-serif for prose (blog, commentary, descriptions). Two typefaces
maximum.
- **Animation is functional** — replay playback, win probability graph
updates, evolution observatory feed. No decorative hover effects or
page transitions.
**Component library:**
A small set of reusable components covers the entire site:
| Component | Used In |
|-----------|---------|
| `ReplayCanvas` | Replay viewer, embed, sandbox, homepage hero |
| `WinProbGraph` | Replay viewer, series page, bot profile |
| `EventTimeline` | Replay viewer |
| `LeaderboardTable` | Leaderboard page, homepage summary |
| `BotCard` | Bot profile, leaderboard, series, playlists |
| `PlaylistRow` | Homepage, /watch |
| `MatchCard` | Playlists, match history, series |
| `PredictionWidget` | Predictions page, homepage |
| `ObservatoryFeed` | Evolution page, homepage mini |
| `BlogPostCard` | Blog page, homepage |
| `AnnotationOverlay` | Replay viewer (spatial + text feedback) |
Each component works at all breakpoints. The replay canvas adapts its
render resolution to the container size. Tables collapse to cards on
mobile. Graphs switch from detailed to sparkline on narrow screens.
### 16.9 Replay Canvas Micro-Animations
The replay renderer decouples **game tick rate** from **render frame rate**.
Game state updates at the turn rate (232 ticks/second depending on speed
setting). The Canvas renders at 60fps via `requestAnimationFrame`,
interpolating positions and animation states between ticks. Bots slide
smoothly between grid positions instead of teleporting.
**Animation inventory:**
| Event | Animation | Duration |
|-------|-----------|----------|
| Bot idle | Subtle 2% scale pulse, 2s cycle. Stops on movement. | Continuous |
| Bot movement | 1-tile motion trail fading behind the bot, indicates direction of travel. At high speed, trails create visible flow patterns. | 150ms fade |
| Combat threat | Thin dashed line between bots within attack range (red). Shows who threatens whom. | 1 turn |
| Attack event | Directed arrows from each attacker to the dying bot's tile on the turn a combat kill lands. The Go engine's `executeCombat()` needs to emit a `combat_death` event (type constant already exists: `EventCombatDeath`) alongside each `bot_died`, listing `killers: [{bot_id, owner, position}]` — the enemies within attack radius that triggered the outnumbering condition. The web viewer reads `combat_death` events and draws a solid line (attacker player color, arrowhead) from each killer's tile center to the defender's tile center, fading over 300ms. Old replays lacking `combat_death` events keep the existing proximity-inference lines. Distinct from the ambient threat dashes — these fire only on actual kills and encode exact participants rather than spatial proximity. | 300ms fade |
| Bot death | Burst of 68 particles scattering outward from death position, fading to transparent. | 400ms |
| Energy collection | 4-line starburst radiating from the energy node + small "+1" text floating upward. | 200ms |
| Core capture | Radial shockwave ring expanding from the core. Core color transitions from loser to capturer. | 500ms |
| Bot spawn | New bot scales up from 0% to 100% with a soft glow matching the player color. | 200ms |
**Particle system:**
A pool of 100 reusable particle objects (prevents GC pressure). Each
particle has: `x, y, vx, vy, alpha, lifetime`. On bot death, 68
particles are activated with random velocities. Each frame updates
position and fades alpha. When alpha reaches 0, the particle returns
to the pool.
```typescript
interface Particle {
x: number; y: number
vx: number; vy: number
alpha: number
color: string
lifetime: number // frames remaining
}
const POOL_SIZE = 100
const pool: Particle[] = Array.from({ length: POOL_SIZE }, () => ({
x: 0, y: 0, vx: 0, vy: 0, alpha: 0, color: '', lifetime: 0
}))
```
**Performance:** All animations are simple Canvas draw calls (arcs, lines,
`globalAlpha` fades). With 50 bots and 20 active particles, frame time
is <1ms on any modern device. The interpolation between ticks uses a
simple lerp:
```typescript
// In render loop:
const t = (now - lastTick) / tickInterval // 0..1 between ticks
for (const bot of bots) {
const renderX = bot.prevX + (bot.x - bot.prevX) * t
const renderY = bot.prevY + (bot.y - bot.prevY) * t
drawBot(renderX, renderY, bot.color, bot.shape)
}
```
### 16.10 Adaptive Auto-Speed Playback (Director Mode)
A playback mode where the replay automatically speeds through uneventful
turns and slows for combat and critical moments.
**Action density per turn:**
```
action_density(turn) = (
deaths × 3.0 +
captures × 5.0 +
energy_collected × 0.5 +
spawns × 1.0 +
abs(delta_win_prob) × 10.0
)
```
**Speed mapping:**
| Action Density | Speed | Effect |
|---------------|-------|--------|
| 0 (nothing) | 16× | Boring turns fly by |
| 0.11.0 (minor) | 8× | Light scouting |
| 1.03.0 (moderate) | 4× | Engagement starting |
| 3.05.0 (significant) | 2× | Active combat |
| 5.0+ (critical) | 1× | Dramatic slowdown |
Speed transitions are eased over 0.5 seconds (not instant) — the viewer
feels the tempo shift.
**User controls:**
- Activated via a "Director" option in the speed selector (alongside
manual 1×, 2×, 4×, 8×, 16×)
- Speed indicator shows live: ` Director 8× 2×` with smooth transition
- Target duration slider: 30s / 1min / 2min / 5min — scales all speeds
proportionally to approximate the target total playback time
- Manual scrubbing pauses Director Mode until the scrubber is released
**Computation:** action density for all turns is pre-computed on replay
load (single pass through the `turns` array, <1ms). A speed schedule
array maps each turn to its target speed. During playback, the tick
interval adjusts smoothly.
### 16.11 Smooth View Mode Morphing
Switching between dots, Voronoi territory, and influence gradient uses a
300ms animated cross-fade morph instead of a hard cut.
**Transition technique:**
The renderer maintains two off-screen Canvas buffers — one for the current
view mode's overlay, one for the target. During a transition, a blend
parameter `t` eases from 0 to 1 over 300ms (`ease-in-out`).
```typescript
function renderOverlayTransition(ctx: CanvasRenderingContext2D, t: number) {
// Draw outgoing mode, fading out
ctx.globalAlpha = 1 - t
renderOverlay(currentMode)
// Draw incoming mode, fading in
ctx.globalAlpha = t
renderOverlay(targetMode)
// Bots always on top at full opacity
ctx.globalAlpha = 1
renderBots()
}
```
**Mode-specific transitions:**
- **Dots → Territory:** Color expands outward from each bot's position
to fill Voronoi cells — like paint spilling from unit positions
- **Territory → Influence:** Sharp Voronoi borders soften and bleed into
gradients at contested zones
- **Influence → Dots:** Colored overlay fades uniformly to transparent
Cost: two overlay renders per frame during the 300ms transition (~18
frames at 60fps). Each overlay is a single-pass pixel fill — no
performance concern.
### 16.12 "Follow Bot" Camera Mode
Lock the viewport to track one player's units. The camera pans and zooms
dynamically to keep the followed player's bots centered and visible.
**Camera algorithm:**
```
Each frame:
1. Compute bounding box of tracked player's living bots
2. Add 8-tile margin on each side (reveals approaching enemies)
3. Lerp viewport center toward bounding box center (factor: 0.15)
4. Lerp zoom level to fit bounding box + margin (factor: 0.10)
5. Clamp: never zoom closer than 15×15 tiles, never wider than full grid
```
The slower lerp on zoom (0.10 vs 0.15) ensures zoom changes are gentler
than panning — prevents disorientation from rapid zoom oscillation.
**Split group handling:** if the tracked player's bots split into groups
>20 tiles apart, the camera briefly zooms out to show both groups, then
follows the larger group once they're too far apart to fit.
**Activation:**
- Click a player name/color in the score overlay
- Press `1``6` to follow that player number
- Press `0` or `Esc` to return to full-grid view
- Mobile: tap a player's color swatch in the score header
**Pairs with fog of war:** Follow mode + fog-of-war perspective on the
followed player creates a first-person experience — you see what they
see, the camera goes where they go. Enemies emerge from darkness at the
edge of vision.
### 16.13 Picture-in-Picture Replay
Navigate away from a replay and it minimizes to a floating mini-player
in the bottom corner. The replay keeps playing while you browse.
**Behavior:**
1. User is watching a replay on `/watch/replay/m_7f3a`
2. User clicks a link to any other page
3. SPA router's `beforeNavigate` hook detects the replay viewer is active
4. Instead of unmounting, the Canvas element is reparented into a
fixed-position container:
```css
.pip-container {
position: fixed;
bottom: 16px;
right: 16px;
width: 200px;
height: 200px;
z-index: 1000;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
cursor: pointer;
}
```
5. Canvas continues rendering at 200×200 resolution. Same
`requestAnimationFrame` loop, same playback speed. Animations and
game state are uninterrupted.
6. Mini-player shows: tiny canvas, current score, play/pause icon
7. Click mini-player → navigates back to replay page at the current turn
8. Click ✕ → closes the mini-player
**Transitions:**
- Minimize: canvas scales from full-size to 200×200 with `ease-out`
(300ms), sliding to bottom-right. Page content fades in underneath.
- Expand: reverse. Uses CSS `transform: scale() translate()` with
`will-change: transform` for GPU-accelerated animation. Zero layout
reflows.
Mobile: mini-player is 120×120, positioned above the bottom tab bar.
### 16.14 Performance Trifecta
Three techniques that together make the site feel like a native app.
**1. Route preloading on hover:**
When the user hovers a link for 100ms (desktop) or fires `touchstart`
(mobile), fetch the target page's primary data file:
```typescript
function preloadOnHover(link: HTMLAnchorElement) {
let timer: number
link.addEventListener('mouseenter', () => {
timer = setTimeout(() => {
const dataUrl = routeToDataUrl(link.pathname)
if (dataUrl && !preloadCache.has(dataUrl)) {
preloadCache.add(dataUrl)
fetch(dataUrl) // browser HTTP cache stores the response
}
}, 100)
})
link.addEventListener('mouseleave', () => clearTimeout(timer))
}
```
By the time the user clicks, the data is already in the browser cache.
**2. Skeleton screens:**
Every page has a skeleton that exactly matches its content layout:
- Leaderboard: rows of grey bars matching rank/name/rating column widths
- Bot profile: grey circle (avatar area), bars for name/rating/stats
- Replay page: grey rectangle (canvas area) + thin bar (scrubber)
Skeletons use a shimmer animation: a light gradient (`linear-gradient`)
sweeps left-to-right at 1.5s intervals. Skeleton → real content:
`opacity` fade-in over 150ms, zero layout shift (skeleton and content
occupy identical space).
**3. Instant back navigation:**
```typescript
const routeCache = new Map<string, {
html: string,
scrollY: number,
data: any
}>()
// On navigate away:
routeCache.set(currentPath, {
html: contentEl.innerHTML,
scrollY: window.scrollY,
data: currentPageData
})
// On back navigation:
const cached = routeCache.get(targetPath)
if (cached) {
contentEl.innerHTML = cached.html
window.scrollTo(0, cached.scrollY)
// Optionally: background-refresh stale data
}
```
Cache holds the last 5 pages. Back navigation is 0ms.
**Combined result:**
| Navigation | Time |
|-----------|------|
| Forward (hovered) | 0ms — data preloaded |
| Forward (not hovered) | 200ms skeleton → 300ms data |
| Back | 0ms — cached |
| First visit | 200ms skeleton → 500ms data |
### 16.15 Progressive Disclosure
The replay viewer reveals features gradually based on user engagement.
**Experience tracking:**
```typescript
// localStorage
let viewerXP = parseInt(localStorage.getItem('viewer_xp') || '0')
// Increment when user watches a replay for >30 seconds
if (watchDuration > 30_000) {
viewerXP++
localStorage.setItem('viewer_xp', String(viewerXP))
}
```
**Feature revelation schedule:**
| XP Level | Controls Visible | New This Level |
|----------|-----------------|----------------|
| 0 | Play/pause, speed, scrubber | — (first visit overlay: "Space = play/pause, ←/→ = step, 1-5 = speed") |
| 2 | + Event timeline | "New: Event Timeline — key moments at a glance" |
| 5 | + View mode toggle, critical moment nav | "New: Territory View — see who controls the map" |
| 10 | + Follow mode, fog perspective, clip export | "New: Follow a bot — camera tracks one player" |
| 20 | + Debug telemetry, annotations, Director Mode | All controls visible |
**Revelation animation:** new controls slide in from below (200ms
`ease-out`) with a brief golden border pulse and a tooltip that fades
after 3 seconds.
**Manual override:** ☰ menu → "Show all controls" reveals everything
immediately. Power users are never gated.
### 16.16 Swipe-Through Playlists (Mobile)
On mobile, playlists become full-screen, auto-playing cards. Swipe up
to advance.
**Layout per card:**
```
┌────────────────────┐
│ Closest Finishes │ ← playlist name (sticky, translucent)
│ 3 of 12 │
├────────────────────┤
│ │
│ [Replay Canvas] │ ← full-width, auto-plays in Director Mode
│ │
├────────────────────┤
│ SwarmBot 3-2 Hunt │ ← compact score bar
│ ⚔️💎🏰 ~45s left │ ← event icons + estimated remaining time
├────────────────────┤
│ ↑ swipe for next │ ← hint (fades after first swipe)
└────────────────────┘
```
**Gestures:**
| Gesture | Action |
|---------|--------|
| Swipe up | Cross-fade to next replay (300ms transition) |
| Swipe down | Go back to previous replay |
| Tap canvas | Play/pause |
| Tap score bar | Expand win probability graph + commentary |
| Swipe right / tap ✕ | Exit playlist mode |
**Auto-advance:** when a replay ends, pause 3 seconds showing final
score with a countdown ring animation, then advance to next replay. Tap
to cancel.
**Preloading:** while the current replay plays, the next replay in the
playlist is fetched in the background. Swipe transitions are instant.
### 16.17 Theater Mode
Fullscreen, chrome-free replay viewing.
**Desktop:** press `F` or click the fullscreen icon.
```
┌──────────────────────────────────────────────────────┐
│ │
│ │
│ [Full-screen Canvas at native res] │
│ Vignette effect at edges (subtle) │
│ │
│ │
├───────────────────────────────────────────────────────┤
│ ▶ Director SwarmBot 3 · HunterBot 1 Turn 203/500 │ ← semi-transparent,
│ ··⚔️··💎··🏰⚔️··💎···⚔️💀··🏰🌟·· │ fades after 3s
└───────────────────────────────────────────────────────┘
```
- Background: pure black
- Controls bar: semi-transparent, auto-hides after 3 seconds of
inactivity. Mouse move / tap to reveal.
- Win probability: two thin colored bars at the top edge of the screen
(proportional width, no chart — ambient information)
- Critical moment vignette pulse: edges darken briefly when win
probability shifts >15%, creating a cinematic "something happened" cue
**Mobile:** triggered by rotating to landscape on a replay page. Same
layout. Touch controls:
| Gesture | Action |
|---------|--------|
| Tap | Play/pause |
| Swipe left/right | Step turns |
| Pinch | Zoom |
| Two-finger tap | Cycle view mode |
| Swipe up from bottom | Reveal controls bar |
Exit: `Esc` (desktop) or rotate to portrait (mobile).
### 16.18 Ambient Activity Awareness
Subtle, non-intrusive signals that keep users aware of platform activity.
**Favicon badge:**
Dynamic favicon updated via Canvas + `<link rel="icon">` swap:
| State | Favicon | Trigger |
|-------|---------|---------|
| Normal | ⚔️ | Default |
| Match result | ⚔️🔴 | Your bot finished a match (detected via data poll) |
| Prediction resolved | ⚔️🟡 | A prediction you made was resolved |
| Season event | ⚔️🟢 | Championship bracket update, season milestone |
Badge clears when the user focuses the tab.
**Tab title updates:**
```
Default: "AI Code Battle"
Unread result: "(1) AI Code Battle"
Bot won: "✓ SwarmBot won! — AI Code Battle"
Prediction: "You were right! — AI Code Battle"
```
Resets on `document.visibilitychange` → visible.
**Mobile haptic:**
Brief 50ms vibration pulse at each critical moment during replay
playback (opt-in toggle in viewer controls):
```typescript
if ('vibrate' in navigator && prefs.haptic) {
navigator.vibrate(50)
}
```
**Seasonal background color shift:**
The page background subtly shifts hue per season. Not a different color —
a subtle tint on the base dark grey:
| Season | Base `#1a1a2e` Shifts To | Mood |
|--------|--------------------------|------|
| The Labyrinth | `#1e1a2e` (hint of purple) | Mysterious |
| Energy Rush | `#1a2e1e` (hint of green) | Abundant |
| Fog of War | `#1a1a3e` (cooler blue) | Uncertain |
| The Colosseum | `#2e1a1a` (hint of red) | Aggressive |
Most users won't consciously notice. The platform just *feels* different
each season.
**Live match counter (homepage):**
```
⚔️ 1,847 matches today · 23 bots active · Gen #852 evolving
```
Updated every 30 seconds from `evolution/live.json`. Shows the platform
is alive.