feat(starter-php): add PHP starter kit (acb-starter-php)

Add starters/php/ with complete starter kit for AI Code Battle:

- index.php: HTTP server with HMAC verification, routing for /turn and /health
- strategy.php: Stub compute_moves() function with example energy-seeking logic
- game.php: Game state types (GameState, Position, VisibleBot, etc.) and grid utilities (toroidal_manhattan, toroidal_chebyshev, bfs, neighbors, cardinal_steps)
- Dockerfile: Alpine-based PHP 8.4 container
- README.md: Quickstart documentation with local/Docker run instructions
- composer.json: Minimal composer config (no external dependencies)

Follows same contract as other starters:
- Listens on port 8080 (BOT_PORT env var)
- POST /turn: Receives game state JSON, returns moves JSON
- GET /health: Health check endpoint
- HMAC-SHA256 signature verification on requests/responses

Reference implementation: bots/guardian/ (GuardianBot in PHP)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-04 03:35:29 -04:00
parent 90431344e8
commit 01da007045
6 changed files with 628 additions and 0 deletions

12
starters/php/Dockerfile Normal file
View file

@ -0,0 +1,12 @@
FROM php:8.4-cli-alpine
WORKDIR /app
COPY index.php strategy.php game.php composer.json ./
ENV BOT_PORT=8080
ENV BOT_SECRET=""
EXPOSE 8080
CMD ["php", "index.php"]

74
starters/php/README.md Normal file
View file

