diff --git a/PROGRESS.md b/PROGRESS.md
index cba8e47..55b0a0e 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -1,152 +1,58 @@
# AI Code Battle - Implementation Progress
-## Current Phase: Phase 4 - Match Orchestration
+## Current Phase: Phase 5 - Web Platform
-**Status: ✅ COMPLETE**
+**Status: 🔄 In Progress**
-### Phase 4 Progress
+### Phase 5 Progress
-- [x] Cloudflare Worker project structure (`worker-api/`)
- - TypeScript + Wrangler configuration
- - D1 database schema (bots, matches, jobs, rating_history tables)
-- [x] Glicko-2 rating system (`worker-api/src/glicko2.ts`)
- - Rating scale conversion
- - Rating updates after matches
- - Rating decay for inactive bots
- - Unit tests (17 tests)
-- [x] Job coordination endpoints (`worker-api/src/jobs.ts`)
- - GET /api/jobs/next - Get next pending job
- - POST /api/jobs/:id/claim - Claim job for execution
- - POST /api/jobs/:id/heartbeat - Update job heartbeat
- - POST /api/jobs/:id/result - Submit match result
- - POST /api/jobs/:id/fail - Mark job as failed
-- [x] Bot management endpoints (`worker-api/src/bots.ts`)
- - POST /api/register - Register new bot
- - GET /api/bots - List all bots
- - GET /api/bots/:id - Get bot details
- - PUT /api/bots/:id - Update bot
- - POST /api/rotate-key - Rotate API key
- - GET /api/leaderboard - Get leaderboard
-- [x] Data export endpoint (`worker-api/src/export.ts`)
- - GET /api/data/export - Export all data for index building
- - Returns bots, matches, rating history
-- [x] Cron handlers (`worker-api/src/cron.ts`)
- - Matchmaker (every minute) - Creates match jobs
- - Health checker (every 15 min) - Pings bot endpoints
- - Stale job reaper (every 5 min) - Reclaims timed-out jobs
-- [x] Match worker container (`cmd/acb-worker/`)
- - Polls Worker API for pending jobs
- - Claims jobs and executes matches using game engine
- - Uploads replays to R2 via S3-compatible API
- - Sends heartbeats during match execution
- - Submits results back to Worker API
- - Retry logic with exponential backoff
- - API client tests (10 tests)
-- [x] Index builder container (`cmd/acb-indexer/`)
- - Fetches data from Worker API via `/api/data/export`
- - Generates static JSON index files:
- - `leaderboard.json` - Sorted bot rankings
- - `bots/index.json` - Bot directory
- - `bots/{bot_id}.json` - Per-bot profiles with rating history
- - `matches/index.json` - Recent match list
- - Optional deploy to Cloudflare Pages
- - Unit tests (6 tests)
+- [x] SPA application shell (`web/app.html`)
+ - Navigation header with links to all sections
+ - Dark theme with CSS custom properties
+ - Responsive layout
+- [x] Hash-based router (`web/src/router.ts`)
+ - Pattern matching with parameter extraction
+ - Navigation and history support
+- [x] Page components (`web/src/pages/`)
+ - Home page with hero, features, quick links
+ - Leaderboard with ranking table
+ - Match history with match cards
+ - Bot directory with bot cards
+ - Bot profile with stats, rating chart, recent matches
+ - Registration form with API key display
+ - Replay viewer (integrated from Phase 3)
+ - Docs/Getting Started page
+- [x] API client (`web/src/api-types.ts`)
+ - fetchLeaderboard()
+ - fetchBotDirectory()
+ - fetchBotProfile()
+ - fetchMatchIndex()
+ - registerBot()
+ - rotateApiKey()
+- [ ] Cloudflare Pages deployment configuration
+- [ ] R2 bucket custom domain for replays
+
+### Phase 4 Completed
### Phase 3 Completed
-### Phase 1 Completed
-
-- [x] Go module initialization (`github.com/aicodebattle/acb`)
-- [x] Project structure (`engine/`, `cmd/acb-local/`, `cmd/acb-mapgen/`)
-- [x] Core types (`engine/types.go`)
-- [x] Grid implementation (`engine/grid.go`) - Toroidal wrapping, distances, visibility
-- [x] Game state (`engine/game.go`) - State management, fog of war
-- [x] Turn execution (`engine/turn.go`) - Movement, combat, capture, energy, spawn
-- [x] Replay writer (`engine/replay.go`) - Full replay JSON format
-- [x] Match runner (`engine/match.go`) - Concurrent bot communication
-- [x] Map generator (`cmd/acb-mapgen/`) - Rotational symmetry, connectivity validation
-- [x] Unit tests - 32+ tests passing, determinism verified
-
### Phase 2 Completed
-- [x] HMAC Authentication (`engine/auth.go`)
- - Request signing: `{match_id}.{turn}.{timestamp}.{sha256(body)}`
- - Response signing: `{match_id}.{turn}.{sha256(body)}`
- - Timestamp tolerance (30s) for replay attack prevention
- - Secret generation (256-bit, hex-encoded)
-- [x] HTTP Bot Client (`engine/bot_http.go`)
- - HTTPBot implementing BotInterface
- - Per-turn timeout (3s default)
- - Crash detection (10 consecutive failures)
- - Move validation (position ownership, direction validity)
- - Response signature verification
-- [x] Integration Tests (`engine/integration_test.go`)
- - Full HTTP match between mock bots
- - HMAC authentication round-trip
- - Response signing verification
-- [x] Strategy Bot Implementations (6 languages)
- - **RandomBot** (Python) - Random moves, rating floor
- - **GathererBot** (Go) - Energy-focused, combat avoidance
- - **RusherBot** (Rust) - Aggressive core rushing
- - **GuardianBot** (PHP) - Defensive core protection
- - **SwarmBot** (TypeScript) - Formation-based combat
- - **HunterBot** (Java) - Target isolation and hunting
-
-### Phase 3 Completed
-
-- [x] Web project setup (`web/`)
- - TypeScript + Vite build tooling
- - Type definitions matching Go replay format
-- [x] ReplayViewer class (`web/src/replay-viewer.ts`)
- - Canvas-based grid rendering
- - Bot, core, energy, wall visualization
- - Player color coding (6 distinct colors)
-- [x] Playback controls
- - Play/pause toggle
- - Turn-by-step navigation (prev/next)
- - Turn scrubber slider
- - Speed control (20ms - 1000ms per turn)
- - Keyboard shortcuts (Space, arrows, Home/End)
-- [x] Fog of War perspective toggle
- - Per-player visibility calculation
- - Vision radius from game config
-- [x] Score overlay
- - Real-time scores per player
- - Energy held display
- - Player name with color indicator
-- [x] Match info panel
- - Match ID, winner, turns, reason
-- [x] Event log
- - Turn-by-turn event display
-- [x] File/URL loading
- - Local file upload
- - Remote URL fetch
-
-### Exit Criteria Progress
+### Phase 5 Exit Criteria
| Criterion | Status |
|-----------|--------|
-| TypeScript Canvas-based replay viewer | ✅ Complete |
-| Play/pause, scrub, speed control | ✅ Complete |
-| Fog of war perspective toggle | ✅ Complete |
-| Score overlay | ✅ Complete |
-| Loads replay JSON from file or URL | ✅ Complete |
+| SPA with navigation (leaderboard, matches, bots, register) | ✅ Complete |
+| Home page with getting started info | ✅ Complete |
+| Registration form with API key display | ✅ Complete |
+| Bot profiles with rating history chart | ✅ Complete |
+| Match history page | ✅ Complete |
+| Leaderboard with rankings | ✅ Complete |
+| Getting started / docs page | ✅ Complete |
+| Cloudflare Pages deployment config | ⏳ Pending |
+| R2 bucket custom domain for replays | ⏳ Pending |
-### Phase 4 Exit Criteria
-
-| Criterion | Status |
-|-----------|--------|
-| Matchmaker cron creates jobs in D1 | ✅ Complete |
-| Workers claim and execute matches | ✅ Complete |
-| Replays land in R2 | ✅ Complete |
-| Results flow into D1 | ✅ Complete |
-| Ratings update via Glicko-2 | ✅ Complete |
-| Leaderboard.json rebuilds automatically | ✅ Complete |
-| Stale job reaper recovers from worker disappearance | ✅ Complete |
-
-## Next Phase: Phase 5 - Web Platform
-
-**Status: Ready to start**
+### Phase 1 Completed
## File Structure
@@ -197,11 +103,22 @@ ai-code-battle/
│ ├── package.json # npm dependencies
│ ├── tsconfig.json # TypeScript config
│ ├── vite.config.ts # Vite bundler config
-│ ├── index.html # Replay viewer page
+│ ├── index.html # Standalone replay viewer
+│ ├── app.html # SPA shell with navigation
│ └── src/
│ ├── types.ts # Replay type definitions
+│ ├── api-types.ts # API client and types
+│ ├── router.ts # Hash-based SPA router
│ ├── replay-viewer.ts # Canvas viewer class
-│ └── main.ts # UI controller
+│ ├── main.ts # Standalone replay viewer
+│ ├── app.ts # SPA entry point
+│ └── pages/ # SPA page components
+│ ├── home.ts
+│ ├── leaderboard.ts
+│ ├── matches.ts
+│ ├── bots.ts
+│ ├── bot-profile.ts
+│ └── register.ts
├── bots/
│ ├── random/ # Python - RandomBot
│ ├── gatherer/ # Go - GathererBot
@@ -253,5 +170,6 @@ go build ./cmd/acb-mapgen
```bash
cd web
npm run dev
-# Open http://localhost:3000 and load replay.json
+# Standalone viewer: http://localhost:3000/index.html
+# Full SPA: http://localhost:3000/app.html (then go to #/replay)
```
diff --git a/web/app.html b/web/app.html
new file mode 100644
index 0000000..603f678
--- /dev/null
+++ b/web/app.html
@@ -0,0 +1,689 @@
+
+
+
+
+
+ AI Code Battle
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/api-types.ts b/web/src/api-types.ts
new file mode 100644
index 0000000..c45fa20
--- /dev/null
+++ b/web/src/api-types.ts
@@ -0,0 +1,143 @@
+// API response types matching the Worker API and index builder
+
+// Leaderboard types
+export interface LeaderboardEntry {
+ rank: number;
+ bot_id: string;
+ name: string;
+ owner_id: string;
+ rating: number;
+ rating_deviation: number;
+ matches_played: number;
+ matches_won: number;
+ win_rate: number;
+ health_status: string;
+}
+
+export interface LeaderboardIndex {
+ updated_at: string;
+ entries: LeaderboardEntry[];
+}
+
+// Bot profile types
+export interface RatingHistoryEntry {
+ bot_id: string;
+ rating: number;
+ rating_deviation: number;
+ recorded_at: string;
+}
+
+export interface MatchSummaryParticipant {
+ bot_id: string;
+ name: string;
+ score: number;
+ won: boolean;
+}
+
+export interface MatchSummary {
+ id: string;
+ completed_at: string | null;
+ participants: MatchSummaryParticipant[];
+ winner_id: string | null;
+ turns: number | null;
+ end_reason: string | null;
+}
+
+export interface BotProfile {
+ id: string;
+ name: string;
+ owner_id: string;
+ rating: number;
+ rating_deviation: number;
+ rating_volatility: number;
+ matches_played: number;
+ matches_won: number;
+ win_rate: number;
+ health_status: string;
+ created_at: string;
+ updated_at: string;
+ rating_history: RatingHistoryEntry[];
+ recent_matches: MatchSummary[];
+}
+
+export interface BotDirectoryEntry {
+ id: string;
+ name: string;
+ rating: number;
+ matches_played: number;
+ win_rate: number;
+}
+
+export interface BotDirectory {
+ updated_at: string;
+ bots: BotDirectoryEntry[];
+}
+
+// Match index types
+export interface MatchIndex {
+ updated_at: string;
+ matches: MatchSummary[];
+}
+
+// Registration types
+export interface RegisterRequest {
+ name: string;
+ endpoint_url: string;
+ owner_id: string;
+}
+
+export interface RegisterResponse {
+ success: boolean;
+ bot_id?: string;
+ api_key?: string;
+ error?: string;
+}
+
+// API configuration
+export const API_BASE = '/api';
+
+// API client functions
+export async function fetchLeaderboard(): Promise {
+ const response = await fetch('/data/leaderboard.json');
+ if (!response.ok) throw new Error(`Failed to fetch leaderboard: ${response.status}`);
+ return response.json();
+}
+
+export async function fetchBotDirectory(): Promise {
+ const response = await fetch('/data/bots/index.json');
+ if (!response.ok) throw new Error(`Failed to fetch bot directory: ${response.status}`);
+ return response.json();
+}
+
+export async function fetchBotProfile(botId: string): Promise {
+ const response = await fetch(`/data/bots/${botId}.json`);
+ if (!response.ok) throw new Error(`Failed to fetch bot profile: ${response.status}`);
+ return response.json();
+}
+
+export async function fetchMatchIndex(): Promise {
+ const response = await fetch('/data/matches/index.json');
+ if (!response.ok) throw new Error(`Failed to fetch match index: ${response.status}`);
+ return response.json();
+}
+
+export async function registerBot(request: RegisterRequest): Promise {
+ const response = await fetch(`${API_BASE}/register`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(request),
+ });
+ return response.json();
+}
+
+export async function rotateApiKey(botId: string, currentKey: string): Promise {
+ const response = await fetch(`${API_BASE}/rotate-key`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${currentKey}`,
+ },
+ body: JSON.stringify({ bot_id: botId }),
+ });
+ return response.json();
+}
diff --git a/web/src/app.ts b/web/src/app.ts
new file mode 100644
index 0000000..5f10e72
--- /dev/null
+++ b/web/src/app.ts
@@ -0,0 +1,691 @@
+// Main SPA entry point with routing
+import { router } from './router';
+import { renderHomePage } from './pages/home';
+import { renderLeaderboardPage } from './pages/leaderboard';
+import { renderMatchesPage } from './pages/matches';
+import { renderBotsPage } from './pages/bots';
+import { renderBotProfilePage } from './pages/bot-profile';
+import { renderRegisterPage } from './pages/register';
+import { ReplayViewer } from './replay-viewer';
+import type { Replay } from './types';
+
+// Route definitions
+router
+ .on('/', renderHomePage)
+ .on('/leaderboard', renderLeaderboardPage)
+ .on('/matches', renderMatchesPage)
+ .on('/bots', renderBotsPage)
+ .on('/bot/:id', renderBotProfilePage)
+ .on('/register', renderRegisterPage)
+ .on('/replay', renderReplayPage)
+ .on('/docs', renderDocsPage)
+ .notFound(renderNotFoundPage);
+
+// Update active nav link on route change
+function updateActiveNavLink(): void {
+ const currentPath = router.getCurrentPath();
+ document.querySelectorAll('.nav-link').forEach(link => {
+ const href = link.getAttribute('href');
+ if (href) {
+ const linkPath = href.slice(2); // Remove '#/'
+ if (currentPath === linkPath || (linkPath !== '' && currentPath.startsWith(linkPath))) {
+ link.classList.add('active');
+ } else {
+ link.classList.remove('active');
+ }
+ }
+ });
+}
+
+// Override router navigation to update nav links
+const originalNavigate = router.navigate.bind(router);
+router.navigate = (path: string) => {
+ originalNavigate(path);
+ updateActiveNavLink();
+};
+
+// Replay viewer page
+function renderReplayPage(params: Record): void {
+ const app = document.getElementById('app');
+ if (!app) return;
+
+ app.innerHTML = `
+
+
Replay Viewer
+
+
+
+
+
+
Load a replay file to view
+
+
+
+
+
+
+
+
+ `;
+
+ // Initialize replay viewer
+ initReplayViewer(params.url);
+}
+
+function initReplayViewer(initialUrl?: string): void {
+ const canvas = document.getElementById('replay-canvas') as HTMLCanvasElement;
+ const noReplayDiv = document.getElementById('no-replay') as HTMLDivElement;
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const urlInput = document.getElementById('url-input') as HTMLInputElement;
+ const loadUrlBtn = document.getElementById('load-url-btn') as HTMLButtonElement;
+ const playBtn = document.getElementById('play-btn') as HTMLButtonElement;
+ const prevBtn = document.getElementById('prev-btn') as HTMLButtonElement;
+ const nextBtn = document.getElementById('next-btn') as HTMLButtonElement;
+ const resetBtn = document.getElementById('reset-btn') as HTMLButtonElement;
+ const turnDisplay = document.getElementById('turn-display') as HTMLSpanElement;
+ const totalTurnsSpan = document.getElementById('total-turns') as HTMLSpanElement;
+ const turnSlider = document.getElementById('turn-slider') as HTMLInputElement;
+ const speedDisplay = document.getElementById('speed-display') as HTMLSpanElement;
+ const speedSlider = document.getElementById('speed-slider') as HTMLInputElement;
+ const fogSelect = document.getElementById('fog-select') as HTMLSelectElement;
+ const cellSizeSelect = document.getElementById('cell-size-select') as HTMLSelectElement;
+ const eventLogDiv = document.getElementById('event-log') as HTMLDivElement;
+ const infoMatchId = document.getElementById('info-match-id') as HTMLElement;
+ const infoWinner = document.getElementById('info-winner') as HTMLElement;
+ const infoTurns = document.getElementById('info-turns') as HTMLElement;
+ const infoReason = document.getElementById('info-reason') as HTMLElement;
+
+ let viewer = new ReplayViewer(canvas, { cellSize: 10 });
+
+ function enableControls(): void {
+ playBtn.disabled = false;
+ prevBtn.disabled = false;
+ nextBtn.disabled = false;
+ resetBtn.disabled = false;
+ turnSlider.disabled = false;
+ noReplayDiv.style.display = 'none';
+ }
+
+ function updateUI(): void {
+ turnDisplay.textContent = String(viewer.getTurn());
+ totalTurnsSpan.textContent = String(viewer.getTotalTurns());
+ turnSlider.value = String(viewer.getTurn());
+ playBtn.textContent = 'Pause';
+ if (!viewer.getReplay() || viewer.isAtEnd()) {
+ playBtn.textContent = 'Play';
+ }
+ }
+
+ function updateEventLog(): void {
+ const events = viewer.getTurnEvents();
+ if (events.length === 0) {
+ eventLogDiv.innerHTML = 'No events
';
+ return;
+ }
+ eventLogDiv.innerHTML = events.map(e => {
+ const type = e.type.replace(/_/g, ' ');
+ return `${type}
`;
+ }).join('');
+ }
+
+ function updateMatchInfo(replay: Replay): void {
+ infoMatchId.textContent = replay.match_id;
+ infoTurns.textContent = String(replay.result.turns);
+ infoReason.textContent = replay.result.reason;
+
+ if (replay.result.winner >= 0 && replay.result.winner < replay.players.length) {
+ infoWinner.textContent = replay.players[replay.result.winner].name;
+ } else if (replay.result.winner === -1) {
+ infoWinner.textContent = 'Draw';
+ } else {
+ infoWinner.textContent = 'Player ' + replay.result.winner;
+ }
+
+ fogSelect.innerHTML = 'Disabled (full view) ';
+ replay.players.forEach((player, idx) => {
+ const option = document.createElement('option');
+ option.value = String(idx);
+ option.textContent = player.name;
+ fogSelect.appendChild(option);
+ });
+ }
+
+ function loadReplay(replay: Replay): void {
+ viewer.loadReplay(replay);
+ enableControls();
+ updateMatchInfo(replay);
+ turnSlider.max = String(viewer.getTotalTurns() - 1);
+ updateUI();
+ updateEventLog();
+ }
+
+ fileInput.addEventListener('change', async (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0];
+ if (!file) return;
+ try {
+ const text = await file.text();
+ const replay = JSON.parse(text) as Replay;
+ loadReplay(replay);
+ } catch (err) {
+ alert('Failed to load replay: ' + err);
+ }
+ });
+
+ loadUrlBtn.addEventListener('click', async () => {
+ const url = urlInput.value.trim();
+ if (!url) return;
+ try {
+ const response = await fetch(url);
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const replay = await response.json() as Replay;
+ loadReplay(replay);
+ } catch (err) {
+ alert('Failed to load replay from URL: ' + err);
+ }
+ });
+
+ playBtn.addEventListener('click', () => viewer.togglePlay());
+ prevBtn.addEventListener('click', () => { viewer.setTurn(viewer.getTurn() - 1); updateUI(); updateEventLog(); });
+ nextBtn.addEventListener('click', () => { viewer.setTurn(viewer.getTurn() + 1); updateUI(); updateEventLog(); });
+ resetBtn.addEventListener('click', () => { viewer.pause(); viewer.setTurn(0); updateUI(); updateEventLog(); });
+
+ turnSlider.addEventListener('input', () => {
+ viewer.setTurn(parseInt(turnSlider.value, 10));
+ updateUI();
+ updateEventLog();
+ });
+
+ speedSlider.addEventListener('input', () => {
+ const speed = parseInt(speedSlider.value, 10);
+ viewer.setSpeed(speed);
+ speedDisplay.textContent = String(speed);
+ });
+
+ fogSelect.addEventListener('change', () => {
+ const value = fogSelect.value;
+ viewer.setFogOfWar(value === '' ? null : parseInt(value, 10));
+ });
+
+ cellSizeSelect.addEventListener('change', () => {
+ const size = parseInt(cellSizeSelect.value, 10);
+ const replay = viewer.getReplay();
+ if (replay) {
+ viewer = new ReplayViewer(canvas, { cellSize: size });
+ loadReplay(replay);
+ }
+ });
+
+ viewer.onTurnChange = () => { updateUI(); updateEventLog(); };
+ viewer.onPlayStateChange = (playing) => { playBtn.textContent = playing ? 'Pause' : 'Play'; };
+
+ document.addEventListener('keydown', (e) => {
+ if (!viewer.getReplay()) return;
+ switch (e.code) {
+ case 'Space':
+ e.preventDefault();
+ viewer.togglePlay();
+ break;
+ case 'ArrowLeft':
+ e.preventDefault();
+ viewer.setTurn(viewer.getTurn() - 1);
+ updateUI();
+ updateEventLog();
+ break;
+ case 'ArrowRight':
+ e.preventDefault();
+ viewer.setTurn(viewer.getTurn() + 1);
+ updateUI();
+ updateEventLog();
+ break;
+ case 'Home':
+ e.preventDefault();
+ viewer.setTurn(0);
+ updateUI();
+ updateEventLog();
+ break;
+ case 'End':
+ e.preventDefault();
+ viewer.setTurn(viewer.getTotalTurns() - 1);
+ updateUI();
+ updateEventLog();
+ break;
+ }
+ });
+
+ // Load from URL param if provided
+ if (initialUrl) {
+ urlInput.value = initialUrl;
+ loadUrlBtn.click();
+ }
+}
+
+// Docs/Getting Started page
+function renderDocsPage(): void {
+ const app = document.getElementById('app');
+ if (!app) return;
+
+ app.innerHTML = `
+
+
Getting Started
+
+
+
+ Overview
+ AI Code Battle is a competitive bot programming platform. You write an HTTP server that controls units on a grid world, competing against other bots for supremacy.
+
+
+
+ Game Basics
+
+ Grid: The game is played on a toroidal (wrapping) grid
+ Units: Each player controls bots that move one tile per turn
+ Resources: Collect energy from nodes to spawn new bots
+ Objectives: Capture enemy cores, eliminate opponents, or dominate through numbers
+
+
+
+
+ HTTP Protocol
+ Your bot must expose an HTTPS endpoint that accepts POST requests with JSON game state and returns JSON move commands.
+
+ Request Format
+ {
+ "match_id": "abc123",
+ "turn": 42,
+ "player_id": 0,
+ "config": { ... },
+ "visible_grid": { ... },
+ "my_bots": [
+ { "id": "bot-1", "position": {"row": 10, "col": 20} }
+ ],
+ "my_energy": 5,
+ "my_score": 3
+}
+
+ Response Format
+ {
+ "moves": [
+ { "bot_id": "bot-1", "direction": "N" }
+ ]
+}
+
+ Valid Directions
+ N (North), E (East), S (South), W (West)
+
+
+
+ Authentication
+ Requests from the game engine are signed with HMAC-SHA256. The signature is sent in the X-Signature header.
+ Format: {match_id}.{turn}.{timestamp}.{sha256(body)}
+ Your bot should verify signatures using your API key to ensure requests are authentic.
+
+
+
+ Requirements
+
+ HTTPS endpoint accessible from the internet
+ Response time under 3 seconds per turn
+ Handle concurrent requests (multiple matches)
+ Return valid JSON for every request
+
+
+
+
+ Example Bot
+ See the example bots in various languages for reference implementations.
+
+
+
+
+
+ `;
+}
+
+// 404 page
+function renderNotFoundPage(): void {
+ const app = document.getElementById('app');
+ if (!app) return;
+
+ app.innerHTML = `
+
+
404
+
Page not found
+
Go Home
+
+
+
+ `;
+}
+
+// Start the router
+document.addEventListener('DOMContentLoaded', () => {
+ updateActiveNavLink();
+ router.start();
+});
+
+// Update nav on initial load
+window.addEventListener('load', () => {
+ updateActiveNavLink();
+});
diff --git a/web/src/pages/bot-profile.ts b/web/src/pages/bot-profile.ts
new file mode 100644
index 0000000..f9ceca2
--- /dev/null
+++ b/web/src/pages/bot-profile.ts
@@ -0,0 +1,181 @@
+// Bot profile page - displays individual bot details
+
+import { fetchBotProfile, type BotProfile } from '../api-types';
+
+export async function renderBotProfilePage(params: Record): Promise {
+ const app = document.getElementById('app');
+ if (!app) return;
+
+ const botId = params.id;
+
+ app.innerHTML = `
+
+
+ Bots / Loading...
+
+
Loading...
+
+ `;
+
+ const content = document.getElementById('profile-content');
+ const breadcrumbName = document.getElementById('bot-breadcrumb-name');
+ if (!content) return;
+
+ try {
+ const profile = await fetchBotProfile(botId);
+ if (breadcrumbName) breadcrumbName.textContent = profile.name;
+ renderProfile(content, profile);
+ } catch (error) {
+ content.innerHTML = `
+
+
Failed to load bot profile: ${error}
+
This bot may not exist or data is not yet available.
+
Back to Bot Directory
+
+ `;
+ }
+}
+
+function renderProfile(container: HTMLElement, profile: BotProfile): void {
+ container.innerHTML = `
+
+
+
+
+
Rating
+
+ ${profile.rating}
+ ±${profile.rating_deviation}
+
+
+
+
+
+
Statistics
+
+
+ ${profile.matches_played}
+ Matches
+
+
+ ${profile.matches_won}
+ Wins
+
+
+ ${profile.win_rate.toFixed(1)}%
+ Win Rate
+
+
+
+
+
+
+
+
Recent Matches
+
+ ${renderRecentMatches(profile.recent_matches)}
+
+
+
+ `;
+
+ // Render simple rating chart if history exists
+ renderRatingChart(profile);
+}
+
+function renderRecentMatches(matches: BotProfile['recent_matches']): string {
+ if (matches.length === 0) {
+ return 'No matches played yet.
';
+ }
+
+ return matches.map(match => {
+ const opponent = match.participants.find(p => p.bot_id !== match.winner_id);
+ const won = match.participants.some(p => p.won);
+ const resultClass = won ? 'match-won' : 'match-lost';
+
+ return `
+
+
${won ? 'W' : 'L'}
+
${opponent ? escapeHtml(opponent.name) : 'Unknown'}
+
${match.participants.map(p => p.score).join(' - ')}
+
Watch
+
+ `;
+ }).join('');
+}
+
+function renderRatingChart(profile: BotProfile): void {
+ const chartContainer = document.getElementById('rating-chart');
+ if (!chartContainer || profile.rating_history.length < 2) {
+ if (chartContainer) {
+ chartContainer.innerHTML = 'Not enough data for chart.
';
+ }
+ return;
+ }
+
+ // Simple SVG sparkline
+ const history = profile.rating_history;
+ const minRating = Math.min(...history.map(h => h.rating));
+ const maxRating = Math.max(...history.map(h => h.rating));
+ const range = maxRating - minRating || 1;
+ const width = 200;
+ const height = 60;
+
+ const points = history.map((h, i) => {
+ const x = (i / (history.length - 1)) * width;
+ const y = height - ((h.rating - minRating) / range) * height;
+ return `${x},${y}`;
+ }).join(' ');
+
+ chartContainer.innerHTML = `
+
+
+
+
+ Min: ${Math.round(minRating)}
+ Max: ${Math.round(maxRating)}
+
+ `;
+}
+
+function getStatusClass(status: string): string {
+ if (status === 'healthy') return 'status-healthy';
+ if (status === 'unhealthy') return 'status-unhealthy';
+ return 'status-unknown';
+}
+
+function formatTimestamp(iso: string): string {
+ try {
+ return new Date(iso).toLocaleString();
+ } catch {
+ return iso;
+ }
+}
+
+function escapeHtml(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
diff --git a/web/src/pages/bots.ts b/web/src/pages/bots.ts
new file mode 100644
index 0000000..c60daaf
--- /dev/null
+++ b/web/src/pages/bots.ts
@@ -0,0 +1,88 @@
+// Bot directory page - lists all registered bots
+
+import { fetchBotDirectory, type BotDirectoryEntry } from '../api-types';
+
+export async function renderBotsPage(): Promise {
+ const app = document.getElementById('app');
+ if (!app) return;
+
+ app.innerHTML = `
+
+
Bot Directory
+
Loading...
+
+ `;
+
+ const content = document.getElementById('bots-content');
+ if (!content) return;
+
+ try {
+ const data = await fetchBotDirectory();
+ renderBotsList(content, data.bots, data.updated_at);
+ } catch (error) {
+ content.innerHTML = `
+
+
Failed to load bot directory: ${error}
+
Bot data may not be available yet.
+
+ `;
+ }
+}
+
+function renderBotsList(
+ container: HTMLElement,
+ bots: BotDirectoryEntry[],
+ updatedAt: string
+): void {
+ if (bots.length === 0) {
+ container.innerHTML = `
+
+ `;
+ return;
+ }
+
+ // Sort by rating descending
+ const sortedBots = [...bots].sort((a, b) => b.rating - a.rating);
+
+ container.innerHTML = `
+ Last updated: ${formatTimestamp(updatedAt)}
+
+ ${sortedBots.map((bot, idx) => renderBotCard(bot, idx + 1)).join('')}
+
+ `;
+}
+
+function renderBotCard(bot: BotDirectoryEntry, rank: number): string {
+ return `
+
+ #${rank}
+
+
${escapeHtml(bot.name)}
+
+ ${bot.rating} rating
+ ${bot.matches_played} matches
+ ${bot.win_rate.toFixed(1)}% win
+
+
+
+ `;
+}
+
+function formatTimestamp(iso: string): string {
+ try {
+ return new Date(iso).toLocaleString();
+ } catch {
+ return iso;
+ }
+}
+
+function escapeHtml(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
diff --git a/web/src/pages/home.ts b/web/src/pages/home.ts
new file mode 100644
index 0000000..bc66d7b
--- /dev/null
+++ b/web/src/pages/home.ts
@@ -0,0 +1,71 @@
+// Home page - landing page with overview
+
+export function renderHomePage(): void {
+ const app = document.getElementById('app');
+ if (!app) return;
+
+ app.innerHTML = `
+
+
+ AI Code Battle
+ Program your bot. Compete for supremacy.
+
+ Write an HTTP server that controls units on a grid world.
+ Collect energy, capture cores, and eliminate your opponents.
+
+
+ Register Your Bot
+ Get Started
+
+
+
+
+ How It Works
+
+
+
Write Code
+
Create a bot in any language that exposes an HTTP endpoint.
+ Your bot receives game state and returns moves each turn.
+
+
+
Deploy
+
Host your bot anywhere - cloud, container, or bare metal.
+ Just make sure it's accessible via HTTP.
+
+
+
Compete
+
Your bot plays matches automatically against other registered bots.
+ Climb the leaderboard with victories.
+
+
+
Watch
+
View replays of every match. Analyze strategies,
+ learn from defeats, and improve your bot.
+
+
+
+
+
+
+ `;
+}
diff --git a/web/src/pages/leaderboard.ts b/web/src/pages/leaderboard.ts
new file mode 100644
index 0000000..5fe6742
--- /dev/null
+++ b/web/src/pages/leaderboard.ts
@@ -0,0 +1,104 @@
+// Leaderboard page - displays bot rankings
+
+import { fetchLeaderboard, type LeaderboardEntry } from '../api-types';
+
+export async function renderLeaderboardPage(): Promise {
+ const app = document.getElementById('app');
+ if (!app) return;
+
+ app.innerHTML = `
+
+
Leaderboard
+
Loading...
+
+ `;
+
+ const content = document.getElementById('leaderboard-content');
+ if (!content) return;
+
+ try {
+ const data = await fetchLeaderboard();
+ renderLeaderboardTable(content, data.entries, data.updated_at);
+ } catch (error) {
+ content.innerHTML = `
+
+
Failed to load leaderboard: ${error}
+
The leaderboard data may not be available yet. Check back after some matches have been played.
+
+ `;
+ }
+}
+
+function renderLeaderboardTable(
+ container: HTMLElement,
+ entries: LeaderboardEntry[],
+ updatedAt: string
+): void {
+ if (entries.length === 0) {
+ container.innerHTML = `
+
+
No bots on the leaderboard yet.
+
Bots appear here after completing their first match.
+
Register a Bot
+
+ `;
+ return;
+ }
+
+ container.innerHTML = `
+ Last updated: ${formatTimestamp(updatedAt)}
+
+
+
+ Rank
+ Bot
+ Rating
+ W/L
+ Win Rate
+ Status
+
+
+
+ ${entries.map(entry => renderLeaderboardRow(entry)).join('')}
+
+
+ `;
+}
+
+function renderLeaderboardRow(entry: LeaderboardEntry): string {
+ const rankClass = entry.rank <= 3 ? `rank-${entry.rank}` : '';
+ const statusClass = entry.health_status === 'healthy' ? 'status-healthy' :
+ entry.health_status === 'unhealthy' ? 'status-unhealthy' : 'status-unknown';
+
+ return `
+
+ ${entry.rank}
+
+ ${escapeHtml(entry.name)}
+
+
+ ${entry.rating}
+ ±${entry.rating_deviation}
+
+ ${entry.matches_won}/${entry.matches_played}
+ ${entry.win_rate.toFixed(1)}%
+ ${entry.health_status}
+
+ `;
+}
+
+function formatTimestamp(iso: string): string {
+ try {
+ return new Date(iso).toLocaleString();
+ } catch {
+ return iso;
+ }
+}
+
+function escapeHtml(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
diff --git a/web/src/pages/matches.ts b/web/src/pages/matches.ts
new file mode 100644
index 0000000..f64b324
--- /dev/null
+++ b/web/src/pages/matches.ts
@@ -0,0 +1,99 @@
+// Match history page - displays recent matches
+
+import { fetchMatchIndex, type MatchSummary } from '../api-types';
+
+export async function renderMatchesPage(): Promise {
+ const app = document.getElementById('app');
+ if (!app) return;
+
+ app.innerHTML = `
+
+
Match History
+
Loading...
+
+ `;
+
+ const content = document.getElementById('matches-content');
+ if (!content) return;
+
+ try {
+ const data = await fetchMatchIndex();
+ renderMatchesList(content, data.matches, data.updated_at);
+ } catch (error) {
+ content.innerHTML = `
+
+
Failed to load match history: ${error}
+
Match data may not be available yet.
+
+ `;
+ }
+}
+
+function renderMatchesList(
+ container: HTMLElement,
+ matches: MatchSummary[],
+ updatedAt: string
+): void {
+ if (matches.length === 0) {
+ container.innerHTML = `
+
+
No matches have been played yet.
+
Matches will appear here once bots are registered and competing.
+
View Leaderboard
+
+ `;
+ return;
+ }
+
+ container.innerHTML = `
+ Last updated: ${formatTimestamp(updatedAt)}
+
+ ${matches.map(match => renderMatchCard(match)).join('')}
+
+ `;
+}
+
+function renderMatchCard(match: MatchSummary): string {
+ const completedAt = match.completed_at ? formatTimestamp(match.completed_at) : 'In progress';
+
+ return `
+
+
+
+ ${match.participants.map(p => `
+
+ `).join('')}
+
+
+
+ `;
+}
+
+function formatTimestamp(iso: string): string {
+ try {
+ return new Date(iso).toLocaleString();
+ } catch {
+ return iso;
+ }
+}
+
+function escapeHtml(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
diff --git a/web/src/pages/register.ts b/web/src/pages/register.ts
new file mode 100644
index 0000000..afe68dd
--- /dev/null
+++ b/web/src/pages/register.ts
@@ -0,0 +1,191 @@
+// Registration page - form to register a new bot
+
+import { registerBot, type RegisterResponse } from '../api-types';
+
+interface FormState {
+ submitting: boolean;
+ result: RegisterResponse | null;
+ error: string | null;
+}
+
+let state: FormState = {
+ submitting: false,
+ result: null,
+ error: null,
+};
+
+export function renderRegisterPage(): void {
+ const app = document.getElementById('app');
+ if (!app) return;
+
+ app.innerHTML = `
+
+
Register Your Bot
+
+
+
+ Register your bot to compete on the ladder. Your bot will be matched
+ against other registered bots automatically.
+
+
+ You'll receive an API key after registration. Keep it safe - you'll
+ need it to manage your bot.
+
+
+
+
+
+
+
Requirements
+
+ Your bot must expose an HTTPS endpoint accessible from the internet
+ The endpoint must respond to POST requests with game state JSON
+ Response time must be under 3 seconds per turn
+ See the Getting Started guide for protocol details
+
+
+
+ `;
+
+ renderForm();
+}
+
+function renderForm(): void {
+ const container = document.getElementById('register-form-container');
+ if (!container) return;
+
+ if (state.result?.success) {
+ container.innerHTML = `
+
+
Registration Successful!
+
Your bot has been registered and will begin competing shortly.
+
+
+
Your API Key
+
Save this key now - it won't be shown again!
+
${escapeHtml(state.result.api_key || '')}
+
+ Copy to Clipboard
+
+
+
+
+
Bot ID: ${escapeHtml(state.result.bot_id || '')}
+
+
+
+
+ `;
+ return;
+ }
+
+ container.innerHTML = `
+
+ `;
+
+ const form = document.getElementById('register-form') as HTMLFormElement;
+ if (form) {
+ form.addEventListener('submit', handleSubmit);
+ }
+}
+
+async function handleSubmit(e: Event): Promise {
+ e.preventDefault();
+
+ const form = e.target as HTMLFormElement;
+ const formData = new FormData(form);
+
+ const name = formData.get('name') as string;
+ const endpointUrl = formData.get('endpoint_url') as string;
+ const ownerId = formData.get('owner_id') as string;
+
+ state = {
+ ...state,
+ submitting: true,
+ error: null,
+ };
+ renderForm();
+
+ try {
+ const result = await registerBot({
+ name,
+ endpoint_url: endpointUrl,
+ owner_id: ownerId,
+ });
+
+ state = {
+ ...state,
+ submitting: false,
+ result,
+ error: result.success ? null : result.error || 'Registration failed',
+ };
+ renderForm();
+ } catch (err) {
+ state = {
+ ...state,
+ submitting: false,
+ error: `Network error: ${err}`,
+ };
+ renderForm();
+ }
+}
+
+function escapeHtml(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
diff --git a/web/src/router.ts b/web/src/router.ts
new file mode 100644
index 0000000..12ced21
--- /dev/null
+++ b/web/src/router.ts
@@ -0,0 +1,89 @@
+// Simple hash-based router for single-page navigation
+
+export type RouteHandler = (params: Record) => void | Promise;
+
+interface Route {
+ pattern: RegExp;
+ handler: RouteHandler;
+ paramNames: string[];
+}
+
+class Router {
+ private routes: Route[] = [];
+ private notFoundHandler: RouteHandler | null = null;
+
+ /**
+ * Register a route with a pattern like "/leaderboard" or "/bot/:id"
+ */
+ on(pattern: string, handler: RouteHandler): this {
+ const paramNames: string[] = [];
+ const regexPattern = pattern.replace(/:(\w+)/g, (_, name) => {
+ paramNames.push(name);
+ return '([^/]+)';
+ });
+
+ this.routes.push({
+ pattern: new RegExp(`^${regexPattern}$`),
+ handler,
+ paramNames,
+ });
+
+ return this;
+ }
+
+ /**
+ * Register a handler for unmatched routes
+ */
+ notFound(handler: RouteHandler): this {
+ this.notFoundHandler = handler;
+ return this;
+ }
+
+ /**
+ * Navigate to a path
+ */
+ navigate(path: string): void {
+ window.location.hash = path;
+ }
+
+ /**
+ * Get current path from hash
+ */
+ getCurrentPath(): string {
+ const hash = window.location.hash.slice(1); // Remove #
+ return hash || '/';
+ }
+
+ /**
+ * Start listening for hash changes
+ */
+ start(): void {
+ window.addEventListener('hashchange', () => this.handleRoute());
+ this.handleRoute();
+ }
+
+ /**
+ * Handle the current route
+ */
+ private handleRoute(): void {
+ const path = this.getCurrentPath();
+
+ for (const route of this.routes) {
+ const match = path.match(route.pattern);
+ if (match) {
+ const params: Record = {};
+ route.paramNames.forEach((name, idx) => {
+ params[name] = decodeURIComponent(match[idx + 1]);
+ });
+ route.handler(params);
+ return;
+ }
+ }
+
+ if (this.notFoundHandler) {
+ this.notFoundHandler({});
+ }
+ }
+}
+
+export const router = new Router();
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 7e527a4..1cc7e5c 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -1,10 +1,17 @@
import { defineConfig } from 'vite'
+import { resolve } from 'path'
export default defineConfig({
root: '.',
build: {
outDir: 'dist',
sourcemap: true,
+ rollupOptions: {
+ input: {
+ main: resolve(__dirname, 'index.html'),
+ app: resolve(__dirname, 'app.html'),
+ },
+ },
},
server: {
port: 3000,