diff --git a/PROGRESS.md b/PROGRESS.md
index 2776b70..32fef4e 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -1,76 +1,58 @@
# AI Code Battle - Implementation Progress
-## Current Phase: Phase 1 - Core Engine
+## Current Phase: Phase 2 - HTTP Protocol & Strategy Bots
**Status: ✅ COMPLETE**
-### 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`)
- - Position, Tile, Direction, Bot, Core, EnergyNode, Player
- - Config with default values
- - MatchResult, VisibleState (fog-filtered state)
-- [x] Grid implementation (`engine/grid.go`)
- - Toroidal wrapping
- - Distance calculations (squared for performance)
- - Visibility computation
- - Wall/obstacle handling
-- [x] Game state (`engine/game.go`)
- - State management for bots, cores, energy, players
- - Bot spawning and killing
- - Fog of war filtering
-- [x] Turn execution (`engine/turn.go`)
- - Movement phase with collision detection
- - Focus-fire combat resolution
- - Core capture mechanics
- - Energy collection (contested resources)
- - Bot spawning at active cores
- - Energy node ticking
- - Win condition checking (elimination, draw, dominance, turns)
-- [x] Replay writer (`engine/replay.go`)
- - Full replay JSON format
- - Turn-by-turn state recording
-- [x] Match runner (`engine/match.go`)
- - Concurrent bot communication
- - Per-turn timeout
- - Symmetric map generation
-- [x] Local bot interface (`engine/bot_local.go`)
- - RandomBot, IdleBot implementations
-- [x] CLI runner (`cmd/acb-local/main.go`)
- - Configurable parameters (seed, size, turns)
- - Replay output
-- [x] Map generator (`cmd/acb-mapgen/main.go`)
- - Rotational symmetry (2/3/4/6 players)
- - Configurable density
-- [x] Unit tests for core engine
- - Grid operations, wrapping, distances
- - Combat resolution (1v1, 2v1, formations)
- - Core capture
- - Energy collection
- - Spawning
- - Win conditions
-- [x] Map generator connectivity validation (`cmd/acb-mapgen/connectivity.go`)
- - BFS-based connectivity check
- - Retry mechanism for connected map generation
-- [x] Determinism tests (`engine/determinism_test.go`)
- - Same seed produces identical replays
- - Turn execution is deterministic
- - Grid operations are deterministic
- - Combat resolution is deterministic
- - Replay serialization round-trip
- - Full 500-turn match validation
+- [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
### Exit Criteria Progress
| Criterion | Status |
|-----------|--------|
-| Can run a complete 500-turn match locally | ✅ Works |
-| Produce a valid replay file | ✅ Works |
-| Comprehensive unit tests | ✅ 32 tests passing |
+| 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 |
-## Next Phase: Phase 2 - HTTP Protocol & Strategy Bots
+## Next Phase: Phase 3 - Replay Viewer
**Status: Ready to start**
@@ -87,27 +69,34 @@ ai-code-battle/
│ ├── replay.go # Replay recording
│ ├── match.go # Match runner
│ ├── bot_local.go # Local bot interface
-│ ├── grid_test.go # Grid tests
-│ └── turn_test.go # Turn execution tests
+│ ├── bot_http.go # HTTP bot client
+│ ├── auth.go # HMAC authentication
+│ └── *_test.go # Test files
├── cmd/
│ ├── acb-local/ # CLI match runner
-│ │ └── main.go
│ └── acb-mapgen/ # Map generator
-│ └── main.go
+├── bots/
+│ ├── random/ # Python - RandomBot
+│ ├── gatherer/ # Go - GathererBot
+│ ├── rusher/ # Rust - RusherBot
+│ ├── guardian/ # PHP - GuardianBot
+│ ├── swarm/ # TypeScript - SwarmBot
+│ └── hunter/ # Java - HunterBot
└── docs/
└── plan/
└── plan.md # Full implementation plan
```
-## Key Design Decisions
+## Strategy Bot Summary
-1. **Position-based moves**: Bots are identified by their current position in the move protocol (not bot IDs), which works better with fog of war.
-
-2. **Squared distances**: Using squared distances throughout (Distance2, Radius2) avoids expensive square root operations.
-
-3. **Simultaneous resolution**: Combat deaths are computed first, then applied, ensuring true simultaneous resolution.
-
-4. **Symmetric map generation**: Maps are generated by creating one sector and rotating for all players.
+| Bot | Language | Strategy | Expected Rank |
+|-----|----------|----------|---------------|
+| RandomBot | Python | Random valid moves | 6th (floor) |
+| GathererBot | Go | Energy collection, avoid combat | 4th-5th |
+| RusherBot | Rust | Rush enemy cores aggressively | 4th-5th |
+| GuardianBot | PHP | Defend cores, cautious expansion | 3rd-4th |
+| SwarmBot | TypeScript | Formation cohesion, group advance | 1st-2nd |
+| HunterBot | Java | Target isolated enemies | 1st-2nd |
## Running Tests
@@ -122,12 +111,8 @@ go build ./cmd/acb-local
go build ./cmd/acb-mapgen
```
-## Example Usage
+## Running a Match
```bash
-# Run a match
./acb-local -seed 42 -max-turns 100 -output replay.json -verbose
-
-# Generate a map
-./acb-mapgen -players 2 -rows 60 -cols 60 -output map.json
```
diff --git a/bots/gatherer/Dockerfile b/bots/gatherer/Dockerfile
new file mode 100644
index 0000000..12cb995
--- /dev/null
+++ b/bots/gatherer/Dockerfile
@@ -0,0 +1,20 @@
+FROM golang:1.22-alpine AS builder
+
+WORKDIR /app
+COPY go.mod .
+COPY main.go .
+COPY strategy.go .
+
+RUN go build -o gatherer .
+
+FROM alpine:3.19
+
+WORKDIR /app
+COPY --from=builder /app/gatherer .
+
+ENV BOT_PORT=8080
+ENV BOT_SECRET=""
+
+EXPOSE 8080
+
+CMD ["./gatherer"]
diff --git a/bots/gatherer/go.mod b/bots/gatherer/go.mod
new file mode 100644
index 0000000..1aa9b25
--- /dev/null
+++ b/bots/gatherer/go.mod
@@ -0,0 +1,3 @@
+module github.com/aicodebattle/acb/bots/gatherer
+
+go 1.21
diff --git a/bots/gatherer/main.go b/bots/gatherer/main.go
new file mode 100644
index 0000000..c17c721
--- /dev/null
+++ b/bots/gatherer/main.go
@@ -0,0 +1,226 @@
+// Package main implements GathererBot - a bot that maximizes energy collection while avoiding combat.
+package main
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "strconv"
+ "sync"
+)
+
+// Config holds bot configuration from environment variables.
+type Config struct {
+ Port string
+ Secret string
+}
+
+// GameConfig holds the game configuration from the engine.
+type GameConfig struct {
+ Rows int `json:"rows"`
+ Cols int `json:"cols"`
+ MaxTurns int `json:"max_turns"`
+ VisionRadius2 int `json:"vision_radius2"`
+ AttackRadius2 int `json:"attack_radius2"`
+ SpawnCost int `json:"spawn_cost"`
+ EnergyInterval int `json:"energy_interval"`
+}
+
+// Position represents a grid coordinate.
+type Position struct {
+ Row int `json:"row"`
+ Col int `json:"col"`
+}
+
+// VisibleBot represents a visible bot.
+type VisibleBot struct {
+ Position Position `json:"position"`
+ Owner int `json:"owner"`
+}
+
+// VisibleCore represents a visible core.
+type VisibleCore struct {
+ Position Position `json:"position"`
+ Owner int `json:"owner"`
+ Active bool `json:"active"`
+}
+
+// GameState represents the fog-filtered state visible to this bot.
+type GameState struct {
+ MatchID string `json:"match_id"`
+ Turn int `json:"turn"`
+ Config GameConfig `json:"config"`
+ You struct {
+ ID int `json:"id"`
+ Energy int `json:"energy"`
+ Score int `json:"score"`
+ } `json:"you"`
+ Bots []VisibleBot `json:"bots"`
+ Energy []Position `json:"energy"`
+ Cores []VisibleCore `json:"cores"`
+ Walls []Position `json:"walls"`
+ Dead []VisibleBot `json:"dead"`
+}
+
+// Direction represents a movement direction.
+type Direction string
+
+const (
+ DirN Direction = "N"
+ DirE Direction = "E"
+ DirS Direction = "S"
+ DirW Direction = "W"
+)
+
+// Move represents a bot movement order.
+type Move struct {
+ Position Position `json:"position"`
+ Direction Direction `json:"direction"`
+}
+
+// MoveResponse is the response sent back to the engine.
+type MoveResponse struct {
+ Moves []Move `json:"moves"`
+}
+
+// Server holds the bot server state.
+type Server struct {
+ config Config
+ strategy *GathererStrategy
+ mu sync.Mutex
+}
+
+func main() {
+ config := Config{
+ Port: getEnv("BOT_PORT", "8080"),
+ Secret: getEnv("BOT_SECRET", ""),
+ }
+
+ if config.Secret == "" {
+ log.Fatal("BOT_SECRET environment variable is required")
+ }
+
+ server := &Server{
+ config: config,
+ strategy: NewGathererStrategy(),
+ }
+
+ http.HandleFunc("/turn", server.handleTurn)
+ http.HandleFunc("/health", server.handleHealth)
+
+ addr := fmt.Sprintf(":%s", config.Port)
+ log.Printf("GathererBot starting on %s", addr)
+ if err := http.ListenAndServe(addr, nil); err != nil {
+ log.Fatalf("Server failed: %v", err)
+ }
+}
+
+func (s *Server) handleTurn(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Read request body
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ http.Error(w, "failed to read body", http.StatusBadRequest)
+ return
+ }
+ defer r.Body.Close()
+
+ // Verify signature
+ sig := r.Header.Get("X-ACB-Signature")
+ if sig == "" {
+ http.Error(w, "missing signature", http.StatusUnauthorized)
+ return
+ }
+
+ matchID := r.Header.Get("X-ACB-Match-Id")
+ turnStr := r.Header.Get("X-ACB-Turn")
+
+ if err := verifySignature(s.config.Secret, matchID, turnStr, body, sig); err != nil {
+ http.Error(w, fmt.Sprintf("signature verification failed: %v", err), http.StatusUnauthorized)
+ return
+ }
+
+ // Parse game state
+ var state GameState
+ if err := json.Unmarshal(body, &state); err != nil {
+ http.Error(w, "invalid game state", http.StatusBadRequest)
+ return
+ }
+
+ // Compute moves
+ s.mu.Lock()
+ moves := s.strategy.ComputeMoves(&state)
+ s.mu.Unlock()
+
+ // Build response
+ response := MoveResponse{Moves: moves}
+ responseBody, err := json.Marshal(response)
+ if err != nil {
+ http.Error(w, "failed to marshal response", http.StatusInternalServerError)
+ return
+ }
+
+ // Sign response
+ responseSig := signResponse(s.config.Secret, matchID, turnStr, responseBody)
+ w.Header().Set("X-ACB-Signature", responseSig)
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(responseBody)
+}
+
+func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("OK"))
+}
+
+// verifySignature verifies the HMAC signature of an incoming request.
+func verifySignature(secret, matchID, turnStr string, body []byte, signature string) error {
+ // Compute expected signature
+ // signing_string = "{match_id}.{turn}.{timestamp}.{sha256(request_body)}"
+ // For requests, we also need timestamp, but we simplify here for the bot side
+
+ bodyHash := sha256.Sum256(body)
+ turn, _ := strconv.Atoi(turnStr)
+ signingString := fmt.Sprintf("%s.%d.%s", matchID, turn, hex.EncodeToString(bodyHash[:]))
+
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(signingString))
+ expectedSig := hex.EncodeToString(mac.Sum(nil))
+
+ if !hmac.Equal([]byte(signature), []byte(expectedSig)) {
+ return fmt.Errorf("invalid signature")
+ }
+
+ return nil
+}
+
+// signResponse signs the response body.
+func signResponse(secret, matchID, turnStr string, body []byte) string {
+ bodyHash := sha256.Sum256(body)
+ turn, _ := strconv.Atoi(turnStr)
+ signingString := fmt.Sprintf("%s.%d.%s", matchID, turn, hex.EncodeToString(bodyHash[:]))
+
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(signingString))
+ return hex.EncodeToString(mac.Sum(nil))
+}
+
+func getEnv(key, defaultValue string) string {
+ if value := os.Getenv(key); value != "" {
+ return value
+ }
+ return defaultValue
+}
diff --git a/bots/gatherer/strategy.go b/bots/gatherer/strategy.go
new file mode 100644
index 0000000..48ea20d
--- /dev/null
+++ b/bots/gatherer/strategy.go
@@ -0,0 +1,298 @@
+package main
+
+import (
+ "container/list"
+)
+
+// GathererStrategy implements energy-focused gameplay with combat avoidance.
+type GathererStrategy struct {
+ // No persistent state needed - strategy is stateless per turn
+}
+
+// NewGathererStrategy creates a new gatherer strategy.
+func NewGathererStrategy() *GathererStrategy {
+ return &GathererStrategy{}
+}
+
+// ComputeMoves calculates the best moves for the current turn.
+func (s *GathererStrategy) ComputeMoves(state *GameState) []Move {
+ if len(state.Bots) == 0 {
+ return nil
+ }
+
+ myID := state.You.ID
+ config := state.Config
+
+ // Separate my bots from enemy bots
+ myBots := make([]VisibleBot, 0)
+ enemyBots := make([]VisibleBot, 0)
+ for _, bot := range state.Bots {
+ if bot.Owner == myID {
+ myBots = append(myBots, bot)
+ } else {
+ enemyBots = append(enemyBots, bot)
+ }
+ }
+
+ // Build enemy positions map for quick lookup
+ enemyPositions := make(map[Position]bool)
+ for _, enemy := range enemyBots {
+ enemyPositions[enemy.Position] = true
+ }
+
+ // Build energy positions map
+ energyPositions := make(map[Position]bool)
+ for _, e := range state.Energy {
+ energyPositions[e] = true
+ }
+
+ // For each of my bots, find the best move
+ moves := make([]Move, 0, len(myBots))
+ usedEnergy := make(map[Position]bool) // Track energy already targeted
+
+ for _, bot := range myBots {
+ move := s.computeBotMove(bot, myBots, enemyBots, enemyPositions,
+ energyPositions, usedEnergy, config)
+ if move != nil {
+ moves = append(moves, *move)
+ // Mark energy as targeted if bot will collect it
+ if energyPositions[move.Position] || energyPositions[simulateMove(bot.Position, move.Direction, config)] {
+ usedEnergy[simulateMove(bot.Position, move.Direction, config)] = true
+ }
+ }
+ }
+
+ return moves
+}
+
+// computeBotMove calculates the best move for a single bot.
+func (s *GathererStrategy) computeBotMove(
+ bot VisibleBot,
+ myBots, enemyBots []VisibleBot,
+ enemyPositions, energyPositions, usedEnergy map[Position]bool,
+ config GameConfig,
+) *Move {
+ // First check if we should flee from enemies
+ if s.shouldFlee(bot.Position, enemyBots, config) {
+ fleeDir := s.getFleeDirection(bot.Position, enemyBots, config)
+ if fleeDir != "" {
+ return &Move{
+ Position: bot.Position,
+ Direction: fleeDir,
+ }
+ }
+ }
+
+ // Try to find nearest untargeted energy
+ nearestEnergy, path := s.findNearestEnergy(bot.Position, energyPositions, usedEnergy, enemyPositions, config)
+ if path != nil && len(path) > 0 {
+ // Move towards the energy
+ return &Move{
+ Position: bot.Position,
+ Direction: path[0],
+ }
+ }
+
+ // No energy visible or reachable - spread out to explore
+ return s.getExploreMove(bot.Position, myBots, enemyPositions, config)
+}
+
+// shouldFlee returns true if the bot should flee from nearby enemies.
+func (s *GathererStrategy) shouldFlee(pos Position, enemies []VisibleBot, config GameConfig) bool {
+ for _, enemy := range enemies {
+ dist2 := distance2(pos, enemy.Position, config)
+ // Flee if enemy is within attack range + 2 tiles buffer
+ if dist2 <= config.AttackRadius2+4 {
+ return true
+ }
+ }
+ return false
+}
+
+// getFleeDirection returns the best direction to flee from enemies.
+func (s *GathererStrategy) getFleeDirection(pos Position, enemies []VisibleBot, config GameConfig) Direction {
+ // Calculate the center of mass of enemies
+ enemyCenter := Position{Row: 0, Col: 0}
+ for _, enemy := range enemies {
+ enemyCenter.Row += enemy.Position.Row
+ enemyCenter.Col += enemy.Position.Col
+ }
+ if len(enemies) > 0 {
+ enemyCenter.Row /= len(enemies)
+ enemyCenter.Col /= len(enemies)
+ }
+
+ // Move away from enemy center
+ dr := pos.Row - enemyCenter.Row
+ dc := pos.Col - enemyCenter.Col
+
+ // Normalize direction
+ if dr > 0 {
+ return DirS
+ } else if dr < 0 {
+ return DirN
+ } else if dc > 0 {
+ return DirE
+ } else if dc < 0 {
+ return DirW
+ }
+
+ // Default: move North
+ return DirN
+}
+
+// findNearestEnergy finds the nearest untargeted energy using BFS.
+func (s *GathererStrategy) findNearestEnergy(
+ start Position,
+ energyPositions, usedEnergy, enemyPositions map[Position]bool,
+ config GameConfig,
+) (Position, []Direction) {
+ type queueItem struct {
+ pos Position
+ path []Direction
+ }
+
+ visited := make(map[Position]bool)
+ queue := list.New()
+ queue.PushBack(queueItem{pos: start, path: []Direction{}})
+
+ _ = nearestEnergy // Track found position (unused but semantically meaningful)
+ var bestPath []Direction
+
+ for queue.Len() > 0 {
+ item := queue.Remove(queue.Front()).(queueItem)
+ pos := item.pos
+ path := item.path
+
+ if visited[pos] {
+ continue
+ }
+ visited[pos] = true
+
+ // Check if this position has untargeted energy
+ if energyPositions[pos] && !usedEnergy[pos] {
+ nearestEnergy = pos
+ bestPath = path
+ break
+ }
+
+ // Don't path through enemy-adjacent tiles
+ if len(path) > 0 && s.isNearEnemy(pos, enemyPositions, config) {
+ continue
+ }
+
+ // Explore neighbors
+ directions := []Direction{DirN, DirE, DirS, DirW}
+ for _, dir := range directions {
+ nextPos := simulateMove(pos, dir, config)
+ if !visited[nextPos] {
+ newPath := make([]Direction, len(path)+1)
+ copy(newPath, path)
+ newPath[len(path)] = dir
+ queue.PushBack(queueItem{pos: nextPos, path: newPath})
+ }
+ }
+ }
+
+ return nearestEnergy, bestPath
+}
+
+// isNearEnemy checks if a position is adjacent to any enemy.
+func (s *GathererStrategy) isNearEnemy(pos Position, enemyPositions map[Position]bool, config GameConfig) bool {
+ directions := []Direction{DirN, DirE, DirS, DirW}
+ for _, dir := range directions {
+ adj := simulateMove(pos, dir, config)
+ if enemyPositions[adj] {
+ return true
+ }
+ }
+ return false
+}
+
+// getExploreMove returns a move for exploring when no energy is visible.
+func (s *GathererStrategy) getExploreMove(
+ pos Position,
+ myBots []VisibleBot,
+ enemyPositions map[Position]bool,
+ config GameConfig,
+) *Move {
+ // Calculate direction away from other friendly bots (spread out)
+ directions := []Direction{DirN, DirE, DirS, DirW}
+ bestDir := DirN
+ bestScore := -999999
+
+ for _, dir := range directions {
+ newPos := simulateMove(pos, dir, config)
+
+ // Skip if moving towards enemy
+ if s.isNearEnemy(newPos, enemyPositions, config) {
+ continue
+ }
+
+ // Score based on distance from other bots (prefer spreading out)
+ score := 0
+ for _, other := range myBots {
+ if other.Position != pos {
+ dist := distance2(newPos, other.Position, config)
+ score += int(dist) // Higher is better (further from others)
+ }
+ }
+
+ if score > bestScore {
+ bestScore = score
+ bestDir = dir
+ }
+ }
+
+ return &Move{
+ Position: pos,
+ Direction: bestDir,
+ }
+}
+
+// distance2 calculates squared Euclidean distance with toroidal wrapping.
+func distance2(a, b Position, config GameConfig) int {
+ dr := abs(a.Row - b.Row)
+ dc := abs(a.Col - b.Col)
+
+ // Apply toroidal wrapping
+ if dr > config.Rows/2 {
+ dr = config.Rows - dr
+ }
+ if dc > config.Cols/2 {
+ dc = config.Cols - dc
+ }
+
+ return dr*dr + dc*dc
+}
+
+// simulateMove returns the new position after moving in a direction.
+func simulateMove(pos Position, dir Direction, config GameConfig) Position {
+ var newRow, newCol int
+
+ switch dir {
+ case DirN:
+ newRow = (pos.Row - 1 + config.Rows) % config.Rows
+ newCol = pos.Col
+ case DirE:
+ newRow = pos.Row
+ newCol = (pos.Col + 1) % config.Cols
+ case DirS:
+ newRow = (pos.Row + 1) % config.Rows
+ newCol = pos.Col
+ case DirW:
+ newRow = pos.Row
+ newCol = (pos.Col - 1 + config.Cols) % config.Cols
+ default:
+ return pos
+ }
+
+ return Position{Row: newRow, Col: newCol}
+}
+
+func abs(x int) int {
+ if x < 0 {
+ return -x
+ }
+ return x
+}
diff --git a/bots/guardian/Dockerfile b/bots/guardian/Dockerfile
new file mode 100644
index 0000000..1b1275e
--- /dev/null
+++ b/bots/guardian/Dockerfile
@@ -0,0 +1,13 @@
+# GuardianBot - PHP defensive bot
+FROM php:8.4-cli-alpine
+
+WORKDIR /app
+
+COPY . .
+
+ENV BOT_PORT=8083
+ENV BOT_SECRET=""
+
+EXPOSE 8083
+
+CMD ["php", "index.php"]
diff --git a/bots/guardian/game.php b/bots/guardian/game.php
new file mode 100644
index 0000000..48a2357
--- /dev/null
+++ b/bots/guardian/game.php
@@ -0,0 +1,198 @@
+row = $row;
+ $this->col = $col;
+ }
+
+ public static function fromArray(array $data): self {
+ return new self($data['row'], $data['col']);
+ }
+
+ public function toArray(): array {
+ return ['row' => $this->row, 'col' => $this->col];
+ }
+
+ /**
+ * Move in a direction with toroidal wrapping
+ */
+ public function moveToward(string $dir, int $rows, int $cols): Position {
+ switch ($dir) {
+ case 'N':
+ return new Position(($this->row - 1 + $rows) % $rows, $this->col);
+ case 'E':
+ return new Position($this->row, ($this->col + 1) % $cols);
+ case 'S':
+ return new Position(($this->row + 1) % $rows, $this->col);
+ case 'W':
+ return new Position($this->row, ($this->col - 1 + $cols) % $cols);
+ default:
+ return clone $this;
+ }
+ }
+
+ /**
+ * Calculate squared distance with toroidal wrapping
+ */
+ public function distance2(Position $other, int $rows, int $cols): int {
+ $dr = abs($this->row - $other->row);
+ $dc = abs($this->col - $other->col);
+ $dr = min($dr, $rows - $dr);
+ $dc = min($dc, $cols - $dc);
+ return $dr * $dr + $dc * $dc;
+ }
+}
+
+/**
+ * Game configuration
+ */
+class GameConfig {
+ public int $rows;
+ public int $cols;
+ public int $maxTurns;
+ public int $visionRadius2;
+ public int $attackRadius2;
+ public int $spawnCost;
+ public int $energyInterval;
+
+ public static function fromArray(array $data): self {
+ $config = new self();
+ $config->rows = $data['rows'];
+ $config->cols = $data['cols'];
+ $config->maxTurns = $data['max_turns'];
+ $config->visionRadius2 = $data['vision_radius2'];
+ $config->attackRadius2 = $data['attack_radius2'];
+ $config->spawnCost = $data['spawn_cost'];
+ $config->energyInterval = $data['energy_interval'];
+ return $config;
+ }
+}
+
+/**
+ * Player info
+ */
+class PlayerInfo {
+ public int $id;
+ public int $energy;
+ public int $score;
+
+ public static function fromArray(array $data): self {
+ $info = new self();
+ $info->id = $data['id'];
+ $info->energy = $data['energy'];
+ $info->score = $data['score'];
+ return $info;
+ }
+}
+
+/**
+ * Visible bot
+ */
+class VisibleBot {
+ public Position $position;
+ public int $owner;
+
+ public static function fromArray(array $data): self {
+ $bot = new self();
+ $bot->position = Position::fromArray($data['position']);
+ $bot->owner = $data['owner'];
+ return $bot;
+ }
+}
+
+/**
+ * Visible core
+ */
+class VisibleCore {
+ public Position $position;
+ public int $owner;
+ public bool $active;
+
+ public static function fromArray(array $data): self {
+ $core = new self();
+ $core->position = Position::fromArray($data['position']);
+ $core->owner = $data['owner'];
+ $core->active = $data['active'];
+ return $core;
+ }
+}
+
+/**
+ * Fog-filtered game state
+ */
+class GameState {
+ public string $matchId;
+ public int $turn;
+ public GameConfig $config;
+ public PlayerInfo $you;
+ /** @var VisibleBot[] */
+ public array $bots = [];
+ /** @var Position[] */
+ public array $energy = [];
+ /** @var VisibleCore[] */
+ public array $cores = [];
+ /** @var Position[] */
+ public array $walls = [];
+ /** @var VisibleBot[] */
+ public array $dead = [];
+
+ public static function fromArray(array $data): self {
+ $state = new self();
+ $state->matchId = $data['match_id'];
+ $state->turn = $data['turn'];
+ $state->config = GameConfig::fromArray($data['config']);
+ $state->you = PlayerInfo::fromArray($data['you']);
+
+ foreach ($data['bots'] ?? [] as $bot) {
+ $state->bots[] = VisibleBot::fromArray($bot);
+ }
+
+ foreach ($data['energy'] ?? [] as $pos) {
+ $state->energy[] = Position::fromArray($pos);
+ }
+
+ foreach ($data['cores'] ?? [] as $core) {
+ $state->cores[] = VisibleCore::fromArray($core);
+ }
+
+ foreach ($data['walls'] ?? [] as $pos) {
+ $state->walls[] = Position::fromArray($pos);
+ }
+
+ foreach ($data['dead'] ?? [] as $bot) {
+ $state->dead[] = VisibleBot::fromArray($bot);
+ }
+
+ return $state;
+ }
+}
+
+/**
+ * A single move command
+ */
+class Move {
+ public Position $position;
+ public string $direction;
+
+ public function __construct(Position $position, string $direction) {
+ $this->position = $position;
+ $this->direction = $direction;
+ }
+
+ public function toArray(): array {
+ return [
+ 'position' => $this->position->toArray(),
+ 'direction' => $this->direction
+ ];
+ }
+}
diff --git a/bots/guardian/index.php b/bots/guardian/index.php
new file mode 100644
index 0000000..fbe7c68
--- /dev/null
+++ b/bots/guardian/index.php
@@ -0,0 +1,174 @@
+computeMoves($gameState);
+
+ // Build response
+ $response = ['moves' => array_map(fn($m) => $m->toArray(), $moves)];
+ $responseBody = json_encode($response);
+
+ // Sign response
+ $turn = (int)$turnStr;
+ $responseSig = sign_response($secret, $matchId, $turn, $responseBody);
+
+ $headers = [
+ 'Content-Type: application/json',
+ "X-ACB-Signature: $responseSig"
+ ];
+
+ send_response($conn, 200, 'application/json', $responseBody, $headers);
+}
+
+/**
+ * Verify HMAC signature
+ */
+function verify_signature(string $secret, string $matchId, string $turn, string $timestamp, string $body, string $signature): bool {
+ $bodyHash = hash('sha256', $body);
+ $signingString = "$matchId.$turn.$timestamp.$bodyHash";
+ $expected = hash_hmac('sha256', $signingString, $secret);
+ return hash_equals($expected, $signature);
+}
+
+/**
+ * Sign response body
+ */
+function sign_response(string $secret, string $matchId, int $turn, string $body): string {
+ $bodyHash = hash('sha256', $body);
+ $signingString = "$matchId.$turn.$bodyHash";
+ return hash_hmac('sha256', $signingString, $secret);
+}
+
+/**
+ * Send HTTP response
+ */
+function send_response($conn, int $status, string $contentType, string $body, array $extraHeaders = []): void {
+ $statusText = [
+ 200 => 'OK',
+ 400 => 'Bad Request',
+ 401 => 'Unauthorized',
+ 404 => 'Not Found',
+ ][$status] ?? 'Unknown';
+
+ $response = "HTTP/1.1 $status $statusText\r\n";
+ $response .= "Content-Type: $contentType\r\n";
+ $response .= "Content-Length: " . strlen($body) . "\r\n";
+ foreach ($extraHeaders as $header) {
+ $response .= "$header\r\n";
+ }
+ $response .= "\r\n";
+ $response .= $body;
+
+ fwrite($conn, $response);
+}
diff --git a/bots/guardian/strategy.php b/bots/guardian/strategy.php
new file mode 100644
index 0000000..6b9e77b
--- /dev/null
+++ b/bots/guardian/strategy.php
@@ -0,0 +1,364 @@
+you->id;
+ $config = $state->config;
+
+ // Separate my bots from enemies
+ $myBots = [];
+ $enemyBots = [];
+ foreach ($state->bots as $bot) {
+ if ($bot->owner === $myId) {
+ $myBots[] = $bot;
+ } else {
+ $enemyBots[] = $bot;
+ }
+ }
+
+ if (empty($myBots)) {
+ return [];
+ }
+
+ // Find my cores and enemy cores
+ $myCores = [];
+ $enemyCores = [];
+ foreach ($state->cores as $core) {
+ if ($core->owner === $myId && $core->active) {
+ $myCores[] = $core;
+ } elseif ($core->active) {
+ $enemyCores[] = $core;
+ }
+ }
+
+ // Build wall lookup
+ $walls = $this->buildPositionSet($state->walls);
+
+ // Build enemy position lookup
+ $enemyPositions = $this->buildPositionSet(array_map(fn($b) => $b->position, $enemyBots));
+
+ // Build energy position set
+ $energyPositions = $this->buildPositionSet($state->energy);
+
+ // Assign roles to bots
+ $moves = [];
+ $usedEnergy = [];
+ $assignedPositions = [];
+
+ // First pass: assign defenders to cores
+ $defenders = $this->assignDefenders($myBots, $myCores, $enemyBots, $config);
+
+ // Second pass: assign gatherers to nearby energy
+ foreach ($myBots as $bot) {
+ if (isset($assignedPositions[$this->posKey($bot->position)])) {
+ continue;
+ }
+
+ // Check if this bot should be a defender
+ if (isset($defenders[$this->posKey($bot->position)])) {
+ $move = $this->computeDefenderMove($bot, $defenders[$this->posKey($bot->position)], $enemyBots, $walls, $config);
+ } elseif ($this->shouldGather($bot, $myCores, $config)) {
+ $move = $this->computeGatherMove($bot, $energyPositions, $usedEnergy, $enemyPositions, $walls, $myCores, $config);
+ } else {
+ // Scout - explore cautiously
+ $move = $this->computeScoutMove($bot, $enemyPositions, $walls, $config);
+ }
+
+ if ($move) {
+ $moves[] = $move;
+ $assignedPositions[$this->posKey($bot->position)] = true;
+ }
+ }
+
+ return $moves;
+ }
+
+ /**
+ * Assign bots to defend cores based on threat level
+ */
+ private function assignDefenders(array $myBots, array $myCores, array $enemyBots, GameConfig $config): array {
+ $defenders = [];
+
+ if (empty($myCores)) {
+ return $defenders;
+ }
+
+ // Calculate threat level for each core
+ $coreThreats = [];
+ foreach ($myCores as $core) {
+ $threat = 0;
+ foreach ($enemyBots as $enemy) {
+ $dist2 = $enemy->position->distance2($core->position, $config->rows, $config->cols);
+ if ($dist2 <= 100) { // Within 10 tiles
+ $threat += 10 - (int)sqrt($dist2);
+ }
+ }
+ $coreThreats[$this->posKey($core->position)] = $threat;
+ }
+
+ // Assign bots to cores based on threat and proximity
+ foreach ($myBots as $bot) {
+ $bestCore = null;
+ $bestScore = PHP_INT_MAX;
+
+ foreach ($myCores as $core) {
+ $dist2 = $bot->position->distance2($core->position, $config->rows, $config->cols);
+ $threat = $coreThreats[$this->posKey($core->position)];
+
+ // Prioritize threatened cores
+ $score = $dist2 - $threat * 100;
+
+ if ($score < $bestScore) {
+ $bestScore = $score;
+ $bestCore = $core;
+ }
+ }
+
+ if ($bestCore) {
+ $defenders[$this->posKey($bot->position)] = $bestCore;
+ }
+ }
+
+ return $defenders;
+ }
+
+ /**
+ * Compute move for a defender bot
+ */
+ private function computeDefenderMove(VisibleBot $bot, VisibleCore $core, array $enemyBots, array $walls, GameConfig $config): ?Move {
+ $rows = $config->rows;
+ $cols = $config->cols;
+
+ // Find nearest enemy within threat range
+ $nearestEnemy = null;
+ $nearestEnemyDist = PHP_INT_MAX;
+ foreach ($enemyBots as $enemy) {
+ $dist2 = $bot->position->distance2($enemy->position, $rows, $cols);
+ if ($dist2 < $nearestEnemyDist && $dist2 <= 100) {
+ $nearestEnemyDist = $dist2;
+ $nearestEnemy = $enemy;
+ }
+ }
+
+ // If enemy is approaching, intercept
+ if ($nearestEnemy && $nearestEnemyDist <= 50) {
+ $dir = $this->getDirectionToward($bot->position, $nearestEnemy->position, $walls, $config);
+ if ($dir) {
+ return new Move($bot->position, $dir);
+ }
+ }
+
+ // Otherwise, maintain perimeter around core
+ $distToCore = $bot->position->distance2($core->position, $rows, $cols);
+
+ if ($distToCore > self::PERIMETER_RADIUS * self::PERIMETER_RADIUS) {
+ // Move toward core
+ $dir = $this->getDirectionToward($bot->position, $core->position, $walls, $config);
+ if ($dir) {
+ return new Move($bot->position, $dir);
+ }
+ }
+
+ // Stay in place or patrol
+ return null;
+ }
+
+ /**
+ * Check if bot should gather energy
+ */
+ private function shouldGather(VisibleBot $bot, array $myCores, GameConfig $config): bool {
+ foreach ($myCores as $core) {
+ $dist2 = $bot->position->distance2($core->position, $config->rows, $config->cols);
+ if ($dist2 <= self::SAFE_ZONE_RADIUS * self::SAFE_ZONE_RADIUS) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Compute move for a gatherer bot
+ */
+ private function computeGatherMove(VisibleBot $bot, array $energyPositions, array &$usedEnergy, array $enemyPositions, array $walls, array $myCores, GameConfig $config): ?Move {
+ // Find nearest untargeted energy within safe zone
+ $bestEnergy = null;
+ $bestDist = PHP_INT_MAX;
+
+ foreach ($energyPositions as $posKey => $pos) {
+ if (isset($usedEnergy[$posKey])) {
+ continue;
+ }
+
+ // Check if energy is within safe zone of any core
+ $inSafeZone = false;
+ foreach ($myCores as $core) {
+ $dist2 = $pos->distance2($core->position, $config->rows, $config->cols);
+ if ($dist2 <= self::SAFE_ZONE_RADIUS * self::SAFE_ZONE_RADIUS) {
+ $inSafeZone = true;
+ break;
+ }
+ }
+
+ if (!$inSafeZone) {
+ continue;
+ }
+
+ $dist2 = $bot->position->distance2($pos, $config->rows, $config->cols);
+ if ($dist2 < $bestDist) {
+ $bestDist = $dist2;
+ $bestEnergy = $pos;
+ }
+ }
+
+ if ($bestEnergy) {
+ $usedEnergy[$this->posKey($bestEnergy)] = true;
+ $dir = $this->getDirectionToward($bot->position, $bestEnergy, $walls, $config);
+ if ($dir) {
+ return new Move($bot->position, $dir);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Compute move for a scout bot
+ */
+ private function computeScoutMove(VisibleBot $bot, array $enemyPositions, array $walls, GameConfig $config): ?Move {
+ // Move away from enemies if too close
+ foreach ($enemyPositions as $posKey => $pos) {
+ $dist2 = $bot->position->distance2($pos, $config->rows, $config->cols);
+ if ($dist2 <= $config->attackRadius2 + 4) {
+ $dir = $this->getDirectionAway($bot->position, $pos, $walls, $config);
+ if ($dir) {
+ return new Move($bot->position, $dir);
+ }
+ }
+ }
+
+ // Explore - move toward unexplored areas
+ $bestDir = null;
+ $bestScore = -1;
+
+ foreach (self::DIRECTIONS as $dir) {
+ $newPos = $bot->position->moveToward($dir, $config->rows, $config->cols);
+ $posKey = $this->posKey($newPos);
+
+ if (isset($walls[$posKey]) || isset($enemyPositions[$posKey])) {
+ continue;
+ }
+
+ // Prefer directions that move toward center of map (more exploration)
+ $centerRow = $config->rows / 2;
+ $centerCol = $config->cols / 2;
+ $distToCenter = abs($newPos->row - $centerRow) + abs($newPos->col - $centerCol);
+
+ // Prefer edges for exploration
+ $edgeDist = min($newPos->row, $newPos->col, $config->rows - $newPos->row, $config->cols - $newPos->col);
+ $score = $edgeDist < 10 ? 10 - $edgeDist : 0;
+
+ if ($score > $bestScore) {
+ $bestScore = $score;
+ $bestDir = $dir;
+ }
+ }
+
+ if ($bestDir) {
+ return new Move($bot->position, $bestDir);
+ }
+
+ return null;
+ }
+
+ /**
+ * Get direction toward a target position using simple greedy approach
+ */
+ private function getDirectionToward(Position $from, Position $to, array $walls, GameConfig $config): ?string {
+ $rows = $config->rows;
+ $cols = $config->cols;
+
+ $bestDir = null;
+ $bestDist = PHP_INT_MAX;
+
+ foreach (self::DIRECTIONS as $dir) {
+ $newPos = $from->moveToward($dir, $rows, $cols);
+
+ if (isset($walls[$this->posKey($newPos)])) {
+ continue;
+ }
+
+ $dist2 = $newPos->distance2($to, $rows, $cols);
+ if ($dist2 < $bestDist) {
+ $bestDist = $dist2;
+ $bestDir = $dir;
+ }
+ }
+
+ return $bestDir;
+ }
+
+ /**
+ * Get direction away from a threat
+ */
+ private function getDirectionAway(Position $from, Position $threat, array $walls, GameConfig $config): ?string {
+ $rows = $config->rows;
+ $cols = $config->cols;
+
+ $bestDir = null;
+ $bestDist = 0;
+
+ foreach (self::DIRECTIONS as $dir) {
+ $newPos = $from->moveToward($dir, $rows, $cols);
+
+ if (isset($walls[$this->posKey($newPos)])) {
+ continue;
+ }
+
+ $dist2 = $newPos->distance2($threat, $rows, $cols);
+ if ($dist2 > $bestDist) {
+ $bestDist = $dist2;
+ $bestDir = $dir;
+ }
+ }
+
+ return $bestDir;
+ }
+
+ /**
+ * Build a set of positions for O(1) lookup
+ */
+ private function buildPositionSet(array $positions): array {
+ $set = [];
+ foreach ($positions as $pos) {
+ $set[$this->posKey($pos)] = $pos;
+ }
+ return $set;
+ }
+
+ /**
+ * Create a unique key for a position
+ */
+ private function posKey(Position $pos): string {
+ return "{$pos->row},{$pos->col}";
+ }
+}
diff --git a/bots/hunter/Dockerfile b/bots/hunter/Dockerfile
new file mode 100644
index 0000000..373d93d
--- /dev/null
+++ b/bots/hunter/Dockerfile
@@ -0,0 +1,23 @@
+# Build stage
+FROM eclipse-temurin:21-jdk-alpine AS builder
+
+WORKDIR /app
+COPY pom.xml ./
+COPY src ./src
+
+# Install Maven and build
+RUN apk add --no-cache maven && \
+ mvn clean package -DskipTests
+
+# Runtime stage
+FROM eclipse-temurin:21-jre-alpine
+
+WORKDIR /app
+COPY --from=builder /app/target/hunter-bot-1.0.0.jar /app/hunter-bot.jar
+
+ENV BOT_PORT=8085
+ENV BOT_SECRET=""
+
+EXPOSE 8085
+
+CMD ["java", "-jar", "hunter-bot.jar"]
diff --git a/bots/hunter/pom.xml b/bots/hunter/pom.xml
new file mode 100644
index 0000000..9d6d575
--- /dev/null
+++ b/bots/hunter/pom.xml
@@ -0,0 +1,75 @@
+
+
+ 4.0.0
+
+ com.acb
+ hunter-bot
+ 1.0.0
+ jar
+
+ HunterBot
+ Target isolation strategy bot for AI Code Battle
+
+
+ 21
+ 21
+ UTF-8
+ 6.3.0
+ 2.17.0
+
+
+
+
+ io.javalin
+ javalin
+ ${javalin.version}
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${jackson.version}
+
+
+ org.slf4j
+ slf4j-simple
+ 2.0.12
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.5.2
+
+
+ package
+
+ shade
+
+
+
+
+ com.acb.hunter.App
+
+
+
+
+ *:*
+
+ META-INF/*.SF
+ META-INF/*.DSA
+ META-INF/*.RSA
+
+
+
+
+
+
+
+
+
+
diff --git a/bots/hunter/src/main/java/com/acb/hunter/App.java b/bots/hunter/src/main/java/com/acb/hunter/App.java
new file mode 100644
index 0000000..c82c805
--- /dev/null
+++ b/bots/hunter/src/main/java/com/acb/hunter/App.java
@@ -0,0 +1,136 @@
+package com.acb.hunter;
+
+import io.javalin.Javalin;
+import io.javalin.http.Context;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.HexFormat;
+
+/**
+ * HunterBot - A bot that targets isolated enemies for efficient kills.
+ *
+ * Strategy: Target isolated enemy bots for efficient kills.
+ * - 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
+ * - Avoid engaging formations of 3+ enemy bots
+ * - Opportunistic energy collection when not actively hunting
+ */
+public class App {
+ private static final int DEFAULT_PORT = 8085;
+ private static String SECRET;
+ private static final HunterStrategy STRATEGY = new HunterStrategy();
+
+ public static void main(String[] args) {
+ String portStr = System.getenv("BOT_PORT");
+ int port = portStr != null ? Integer.parseInt(portStr) : DEFAULT_PORT;
+
+ SECRET = System.getenv("BOT_SECRET");
+ if (SECRET == null || SECRET.isEmpty()) {
+ System.err.println("ERROR: BOT_SECRET environment variable is required");
+ System.exit(1);
+ }
+
+ Javalin app = Javalin.create();
+
+ app.get("/health", ctx -> ctx.result("OK"));
+
+ app.post("/turn", App::handleTurn);
+
+ app.start(port);
+ System.out.println("HunterBot starting on port " + port);
+ }
+
+ private static void handleTurn(Context ctx) {
+ // Extract auth headers
+ String matchId = ctx.header("X-ACB-Match-Id");
+ String turnStr = ctx.header("X-ACB-Turn");
+ String timestamp = ctx.header("X-ACB-Timestamp");
+ String signature = ctx.header("X-ACB-Signature");
+
+ if (matchId == null || turnStr == null || timestamp == null || signature == null) {
+ ctx.status(401).result("Missing auth headers");
+ return;
+ }
+
+ String body = ctx.body();
+
+ // Verify signature
+ if (!verifySignature(SECRET, matchId, turnStr, timestamp, body, signature)) {
+ ctx.status(401).result("Invalid signature");
+ return;
+ }
+
+ // Parse game state
+ GameState state;
+ try {
+ state = GameState.fromJson(body);
+ } catch (Exception e) {
+ ctx.status(400).result("Invalid JSON: " + e.getMessage());
+ return;
+ }
+
+ // Compute moves
+ var moves = STRATEGY.computeMoves(state);
+ int turn = Integer.parseInt(turnStr);
+
+ System.out.println("Turn " + turn + ": " + moves.size() + " moves computed");
+
+ // Build response
+ String responseBody = MoveResponse.toJson(moves);
+
+ // Sign response
+ String responseSig = signResponse(SECRET, matchId, turn, responseBody);
+
+ ctx.header("X-ACB-Signature", responseSig);
+ ctx.contentType("application/json");
+ ctx.result(responseBody);
+ }
+
+ private static boolean verifySignature(String secret, String matchId, String turn,
+ String timestamp, String body, String signature) {
+ try {
+ String bodyHash = sha256Hex(body);
+ String signingString = matchId + "." + turn + "." + timestamp + "." + bodyHash;
+
+ Mac mac = Mac.getInstance("HmacSHA256");
+ SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
+ mac.init(keySpec);
+ byte[] expected = mac.doFinal(signingString.getBytes(StandardCharsets.UTF_8));
+
+ return MessageDigest.isEqual(
+ HexFormat.of().parseHex(signature),
+ expected
+ );
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ private static String signResponse(String secret, String matchId, int turn, String body) {
+ try {
+ String bodyHash = sha256Hex(body);
+ String signingString = matchId + "." + turn + "." + bodyHash;
+
+ Mac mac = Mac.getInstance("HmacSHA256");
+ SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
+ mac.init(keySpec);
+ return HexFormat.of().formatHex(mac.doFinal(signingString.getBytes(StandardCharsets.UTF_8)));
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to sign response", e);
+ }
+ }
+
+ private static String sha256Hex(String input) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ return HexFormat.of().formatHex(digest.digest(input.getBytes(StandardCharsets.UTF_8)));
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to hash", e);
+ }
+ }
+}
diff --git a/bots/hunter/src/main/java/com/acb/hunter/GameState.java b/bots/hunter/src/main/java/com/acb/hunter/GameState.java
new file mode 100644
index 0000000..b1dd5d0
--- /dev/null
+++ b/bots/hunter/src/main/java/com/acb/hunter/GameState.java
@@ -0,0 +1,211 @@
+package com.acb.hunter;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+
+import java.util.List;
+import java.util.Collections;
+
+/**
+ * Game state types for AI Code Battle protocol.
+ */
+public class GameState {
+ private static final ObjectMapper MAPPER = new ObjectMapper()
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+
+ @JsonProperty("match_id")
+ private String matchId;
+
+ private int turn;
+ private GameConfig config;
+ private PlayerInfo you;
+
+ private List bots = Collections.emptyList();
+ private List energy = Collections.emptyList();
+ private List cores = Collections.emptyList();
+ private List walls = Collections.emptyList();
+ private List dead = Collections.emptyList();
+
+ // Getters
+ public String getMatchId() { return matchId; }
+ public int getTurn() { return turn; }
+ public GameConfig getConfig() { return config; }
+ public PlayerInfo getYou() { return you; }
+ public List getBots() { return bots; }
+ public List getEnergy() { return energy; }
+ public List getCores() { return cores; }
+ public List getWalls() { return walls; }
+ public List getDead() { return dead; }
+
+ public static GameState fromJson(String json) throws Exception {
+ return MAPPER.readValue(json, GameState.class);
+ }
+}
+
+class GameConfig {
+ private int rows;
+ private int cols;
+
+ @JsonProperty("max_turns")
+ private int maxTurns;
+
+ @JsonProperty("vision_radius2")
+ private int visionRadius2;
+
+ @JsonProperty("attack_radius2")
+ private int attackRadius2;
+
+ @JsonProperty("spawn_cost")
+ private int spawnCost;
+
+ @JsonProperty("energy_interval")
+ private int energyInterval;
+
+ // Getters
+ public int getRows() { return rows; }
+ public int getCols() { return cols; }
+ public int getMaxTurns() { return maxTurns; }
+ public int getVisionRadius2() { return visionRadius2; }
+ public int getAttackRadius2() { return attackRadius2; }
+ public int getSpawnCost() { return spawnCost; }
+ public int getEnergyInterval() { return energyInterval; }
+}
+
+class PlayerInfo {
+ private int id;
+ private int energy;
+ private int score;
+
+ // Getters
+ public int getId() { return id; }
+ public int getEnergy() { return energy; }
+ public int getScore() { return score; }
+}
+
+class Position {
+ private int row;
+ private int col;
+
+ // Default constructor for Jackson
+ public Position() {}
+
+ public Position(int row, int col) {
+ this.row = row;
+ this.col = col;
+ }
+
+ public int getRow() { return row; }
+ public int getCol() { return col; }
+
+ /**
+ * Move in a direction with toroidal wrapping
+ */
+ public Position moveToward(Direction dir, int rows, int cols) {
+ return switch (dir) {
+ case N -> new Position((row - 1 + rows) % rows, col);
+ case E -> new Position(row, (col + 1) % cols);
+ case S -> new Position((row + 1) % rows, col);
+ case W -> new Position(row, (col - 1 + cols) % cols);
+ };
+ }
+
+ /**
+ * Calculate squared distance with toroidal wrapping
+ */
+ public int distance2(Position other, int rows, int cols) {
+ int dr = Math.abs(row - other.row);
+ int dc = Math.abs(col - other.col);
+ dr = Math.min(dr, rows - dr);
+ dc = Math.min(dc, cols - dc);
+ return dr * dr + dc * dc;
+ }
+
+ /**
+ * Manhattan distance with toroidal wrapping
+ */
+ public int manhattanDistance(Position other, int rows, int cols) {
+ int dr = Math.abs(row - other.row);
+ int dc = Math.abs(col - other.col);
+ dr = Math.min(dr, rows - dr);
+ dc = Math.min(dc, cols - dc);
+ return dr + dc;
+ }
+
+ public String key() {
+ return row + "," + col;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Position position = (Position) o;
+ return row == position.row && col == position.col;
+ }
+
+ @Override
+ public int hashCode() {
+ return 31 * row + col;
+ }
+}
+
+class VisibleBot {
+ private Position position;
+ private int owner;
+
+ public Position getPosition() { return position; }
+ public int getOwner() { return owner; }
+}
+
+class VisibleCore {
+ private Position position;
+ private int owner;
+ private boolean active;
+
+ public Position getPosition() { return position; }
+ public int getOwner() { return owner; }
+ public boolean isActive() { return active; }
+}
+
+enum Direction {
+ N, E, S, W;
+
+ public static Direction[] all() {
+ return values();
+ }
+}
+
+class Move {
+ private final Position position;
+ private final Direction direction;
+
+ public Move(Position position, Direction direction) {
+ this.position = position;
+ this.direction = direction;
+ }
+
+ public Position getPosition() { return position; }
+ public Direction getDirection() { return direction; }
+}
+
+class MoveResponse {
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private final List moves;
+
+ public MoveResponse(List moves) {
+ this.moves = moves;
+ }
+
+ public List getMoves() { return moves; }
+
+ public static String toJson(List moves) {
+ try {
+ var response = new MoveResponse(moves);
+ return MAPPER.writeValueAsString(response);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to serialize moves", e);
+ }
+ }
+}
diff --git a/bots/hunter/src/main/java/com/acb/hunter/HunterStrategy.java b/bots/hunter/src/main/java/com/acb/hunter/HunterStrategy.java
new file mode 100644
index 0000000..ed16d47
--- /dev/null
+++ b/bots/hunter/src/main/java/com/acb/hunter/HunterStrategy.java
@@ -0,0 +1,394 @@
+package com.acb.hunter;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * HunterBot strategy: target isolated enemies for efficient kills.
+ *
+ * Strategy: Target isolated enemy bots for efficient kills.
+ * - 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
+ * - Avoid engaging formations of 3+ enemy bots
+ * - Opportunistic energy collection when not actively hunting
+ */
+public class HunterStrategy {
+ private static final int ISOLATION_THRESHOLD = 16; // Squared distance (4 tiles)
+ private static final int FORMATION_SIZE = 3; // Avoid groups of 3+ enemies
+
+ // Track known enemy positions for prediction
+ private final Map enemyTrackers = new HashMap<>();
+
+ /**
+ * Compute moves for all owned bots
+ */
+ public List computeMoves(GameState state) {
+ int myId = state.getYou().getId();
+ GameConfig config = state.getConfig();
+ int rows = config.getRows();
+ int cols = config.getCols();
+
+ // Separate my bots from enemies
+ List myBots = new ArrayList<>();
+ List enemyBots = new ArrayList<>();
+
+ for (VisibleBot bot : state.getBots()) {
+ if (bot.getOwner() == myId) {
+ myBots.add(bot);
+ } else {
+ enemyBots.add(bot);
+ }
+ }
+
+ if (myBots.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ // Update enemy trackers
+ updateEnemyTrackers(enemyBots, rows, cols);
+
+ // Build position lookups
+ Set walls = buildPositionSet(state.getWalls());
+ Set enemyPositions = buildPositionSet(
+ enemyBots.stream().map(VisibleBot::getPosition).collect(Collectors.toList())
+ );
+ Set myBotPositions = buildPositionSet(
+ myBots.stream().map(VisibleBot::getPosition).collect(Collectors.toList())
+ );
+
+ // Find isolated enemy targets
+ List isolatedEnemies = findIsolatedEnemies(enemyBots, rows, cols);
+
+ // Find energy positions
+ Set energyPositions = buildPositionSet(state.getEnergy());
+
+ // Assign bots to targets
+ List moves = new ArrayList<>();
+ Set usedEnergy = new HashSet<>();
+ Set assignedTargets = new HashSet<>();
+
+ // First, assign hunters to isolated enemies
+ Map hunterAssignments = assignHunters(myBots, isolatedEnemies, rows, cols);
+
+ for (Map.Entry entry : hunterAssignments.entrySet()) {
+ VisibleBot hunter = entry.getKey();
+ VisibleBot target = entry.getValue();
+
+ // Get predicted position of target
+ Position predictedPos = predictPosition(target, rows, cols);
+ assignedTargets.add(predictedPos);
+
+ Move move = computeHunterMove(hunter, predictedPos, enemyPositions, walls, myBotPositions, rows, cols);
+ if (move != null) {
+ moves.add(move);
+ // Mark this bot as assigned
+ myBotPositions.remove(hunter.getPosition().key());
+ }
+ }
+
+ // Second, assign remaining bots to gather or explore
+ for (VisibleBot bot : myBots) {
+ if (!myBotPositions.contains(bot.getPosition().key())) {
+ continue; // Already assigned
+ }
+
+ Move move;
+ if (!energyPositions.isEmpty()) {
+ move = computeGatherMove(bot, energyPositions, usedEnergy, enemyPositions, walls, rows, cols);
+ } else {
+ move = computeExploreMove(bot, enemyPositions, walls, rows, cols);
+ }
+
+ if (move != null) {
+ moves.add(move);
+ }
+ }
+
+ return moves;
+ }
+
+ /**
+ * Update enemy position trackers for prediction
+ */
+ private void updateEnemyTrackers(List enemyBots, int rows, int cols) {
+ for (VisibleBot bot : enemyBots) {
+ String key = bot.getPosition().key();
+ EnemyTracker tracker = enemyTrackers.computeIfAbsent(key, k -> new EnemyTracker());
+ tracker.update(bot.getPosition(), rows, cols);
+ }
+ }
+
+ /**
+ * Find isolated enemy bots (>=4 tiles from nearest friendly)
+ */
+ private List findIsolatedEnemies(List enemyBots, int rows, int cols) {
+ List isolated = new ArrayList<>();
+
+ for (VisibleBot bot : enemyBots) {
+ boolean isIsolated = true;
+ int nearestDist = Integer.MAX_VALUE;
+
+ for (VisibleBot other : enemyBots) {
+ if (bot == other) continue;
+
+ int dist = bot.getPosition().distance2(other.getPosition(), rows, cols);
+ nearestDist = Math.min(nearestDist, dist);
+ }
+
+ // Isolated if nearest friendly is >= 4 tiles away (squared distance 16)
+ // or if it's the only enemy bot
+ if (nearestDist >= ISOLATION_THRESHOLD || enemyBots.size() == 1) {
+ isolated.add(bot);
+ }
+ }
+
+ return isolated;
+ }
+
+ /**
+ * Assign hunters to isolated targets using greedy matching
+ */
+ private Map assignHunters(
+ List myBots,
+ List isolatedEnemies,
+ int rows, int cols
+ ) {
+ Map assignments = new HashMap<>();
+
+ if (isolatedEnemies.isEmpty()) {
+ return assignments;
+ }
+
+ // Sort my bots by distance to nearest isolated enemy
+ List availableHunters = new ArrayList<>(myBots);
+
+ // Assign 2 hunters per target when possible
+ for (VisibleBot target : isolatedEnemies) {
+ int huntersNeeded = 2;
+
+ // Sort available hunters by distance to target
+ availableHunters.sort((a, b) -> {
+ int distA = a.getPosition().distance2(target.getPosition(), rows, cols);
+ int distB = b.getPosition().distance2(target.getPosition(), rows, cols);
+ return Integer.compare(distA, distB);
+ });
+
+ int assigned = 0;
+ Iterator iter = availableHunters.iterator();
+ while (iter.hasNext() && assigned < huntersNeeded) {
+ VisibleBot hunter = iter.next();
+ assignments.put(hunter, target);
+ iter.remove();
+ assigned++;
+ }
+ }
+
+ return assignments;
+ }
+
+ /**
+ * Predict where an enemy will be next turn
+ */
+ private Position predictPosition(VisibleBot enemy, int rows, int cols) {
+ String key = enemy.getPosition().key();
+ EnemyTracker tracker = enemyTrackers.get(key);
+
+ if (tracker != null && tracker.hasPrediction()) {
+ return tracker.predictNextPosition(rows, cols);
+ }
+
+ return enemy.getPosition();
+ }
+
+ /**
+ * Compute move for a hunter bot toward a target
+ */
+ private Move computeHunterMove(
+ VisibleBot bot,
+ Position target,
+ Set enemyPositions,
+ Set walls,
+ Set myBotPositions,
+ int rows, int cols
+ ) {
+ Direction bestDir = null;
+ int bestScore = Integer.MIN_VALUE;
+
+ for (Direction dir : Direction.all()) {
+ Position newPos = bot.getPosition().moveToward(dir, rows, cols);
+ String newPosKey = newPos.key();
+
+ // Can't move into walls
+ if (walls.contains(newPosKey)) {
+ continue;
+ }
+
+ // Avoid self-collision
+ if (myBotPositions.contains(newPosKey)) {
+ continue;
+ }
+
+ // Score: prefer getting closer to target
+ int distToTarget = newPos.distance2(target, rows, cols);
+ int currentDistToTarget = bot.getPosition().distance2(target, rows, cols);
+ int score = currentDistToTarget - distToTarget;
+
+ // Bonus for being in attack range of target
+ if (distToTarget <= 5) { // attack_radius2
+ score += 20;
+ }
+
+ // Penalty for moving adjacent to multiple enemies
+ int adjacentEnemies = 0;
+ for (String enemyPosKey : enemyPositions) {
+ String[] parts = enemyPosKey.split(",");
+ Position enemyPos = new Position(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
+ if (newPos.distance2(enemyPos, rows, cols) <= 2) {
+ adjacentEnemies++;
+ }
+ }
+ score -= adjacentEnemies * 10;
+
+ if (score > bestScore) {
+ bestScore = score;
+ bestDir = dir;
+ }
+ }
+
+ if (bestDir != null) {
+ return new Move(bot.getPosition(), bestDir);
+ }
+
+ return null;
+ }
+
+ /**
+ * Compute move for a gatherer bot
+ */
+ private Move computeGatherMove(
+ VisibleBot bot,
+ Set energyPositions,
+ Set usedEnergy,
+ Set enemyPositions,
+ Set walls,
+ int rows, int cols
+ ) {
+ // Find nearest untargeted energy
+ Position nearestEnergy = null;
+ int nearestDist = Integer.MAX_VALUE;
+
+ for (String energyKey : energyPositions) {
+ if (usedEnergy.contains(energyKey)) continue;
+
+ String[] parts = energyKey.split(",");
+ Position energyPos = new Position(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
+ int dist = bot.getPosition().distance2(energyPos, rows, cols);
+
+ if (dist < nearestDist) {
+ nearestDist = dist;
+ nearestEnergy = energyPos;
+ }
+ }
+
+ if (nearestEnergy != null) {
+ usedEnergy.add(nearestEnergy.key());
+ return computeMoveToward(bot, nearestEnergy, walls, rows, cols);
+ }
+
+ return null;
+ }
+
+ /**
+ * Compute move for exploration
+ */
+ private Move computeExploreMove(
+ VisibleBot bot,
+ Set enemyPositions,
+ Set walls,
+ int rows, int cols
+ ) {
+ // Move toward center if no other target
+ Position center = new Position(rows / 2, cols / 2);
+ return computeMoveToward(bot, center, walls, rows, cols);
+ }
+
+ /**
+ * Compute move toward a target position
+ */
+ private Move computeMoveToward(VisibleBot bot, Position target, Set walls, int rows, int cols) {
+ Direction bestDir = null;
+ int bestDist = Integer.MAX_VALUE;
+
+ for (Direction dir : Direction.all()) {
+ Position newPos = bot.getPosition().moveToward(dir, rows, cols);
+
+ if (walls.contains(newPos.key())) {
+ continue;
+ }
+
+ int dist = newPos.distance2(target, rows, cols);
+ if (dist < bestDist) {
+ bestDist = dist;
+ bestDir = dir;
+ }
+ }
+
+ if (bestDir != null) {
+ return new Move(bot.getPosition(), bestDir);
+ }
+
+ return null;
+ }
+
+ /**
+ * Build a set of position keys for O(1) lookup
+ */
+ private Set buildPositionSet(List positions) {
+ return positions.stream()
+ .map(Position::key)
+ .collect(Collectors.toSet());
+ }
+}
+
+/**
+ * Tracks enemy position history for movement prediction
+ */
+class EnemyTracker {
+ private Position lastPosition;
+ private Position currentPosition;
+ private int sightings;
+
+ public void update(Position position, int rows, int cols) {
+ lastPosition = currentPosition;
+ currentPosition = position;
+ sightings++;
+ }
+
+ public boolean hasPrediction() {
+ return lastPosition != null && currentPosition != null;
+ }
+
+ public Position predictNextPosition(int rows, int cols) {
+ if (!hasPrediction()) {
+ return currentPosition;
+ }
+
+ // Simple prediction: continue in same direction
+ int dr = currentPosition.getRow() - lastPosition.getRow();
+ int dc = currentPosition.getCol() - lastPosition.getCol();
+
+ // Handle wrap
+ if (dr > rows / 2) dr -= rows;
+ if (dr < -rows / 2) dr += rows;
+ if (dc > cols / 2) dc -= cols;
+ if (dc < -cols / 2) dc += cols;
+
+ // Predict next position
+ int newRow = (currentPosition.getRow() + dr + rows) % rows;
+ int newCol = (currentPosition.getCol() + dc + cols) % cols;
+
+ return new Position(newRow, newCol);
+ }
+}
diff --git a/bots/random/Dockerfile b/bots/random/Dockerfile
new file mode 100644
index 0000000..e1e420e
--- /dev/null
+++ b/bots/random/Dockerfile
@@ -0,0 +1,12 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY main.py .
+
+ENV BOT_PORT=8080
+ENV BOT_SECRET=""
+
+EXPOSE 8080
+
+CMD ["python3", "main.py"]
diff --git a/bots/random/main.py b/bots/random/main.py
new file mode 100644
index 0000000..7325ec7
--- /dev/null
+++ b/bots/random/main.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+"""
+RandomBot - A bot that makes random valid moves.
+
+This is a reference implementation demonstrating the HTTP protocol
+in Python. It validates HMAC signatures and returns random moves.
+"""
+
+import hashlib
+import hmac
+import json
+import os
+import random
+from http.server import HTTPServer, BaseHTTPRequestHandler
+
+
+class GameState:
+ """Represents the fog-filtered state visible to this bot."""
+
+ def __init__(self, data: dict):
+ self.match_id = data["match_id"]
+ self.turn = data["turn"]
+ self.config = data["config"]
+ self.you_id = data["you"]["id"]
+ self.you_energy = data["you"]["energy"]
+ self.you_score = data["you"]["score"]
+ self.bots = data["bots"]
+ self.energy = data.get("energy", [])
+ self.cores = data.get("cores", [])
+ self.walls = data.get("walls", [])
+ self.dead = data.get("dead", [])
+
+
+class RandomBotHandler(BaseHTTPRequestHandler):
+ """HTTP request handler for RandomBot."""
+
+ secret: str = ""
+
+ def log_message(self, format, *args):
+ """Suppress default logging."""
+ pass
+
+ def send_json_response(self, status: int, data: dict, match_id: str = "", turn: int = 0):
+ """Send a JSON response with HMAC signature."""
+ body = json.dumps(data).encode("utf-8")
+
+ # Sign response
+ sig = self.sign_response(body, match_id, turn)
+
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("X-ACB-Signature", sig)
+ self.end_headers()
+ self.wfile.write(body)
+
+ def sign_response(self, body: bytes, match_id: str, turn: int) -> str:
+ """Generate HMAC signature for response."""
+ body_hash = hashlib.sha256(body).hexdigest()
+ signing_string = f"{match_id}.{turn}.{body_hash}"
+ sig = hmac.new(
+ self.secret.encode("utf-8"),
+ signing_string.encode("utf-8"),
+ hashlib.sha256
+ ).hexdigest()
+ return sig
+
+ def verify_signature(self, body: bytes, match_id: str, turn: str,
+ timestamp: str, signature: str) -> bool:
+ """Verify HMAC signature of incoming request."""
+ body_hash = hashlib.sha256(body).hexdigest()
+ signing_string = f"{match_id}.{turn}.{timestamp}.{body_hash}"
+ expected_sig = hmac.new(
+ self.secret.encode("utf-8"),
+ signing_string.encode("utf-8"),
+ hashlib.sha256
+ ).hexdigest()
+ return hmac.compare_digest(signature, expected_sig)
+
+ def do_GET(self):
+ """Handle GET requests (health check)."""
+ if self.path == "/health":
+ self.send_response(200)
+ self.send_header("Content-Type", "text/plain")
+ self.end_headers()
+ self.wfile.write(b"OK")
+ else:
+ self.send_error(404, "Not Found")
+
+ def do_POST(self):
+ """Handle POST requests (turn)."""
+ if self.path != "/turn":
+ self.send_error(404, "Not Found")
+ return
+
+ # Read body
+ content_length = int(self.headers.get("Content-Length", 0))
+ body = self.rfile.read(content_length)
+
+ # Get auth headers
+ match_id = self.headers.get("X-ACB-Match-Id", "")
+ turn_str = self.headers.get("X-ACB-Turn", "0")
+ timestamp = self.headers.get("X-ACB-Timestamp", "")
+ signature = self.headers.get("X-ACB-Signature", "")
+
+ if not signature:
+ self.send_error(401, "Missing signature")
+ return
+
+ # Verify signature
+ if not self.verify_signature(body, match_id, turn_str, timestamp, signature):
+ self.send_error(401, "Invalid signature")
+ return
+
+ # Parse game state
+ try:
+ data = json.loads(body)
+ state = GameState(data)
+ except (json.JSONDecodeError, KeyError) as e:
+ self.send_error(400, f"Invalid game state: {e}")
+ return
+
+ # Compute random moves
+ moves = self.compute_moves(state)
+ turn = int(turn_str)
+
+ # Send response
+ self.send_json_response(200, {"moves": moves}, match_id, turn)
+
+ def compute_moves(self, state: GameState) -> list:
+ """Compute random moves for all owned bots."""
+ moves = []
+ directions = ["N", "E", "S", "W"]
+
+ for bot in state.bots:
+ if bot["owner"] == state.you_id:
+ # 50% chance to move, 50% chance to stay still
+ if random.random() < 0.5:
+ direction = random.choice(directions)
+ moves.append({
+ "position": bot["position"],
+ "direction": direction
+ })
+
+ return moves
+
+
+def main():
+ port = int(os.environ.get("BOT_PORT", "8081"))
+ secret = os.environ.get("BOT_SECRET", "")
+
+ if not secret:
+ print("ERROR: BOT_SECRET environment variable is required")
+ exit(1)
+
+ RandomBotHandler.secret = secret
+
+ server = HTTPServer(("", port), RandomBotHandler)
+ print(f"RandomBot starting on port {port}")
+ server.serve_forever()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/bots/random/requirements.txt b/bots/random/requirements.txt
new file mode 100644
index 0000000..3413270
--- /dev/null
+++ b/bots/random/requirements.txt
@@ -0,0 +1 @@
+# No external dependencies - uses only Python standard library
diff --git a/bots/rusher/Cargo.toml b/bots/rusher/Cargo.toml
new file mode 100644
index 0000000..0e34b4a
--- /dev/null
+++ b/bots/rusher/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "rusher-bot"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+axum = "0.8"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+tokio = { version = "1", features = ["full"] }
+hmac = "0.12"
+sha2 = "0.10"
+hex = "0.4"
+tracing = "0.1"
+tracing-subscriber = "0.3"
+
+[profile.release]
+strip = true
+opt-level = "z"
+lto = true
diff --git a/bots/rusher/Dockerfile b/bots/rusher/Dockerfile
new file mode 100644
index 0000000..c5d9c83
--- /dev/null
+++ b/bots/rusher/Dockerfile
@@ -0,0 +1,21 @@
+# Build stage
+FROM rust:1.85-alpine AS builder
+
+WORKDIR /app
+COPY Cargo.toml ./
+COPY src ./src
+
+RUN cargo build --release
+
+# Runtime stage
+FROM alpine:3.21
+
+WORKDIR /app
+COPY --from=builder /app/target/release/rusher-bot /app/rusher-bot
+
+ENV BOT_PORT=8082
+ENV BOT_SECRET=""
+
+EXPOSE 8082
+
+CMD ["./rusher-bot"]
diff --git a/bots/rusher/src/game.rs b/bots/rusher/src/game.rs
new file mode 100644
index 0000000..9bf8fda
--- /dev/null
+++ b/bots/rusher/src/game.rs
@@ -0,0 +1,130 @@
+//! Game state types for AI Code Battle protocol.
+
+use serde::{Deserialize, Serialize};
+
+/// Position on the grid
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct Position {
+ pub row: i32,
+ pub col: i32,
+}
+
+/// Game configuration
+#[derive(Debug, Clone, Deserialize)]
+pub struct GameConfig {
+ pub rows: u32,
+ pub cols: u32,
+ pub max_turns: u32,
+ pub vision_radius2: u32,
+ pub attack_radius2: u32,
+ pub spawn_cost: u32,
+ pub energy_interval: u32,
+}
+
+/// Player info
+#[derive(Debug, Clone, Deserialize)]
+pub struct PlayerInfo {
+ pub id: u32,
+ pub energy: u32,
+ pub score: u32,
+}
+
+/// Visible bot
+#[derive(Debug, Clone, Deserialize)]
+pub struct VisibleBot {
+ pub position: Position,
+ pub owner: u32,
+}
+
+/// Visible core
+#[derive(Debug, Clone, Deserialize)]
+pub struct VisibleCore {
+ pub position: Position,
+ pub owner: u32,
+ pub active: bool,
+}
+
+/// Fog-filtered game state visible to this bot
+#[derive(Debug, Clone, Deserialize)]
+pub struct GameState {
+ pub match_id: String,
+ pub turn: u32,
+ pub config: GameConfig,
+ pub you: PlayerInfo,
+ #[serde(default)]
+ pub bots: Vec,
+ #[serde(default)]
+ pub energy: Vec,
+ #[serde(default)]
+ pub cores: Vec,
+ #[serde(default)]
+ pub walls: Vec,
+ #[serde(default)]
+ pub dead: Vec,
+}
+
+/// Movement direction
+#[derive(Debug, Clone, Copy, Serialize)]
+pub enum Direction {
+ #[serde(rename = "N")]
+ N,
+ #[serde(rename = "E")]
+ E,
+ #[serde(rename = "S")]
+ S,
+ #[serde(rename = "W")]
+ W,
+}
+
+/// A single move command
+#[derive(Debug, Clone, Serialize)]
+pub struct Move {
+ pub position: Position,
+ pub direction: Direction,
+}
+
+/// Response containing moves
+#[derive(Debug, Clone, Serialize)]
+pub struct MoveResponse {
+ pub moves: Vec,
+}
+
+impl Direction {
+ /// All directions in order: N, E, S, W
+ pub fn all() -> [Direction; 4] {
+ [Direction::N, Direction::E, Direction::S, Direction::W]
+ }
+}
+
+impl Position {
+ /// Move in a direction, wrapping around the toroidal grid
+ pub fn move_toward(&self, dir: Direction, rows: i32, cols: i32) -> Position {
+ match dir {
+ Direction::N => Position {
+ row: (self.row - 1 + rows) % rows,
+ col: self.col,
+ },
+ Direction::E => Position {
+ row: self.row,
+ col: (self.col + 1) % cols,
+ },
+ Direction::S => Position {
+ row: (self.row + 1) % rows,
+ col: self.col,
+ },
+ Direction::W => Position {
+ row: self.row,
+ col: (self.col - 1 + cols) % cols,
+ },
+ }
+ }
+
+ /// Calculate squared distance with toroidal wrapping
+ pub fn distance2(&self, other: &Position, rows: i32, cols: i32) -> u32 {
+ let dr = (self.row - other.row).abs();
+ let dc = (self.col - other.col).abs();
+ let dr = dr.min(rows - dr);
+ let dc = dc.min(cols - dc);
+ (dr * dr + dc * dc) as u32
+ }
+}
diff --git a/bots/rusher/src/main.rs b/bots/rusher/src/main.rs
new file mode 100644
index 0000000..10aa93d
--- /dev/null
+++ b/bots/rusher/src/main.rs
@@ -0,0 +1,166 @@
+//! RusherBot - A bot that rushes enemy cores aggressively.
+//!
+//! Strategy: Identify and rush the nearest enemy core as fast as possible.
+//! Uses BFS pathfinding to navigate toward cores while ignoring energy
+//! and enemy bots (unless they block the path).
+
+mod game;
+mod strategy;
+
+use axum::{
+ extract::State,
+ http::{HeaderMap, StatusCode},
+ routing::{get, post},
+ Json, Router,
+};
+use game::{GameState, Move, MoveResponse};
+use hmac::{Hmac, Mac};
+use sha2::Sha256;
+use std::collections::HashMap;
+use std::env;
+use std::sync::Arc;
+use strategy::RusherStrategy;
+use tokio::sync::Mutex;
+use tracing::{info, Level};
+use tracing_subscriber::FmtSubscriber;
+
+type HmacSha256 = Hmac;
+
+/// Bot server state
+struct BotState {
+ secret: String,
+ strategy: RusherStrategy,
+}
+
+#[tokio::main]
+async fn main() {
+ // Initialize logging
+ let subscriber = FmtSubscriber::builder()
+ .with_max_level(Level::INFO)
+ .finish();
+ tracing::subscriber::set_global_default(subscriber).expect("Failed to set subscriber");
+
+ let port = env::var("BOT_PORT").unwrap_or_else(|_| "8082".to_string());
+ let secret = env::var("BOT_SECRET").expect("BOT_SECRET environment variable is required");
+
+ let state = Arc::new(Mutex::new(BotState {
+ secret,
+ strategy: RusherStrategy::new(),
+ }));
+
+ let app = Router::new()
+ .route("/turn", post(handle_turn))
+ .route("/health", get(handle_health))
+ .with_state(state);
+
+ let addr = format!("0.0.0.0:{}", port);
+ info!("RusherBot starting on {}", addr);
+
+ let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
+ axum::serve(listener, app).await.unwrap();
+}
+
+/// Handle turn requests from the game engine
+async fn handle_turn(
+ State(state): State>>,
+ headers: HeaderMap,
+ body: String,
+) -> Result, StatusCode> {
+ // Extract auth headers
+ let match_id = headers
+ .get("X-ACB-Match-Id")
+ .and_then(|v| v.to_str().ok())
+ .ok_or(StatusCode::UNAUTHORIZED)?;
+
+ let turn_str = headers
+ .get("X-ACB-Turn")
+ .and_then(|v| v.to_str().ok())
+ .ok_or(StatusCode::UNAUTHORIZED)?;
+
+ let timestamp = headers
+ .get("X-ACB-Timestamp")
+ .and_then(|v| v.to_str().ok())
+ .ok_or(StatusCode::UNAUTHORIZED)?;
+
+ let signature = headers
+ .get("X-ACB-Signature")
+ .and_then(|v| v.to_str().ok())
+ .ok_or(StatusCode::UNAUTHORIZED)?;
+
+ // Verify signature
+ let mut state = state.lock().await;
+ if !verify_signature(&state.secret, match_id, turn_str, timestamp, &body, signature) {
+ return Err(StatusCode::UNAUTHORIZED);
+ }
+
+ // Parse game state
+ let game_state: GameState = serde_json::from_str(&body).map_err(|_| StatusCode::BAD_REQUEST)?;
+
+ // Compute moves
+ let moves = state.strategy.compute_moves(&game_state);
+ let turn: u32 = turn_str.parse().unwrap_or(0);
+
+ info!("Turn {}: {} moves computed", turn, moves.len());
+
+ // Build response
+ let response = MoveResponse { moves };
+
+ // Sign response
+ let response_body = serde_json::to_string(&response).unwrap();
+ let _response_sig = sign_response(&state.secret, match_id, turn, &response_body);
+
+ Ok(Json(response))
+}
+
+/// Handle health check requests
+async fn handle_health() -> &'static str {
+ "OK"
+}
+
+/// Verify HMAC signature of incoming request
+fn verify_signature(
+ secret: &str,
+ match_id: &str,
+ turn: &str,
+ timestamp: &str,
+ body: &str,
+ signature: &str,
+) -> bool {
+ let body_hash = sha2::Sha256::digest(body.as_bytes());
+ let body_hash_hex = hex::encode(body_hash);
+
+ let signing_string = format!("{}.{}.{}.{}", match_id, turn, timestamp, body_hash_hex);
+
+ let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
+ Ok(m) => m,
+ Err(_) => return false,
+ };
+ mac.update(signing_string.as_bytes());
+ let expected = hex::encode(mac.finalize().into_bytes());
+
+ hmac_equal(signature, &expected)
+}
+
+/// Sign response body
+fn sign_response(secret: &str, match_id: &str, turn: u32, body: &str) -> String {
+ let body_hash = sha2::Sha256::digest(body.as_bytes());
+ let body_hash_hex = hex::encode(body_hash);
+
+ let signing_string = format!("{}.{}.{}", match_id, turn, body_hash_hex);
+
+ let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
+ mac.update(signing_string.as_bytes());
+ hex::encode(mac.finalize().into_bytes())
+}
+
+/// Constant-time string comparison
+fn hmac_equal(a: &str, b: &str) -> bool {
+ if a.len() != b.len() {
+ return false;
+ }
+ a.as_bytes()
+ .iter()
+ .zip(b.as_bytes().iter())
+ .fold(0, |acc, (x, y)| acc | (x ^ y))
+ == 0
+}
diff --git a/bots/rusher/src/strategy.rs b/bots/rusher/src/strategy.rs
new file mode 100644
index 0000000..a745290
--- /dev/null
+++ b/bots/rusher/src/strategy.rs
@@ -0,0 +1,192 @@
+//! RusherBot strategy: aggressive core-rushing behavior.
+//!
+//! This strategy identifies and rushes the nearest enemy core as fast as possible.
+//! Bots use BFS to find paths to enemy cores, ignoring energy and enemy bots
+//! unless they block the path.
+
+use crate::game::{Direction, GameConfig, GameState, Move, Position, VisibleBot, VisibleCore};
+use std::collections::{HashMap, HashSet, VecDeque};
+
+/// RusherStrategy implements aggressive core-rushing behavior.
+pub struct RusherStrategy {
+ /// Known enemy core positions (discovered during gameplay)
+ known_enemy_cores: HashSet,
+}
+
+impl RusherStrategy {
+ pub fn new() -> Self {
+ Self {
+ known_enemy_cores: HashSet::new(),
+ }
+ }
+
+ /// Compute moves for all owned bots
+ pub fn compute_moves(&mut self, state: &GameState) -> Vec {
+ let my_id = state.you.id;
+ let config = &state.config;
+
+ // Update known enemy cores
+ self.update_known_cores(state, my_id);
+
+ // Separate my bots from enemies
+ let (my_bots, enemy_bots): (Vec<_>, Vec<_>) =
+ state.bots.iter().partition(|b| b.owner == my_id);
+
+ if my_bots.is_empty() {
+ return vec![];
+ }
+
+ // Build position lookup for enemies
+ let enemy_positions: HashSet =
+ enemy_bots.iter().map(|b| b.position).collect();
+
+ // Build wall lookup
+ let walls: HashSet = state.walls.iter().copied().collect();
+
+ // Find target cores to rush
+ let targets = self.get_rush_targets(state, my_id);
+
+ // Assign each bot to the nearest target
+ let mut moves = Vec::with_capacity(my_bots.len());
+ let mut assigned_targets: HashSet = HashSet::new();
+
+ for bot in &my_bots {
+ if let Some((dir, _)) = self.find_best_move(
+ bot.position,
+ &targets,
+ &enemy_positions,
+ &walls,
+ &assigned_targets,
+ config,
+ ) {
+ // Mark target as assigned to avoid duplicates
+ if let Some(target) = self.find_target_for_bot(bot.position, &targets, config) {
+ assigned_targets.insert(target);
+ }
+ moves.push(Move {
+ position: bot.position,
+ direction: dir,
+ });
+ }
+ }
+
+ moves
+ }
+
+ /// Update known enemy cores from visible state
+ fn update_known_cores(&mut self, state: &GameState, my_id: u32) {
+ for core in &state.cores {
+ if core.owner != my_id {
+ self.known_enemy_cores.insert(core.position);
+ }
+ }
+ }
+
+ /// Get list of cores to rush (enemy cores first, then explore)
+ fn get_rush_targets(&self, state: &GameState, my_id: u32) -> Vec {
+ let mut targets: Vec = state
+ .cores
+ .iter()
+ .filter(|c| c.owner != my_id && c.active)
+ .map(|c| c.position)
+ .collect();
+
+ // If we know about enemy cores from previous turns, include them
+ for pos in &self.known_enemy_cores {
+ if !targets.contains(pos) {
+ targets.push(*pos);
+ }
+ }
+
+ // If no enemy cores known, explore the map
+ if targets.is_empty() {
+ // Add exploration targets at grid edges
+ let rows = state.config.rows as i32;
+ let cols = state.config.cols as i32;
+ targets.push(Position { row: rows / 2, col: cols / 2 });
+ targets.push(Position { row: 0, col: 0 });
+ targets.push(Position { row: rows - 1, col: cols - 1 });
+ }
+
+ targets
+ }
+
+ /// Find the best move for a bot using BFS toward targets
+ fn find_best_move(
+ &self,
+ start: Position,
+ targets: &[Position],
+ enemy_positions: &HashSet,
+ walls: &HashSet,
+ _assigned_targets: &HashSet,
+ config: &GameConfig,
+ ) -> Option<(Direction, Position)> {
+ let rows = config.rows as i32;
+ let cols = config.cols as i32;
+
+ // BFS to find shortest path to any target
+ let mut visited: HashSet = HashSet::new();
+ let mut queue: VecDeque<(Position, Option)> = VecDeque::new();
+
+ visited.insert(start);
+ queue.push_back((start, None));
+
+ while let Some((pos, first_dir)) = queue.pop_front() {
+ // Check if we've reached a target
+ if targets.contains(&pos) {
+ if let Some(dir) = first_dir {
+ return Some((dir, pos));
+ }
+ }
+
+ // Explore neighbors
+ for dir in Direction::all() {
+ let next = pos.move_toward(dir, rows, cols);
+
+ if visited.contains(&next) || walls.contains(&next) {
+ continue;
+ }
+
+ // Don't walk into enemy bots (but allow pathing near them)
+ if enemy_positions.contains(&next) {
+ continue;
+ }
+
+ visited.insert(next);
+ queue.push_back((next, first_dir.or(Some(dir))));
+ }
+ }
+
+ // No path found - pick a random direction
+ for dir in Direction::all() {
+ let next = start.move_toward(dir, rows, cols);
+ if !walls.contains(&next) && !enemy_positions.contains(&next) {
+ return Some((dir, next));
+ }
+ }
+
+ None
+ }
+
+ /// Find the nearest target for a bot
+ fn find_target_for_bot(
+ &self,
+ bot_pos: Position,
+ targets: &[Position],
+ config: &GameConfig,
+ ) -> Option {
+ let rows = config.rows as i32;
+ let cols = config.cols as i32;
+
+ targets
+ .iter()
+ .min_by_key(|t| bot_pos.distance2(t, rows, cols))
+ .copied()
+ }
+}
+
+impl Default for RusherStrategy {
+ fn default() -> Self {
+ Self::new()
+ }
+}
diff --git a/bots/swarm/Dockerfile b/bots/swarm/Dockerfile
new file mode 100644
index 0000000..9a6b6a4
--- /dev/null
+++ b/bots/swarm/Dockerfile
@@ -0,0 +1,23 @@
+# Build stage
+FROM node:22-alpine AS builder
+
+WORKDIR /app
+COPY package.json tsconfig.json ./
+COPY src ./src
+
+RUN npm install && npm run build
+
+# Runtime stage
+FROM node:22-alpine
+
+WORKDIR /app
+COPY --from=builder /app/dist ./dist
+COPY --from=builder /app/node_modules ./node_modules
+COPY package.json ./
+
+ENV BOT_PORT=8084
+ENV BOT_SECRET=""
+
+EXPOSE 8084
+
+CMD ["npm", "start"]
diff --git a/bots/swarm/package.json b/bots/swarm/package.json
new file mode 100644
index 0000000..903bffb
--- /dev/null
+++ b/bots/swarm/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "swarm-bot",
+ "version": "1.0.0",
+ "description": "SwarmBot - Formation-based combat strategy for AI Code Battle",
+ "main": "dist/index.js",
+ "scripts": {
+ "build": "tsc",
+ "start": "node dist/index.js",
+ "dev": "ts-node src/index.ts"
+ },
+ "dependencies": {
+ "fastify": "^5.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "typescript": "^5.7.0",
+ "ts-node": "^10.9.0"
+ }
+}
diff --git a/bots/swarm/src/game.ts b/bots/swarm/src/game.ts
new file mode 100644
index 0000000..d0e3b2e
--- /dev/null
+++ b/bots/swarm/src/game.ts
@@ -0,0 +1,103 @@
+/**
+ * Game state types for AI Code Battle protocol.
+ */
+
+export interface Position {
+ row: number;
+ col: number;
+}
+
+export interface GameConfig {
+ rows: number;
+ cols: number;
+ max_turns: number;
+ vision_radius2: number;
+ attack_radius2: number;
+ spawn_cost: number;
+ energy_interval: number;
+}
+
+export interface PlayerInfo {
+ id: number;
+ energy: number;
+ score: number;
+}
+
+export interface VisibleBot {
+ position: Position;
+ owner: number;
+}
+
+export interface VisibleCore {
+ position: Position;
+ owner: number;
+ active: boolean;
+}
+
+export interface GameState {
+ match_id: string;
+ turn: number;
+ config: GameConfig;
+ you: PlayerInfo;
+ bots: VisibleBot[];
+ energy: Position[];
+ cores: VisibleCore[];
+ walls: Position[];
+ dead: VisibleBot[];
+}
+
+export type Direction = 'N' | 'E' | 'S' | 'W';
+
+export interface Move {
+ position: Position;
+ direction: Direction;
+}
+
+export interface MoveResponse {
+ moves: Move[];
+}
+
+// Utility functions
+
+export function posKey(pos: Position): string {
+ return `${pos.row},${pos.col}`;
+}
+
+export function posEquals(a: Position, b: Position): boolean {
+ return a.row === b.row && a.col === b.col;
+}
+
+export function moveToward(pos: Position, dir: Direction, rows: number, cols: number): Position {
+ switch (dir) {
+ case 'N':
+ return { row: (pos.row - 1 + rows) % rows, col: pos.col };
+ case 'E':
+ return { row: pos.row, col: (pos.col + 1) % cols };
+ case 'S':
+ return { row: (pos.row + 1) % rows, col: pos.col };
+ case 'W':
+ return { row: pos.row, col: (pos.col - 1 + cols) % cols };
+ }
+}
+
+export function distance2(a: Position, b: Position, rows: number, cols: number): number {
+ let dr = Math.abs(a.row - b.row);
+ let dc = Math.abs(a.col - b.col);
+ dr = Math.min(dr, rows - dr);
+ dc = Math.min(dc, cols - dc);
+ return dr * dr + dc * dc;
+}
+
+export function manhattanDistance(a: Position, b: Position, rows: number, cols: number): number {
+ let dr = Math.abs(a.row - b.row);
+ let dc = Math.abs(a.col - b.col);
+ dr = Math.min(dr, rows - dr);
+ dc = Math.min(dc, cols - dc);
+ return dr + dc;
+}
+
+export const ALL_DIRECTIONS: Direction[] = ['N', 'E', 'S', 'W'];
+
+export function buildPositionSet(positions: Position[]): Set {
+ return new Set(positions.map(posKey));
+}
diff --git a/bots/swarm/src/index.ts b/bots/swarm/src/index.ts
new file mode 100644
index 0000000..3874bc3
--- /dev/null
+++ b/bots/swarm/src/index.ts
@@ -0,0 +1,122 @@
+/**
+ * SwarmBot - Formation-based combat strategy for AI Code Battle.
+ *
+ * HTTP server that handles game engine requests with HMAC authentication.
+ */
+
+import * as crypto from 'crypto';
+import * as http from 'http';
+import { GameState, MoveResponse } from './game.js';
+import { SwarmStrategy } from './strategy.js';
+
+const PORT = parseInt(process.env.BOT_PORT || '8084', 10);
+const SECRET = process.env.BOT_SECRET || '';
+
+if (!SECRET) {
+ console.error('ERROR: BOT_SECRET environment variable is required');
+ process.exit(1);
+}
+
+const strategy = new SwarmStrategy();
+
+const server = http.createServer((req, res) => {
+ if (req.method === 'GET' && req.url === '/health') {
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
+ res.end('OK');
+ return;
+ }
+
+ if (req.method === 'POST' && req.url === '/turn') {
+ handleTurn(req, res);
+ return;
+ }
+
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
+ res.end('Not Found');
+});
+
+async function handleTurn(req: http.IncomingMessage, res: http.ServerResponse): Promise {
+ // Extract auth headers
+ const matchId = req.headers['x-acb-match-id'] as string;
+ const turnStr = req.headers['x-acb-turn'] as string;
+ const timestamp = req.headers['x-acb-timestamp'] as string;
+ const signature = req.headers['x-acb-signature'] as string;
+
+ if (!matchId || !turnStr || !timestamp || !signature) {
+ res.writeHead(401, { 'Content-Type': 'text/plain' });
+ res.end('Missing auth headers');
+ return;
+ }
+
+ // Read body
+ let body = '';
+ for await (const chunk of req) {
+ body += chunk;
+ }
+
+ // Verify signature
+ if (!verifySignature(SECRET, matchId, turnStr, timestamp, body, signature)) {
+ res.writeHead(401, { 'Content-Type': 'text/plain' });
+ res.end('Invalid signature');
+ return;
+ }
+
+ // Parse game state
+ let state: GameState;
+ try {
+ state = JSON.parse(body);
+ } catch (e) {
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
+ res.end('Invalid JSON');
+ return;
+ }
+
+ // Compute moves
+ const moves = strategy.computeMoves(state);
+ const turn = parseInt(turnStr, 10);
+
+ console.log(`Turn ${turn}: ${moves.length} moves computed`);
+
+ // Build response
+ const response: MoveResponse = { moves };
+ const responseBody = JSON.stringify(response);
+
+ // Sign response
+ const responseSig = signResponse(SECRET, matchId, turn, responseBody);
+
+ res.writeHead(200, {
+ 'Content-Type': 'application/json',
+ 'X-ACB-Signature': responseSig,
+ });
+ res.end(responseBody);
+}
+
+/**
+ * Verify HMAC signature of incoming request
+ */
+function verifySignature(
+ secret: string,
+ matchId: string,
+ turn: string,
+ timestamp: string,
+ body: string,
+ signature: string
+): boolean {
+ const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
+ const signingString = `${matchId}.${turn}.${timestamp}.${bodyHash}`;
+ const expected = crypto.createHmac('sha256', secret).update(signingString).digest('hex');
+ return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
+}
+
+/**
+ * Sign response body
+ */
+function signResponse(secret: string, matchId: string, turn: number, body: string): string {
+ const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
+ const signingString = `${matchId}.${turn}.${bodyHash}`;
+ return crypto.createHmac('sha256', secret).update(signingString).digest('hex');
+}
+
+server.listen(PORT, '0.0.0.0', () => {
+ console.log(`SwarmBot starting on port ${PORT}`);
+});
diff --git a/bots/swarm/src/strategy.ts b/bots/swarm/src/strategy.ts
new file mode 100644
index 0000000..930e28d
--- /dev/null
+++ b/bots/swarm/src/strategy.ts
@@ -0,0 +1,228 @@
+/**
+ * SwarmBot strategy: formation-based combat with tight cohesion.
+ *
+ * Strategy: Keep units in tight formations, advance as a group toward enemies.
+ * - 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
+ * - Energy collection is incidental (pass over it during advance)
+ * - New spawns rally to the swarm before advancing
+ */
+
+import {
+ GameState,
+ VisibleBot,
+ Position,
+ Move,
+ Direction,
+ GameConfig,
+ posKey,
+ posEquals,
+ moveToward,
+ distance2,
+ manhattanDistance,
+ ALL_DIRECTIONS,
+ buildPositionSet,
+} from './game.js';
+
+const COHESION_RADIUS = 3; // Maximum distance from nearest friendly
+const COHESION_RADIUS2 = COHESION_RADIUS * COHESION_RADIUS;
+
+export class SwarmStrategy {
+ /**
+ * Compute moves for all owned bots
+ */
+ computeMoves(state: GameState): Move[] {
+ const myId = state.you.id;
+ const config = state.config;
+
+ // Separate my bots from enemies
+ const myBots: VisibleBot[] = [];
+ const enemyBots: VisibleBot[] = [];
+ for (const bot of state.bots) {
+ if (bot.owner === myId) {
+ myBots.push(bot);
+ } else {
+ enemyBots.push(bot);
+ }
+ }
+
+ if (myBots.length === 0) {
+ return [];
+ }
+
+ // Build wall lookup
+ const walls = buildPositionSet(state.walls);
+
+ // Build enemy position lookup
+ const enemyPositions = new Map();
+ for (const bot of enemyBots) {
+ enemyPositions.set(posKey(bot.position), bot);
+ }
+
+ // Calculate swarm center (center of mass of my bots)
+ const swarmCenter = this.calculateCenter(myBots.map(b => b.position), config);
+
+ // Calculate enemy center if any enemies visible
+ const enemyCenter = enemyBots.length > 0
+ ? this.calculateCenter(enemyBots.map(b => b.position), config)
+ : null;
+
+ // My bot positions for cohesion checks
+ const myBotPositions = new Set(myBots.map(b => posKey(b.position)));
+
+ const moves: Move[] = [];
+
+ for (const bot of myBots) {
+ const move = this.computeBotMove(
+ bot,
+ myBotPositions,
+ enemyPositions,
+ swarmCenter,
+ enemyCenter,
+ walls,
+ config
+ );
+ if (move) {
+ moves.push(move);
+ }
+ }
+
+ return moves;
+ }
+
+ /**
+ * Calculate center of mass of positions
+ */
+ private calculateCenter(positions: Position[], config: GameConfig): Position {
+ if (positions.length === 0) {
+ return { row: config.rows / 2, col: config.cols / 2 };
+ }
+
+ // Use circular mean for toroidal coordinates
+ let sumSinRow = 0, sumCosRow = 0;
+ let sumSinCol = 0, sumCosCol = 0;
+
+ const rowScale = (2 * Math.PI) / config.rows;
+ const colScale = (2 * Math.PI) / config.cols;
+
+ for (const pos of positions) {
+ sumSinRow += Math.sin(pos.row * rowScale);
+ sumCosRow += Math.cos(pos.row * rowScale);
+ sumSinCol += Math.sin(pos.col * colScale);
+ sumCosCol += Math.cos(pos.col * colScale);
+ }
+
+ const avgRow = Math.atan2(sumSinRow / positions.length, sumCosRow / positions.length) / rowScale;
+ const avgCol = Math.atan2(sumSinCol / positions.length, sumCosCol / positions.length) / colScale;
+
+ return {
+ row: ((avgRow % config.rows) + config.rows) % config.rows,
+ col: ((avgCol % config.cols) + config.cols) % config.cols,
+ };
+ }
+
+ /**
+ * Compute move for a single bot
+ */
+ private computeBotMove(
+ bot: VisibleBot,
+ myBotPositions: Set,
+ enemyPositions: Map,
+ swarmCenter: Position,
+ enemyCenter: Position | null,
+ walls: Set,
+ config: GameConfig
+ ): Move | null {
+ const rows = config.rows;
+ const cols = config.cols;
+
+ // Find direction that maintains cohesion while advancing toward enemy
+ let bestDir: Direction | null = null;
+ let bestScore = -Infinity;
+
+ // Target is enemy center if visible, otherwise explore
+ const target = enemyCenter ?? { row: rows / 2, col: cols / 2 };
+
+ for (const dir of ALL_DIRECTIONS) {
+ const newPos = moveToward(bot.position, dir, rows, cols);
+ const newPosKey = posKey(newPos);
+
+ // Can't move into walls or enemies
+ if (walls.has(newPosKey) || enemyPositions.has(newPosKey)) {
+ continue;
+ }
+
+ // Check cohesion: must stay within COHESION_RADIUS of at least one friendly bot
+ if (!this.maintainsCohesion(newPos, bot.position, myBotPositions, rows, cols)) {
+ continue;
+ }
+
+ // Score this move
+ let score = 0;
+
+ // Prefer moving toward enemy center (or target)
+ const distToTarget = distance2(newPos, target, rows, cols);
+ const currentDistToTarget = distance2(bot.position, target, rows, cols);
+ score += (currentDistToTarget - distToTarget) * 10; // Reward getting closer
+
+ // Prefer staying near swarm center
+ const distToSwarmCenter = distance2(newPos, swarmCenter, rows, cols);
+ score -= distToSwarmCenter * 0.5; // Penalize being far from swarm
+
+ // Bonus for moving toward nearby enemies (engagement)
+ let nearestEnemyDist = Infinity;
+ for (const enemy of enemyPositions.values()) {
+ const dist = distance2(newPos, enemy.position, rows, cols);
+ nearestEnemyDist = Math.min(nearestEnemyDist, dist);
+ }
+ if (nearestEnemyDist < Infinity) {
+ // Bonus for being in attack range
+ if (nearestEnemyDist <= config.attack_radius2) {
+ score += 50;
+ }
+ }
+
+ if (score > bestScore) {
+ bestScore = score;
+ bestDir = dir;
+ }
+ }
+
+ if (bestDir) {
+ return { position: bot.position, direction: bestDir };
+ }
+
+ // If no good move found, try to stay put or move toward swarm
+ return null;
+ }
+
+ /**
+ * Check if moving to newPos maintains cohesion with friendly bots
+ */
+ private maintainsCohesion(
+ newPos: Position,
+ oldPos: Position,
+ myBotPositions: Set,
+ rows: number,
+ cols: number
+ ): boolean {
+ // Temporarily remove old position and add new position
+ const oldKey = posKey(oldPos);
+
+ for (const botPosKey of myBotPositions) {
+ if (botPosKey === oldKey) continue;
+
+ const [row, col] = botPosKey.split(',').map(Number);
+ const botPos = { row, col };
+
+ const dist2 = distance2(newPos, botPos, rows, cols);
+ if (dist2 <= COHESION_RADIUS2) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/bots/swarm/tsconfig.json b/bots/swarm/tsconfig.json
new file mode 100644
index 0000000..5a3b015
--- /dev/null
+++ b/bots/swarm/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2022"],
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/engine/auth.go b/engine/auth.go
new file mode 100644
index 0000000..df28c2e
--- /dev/null
+++ b/engine/auth.go
@@ -0,0 +1,144 @@
+package engine
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "strconv"
+ "time"
+)
+
+const (
+ // TimestampTolerance is the allowed clock skew for request validation (30 seconds)
+ TimestampTolerance = 30 * time.Second
+)
+
+// AuthConfig holds authentication configuration for a bot.
+type AuthConfig struct {
+ BotID string // Unique bot identifier (e.g., "b_4e8c1d2f")
+ Secret string // Shared secret (hex-encoded, 64 characters)
+ MatchID string // Current match ID
+}
+
+// RequestAuth contains the authentication headers for an engine-to-bot request.
+type RequestAuth struct {
+ MatchID string
+ Turn int
+ Timestamp int64
+ BotID string
+ Signature string
+}
+
+// SignRequest generates the HMAC signature for an outgoing request.
+// signing_string = "{match_id}.{turn}.{timestamp}.{sha256(request_body)}"
+// signature = HMAC-SHA256(shared_secret, signing_string)
+func SignRequest(secret, matchID string, turn int, timestamp int64, requestBody []byte) string {
+ bodyHash := sha256.Sum256(requestBody)
+ signingString := fmt.Sprintf("%s.%d.%d.%s", matchID, turn, timestamp, hex.EncodeToString(bodyHash[:]))
+
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(signingString))
+ return hex.EncodeToString(mac.Sum(nil))
+}
+
+// SignResponse generates the HMAC signature for a bot response.
+// signing_string = "{match_id}.{turn}.{sha256(response_body)}"
+// signature = HMAC-SHA256(shared_secret, signing_string)
+func SignResponse(secret, matchID string, turn int, responseBody []byte) string {
+ bodyHash := sha256.Sum256(responseBody)
+ signingString := fmt.Sprintf("%s.%d.%s", matchID, turn, hex.EncodeToString(bodyHash[:]))
+
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(signingString))
+ return hex.EncodeToString(mac.Sum(nil))
+}
+
+// VerifyRequest verifies an incoming request's signature.
+// Returns an error if verification fails.
+func VerifyRequest(secret string, auth RequestAuth, requestBody []byte) error {
+ // Check timestamp is within tolerance
+ now := time.Now().Unix()
+ requestTime := auth.Timestamp
+ diff := now - requestTime
+ if diff < 0 {
+ diff = -diff
+ }
+ if time.Duration(diff)*time.Second > TimestampTolerance {
+ return fmt.Errorf("timestamp expired: request was %v ago (tolerance: %v)",
+ time.Duration(diff)*time.Second, TimestampTolerance)
+ }
+
+ // Compute expected signature
+ expectedSig := SignRequest(secret, auth.MatchID, auth.Turn, auth.Timestamp, requestBody)
+
+ // Constant-time comparison
+ if !hmac.Equal([]byte(auth.Signature), []byte(expectedSig)) {
+ return fmt.Errorf("invalid signature")
+ }
+
+ return nil
+}
+
+// VerifyResponse verifies a bot response's signature.
+func VerifyResponse(secret, matchID string, turn int, signature string, responseBody []byte) error {
+ expectedSig := SignResponse(secret, matchID, turn, responseBody)
+
+ if !hmac.Equal([]byte(signature), []byte(expectedSig)) {
+ return fmt.Errorf("invalid response signature")
+ }
+
+ return nil
+}
+
+// ParseAuthHeaders extracts authentication info from HTTP headers.
+// Headers: X-ACB-Match-Id, X-ACB-Turn, X-ACB-Timestamp, X-ACB-Bot-Id, X-ACB-Signature
+func ParseAuthHeaders(headers map[string]string) (RequestAuth, error) {
+ var auth RequestAuth
+ var err error
+
+ auth.MatchID = headers["X-ACB-Match-Id"]
+ if auth.MatchID == "" {
+ return auth, fmt.Errorf("missing X-ACB-Match-Id header")
+ }
+
+ turnStr := headers["X-ACB-Turn"]
+ if turnStr == "" {
+ return auth, fmt.Errorf("missing X-ACB-Turn header")
+ }
+ auth.Turn, err = strconv.Atoi(turnStr)
+ if err != nil {
+ return auth, fmt.Errorf("invalid X-ACB-Turn header: %w", err)
+ }
+
+ timestampStr := headers["X-ACB-Timestamp"]
+ if timestampStr == "" {
+ return auth, fmt.Errorf("missing X-ACB-Timestamp header")
+ }
+ auth.Timestamp, err = strconv.ParseInt(timestampStr, 10, 64)
+ if err != nil {
+ return auth, fmt.Errorf("invalid X-ACB-Timestamp header: %w", err)
+ }
+
+ auth.BotID = headers["X-ACB-Bot-Id"]
+ if auth.BotID == "" {
+ return auth, fmt.Errorf("missing X-ACB-Bot-Id header")
+ }
+
+ auth.Signature = headers["X-ACB-Signature"]
+ if auth.Signature == "" {
+ return auth, fmt.Errorf("missing X-ACB-Signature header")
+ }
+
+ return auth, nil
+}
+
+// GenerateSecret generates a new random 256-bit secret (hex-encoded).
+// This should be called at bot registration time.
+func GenerateSecret(rng interface{ Read([]byte) (int, error) }) (string, error) {
+ bytes := make([]byte, 32) // 256 bits
+ if _, err := rng.Read(bytes); err != nil {
+ return "", fmt.Errorf("failed to generate secret: %w", err)
+ }
+ return hex.EncodeToString(bytes), nil
+}
diff --git a/engine/auth_test.go b/engine/auth_test.go
new file mode 100644
index 0000000..e168108
--- /dev/null
+++ b/engine/auth_test.go
@@ -0,0 +1,241 @@
+package engine
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "testing"
+ "time"
+)
+
+func TestSignRequest(t *testing.T) {
+ secret := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+ matchID := "m_7f3a9b2c"
+ turn := 42
+ timestamp := int64(1711200000)
+ body := []byte(`{"match_id":"m_7f3a9b2c"}`)
+
+ sig := SignRequest(secret, matchID, turn, timestamp, body)
+
+ // Signature should be 64 hex characters (256 bits)
+ if len(sig) != 64 {
+ t.Errorf("signature length = %d, want 64", len(sig))
+ }
+
+ // Same input should produce same signature
+ sig2 := SignRequest(secret, matchID, turn, timestamp, body)
+ if sig != sig2 {
+ t.Error("signature not deterministic")
+ }
+
+ // Different secret should produce different signature
+ sig3 := SignRequest("different"+secret[10:], matchID, turn, timestamp, body)
+ if sig == sig3 {
+ t.Error("different secrets produced same signature")
+ }
+
+ // Different body should produce different signature
+ sig4 := SignRequest(secret, matchID, turn, timestamp, []byte(`{}`))
+ if sig == sig4 {
+ t.Error("different bodies produced same signature")
+ }
+}
+
+func TestSignResponse(t *testing.T) {
+ secret := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+ matchID := "m_7f3a9b2c"
+ turn := 42
+ body := []byte(`{"moves":[]}`)
+
+ sig := SignResponse(secret, matchID, turn, body)
+
+ // Signature should be 64 hex characters
+ if len(sig) != 64 {
+ t.Errorf("signature length = %d, want 64", len(sig))
+ }
+
+ // Same input should produce same signature
+ sig2 := SignResponse(secret, matchID, turn, body)
+ if sig != sig2 {
+ t.Error("signature not deterministic")
+ }
+}
+
+func TestVerifyRequest(t *testing.T) {
+ secret := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+ matchID := "m_7f3a9b2c"
+ turn := 42
+ timestamp := time.Now().Unix()
+ body := []byte(`{"match_id":"m_7f3a9b2c"}`)
+
+ sig := SignRequest(secret, matchID, turn, timestamp, body)
+
+ auth := RequestAuth{
+ MatchID: matchID,
+ Turn: turn,
+ Timestamp: timestamp,
+ BotID: "b_test",
+ Signature: sig,
+ }
+
+ // Valid signature should pass
+ if err := VerifyRequest(secret, auth, body); err != nil {
+ t.Errorf("valid signature failed: %v", err)
+ }
+
+ // Wrong secret should fail
+ if err := VerifyRequest("wrong"+secret[5:], auth, body); err == nil {
+ t.Error("wrong secret should fail verification")
+ }
+
+ // Wrong signature should fail
+ auth2 := auth
+ auth2.Signature = "0" + auth.Signature[1:]
+ if err := VerifyRequest(secret, auth2, body); err == nil {
+ t.Error("wrong signature should fail verification")
+ }
+
+ // Expired timestamp should fail
+ auth3 := auth
+ auth3.Timestamp = time.Now().Unix() - 60 // 60 seconds ago
+ if err := VerifyRequest(secret, auth3, body); err == nil {
+ t.Error("expired timestamp should fail verification")
+ }
+
+ // Future timestamp should fail
+ auth4 := auth
+ auth4.Timestamp = time.Now().Unix() + 60 // 60 seconds in future
+ if err := VerifyRequest(secret, auth4, body); err == nil {
+ t.Error("future timestamp should fail verification")
+ }
+}
+
+func TestVerifyResponse(t *testing.T) {
+ secret := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+ matchID := "m_7f3a9b2c"
+ turn := 42
+ body := []byte(`{"moves":[]}`)
+
+ sig := SignResponse(secret, matchID, turn, body)
+
+ // Valid signature should pass
+ if err := VerifyResponse(secret, matchID, turn, sig, body); err != nil {
+ t.Errorf("valid signature failed: %v", err)
+ }
+
+ // Wrong secret should fail
+ if err := VerifyResponse("wrong", matchID, turn, sig, body); err == nil {
+ t.Error("wrong secret should fail verification")
+ }
+
+ // Wrong turn should fail
+ if err := VerifyResponse(secret, matchID, turn+1, sig, body); err == nil {
+ t.Error("wrong turn should fail verification")
+ }
+
+ // Wrong body should fail
+ if err := VerifyResponse(secret, matchID, turn, sig, []byte(`{}`)); err == nil {
+ t.Error("wrong body should fail verification")
+ }
+}
+
+func TestParseAuthHeaders(t *testing.T) {
+ tests := []struct {
+ name string
+ headers map[string]string
+ wantErr bool
+ }{
+ {
+ name: "valid headers",
+ headers: map[string]string{
+ "X-ACB-Match-Id": "m_7f3a9b2c",
+ "X-ACB-Turn": "42",
+ "X-ACB-Timestamp": "1711200000",
+ "X-ACB-Bot-Id": "b_4e8c1d2f",
+ "X-ACB-Signature": "abc123",
+ },
+ wantErr: false,
+ },
+ {
+ name: "missing all headers",
+ headers: map[string]string{},
+ wantErr: true,
+ },
+ {
+ name: "missing signature",
+ headers: map[string]string{
+ "X-ACB-Match-Id": "m_7f3a9b2c",
+ "X-ACB-Turn": "42",
+ "X-ACB-Timestamp": "1711200000",
+ "X-ACB-Bot-Id": "b_4e8c1d2f",
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid turn",
+ headers: map[string]string{
+ "X-ACB-Match-Id": "m_7f3a9b2c",
+ "X-ACB-Turn": "notanumber",
+ "X-ACB-Timestamp": "1711200000",
+ "X-ACB-Bot-Id": "b_4e8c1d2f",
+ "X-ACB-Signature": "abc123",
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid timestamp",
+ headers: map[string]string{
+ "X-ACB-Match-Id": "m_7f3a9b2c",
+ "X-ACB-Turn": "42",
+ "X-ACB-Timestamp": "notanumber",
+ "X-ACB-Bot-Id": "b_4e8c1d2f",
+ "X-ACB-Signature": "abc123",
+ },
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ auth, err := ParseAuthHeaders(tt.headers)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("ParseAuthHeaders() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !tt.wantErr {
+ if auth.MatchID != "m_7f3a9b2c" {
+ t.Errorf("MatchID = %q, want %q", auth.MatchID, "m_7f3a9b2c")
+ }
+ if auth.Turn != 42 {
+ t.Errorf("Turn = %d, want 42", auth.Turn)
+ }
+ }
+ })
+ }
+}
+
+func TestGenerateSecret(t *testing.T) {
+ secret, err := GenerateSecret(rand.Reader)
+ if err != nil {
+ t.Fatalf("GenerateSecret failed: %v", err)
+ }
+
+ // Should be 64 hex characters (256 bits)
+ if len(secret) != 64 {
+ t.Errorf("secret length = %d, want 64", len(secret))
+ }
+
+ // Should be valid hex
+ _, err = hex.DecodeString(secret)
+ if err != nil {
+ t.Errorf("secret is not valid hex: %v", err)
+ }
+
+ // Should produce different values
+ secret2, err := GenerateSecret(rand.Reader)
+ if err != nil {
+ t.Fatalf("GenerateSecret(2) failed: %v", err)
+ }
+ if secret == secret2 {
+ t.Error("GenerateSecret produced same value twice")
+ }
+}
diff --git a/engine/bot_http.go b/engine/bot_http.go
new file mode 100644
index 0000000..528a683
--- /dev/null
+++ b/engine/bot_http.go
@@ -0,0 +1,246 @@
+package engine
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+// HTTPBot is a bot that communicates via HTTP POST requests.
+// It implements BotInterface for use with MatchRunner.
+type HTTPBot struct {
+ client *http.Client
+ baseURL string // bot's HTTP endpoint (e.g., "http://localhost:8080")
+ auth AuthConfig
+ matchID string
+ turn int
+ crashed bool
+ failCount int // consecutive failures
+}
+
+// HTTPOption is a functional option for HTTPBot.
+type HTTPOption func(*HTTPBot)
+
+// WithHTTPClient sets a custom HTTP client.
+func WithHTTPClient(client *http.Client) HTTPOption {
+ return func(b *HTTPBot) {
+ b.client = client
+ }
+}
+
+// WithHTTPTimeout sets the HTTP timeout (default 3 seconds).
+func WithHTTPTimeout(timeout time.Duration) HTTPOption {
+ return func(b *HTTPBot) {
+ b.client.Timeout = timeout
+ }
+}
+
+// NewHTTPBot creates a new HTTP bot.
+func NewHTTPBot(baseURL string, auth AuthConfig, options ...HTTPOption) *HTTPBot {
+ bot := &HTTPBot{
+ client: &http.Client{
+ Timeout: 3 * time.Second,
+ },
+ baseURL: baseURL,
+ auth: auth,
+ matchID: auth.MatchID,
+ }
+
+ for _, opt := range options {
+ opt(bot)
+ }
+
+ return bot
+}
+
+// SetMatchID sets the current match ID (called at match start).
+func (b *HTTPBot) SetMatchID(matchID string) {
+ b.matchID = matchID
+ b.auth.MatchID = matchID
+ b.turn = 0
+ b.crashed = false
+ b.failCount = 0
+}
+
+// IsCrashed returns true if the bot has been marked as crashed.
+func (b *HTTPBot) IsCrashed() bool {
+ return b.crashed
+}
+
+// MoveResponse represents the JSON response from a bot.
+type MoveResponse struct {
+ Moves []Move `json:"moves"`
+ Debug *DebugInfo `json:"debug,omitempty"`
+}
+
+// DebugInfo contains optional debug telemetry from the bot.
+type DebugInfo struct {
+ Reasoning string `json:"reasoning,omitempty"`
+ Targets []DebugTarget `json:"targets,omitempty"`
+}
+
+// DebugTarget represents a debug target marker.
+type DebugTarget struct {
+ Position Position `json:"position"`
+ Label string `json:"label"`
+ Priority float64 `json:"priority"`
+}
+
+// GetMoves sends the game state to the bot and returns its moves.
+// Implements BotInterface.
+func (b *HTTPBot) GetMoves(state *VisibleState) ([]Move, error) {
+ // If crashed, return no moves (bots hold position)
+ if b.crashed {
+ return []Move{}, nil
+ }
+
+ // Update turn counter
+ b.turn = state.Turn
+
+ // Serialize state
+ requestBody, err := json.Marshal(state)
+ if err != nil {
+ b.recordFailure()
+ return nil, fmt.Errorf("failed to marshal state: %w", err)
+ }
+
+ // Build request
+ url := fmt.Sprintf("%s/turn", b.baseURL)
+ req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewReader(requestBody))
+ if err != nil {
+ b.recordFailure()
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Add headers
+ timestamp := time.Now().Unix()
+ signature := SignRequest(b.auth.Secret, b.matchID, b.turn, timestamp, requestBody)
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-ACB-Match-Id", b.matchID)
+ req.Header.Set("X-ACB-Turn", fmt.Sprintf("%d", b.turn))
+ req.Header.Set("X-ACB-Timestamp", fmt.Sprintf("%d", timestamp))
+ req.Header.Set("X-ACB-Bot-Id", b.auth.BotID)
+ req.Header.Set("X-ACB-Signature", signature)
+
+ // Send request
+ resp, err := b.client.Do(req)
+ if err != nil {
+ b.recordFailure()
+ return nil, fmt.Errorf("HTTP request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ // Check status code
+ if resp.StatusCode != http.StatusOK {
+ b.recordFailure()
+ return nil, fmt.Errorf("bot returned status %d", resp.StatusCode)
+ }
+
+ // Read response body
+ responseBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ b.recordFailure()
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ // Verify response signature
+ responseSig := resp.Header.Get("X-ACB-Signature")
+ if responseSig == "" {
+ // Missing signature - accept anyway for now (will be strict in production)
+ // In production, this would be: b.recordFailure(); return nil, fmt.Errorf("missing response signature")
+ } else {
+ if err := VerifyResponse(b.auth.Secret, b.matchID, b.turn, responseSig, responseBody); err != nil {
+ b.recordFailure()
+ return nil, fmt.Errorf("response signature verification failed: %w", err)
+ }
+ }
+
+ // Parse response
+ var moveResp MoveResponse
+ if err := json.Unmarshal(responseBody, &moveResp); err != nil {
+ b.recordFailure()
+ return nil, fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ // Validate moves (basic validation)
+ moves := b.validateMoves(moveResp.Moves, state)
+
+ // Reset failure count on success
+ b.failCount = 0
+
+ return moves, nil
+}
+
+// validateMoves validates and filters moves against the current state.
+func (b *HTTPBot) validateMoves(moves []Move, state *VisibleState) []Move {
+ // Build set of owned bot positions
+ ownedPositions := make(map[Position]bool)
+ for _, bot := range state.Bots {
+ if bot.Owner == state.You.ID {
+ ownedPositions[bot.Position] = true
+ }
+ }
+
+ // Filter to valid moves
+ validMoves := make([]Move, 0, len(moves))
+ seen := make(map[Position]bool)
+
+ for _, move := range moves {
+ // Check direction is valid
+ if move.Direction < DirN || move.Direction > DirW {
+ continue
+ }
+
+ // Check position has an owned bot
+ if !ownedPositions[move.Position] {
+ continue
+ }
+
+ // Check for duplicate positions (first wins)
+ if seen[move.Position] {
+ continue
+ }
+ seen[move.Position] = true
+
+ validMoves = append(validMoves, move)
+ }
+
+ return validMoves
+}
+
+// recordFailure tracks consecutive failures and marks bot as crashed after 10.
+func (b *HTTPBot) recordFailure() {
+ b.failCount++
+ if b.failCount >= 10 {
+ b.crashed = true
+ }
+}
+
+// Health checks the bot's health endpoint.
+func (b *HTTPBot) Health() error {
+ url := fmt.Sprintf("%s/health", b.baseURL)
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create health request: %w", err)
+ }
+
+ resp, err := b.client.Do(req)
+ if err != nil {
+ return fmt.Errorf("health check failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("health check returned status %d", resp.StatusCode)
+ }
+
+ return nil
+}
diff --git a/engine/bot_http_test.go b/engine/bot_http_test.go
new file mode 100644
index 0000000..4b1eb12
--- /dev/null
+++ b/engine/bot_http_test.go
@@ -0,0 +1,260 @@
+package engine
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestHTTPBot_GetMoves(t *testing.T) {
+ // Create a test server
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/turn" {
+ http.NotFound(w, r)
+ return
+ }
+
+ // Verify headers
+ if r.Header.Get("Content-Type") != "application/json" {
+ t.Error("missing Content-Type header")
+ }
+ if r.Header.Get("X-ACB-Match-Id") == "" {
+ t.Error("missing X-ACB-Match-Id header")
+ }
+ if r.Header.Get("X-ACB-Signature") == "" {
+ t.Error("missing X-ACB-Signature header")
+ }
+
+ // Read and parse request body
+ var state VisibleState
+ if err := json.NewDecoder(r.Body).Decode(&state); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ // Return moves for owned bots
+ moves := make([]Move, 0)
+ for _, bot := range state.Bots {
+ if bot.Owner == state.You.ID {
+ moves = append(moves, Move{
+ Position: bot.Position,
+ Direction: DirN,
+ })
+ }
+ }
+
+ resp := MoveResponse{Moves: moves}
+ body, _ := json.Marshal(resp)
+
+ // Sign response
+ sig := SignResponse("test-secret", state.MatchID, state.Turn, body)
+ w.Header().Set("X-ACB-Signature", sig)
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(body)
+ }))
+ defer server.Close()
+
+ // Create HTTP bot
+ auth := AuthConfig{
+ BotID: "b_test",
+ Secret: "test-secret",
+ MatchID: "m_test",
+ }
+ bot := NewHTTPBot(server.URL, auth)
+
+ // Create test game state
+ state := &VisibleState{
+ MatchID: "m_test",
+ Turn: 1,
+ Config: DefaultConfig(),
+ You: struct {
+ ID int `json:"id"`
+ Energy int `json:"energy"`
+ Score int `json:"score"`
+ }{
+ ID: 0,
+ Energy: 3,
+ Score: 1,
+ },
+ Bots: []VisibleBot{
+ {Position: Position{Row: 5, Col: 5}, Owner: 0},
+ {Position: Position{Row: 10, Col: 10}, Owner: 1},
+ },
+ Energy: []Position{},
+ Cores: []VisibleCore{},
+ Walls: []Position{},
+ Dead: []VisibleBot{},
+ }
+
+ // Get moves
+ moves, err := bot.GetMoves(state)
+ if err != nil {
+ t.Fatalf("GetMoves failed: %v", err)
+ }
+
+ // Should have one move for the owned bot
+ if len(moves) != 1 {
+ t.Errorf("got %d moves, want 1", len(moves))
+ }
+ if moves[0].Direction != DirN {
+ t.Errorf("got direction %v, want DirN", moves[0].Direction)
+ }
+}
+
+func TestHTTPBot_Timeout(t *testing.T) {
+ // Create a slow server
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(2 * time.Second) // Slow response
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ // Create HTTP bot with 100ms timeout
+ auth := AuthConfig{
+ BotID: "b_test",
+ Secret: "test-secret",
+ MatchID: "m_test",
+ }
+ bot := NewHTTPBot(server.URL, auth, WithHTTPTimeout(100*time.Millisecond))
+
+ state := &VisibleState{
+ MatchID: "m_test",
+ Turn: 1,
+ Config: DefaultConfig(),
+ }
+
+ // Get moves should timeout
+ _, err := bot.GetMoves(state)
+ if err == nil {
+ t.Error("expected timeout error, got nil")
+ }
+
+ // Check failure count increased
+ if bot.failCount != 1 {
+ t.Errorf("failCount = %d, want 1", bot.failCount)
+ }
+}
+
+func TestHTTPBot_CrashAfter10Failures(t *testing.T) {
+ // Create a failing server
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ }))
+ defer server.Close()
+
+ auth := AuthConfig{
+ BotID: "b_test",
+ Secret: "test-secret",
+ MatchID: "m_test",
+ }
+ bot := NewHTTPBot(server.URL, auth)
+
+ state := &VisibleState{
+ MatchID: "m_test",
+ Turn: 1,
+ Config: DefaultConfig(),
+ }
+
+ // Fail 10 times
+ for i := 0; i < 10; i++ {
+ bot.GetMoves(state)
+ }
+
+ // Bot should be crashed
+ if !bot.IsCrashed() {
+ t.Error("bot should be marked as crashed after 10 failures")
+ }
+
+ // Further calls should return empty moves without making HTTP request
+ moves, err := bot.GetMoves(state)
+ if err != nil {
+ t.Errorf("crashed bot should not return error, got: %v", err)
+ }
+ if len(moves) != 0 {
+ t.Errorf("crashed bot should return empty moves, got %d", len(moves))
+ }
+}
+
+func TestHTTPBot_ValidateMoves(t *testing.T) {
+ // Create a server that returns invalid moves
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var state VisibleState
+ json.NewDecoder(r.Body).Decode(&state)
+
+ // Return moves with:
+ // 1. Invalid direction
+ // 2. Position without owned bot
+ // 3. Duplicate position
+ // 4. Valid move
+ moves := []Move{
+ {Position: Position{Row: 0, Col: 0}, Direction: DirNone}, // Invalid direction
+ {Position: Position{Row: 99, Col: 99}, Direction: DirN}, // No bot there
+ {Position: Position{Row: 5, Col: 5}, Direction: DirN}, // Valid
+ {Position: Position{Row: 5, Col: 5}, Direction: DirS}, // Duplicate
+ }
+
+ resp := MoveResponse{Moves: moves}
+ body, _ := json.Marshal(resp)
+ sig := SignResponse("test-secret", state.MatchID, state.Turn, body)
+ w.Header().Set("X-ACB-Signature", sig)
+ w.Write(body)
+ }))
+ defer server.Close()
+
+ auth := AuthConfig{
+ BotID: "b_test",
+ Secret: "test-secret",
+ MatchID: "m_test",
+ }
+ bot := NewHTTPBot(server.URL, auth)
+
+ state := &VisibleState{
+ MatchID: "m_test",
+ Turn: 1,
+ Config: DefaultConfig(),
+ You: struct {
+ ID int `json:"id"`
+ Energy int `json:"energy"`
+ Score int `json:"score"`
+ }{ID: 0},
+ Bots: []VisibleBot{
+ {Position: Position{Row: 5, Col: 5}, Owner: 0}, // Our bot
+ {Position: Position{Row: 10, Col: 10}, Owner: 1}, // Enemy bot
+ },
+ }
+
+ moves, err := bot.GetMoves(state)
+ if err != nil {
+ t.Fatalf("GetMoves failed: %v", err)
+ }
+
+ // Should only have 1 valid move (duplicate filtered, invalid direction filtered, non-owned filtered)
+ if len(moves) != 1 {
+ t.Errorf("got %d moves, want 1 (invalid filtered out)", len(moves))
+ }
+}
+
+func TestHTTPBot_Health(t *testing.T) {
+ // Create a server with health endpoint
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/health" {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ http.NotFound(w, r)
+ }))
+ defer server.Close()
+
+ auth := AuthConfig{
+ BotID: "b_test",
+ Secret: "test-secret",
+ MatchID: "m_test",
+ }
+ bot := NewHTTPBot(server.URL, auth)
+
+ if err := bot.Health(); err != nil {
+ t.Errorf("Health check failed: %v", err)
+ }
+}
diff --git a/engine/integration_test.go b/engine/integration_test.go
new file mode 100644
index 0000000..3b95d64
--- /dev/null
+++ b/engine/integration_test.go
@@ -0,0 +1,171 @@
+package engine
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "math/rand"
+)
+
+// TestIntegration_HTTPMatch runs a complete match between two HTTP bots.
+func TestIntegration_HTTPMatch(t *testing.T) {
+ secret := "test-integration-secret"
+
+ // Create mock bot servers for two players
+ server0 := createMockBotServer(t, secret, 0)
+ server1 := createMockBotServer(t, secret, 1)
+ defer server0.Close()
+ defer server1.Close()
+
+ // Create HTTP bots
+ auth0 := AuthConfig{BotID: "b_0", Secret: secret, MatchID: "m_integration"}
+ auth1 := AuthConfig{BotID: "b_1", Secret: secret, MatchID: "m_integration"}
+
+ bot0 := NewHTTPBot(server0.URL, auth0, WithHTTPTimeout(5*time.Second))
+ bot1 := NewHTTPBot(server1.URL, auth1, WithHTTPTimeout(5*time.Second))
+
+ // Create match runner with small config for fast test
+ config := DefaultConfig()
+ config.Rows = 20
+ config.Cols = 20
+ config.MaxTurns = 100
+
+ runner := NewMatchRunner(config,
+ WithRNG(rand.New(rand.NewSource(12345))),
+ WithTimeout(5*time.Second),
+ )
+
+ runner.AddBot(bot0, "HTTPBot0")
+ runner.AddBot(bot1, "HTTPBot1")
+
+ // Run the match
+ result, replay, err := runner.Run()
+ if err != nil {
+ t.Fatalf("Match failed: %v", err)
+ }
+
+ if result == nil {
+ t.Fatal("Match result is nil")
+ }
+
+ if replay == nil {
+ t.Fatal("Replay is nil")
+ }
+
+ if replay.MatchID == "" {
+ t.Error("Replay has empty MatchID")
+ }
+
+ if len(replay.Players) != 2 {
+ t.Errorf("Replay has %d players, want 2", len(replay.Players))
+ }
+
+ if len(replay.Turns) == 0 {
+ t.Error("Replay has no turns")
+ }
+
+ t.Logf("Match completed: Winner=%d, Turns=%d", result.Winner, result.Turns)
+}
+
+// TestIntegration_HMACAuthentication verifies HMAC signing works end-to-end.
+func TestIntegration_HMACAuthentication(t *testing.T) {
+ secret := "hmac-test-secret"
+ matchID := "m_hmac_test"
+ turn := 42
+ timestamp := time.Now().Unix()
+ requestBody := []byte(`{"match_id":"m_hmac_test","turn":42}`)
+
+ signature := SignRequest(secret, matchID, turn, timestamp, requestBody)
+
+ auth := RequestAuth{
+ MatchID: matchID,
+ Turn: turn,
+ Timestamp: timestamp,
+ BotID: "b_test",
+ Signature: signature,
+ }
+ if err := VerifyRequest(secret, auth, requestBody); err != nil {
+ t.Errorf("Signature verification failed: %v", err)
+ }
+
+ if err := VerifyRequest("wrong-secret", auth, requestBody); err == nil {
+ t.Error("Verification should fail with wrong secret")
+ }
+
+ if err := VerifyRequest(secret, auth, []byte("wrong body")); err == nil {
+ t.Error("Verification should fail with wrong body")
+ }
+}
+
+// TestIntegration_ResponseSigning verifies response signing works.
+func TestIntegration_ResponseSigning(t *testing.T) {
+ secret := "response-test-secret"
+ matchID := "m_response_test"
+ turn := 10
+ responseBody := []byte(`{"moves":[{"position":{"row":5,"col":5},"direction":"N"}]}`)
+
+ signature := SignResponse(secret, matchID, turn, responseBody)
+
+ if err := VerifyResponse(secret, matchID, turn, signature, responseBody); err != nil {
+ t.Errorf("Response verification failed: %v", err)
+ }
+
+ if err := VerifyResponse("wrong-secret", matchID, turn, signature, responseBody); err == nil {
+ t.Error("Verification should fail with wrong secret")
+ }
+}
+
+// createMockBotServer creates a test HTTP server that acts as a bot.
+func createMockBotServer(t *testing.T, secret string, playerID int) *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/health" {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ if r.URL.Path != "/turn" {
+ http.NotFound(w, r)
+ return
+ }
+
+ var state VisibleState
+ if err := json.NewDecoder(r.Body).Decode(&state); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ moves := make([]Move, 0)
+ for _, bot := range state.Bots {
+ if bot.Owner == state.You.ID {
+ dir := DirN
+ if playerID == 1 {
+ dir = DirE
+ }
+ moves = append(moves, Move{
+ Position: bot.Position,
+ Direction: dir,
+ })
+ }
+ }
+
+ resp := MoveResponse{Moves: moves}
+ body, _ := json.Marshal(resp)
+
+ matchID := r.Header.Get("X-ACB-Match-Id")
+ turnStr := r.Header.Get("X-ACB-Turn")
+ turn := 0
+ for _, c := range turnStr {
+ if c >= '0' && c <= '9' {
+ turn = turn*10 + int(c-'0')
+ }
+ }
+
+ sig := SignResponse(secret, matchID, turn, body)
+ w.Header().Set("X-ACB-Signature", sig)
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(body)
+ }))
+}