@ -0,0 +1,74 @@
# acb-starter-php
PHP 8 starter kit for [AI Code Battle](https://aicodebattle.com) — a competitive bot programming platform.
## Quick Start
```bash
# Run locally
BOT_SECRET=dev-secret php index.php
# Run with Docker
docker build -t my-bot .
docker run -e BOT_SECRET=your-secret -p 8080:8080 my-bot
```
Your bot listens on port 8080 and responds to `POST /turn` with move commands.
## Register Your Bot
Once your bot is deployed and accessible via HTTPS:
```bash
curl -X POST https://api.aicodebattle.com/api/register \
-H "Content-Type: application/json" \
-d '{
"name": "my-php-bot",
"endpoint_url": "https://my-bot.example.com",
"owner": "your-name",
"description": "My awesome bot"
}'
```
Save the `bot_id` and `shared_secret` from the response — the secret is shown only once.
## Project Structure
```
index.php # HTTP server, HMAC auth, and routing
strategy.php # Your bot logic (implement compute_moves())
game.php # Game state types and grid utilities
composer.json # Composer config (no external deps required)
Dockerfile # Container build
```
## Grid Helpers
`game.php` provides utility functions for the toroidal grid:
- `toroidal_manhattan($a, $b, $rows, $cols)` — Manhattan distance with wrap-around
- `toroidal_chebyshev($a, $b, $rows, $cols)` — Chebyshev distance with wrap-around
- `neighbors($p, $rows, $cols)` — 8-directional neighbors with wrap
- `cardinal_steps($p, $rows, $cols)` — Cardinal directions with positions
- `bfs($start, $goal, $passable, $rows, $cols)` — BFS pathfinding, returns path or `null`
## Customization
Edit `compute_moves()` in `strategy.php` to implement your strategy. The `GameState` object provides:
- `bots` — all visible bots (yours and enemies)
- `energy` — visible energy pickup locations
- `cores` — visible core positions
- `walls` — visible wall positions
- `you->energy` — your current energy count
- `you->score` — your current score
- `config` — match parameters (grid size, vision radius, etc.)
Return an array of `Move` objects, each with the bot's current `position` and a `direction` (`"N"`, `"E"`, `"S"`, or `"W"`). Bots not included in the moves array stay in place.
## Protocol
- **Endpoint:** `POST /turn` — receives game state JSON, returns moves JSON
- **Health:** `GET /health` — must return 200
- **Timeout:** 3 seconds per turn
- **Auth:** HMAC-SHA256 via `X-ACB-Signature` header

View file

@ -0,0 +1,14 @@
{
"name": "acb-starter-php",
"description": "PHP starter kit for AI Code Battle",
"type": "project",
"require": {
"php": ">=8.4"
},
"license": "MIT",
"autoload": {
"psr-4": {
"AcbStarter\\": "src/"
}
}
}

288
starters/php/game.php Normal file
View file

@ -0,0 +1,288 @@
<?php
/**
* Game state types and grid utilities for AI Code Battle protocol.
*/
/**
* Position on the grid
*/
class Position {
public int $row;
public int $col;
public function __construct(int $row, int $col) {
$this->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];
}
public function equals(Position $other): bool {
return $this->row === $other->row && $this->col === $other->col;
}
public function __toString(): string {
return "{$this->row},{$this->col}";
}
}
/**
* 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 ?string $seasonId;
public ?string $rulesVersion;
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'];
$config->seasonId = $data['season_id'] ?? null;
$config->rulesVersion = $data['rules_version'] ?? null;
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
];
}
}
// ============================================================================
// Grid Utilities
// ============================================================================
/**
* Calculate Manhattan distance with toroidal wrapping
*/
function toroidal_manhattan(Position $a, Position $b, int $rows, int $cols): int {
$dr = abs($a->row - $b->row);
$dc = abs($a->col - $b->col);
$dr = min($dr, $rows - $dr);
$dc = min($dc, $cols - $dc);
return $dr + $dc;
}
/**
* Calculate Chebyshev distance with toroidal wrapping
*/
function toroidal_chebyshev(Position $a, Position $b, int $rows, int $cols): int {
$dr = abs($a->row - $b->row);
$dc = abs($a->col - $b->col);
$dr = min($dr, $rows - $dr);
$dc = min($dc, $cols - $dc);
return max($dr, $dc);
}
/**
* Get 8-directional neighbors with wrap-around
* @return Position[]
*/
function neighbors(Position $p, int $rows, int $cols): array {
$offsets = [
[-1, -1], [-1, 0], [-1, 1],
[0, -1], [0, 1],
[1, -1], [1, 0], [1, 1]
];
$result = [];
foreach ($offsets as [$dr, $dc]) {
$result[] = new Position(
($p->row + $dr + $rows) % $rows,
($p->col + $dc + $cols) % $cols
);
}
return $result;
}
/**
* Get cardinal direction steps with wrap-around
* @return array Array of ['pos' => Position, 'dir' => string]
*/
function cardinal_steps(Position $p, int $rows, int $cols): array {
$steps = [
[-1, 0, 'N'],
[0, 1, 'E'],
[1, 0, 'S'],
[0, -1, 'W']
];
$result = [];
foreach ($steps as [$dr, $dc, $dir]) {
$result[] = [
'pos' => new Position(
($p->row + $dr + $rows) % $rows,
($p->col + $dc + $cols) % $cols
),
'dir' => $dir
];
}
return $result;
}
/**
* BFS pathfinding on a toroidal grid
*
* @param Position $start Starting position
* @param Position $goal Goal position
* @param callable $passable callable(Position): bool - returns true if cell can be entered
* @param int $rows Grid rows
* @param int $cols Grid cols
* @return Position[]|null Array of positions from start to goal (excluding start), or null if unreachable
*/
function bfs(Position $start, Position $goal, callable $passable, int $rows, int $cols): ?array {
if ($start->equals($goal)) {
return [];
}
$queue = [[$start, []]];
$visited = [(string)$start => true];
while (!empty($queue)) {
[$current, $path] = array_shift($queue);
foreach (neighbors($current, $rows, $cols) as $next) {
if ($next->equals($goal)) {
return [...$path, $next];
}
$key = (string)$next;
if (!isset($visited[$key]) && $passable($next)) {
$visited[$key] = true;
$queue[] = [$next, [...$path, $next]];
}
}
}
return null;
}

178
starters/php/index.php Normal file
View file

