diff --git a/PROGRESS.md b/PROGRESS.md
index 32fef4e..0099cce 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -1,6 +1,6 @@
# AI Code Battle - Implementation Progress
-## Current Phase: Phase 2 - HTTP Protocol & Strategy Bots
+## Current Phase: Phase 3 - Replay Viewer
**Status: ✅ COMPLETE**
@@ -42,17 +42,47 @@
- **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
| Criterion | Status |
|-----------|--------|
-| HMAC auth implementation | ✅ Complete |
-| HTTP bot client with timeout | ✅ Complete |
-| 6 strategy bots in 6 languages | ✅ Complete |
-| All bots have Dockerfile | ✅ Complete |
-| Integration tests passing | ✅ Complete |
+| 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 |
-## Next Phase: Phase 3 - Replay Viewer
+## Next Phase: Phase 4 - Match Orchestration
**Status: Ready to start**
@@ -75,6 +105,15 @@ ai-code-battle/
├── cmd/
│ ├── acb-local/ # CLI match runner
│ └── acb-mapgen/ # Map generator
+├── web/
+│ ├── package.json # npm dependencies
+│ ├── tsconfig.json # TypeScript config
+│ ├── vite.config.ts # Vite bundler config
+│ ├── index.html # Replay viewer page
+│ └── src/
+│ ├── types.ts # Replay type definitions
+│ ├── replay-viewer.ts # Canvas viewer class
+│ └── main.ts # UI controller
├── bots/
│ ├── random/ # Python - RandomBot
│ ├── gatherer/ # Go - GathererBot
@@ -101,7 +140,11 @@ ai-code-battle/
## Running Tests
```bash
+# Go engine tests
go test ./engine/... -v
+
+# Web build verification
+cd web && npm run build
```
## Building CLI Tools
@@ -116,3 +159,11 @@ go build ./cmd/acb-mapgen
```bash
./acb-local -seed 42 -max-turns 100 -output replay.json -verbose
```
+
+## Viewing a Replay
+
+```bash
+cd web
+npm run dev
+# Open http://localhost:3000 and load replay.json
+```
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..b947077
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+dist/
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..afb1feb
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,316 @@
+
+
+
+
+
+ AI Code Battle - Replay Viewer
+
+
+
+
+
AI Code Battle - Replay Viewer
+
+
+
+
+
+
Load a replay file to view
+
+
+
+
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..76b17d6
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,944 @@
+{
+ "name": "acb-web",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "acb-web",
+ "version": "0.1.0",
+ "devDependencies": {
+ "typescript": "^5.0.0",
+ "vite": "^5.0.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
+ "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
+ "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
+ "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
+ "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
+ "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
+ "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
+ "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
+ "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
+ "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
+ "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
+ "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
+ "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
+ "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
+ "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
+ "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
+ "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
+ "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
+ "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
+ "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
+ "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
+ "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
+ "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
+ "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
+ "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
+ "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
+ "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.0",
+ "@rollup/rollup-android-arm64": "4.60.0",
+ "@rollup/rollup-darwin-arm64": "4.60.0",
+ "@rollup/rollup-darwin-x64": "4.60.0",
+ "@rollup/rollup-freebsd-arm64": "4.60.0",
+ "@rollup/rollup-freebsd-x64": "4.60.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.0",
+ "@rollup/rollup-linux-arm64-musl": "4.60.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.0",
+ "@rollup/rollup-linux-loong64-musl": "4.60.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.0",
+ "@rollup/rollup-linux-x64-gnu": "4.60.0",
+ "@rollup/rollup-linux-x64-musl": "4.60.0",
+ "@rollup/rollup-openbsd-x64": "4.60.0",
+ "@rollup/rollup-openharmony-arm64": "4.60.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.0",
+ "@rollup/rollup-win32-x64-gnu": "4.60.0",
+ "@rollup/rollup-win32-x64-msvc": "4.60.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..b93b10e
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "acb-web",
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "typescript": "^5.0.0",
+ "vite": "^5.0.0"
+ }
+}
diff --git a/web/src/main.ts b/web/src/main.ts
new file mode 100644
index 0000000..f462932
--- /dev/null
+++ b/web/src/main.ts
@@ -0,0 +1,235 @@
+import { ReplayViewer } from './replay-viewer';
+import type { Replay } from './types';
+
+// DOM elements
+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;
+
+// Info elements
+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;
+
+// Initialize viewer
+let viewer = new ReplayViewer(canvas, { cellSize: 10 });
+
+// Enable controls when replay is loaded
+function enableControls(): void {
+ playBtn.disabled = false;
+ prevBtn.disabled = false;
+ nextBtn.disabled = false;
+ resetBtn.disabled = false;
+ turnSlider.disabled = false;
+ noReplayDiv.style.display = 'none';
+}
+
+// Update UI state
+function updateUI(): void {
+ turnDisplay.textContent = String(viewer.getTurn());
+ totalTurnsSpan.textContent = String(viewer.getTotalTurns());
+ turnSlider.value = String(viewer.getTurn());
+
+ // Update play button text
+ playBtn.textContent = 'Pause';
+ if (!viewer.getReplay() || viewer.isAtEnd()) {
+ playBtn.textContent = 'Play';
+ }
+}
+
+// Update event log
+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('');
+}
+
+// Update match info panel
+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;
+ }
+
+ // Update fog of war options
+ fogSelect.innerHTML = '';
+ replay.players.forEach((player, idx) => {
+ const option = document.createElement('option');
+ option.value = String(idx);
+ option.textContent = player.name;
+ fogSelect.appendChild(option);
+ });
+}
+
+// Load replay from JSON
+function loadReplay(replay: Replay): void {
+ viewer.loadReplay(replay);
+ enableControls();
+ updateMatchInfo(replay);
+
+ // Update slider max
+ turnSlider.max = String(viewer.getTotalTurns() - 1);
+
+ updateUI();
+ updateEventLog();
+}
+
+// File input handler
+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);
+ }
+});
+
+// URL load handler
+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);
+ }
+});
+
+// Playback controls
+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();
+});
+
+// Turn slider
+turnSlider.addEventListener('input', () => {
+ viewer.setTurn(parseInt(turnSlider.value, 10));
+ updateUI();
+ updateEventLog();
+});
+
+// Speed slider
+speedSlider.addEventListener('input', () => {
+ const speed = parseInt(speedSlider.value, 10);
+ viewer.setSpeed(speed);
+ speedDisplay.textContent = String(speed);
+});
+
+// Fog of war select
+fogSelect.addEventListener('change', () => {
+ const value = fogSelect.value;
+ viewer.setFogOfWar(value === '' ? null : parseInt(value, 10));
+});
+
+// Cell size select
+cellSizeSelect.addEventListener('change', () => {
+ const size = parseInt(cellSizeSelect.value, 10);
+ const replay = viewer.getReplay();
+ if (replay) {
+ viewer = new ReplayViewer(canvas, { cellSize: size });
+ loadReplay(replay);
+ }
+});
+
+// Viewer callbacks
+viewer.onTurnChange = () => {
+ updateUI();
+ updateEventLog();
+};
+
+viewer.onPlayStateChange = (playing) => {
+ playBtn.textContent = playing ? 'Pause' : 'Play';
+};
+
+// Keyboard shortcuts
+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;
+ }
+});
+
+console.log('AI Code Battle Replay Viewer initialized');
diff --git a/web/src/replay-viewer.ts b/web/src/replay-viewer.ts
new file mode 100644
index 0000000..e940d90
--- /dev/null
+++ b/web/src/replay-viewer.ts
@@ -0,0 +1,367 @@
+import type { Replay, ReplayTurn, Position, ReplayBot, GameEvent } from './types';
+
+// Player colors - accessible and distinct
+const PLAYER_COLORS = [
+ '#3b82f6', // Blue (player 0)
+ '#ef4444', // Red (player 1)
+ '#22c55e', // Green (player 2)
+ '#f59e0b', // Amber (player 3)
+ '#8b5cf6', // Purple (player 4)
+ '#06b6d4', // Cyan (player 5)
+];
+
+const NEUTRAL_COLOR = '#6b7280'; // Gray
+const WALL_COLOR = '#1f2937'; // Dark gray
+const ENERGY_COLOR = '#fbbf24'; // Yellow
+const BACKGROUND_COLOR = '#111827'; // Very dark gray
+const GRID_COLOR = '#374151'; // Medium gray
+
+export interface ViewerOptions {
+ cellSize?: number;
+ showGrid?: boolean;
+ fogOfWarPlayer?: number | null; // null = disabled, number = player perspective
+ animationSpeed?: number; // ms per frame
+}
+
+export class ReplayViewer {
+ private canvas: HTMLCanvasElement;
+ private ctx: CanvasRenderingContext2D;
+ private replay: Replay | null = null;
+ private currentTurn: number = 0;
+ private isPlaying: boolean = false;
+ private animationFrame: number | null = null;
+ private lastFrameTime: number = 0;
+ private cellSize: number;
+ private showGrid: boolean;
+ private fogOfWarPlayer: number | null;
+ private animationSpeed: number;
+
+ // Event callbacks
+ public onTurnChange?: (turn: number) => void;
+ public onPlayStateChange?: (playing: boolean) => void;
+ public onReplayLoad?: (replay: Replay) => void;
+
+ constructor(canvas: HTMLCanvasElement, options: ViewerOptions = {}) {
+ this.canvas = canvas;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) throw new Error('Could not get 2D context');
+ this.ctx = ctx;
+
+ this.cellSize = options.cellSize ?? 10;
+ this.showGrid = options.showGrid ?? true;
+ this.fogOfWarPlayer = options.fogOfWarPlayer ?? null;
+ this.animationSpeed = options.animationSpeed ?? 100;
+
+ this.render = this.render.bind(this);
+ }
+
+ loadReplay(replay: Replay): void {
+ this.replay = replay;
+ this.currentTurn = 0;
+
+ // Resize canvas to fit the grid
+ this.resizeCanvas();
+
+ // Render initial state
+ this.render();
+
+ if (this.onReplayLoad) this.onReplayLoad(replay);
+ }
+
+ private resizeCanvas(): void {
+ if (!this.replay) return;
+ const { rows, cols } = this.replay.map;
+ this.canvas.width = cols * this.cellSize;
+ this.canvas.height = rows * this.cellSize;
+ }
+
+ private posKey(pos: Position): string {
+ return `${pos.row},${pos.col}`;
+ }
+
+ setTurn(turn: number): void {
+ if (!this.replay) return;
+ this.currentTurn = Math.max(0, Math.min(turn, this.replay.turns.length - 1));
+ this.render();
+ if (this.onTurnChange) this.onTurnChange(this.currentTurn);
+ }
+
+ getTurn(): number {
+ return this.currentTurn;
+ }
+
+ getTotalTurns(): number {
+ return this.replay?.turns.length ?? 0;
+ }
+
+ play(): void {
+ if (this.isPlaying || !this.replay) return;
+ this.isPlaying = true;
+ this.lastFrameTime = performance.now();
+ this.animationFrame = requestAnimationFrame(this.animate.bind(this));
+ if (this.onPlayStateChange) this.onPlayStateChange(true);
+ }
+
+ pause(): void {
+ this.isPlaying = false;
+ if (this.animationFrame !== null) {
+ cancelAnimationFrame(this.animationFrame);
+ this.animationFrame = null;
+ }
+ if (this.onPlayStateChange) this.onPlayStateChange(false);
+ }
+
+ togglePlay(): void {
+ if (this.isPlaying) {
+ this.pause();
+ } else {
+ this.play();
+ }
+ }
+
+ setSpeed(msPerFrame: number): void {
+ this.animationSpeed = Math.max(10, Math.min(2000, msPerFrame));
+ }
+
+ getSpeed(): number {
+ return this.animationSpeed;
+ }
+
+ setFogOfWar(player: number | null): void {
+ this.fogOfWarPlayer = player;
+ this.render();
+ }
+
+ getFogOfWar(): number | null {
+ return this.fogOfWarPlayer;
+ }
+
+ private animate(timestamp: number): void {
+ if (!this.isPlaying || !this.replay) return;
+
+ const elapsed = timestamp - this.lastFrameTime;
+ if (elapsed >= this.animationSpeed) {
+ this.lastFrameTime = timestamp;
+
+ // Advance to next turn
+ if (this.currentTurn < this.replay.turns.length - 1) {
+ this.currentTurn++;
+ this.render();
+ if (this.onTurnChange) this.onTurnChange(this.currentTurn);
+ } else {
+ // End of replay
+ this.pause();
+ return;
+ }
+ }
+
+ this.animationFrame = requestAnimationFrame(this.animate.bind(this));
+ }
+
+ private render(): void {
+ if (!this.replay) return;
+
+ const { ctx, cellSize, canvas } = this;
+ const { rows, cols } = this.replay.map;
+
+ // Clear canvas
+ ctx.fillStyle = BACKGROUND_COLOR;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ // Draw grid lines
+ if (this.showGrid) {
+ ctx.strokeStyle = GRID_COLOR;
+ ctx.lineWidth = 0.5;
+ for (let r = 0; r <= rows; r++) {
+ ctx.beginPath();
+ ctx.moveTo(0, r * cellSize);
+ ctx.lineTo(cols * cellSize, r * cellSize);
+ ctx.stroke();
+ }
+ for (let c = 0; c <= cols; c++) {
+ ctx.beginPath();
+ ctx.moveTo(c * cellSize, 0);
+ ctx.lineTo(c * cellSize, rows * cellSize);
+ ctx.stroke();
+ }
+ }
+
+ // Get current turn data
+ const turnData = this.replay.turns[this.currentTurn];
+ if (!turnData) return;
+
+ // Determine visibility for fog of war
+ const visible = this.fogOfWarPlayer !== null
+ ? this.computeVisibility(turnData, this.fogOfWarPlayer)
+ : null;
+
+ // Draw walls (always visible)
+ for (const wall of this.replay.map.walls) {
+ this.drawCell(wall.row, wall.col, WALL_COLOR);
+ }
+
+ // Draw cores
+ for (const core of turnData.cores) {
+ if (visible && !visible.has(this.posKey(core.position))) continue;
+ const color = core.active ? PLAYER_COLORS[core.owner] : NEUTRAL_COLOR;
+ this.drawCore(core.position.row, core.position.col, color, core.active);
+ }
+
+ // Draw energy
+ for (const energy of turnData.energy) {
+ if (visible && !visible.has(this.posKey(energy))) continue;
+ this.drawEnergy(energy.row, energy.col);
+ }
+
+ // Draw bots
+ for (const bot of turnData.bots) {
+ if (!bot.alive) continue;
+ if (visible && !visible.has(this.posKey(bot.position))) continue;
+ const color = PLAYER_COLORS[bot.owner];
+ this.drawBot(bot, color);
+ }
+
+ // Draw score overlay
+ this.drawScoreOverlay(turnData);
+ }
+
+ private computeVisibility(turnData: ReplayTurn, player: number): Set {
+ const visible = new Set();
+ const config = this.replay!.config;
+ const visionRadius2 = config.vision_radius2;
+
+ // Add all positions visible from this player's bots
+ for (const bot of turnData.bots) {
+ if (bot.owner !== player || !bot.alive) continue;
+
+ // Add all cells within vision radius
+ const vr = Math.ceil(Math.sqrt(visionRadius2));
+ for (let dr = -vr; dr <= vr; dr++) {
+ for (let dc = -vr; dc <= vr; dc++) {
+ const dist2 = dr * dr + dc * dc;
+ if (dist2 <= visionRadius2) {
+ const r = (bot.position.row + dr + this.replay!.map.rows) % this.replay!.map.rows;
+ const c = (bot.position.col + dc + this.replay!.map.cols) % this.replay!.map.cols;
+ visible.add(`${r},${c}`);
+ }
+ }
+ }
+ }
+
+ // Also add this player's cores (always visible)
+ for (const core of this.replay!.map.cores) {
+ if (core.owner === player) {
+ visible.add(this.posKey(core.position));
+ }
+ }
+
+ return visible;
+ }
+
+ private drawCell(row: number, col: number, color: string): void {
+ const { ctx, cellSize } = this;
+ ctx.fillStyle = color;
+ ctx.fillRect(col * cellSize, row * cellSize, cellSize, cellSize);
+ }
+
+ private drawCore(row: number, col: number, color: string, active: boolean): void {
+ const { ctx, cellSize } = this;
+ const x = col * cellSize + cellSize / 2;
+ const y = row * cellSize + cellSize / 2;
+ const radius = (cellSize / 2) - 1;
+
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Draw inactive marker
+ if (!active) {
+ ctx.strokeStyle = BACKGROUND_COLOR;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(x - radius / 2, y - radius / 2);
+ ctx.lineTo(x + radius / 2, y + radius / 2);
+ ctx.stroke();
+ }
+ }
+
+ private drawEnergy(row: number, col: number): void {
+ const { ctx, cellSize } = this;
+ const x = col * cellSize + cellSize / 2;
+ const y = row * cellSize + cellSize / 2;
+ const radius = (cellSize / 3);
+
+ ctx.fillStyle = ENERGY_COLOR;
+ ctx.beginPath();
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ private drawBot(bot: ReplayBot, color: string): void {
+ const { ctx, cellSize } = this;
+ const x = bot.position.col * cellSize + cellSize / 2;
+ const y = bot.position.row * cellSize + cellSize / 2;
+ const radius = (cellSize / 2) - 1;
+
+ // Draw bot as filled circle
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Draw border
+ ctx.strokeStyle = '#ffffff';
+ ctx.lineWidth = 1;
+ ctx.stroke();
+ }
+
+ private drawScoreOverlay(turnData: ReplayTurn): void {
+ if (!this.replay) return;
+
+ const { ctx } = this;
+ const padding = 10;
+ const lineHeight = 20;
+
+ // Draw semi-transparent background
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
+ ctx.fillRect(0, 0, 150, padding * 2 + lineHeight * this.replay.players.length);
+
+ // Draw scores for each player
+ ctx.font = '14px monospace';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+
+ this.replay.players.forEach((player, idx) => {
+ const score = turnData.scores[idx] ?? 0;
+ const energy = turnData.energy_held[idx] ?? 0;
+ const color = PLAYER_COLORS[idx];
+
+ // Draw color indicator
+ ctx.fillStyle = color;
+ ctx.fillRect(padding, padding + idx * lineHeight, 12, 12);
+
+ // Draw text
+ ctx.fillStyle = '#ffffff';
+ ctx.fillText(`${player.name}: ${score} (E:${energy})`, padding + 18, padding + idx * lineHeight);
+ });
+ }
+
+ // Utility to get current turn events
+ getTurnEvents(): GameEvent[] {
+ if (!this.replay) return [];
+ const turnData = this.replay.turns[this.currentTurn];
+ return turnData?.events ?? [];
+ }
+
+ // Get replay info
+ getReplay(): Replay | null {
+ return this.replay;
+ }
+
+ // Check if at end of replay
+ isAtEnd(): boolean {
+ if (!this.replay) return true;
+ return this.currentTurn >= this.replay.turns.length - 1;
+ }
+}
diff --git a/web/src/types.ts b/web/src/types.ts
new file mode 100644
index 0000000..47976cd
--- /dev/null
+++ b/web/src/types.ts
@@ -0,0 +1,121 @@
+// Replay format types matching the Go engine
+
+export interface Position {
+ row: number;
+ col: number;
+}
+
+export interface Config {
+ rows: number;
+ cols: number;
+ max_turns: number;
+ vision_radius2: number;
+ attack_radius2: number;
+ spawn_cost: number;
+ energy_interval: number;
+}
+
+export interface MatchResult {
+ winner: number;
+ reason: string;
+ turns: number;
+ scores: number[];
+ energy: number[];
+ bots_alive: number[];
+}
+
+export interface ReplayPlayer {
+ id: number;
+ name: string;
+}
+
+export interface ReplayCore {
+ position: Position;
+ owner: number;
+}
+
+export interface ReplayMap {
+ rows: number;
+ cols: number;
+ walls: Position[];
+ cores: ReplayCore[];
+ energy_nodes: Position[];
+}
+
+export interface ReplayBot {
+ id: number;
+ owner: number;
+ position: Position;
+ alive: boolean;
+}
+
+export interface ReplayCoreState {
+ position: Position;
+ owner: number;
+ active: boolean;
+}
+
+export interface GameEvent {
+ type: string;
+ turn: number;
+ details: unknown;
+}
+
+export interface ReplayTurn {
+ turn: number;
+ bots: ReplayBot[];
+ cores: ReplayCoreState[];
+ energy: Position[];
+ scores: number[];
+ energy_held: number[];
+ events?: GameEvent[];
+}
+
+export interface Replay {
+ match_id: string;
+ config: Config;
+ start_time: string;
+ end_time: string;
+ result: MatchResult;
+ players: ReplayPlayer[];
+ map: ReplayMap;
+ turns: ReplayTurn[];
+}
+
+// Event detail types
+export interface BotSpawnedDetails {
+ bot_id: number;
+ owner: number;
+ position: Position;
+}
+
+export interface BotDiedDetails {
+ bot_id: number;
+ owner: number;
+ position: Position;
+}
+
+export interface EnergyCollectedDetails {
+ bot_id: number;
+ owner: number;
+ position: Position;
+}
+
+export interface CoreCapturedDetails {
+ position: Position;
+ old_owner: number;
+ new_owner: number;
+}
+
+export interface CombatDeathDetails {
+ attacker_id: number;
+ attacker_owner: number;
+ defender_id: number;
+ defender_owner: number;
+ position: Position;
+}
+
+export interface CollisionDeathDetails {
+ bot_ids: number[];
+ position: Position;
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..c298359
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "outDir": "./dist"
+ },
+ "include": ["src"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..7e527a4
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ root: '.',
+ build: {
+ outDir: 'dist',
+ sourcemap: true,
+ },
+ server: {
+ port: 3000,
+ },
+})