Arena API¶
The BDPD Platform exposes a RESTful API over HTTP. All endpoints return JSON. Authentication uses three distinct token types.
Authentication¶
| Token | Prefix | Purpose | Transport |
|---|---|---|---|
| Owner key | owner_* |
Administrative operations (start, tick, close, summary, delete) | X-Owner-Key header |
| Arena key | arena_* |
Player join | X-Arena-Key header |
| Spectator token | spec_* |
Read-only SSE streaming | ?token=… query string or X-Spectator-Token header |
All three tokens are returned on arena creation. Store them — they are
not recoverable. The URL always references the arena by its arenaId
(UUID); the keys go in headers/query, never in the URL path.
Endpoint Reference¶
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/api/arenas |
— (or owner key to reuse) | Create a new arena; returns all three keys |
GET |
/api/arenas |
owner (optional) | List arenas (filtered by owner if key sent) |
GET |
/api/arenas/history |
— | List closed arenas from disk (meta.json) |
GET |
/api/arenas/history/:id/summary |
— | Replay summary of a closed arena from disk |
GET |
/api/arenas/:id |
— | Public view |
GET |
/api/arenas/:id/owner |
owner | Owner view (includes secrets, full state) |
POST |
/api/arenas/:id/join |
arena key | Add a player (builtin, code, http) |
POST |
/api/arenas/:id/start |
owner | Start the arena (waiting → running) |
POST |
/api/arenas/:id/tick |
owner | Advance one turn (manual tick mode) |
POST |
/api/arenas/:id/close |
owner | Close the arena |
GET |
/api/arenas/:id/stream |
spectator / arena / owner | SSE stream of real-time events |
GET |
/api/arenas/:id/summary |
owner | Full post-game report |
POST |
/api/arenas/:id/perturb |
owner | Fire a perturbation manually |
GET |
/api/arenas/:id/perturbations |
owner | List perturbation specs + log |
DELETE |
/api/arenas/:id |
owner | Delete arena from memory and disk |
GET |
/api/health |
— | Liveness probe ({"ok": true, "ts": "…", "apiVersions": ["legacy", "v1"]}) |
GET |
/api/v1/health |
— | Versioned alias for /api/health (same response shape) |
World API (≥ v1.0)¶
The polycentric federation API lives under /api/v1/worlds. Create
worlds, attach arenas, add cross-arena links and treaties, drive
global rounds. See World API for the full reference.
Error format¶
All endpoints return JSON. On failure the body is
with the HTTP status reflecting the class of failure:
| Status | Class | Example |
|---|---|---|
400 |
client payload validation | missing field, invalid type, unknown perturbation |
401 |
missing or unrecognised key | no Authorization: Bearer … on an authenticated route |
403 |
wrong key class for the route | arena-key on an owner-only route |
404 |
resource not found | arena id does not exist (in memory or on disk) |
409 |
state conflict | starting an arena already running, joining an arena past start |
500 |
server bug | unhandled exception in a handler — file an issue |
Success responses do not wrap the payload in { ok: true, ... }; they
return the resource shape directly. The ok: false envelope is used
only on error so client code can branch on the presence of an
error field without parsing status codes.
Create an Arena¶
curl -s -X POST http://localhost:3000/api/arenas \
-H "Content-Type: application/json" \
-d '{
"model": {
"engineMode": "logistic",
"commonsInitial": 150,
"commonsCapacity": 150,
"regenRate": 0.12,
"threshold": 10,
"maxTurns": 60,
"scheduler": "simultaneous",
"minPlayers": 3,
"maxPlayers": 6
},
"observability": {
"commonsStock": { "visible": true, "noise": 0.05 },
"regenRate": { "visible": false },
"othersWealth": { "visible": true, "noise": 0.10 },
"othersHarvest": { "visible": true, "noise": 0.0 }
}
}'
Full Arena Creation Payload (≥ v0.5.1)¶
{
"model": {
"engineMode": "logistic",
"name": "fishery_commons_v1",
"description": "Standard 4-agent fishery",
"commonsInitial": 150,
"commonsCapacity": 150,
"regenRate": 0.12,
"threshold": 10,
"maxTurns": 60,
"scheduler": "simultaneous",
"tickInterval": null,
"minPlayers": 3,
"maxPlayers": 6,
"useHiddenReserve": false,
"hiddenReserveInitial": 20,
"collapseDie": { "sides": 6, "successThreshold": 4 },
"seneca": { "k1": 0.03, "k2": 0.30, "l2": 0.01 },
"capitalSeed": 0.01,
"pollutionSeed": 0.001
},
"observability": {
"commonsStock": { "visible": true, "noise": 0.05, "resolution": "exact" },
"regenRate": { "visible": false },
"capitalStock": { "visible": true, "noise": 0.0 },
"pollutionLevel": { "visible": false },
"othersWealth": { "visible": true, "noise": 0.10 },
"othersHarvest": { "visible": true, "noise": 0.0 }
},
"perturbations": [
{
"id": "shock1",
"type": "regen_shock",
"trigger": { "kind": "turn", "turn": 20 },
"payload": { "factor": 0.5 }
}
],
"agentTimeout": 30000
}
| Field | Type | Default | Description |
|---|---|---|---|
model.engineMode |
"logistic" | "seneca" |
"logistic" |
Resource model (≥ v0.5.0) |
model.commonsInitial |
number | 150 (logistic) / 1.0 (Seneca) | Starting resource stock / \(R_0\) |
model.commonsCapacity |
number | 150 (logistic), \(R_0\) (Seneca) | Carrying capacity \(K\) — ignored by Seneca |
model.regenRate |
number | 0.12 | Regeneration rate \(r\) (logistic only) |
model.threshold |
number | 10 | Gate threshold (ignored if useHiddenReserve: true) |
model.maxTurns |
number | 60 | Maximum game length |
model.scheduler |
"simultaneous" | "sequential_random" | "wealth_weighted" |
"simultaneous" |
Turn order (≥ v0.4.0) |
model.tickInterval |
number | null | null |
Auto-tick interval in ms (null = manual POST /tick) |
model.useHiddenReserve |
boolean | false |
Enable stochastic collapse — logistic only (≥ v0.4.0) |
model.hiddenReserveInitial |
number | 20 | Size of hidden reserve |
model.collapseDie.sides |
number | 6 | Forest Die faces |
model.collapseDie.successThreshold |
number | 4 | Roll ≤ this = success; otherwise collapse |
model.seneca |
object | {} |
Seneca ODE parameter overrides (k1, k2, l1, l2, l3, dt, steps) |
model.capitalSeed |
number | 0.01 | Seneca initial capital \(C_0\) |
model.pollutionSeed |
number | 0.001 | Seneca initial pollution \(P_0\) |
observability.*.visible |
boolean | varies | Whether the variable is visible to agents |
observability.*.noise |
number | 0.0 | Gaussian noise σ/μ on the variable |
observability.*.resolution |
"exact" | "bucket" | "sign" |
"exact" |
Resolution mode |
perturbations |
array | [] |
List of perturbation specs (see Perturbations) |
agentTimeout |
number | 30000 | HTTP agent response timeout (ms) |
Seneca commonsCapacity
Passing commonsCapacity with a value different from commonsInitial
for a Seneca arena is valid but has no effect on the ODE. The creation
response will include a warnings array explaining the mismatch.
Join an Agent¶
curl -s -X POST "http://localhost:3000/api/arenas/${ARENA_ID}/join" \
-H "Content-Type: application/json" \
-H "X-Arena-Key: ${ARENA_KEY}" \
-d '{
"agentType": "builtin",
"strategy": "aggressive",
"displayName": "Aggr",
"privateResource": 30,
"strategyParams": { "intensity": 0.5 }
}'
Built-in strategies: aggressive, conservative, adaptive
(locally reactive — see Agent Types), rcp
(≥ v0.4.0, Seneca only), random.
curl -s -X POST "http://localhost:3000/api/arenas/${ARENA_ID}/join" \
-H "Content-Type: application/json" \
-H "X-Arena-Key: ${ARENA_KEY}" \
-d '{
"agentType": "code",
"displayName": "TrendConservative",
"privateResource": 30,
"strategyCode": "function decide(obs, memory) { ... }"
}'
The function runs in a sandboxed VM context with 50ms timeout and a
persistent private memory object. See Agent Types
for a worked example.
curl -s -X POST "http://localhost:3000/api/arenas/${ARENA_ID}/join" \
-H "Content-Type: application/json" \
-H "X-Arena-Key: ${ARENA_KEY}" \
-d '{
"agentType": "http",
"displayName": "MyLLMAgent",
"privateResource": 30,
"callbackUrl": "http://localhost:5001/decide"
}'
The platform POSTs the observability-filtered state to callbackUrl
and expects {"harvest": <number>} within agentTimeout ms. On
timeout or error, falls back to \(0.2 \times \text{capacity}(w)\).
SSE Streaming¶
Subscribe to real-time arena events:
| Event | Payload | When |
|---|---|---|
init |
Compact history of all past ticks | On connection |
tick |
Full turn record (agent decisions, commons state, metrics) | Each turn |
player_joined |
Agent ID and type | When an agent joins |
arena_started |
Turn 0 state | Transition to running |
arena_closed |
Victory evaluation, rankings, end reason | Arena closes |
The init event includes all past ticks, allowing late-connecting clients
to reconstruct the full trajectory without additional API calls.
Complete Walkthrough¶
# 1. Start the platform
node main.js &
sleep 2
# 2. Create an arena (manual tick mode: tickInterval=null)
ARENA=$(curl -s -X POST http://localhost:3000/api/arenas \
-H "Content-Type: application/json" \
-d '{"model":{"commonsInitial":150,"commonsCapacity":150,"regenRate":0.12,"maxTurns":30,"threshold":10,"scheduler":"simultaneous","tickInterval":null,"minPlayers":1,"maxPlayers":4}}')
ARENA_ID=$(echo "$ARENA" | jq -r .arenaId)
ARENA_KEY=$(echo "$ARENA" | jq -r .arenaKey)
OWNER_KEY=$(echo "$ARENA" | jq -r .ownerKey)
# 3. Join agents (X-Arena-Key authenticates; URL uses arenaId)
for strat in aggressive conservative adaptive; do
curl -s -X POST "http://localhost:3000/api/arenas/${ARENA_ID}/join" \
-H "Content-Type: application/json" \
-H "X-Arena-Key: ${ARENA_KEY}" \
-d "{\"agentType\":\"builtin\",\"strategy\":\"$strat\",\"displayName\":\"$strat\",\"privateResource\":30}"
done
# 4. Start the game (X-Owner-Key authenticates)
curl -s -X POST "http://localhost:3000/api/arenas/${ARENA_ID}/start" \
-H "X-Owner-Key: ${OWNER_KEY}"
# 5. Drive ticks manually until the arena closes
while true; do
T=$(curl -s -X POST "http://localhost:3000/api/arenas/${ARENA_ID}/tick" -H "X-Owner-Key: ${OWNER_KEY}")
echo "$T" | jq -e '.ok == false' >/dev/null 2>&1 && break
echo "$T" | jq -r '.record.turn'
done
# 6. Read summary
curl -s "http://localhost:3000/api/arenas/${ARENA_ID}/summary" \
-H "X-Owner-Key: ${OWNER_KEY}" | jq .
Auto-tick mode
To let the arena tick on its own timer, set tickInterval to a value
in milliseconds (e.g. 1000 for one turn per second) at creation. The
manual POST /tick is then disabled — observe progress via the SSE
stream instead.