@ -0,0 +1,178 @@
<?php
/**
* AI Code Battle - PHP Starter Kit
*
* A minimal bot scaffold with HMAC authentication and a placeholder
* strategy. Implement your strategy in strategy.php.
*
* Usage:
* BOT_SECRET=your-secret php index.php
*/
// Get configuration from environment
$port = getenv('BOT_PORT') ?: '8080';
$secret = getenv('BOT_SECRET');
if (!$secret) {
fwrite(STDERR, "ERROR: BOT_SECRET environment variable is required\n");
exit(1);
}
require_once __DIR__ . '/game.php';
require_once __DIR__ . '/strategy.php';
// Build HTTP server using PHP built-in
$server = stream_socket_server("tcp://0.0.0.0:$port", $errno, $errstr);
if (!$server) {
fwrite(STDERR, "Failed to create server: $errstr ($errno)\n");
exit(1);
}
fwrite(STDOUT, "Bot listening on port $port\n");
while ($conn = stream_socket_accept($server)) {
handle_request($conn, $secret);
fclose($conn);
}
/**
* Handle an incoming HTTP request
*/
function handle_request($conn, string $secret): void {
// Read request
$request = fread($conn, 65536);
// Parse request line
$lines = explode("\r\n", $request);
$requestLine = explode(' ', $lines[0] ?? '');
$method = $requestLine[0] ?? '';
$path = $requestLine[1] ?? '/';
// Parse headers
$headers = [];
$bodyStart = 0;
for ($i = 1; $i < count($lines); $i++) {
if ($lines[$i] === '') {
$bodyStart = $i + 1;
break;
}
$parts = explode(': ', $lines[$i], 2);
if (count($parts) === 2) {
$headers[$parts[0]] = $parts[1];
}
}
// Extract body
$body = implode("\r\n", array_slice($lines, $bodyStart));
// Route request
if ($method === 'GET' && $path === '/health') {
send_response($conn, 200, 'text/plain', 'OK');
return;
}
if ($method === 'POST' && $path === '/turn') {
handle_turn($conn, $secret, $headers, $body);
return;
}
send_response($conn, 404, 'text/plain', 'Not Found');
}
/**
* Handle turn request
*/
function handle_turn($conn, string $secret, array $headers, string $body): void {
// Extract auth headers
$matchId = $headers['X-ACB-Match-Id'] ?? '';
$turnStr = $headers['X-ACB-Turn'] ?? '';
$timestamp = $headers['X-ACB-Timestamp'] ?? '';
$signature = $headers['X-ACB-Signature'] ?? '';
if (!$matchId || !$turnStr || !$timestamp || !$signature) {
send_response($conn, 401, 'text/plain', 'Missing auth headers');
return;
}
// Verify signature
if (!verify_signature($secret, $matchId, $turnStr, $timestamp, $body, $signature)) {
send_response($conn, 401, 'text/plain', 'Invalid signature');
return;
}
// Parse game state
$state = json_decode($body, true);
if (!$state) {
send_response($conn, 400, 'text/plain', 'Invalid JSON');
return;
}
$gameState = GameState::fromArray($state);
// Log match start
if ($gameState->turn === 0) {
$seasonId = $gameState->config->seasonId ?? '';
$rulesVersion = $gameState->config->rulesVersion ?? '';
fwrite(STDOUT, "match={$gameState->matchId} season_id={$seasonId} rules_version={$rulesVersion} rows={$gameState->config->rows} cols={$gameState->config->cols}\n");
}
// Compute moves
$moves = compute_moves($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);
}

62
starters/php/strategy.php Normal file
View file

@ -0,0 +1,62 @@
<?php
/**
* Strategy implementation for AI Code Battle.
*
* This file contains the compute_moves() function which is called each turn.
* Implement your bot logic here.
*
* Available helpers from game.php:
* - toroidal_manhattan(Position $a, Position $b, int $rows, int $cols): int
* - toroidal_chebyshev(Position $a, Position $b, int $rows, int $cols): int
* - bfs(Position $start, Position $goal, callable $passable, int $rows, int $cols): ?array
* - cardinal_steps(Position $p, int $rows, int $cols): array
*/
/**
* Compute moves for all owned bots.
*
* @param GameState $state The current game state
* @return Move[] Array of move commands (bots not included stay in place)
*/
function compute_moves(GameState $state): array {
$myId = $state->you->id;
$config = $state->config;
$rows = $config->rows;
$cols = $config->cols;
$moves = [];
foreach ($state->bots as $bot) {
if ($bot->owner !== $myId) {
continue;
}
// Example: Find nearest energy and move toward it
if (!empty($state->energy)) {
$bestDist = PHP_INT_MAX;
$bestDir = null;
foreach (cardinal_steps($bot->position, $rows, $cols) as $step) {
foreach ($state->energy as $energy) {
$dist = toroidal_manhattan($step['pos'], $energy, $rows, $cols);
if ($dist < $bestDist) {
$bestDist = $dist;
$bestDir = $step['dir'];
}
}
}
if ($bestDir) {
$moves[] = new Move($bot->position, $bestDir);
continue;
}
}
// Default: hold position (don't add a move)
// Or move randomly:
// $directions = ['N', 'E', 'S', 'W'];
// $moves[] = new Move($bot->position, $directions[array_rand($directions)]);
}
return $moves;
}