Skip to content

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

{ "ok": false, "error": "<human-readable reason>" }

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  }
    }
  }'
{
  "ok": true,
  "arenaId":        "a1b2c3d4-e5f6-…",
  "ownerKey":       "owner_e5f6a7b8…",
  "arenaKey":       "arena_c9d0e1f2…",
  "spectatorToken": "spec_g3h4i5j6…"
}

For Seneca arenas where commonsCapacitycommonsInitial, the response also includes:

{
  "ok": true,
  "arenaId": "…",
  "warnings": [
    "commonsCapacity (150) ignored for Seneca engine — using commonsInitial (1.0) as reference capacity"
  ]
}

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:

curl -s -N "http://localhost:3000/api/arenas/${ARENA_ID}/stream?token=${SPECTATOR_TOKEN}"
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
event: init
data: {"turns": [...], "arenaId": "...", "players": [...]}

event: tick
data: {"turn": 1, "commons": {...}, "agents": [...], "metrics": {...}}

event: arena_closed
data: {"victory": {...}, "rankings": [...]}

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.