flowchart TD
subgraph CLIENT["Client Layer"]
WEB["Web Dashboard\n(arena.html)"]
SCRIPT["Orchestration Script\n(scripts/run_arena.sh / Python)"]
EXT["External Agent\n(bdpd_agent.py / RL model)"]
end
subgraph SERVER["Node.js Server"]
API["REST API\n/api/arenas/*"]
REG["Arena Registry\n(in-memory index)"]
STR["Streamer\n(SSE broadcast)"]
subgraph ARENA["Arena Instance"]
EREG["Engine Registry\n(logistic | seneca | …)"]
ENG["Active Engine\n(ODE step · checkEnd)"]
SCHED["Scheduler\n(simultaneous / sequential / wealth-weighted)"]
PERT["Perturbation Engine\n(shocks / strategy override)"]
OBS["Observability Layer\n(noise / visibility / resolution)"]
EREG --> ENG
end
subgraph AGENTS["Agent Runners"]
BUILTIN["Built-in Heuristics\n(aggressive / conservative / adaptive / rcp / random)"]
SANDBOX["Sandboxed JS\n(user code, 50ms timeout)"]
HTTP["HTTP Dispatcher\n(async fetch + AbortController)"]
end
end
subgraph STORAGE["Persistence"]
JSONL["turns.jsonl\n(per-tick log)"]
META["meta.json\n(arena metadata)"]
SUMM["summary.json\n(final report)"]
end
SCRIPT -->|"POST /api/arenas"| API
WEB -->|"GET /api/arenas/:id/stream (SSE)"| STR
HTTP -->|"POST /decide"| EXT
API --> REG
REG --> ARENA
ENG --> SCHED
SCHED --> OBS
OBS --> AGENTS
AGENTS --> ENG
ENG --> PERT
ENG --> STR
ENG --> JSONL
REG --> META
REG --> SUMM
Appendix A — The BDPD Platform — Technical Reference
This appendix provides a detailed technical reference for the BDPD Arena platform, complementing the high-level description in Section 2.1. It covers the system architecture, the HTTP API, agent types, and the observability and perturbation subsystems. A worked example of a sandboxed code agent with private memory is provided in Section A.4.
A.1 System Architecture
The BDPD Arena is a stateless Node.js HTTP server that manages one or more concurrent arenas. Each arena is an independent game instance with its own commons, players, scheduler, and event log. Figure A.1 illustrates the overall architecture.
A.1.1 Key Subsystems
Arena Registry. A singleton in-memory index that maps arena IDs, owner keys, arena keys, and spectator tokens to live Arena objects. All authentication is performed here before dispatching to the arena instance. On server restart, closed arenas are not reloaded into memory, but their files on disk remain accessible for historical exploration.
Engine Plugin Registry. Resource dynamics are implemented as pluggable engine classes selected by the engineMode field at arena creation. The registry currently provides two engines: logistic (single-stock logistic growth with optional hidden reserve and stochastic collapse, see Section 2.1) and seneca (Bardi 2011 three-variable ODE, \(\dot{R} = -k_1 RC - l_3 R\), \(\dot{C} = k_1 RC - k_2 CP - l_1 C\), \(\dot{P} = k_2 CP - l_2 P\), exposing capital stock \(C_t\) and pollution level \(P_t\) as additional observations). The active engine is instantiated once at construction and called on every tick via a common interface (step, stepOne, checkEnd, extraObservations); arena.js is engine-agnostic. All state mutations are synchronous within a single tick; concurrent ticks on the same arena are serialized.
Observability Layer. Intercepts the raw game state before it is passed to any agent, applying per-variable transformations: visibility (suppress entirely), Gaussian noise (add zero-mean noise with configurable standard deviation as a fraction of the true value), and resolution (bucket into qualitative categories or return only the sign of change). This layer ensures that agents never receive ground-truth state unless explicitly configured to do so.
Perturbation Engine. A rule-based event scheduler that fires at configurable turns or when threshold conditions are met (e.g., Gini \(> 0.4\)). Supported perturbation types are listed in Table A.1.
| Type | Effect | Research Use |
|---|---|---|
regen_shock |
Multiply regeneration rate by factor \(f\) | Drought (\(f < 1\)) or boom (\(f > 1\)) |
regen_set |
Set regeneration rate to an exact value | Policy mandate / regime change |
commons_shock |
Add/remove fixed stock \(\Delta S\) | Sudden environmental event |
capacity_shock |
Multiply carrying capacity \(K\) by factor \(f\) | Habitat expansion or loss |
wealth_shock |
Multiplicative or additive wealth change | Policy intervention / redistribution |
strategy_override |
Replace agent’s decision function | “Mule” defection experiment |
observability_flip |
Toggle variable visibility mid-game | Information regime change |
threshold_shift |
Move the collapse threshold | Regulatory adjustment |
Streamer. Maintains a list of open SSE connections per arena, broadcasting tick, player_joined, arena_started, and arena_closed events. The init event sent on connection includes a compact history of all past ticks, allowing late-connecting clients to reconstruct the full trajectory without additional API calls.
Persistence. Every tick is appended to turns.jsonl in real time, ensuring that data is not lost if the server crashes. On arena close, meta.json (lightweight metadata) and summary.json (full game report including victory evaluation and player rankings) are written atomically.
A.2 HTTP API Reference
The platform exposes a RESTful API. Authentication uses three distinct token types: the owner key (owner_*) for administrative operations, the arena key for player join, and the spectator token (spec_*) for read-only streaming.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/api/arenas |
— | Create a new arena; returns all keys |
GET |
/api/arenas |
owner (opt.) | 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 of arena state |
GET |
/api/arenas/:id/owner |
owner | Owner view (full state) |
GET |
/api/arenas/:id/stream |
spectator / arena / owner | SSE stream of real-time events |
POST |
/api/arenas/:id/join |
arena key | Add a player (builtin, code, or HTTP) |
POST |
/api/arenas/:id/start |
owner | Start the arena (waiting \(\to\) running) |
POST |
/api/arenas/:id/tick |
owner | Advance one turn (manual tick mode) |
POST |
/api/arenas/:id/close |
owner | Close the arena |
POST |
/api/arenas/:id/perturb |
owner | Fire a perturbation (by specId or ad-hoc spec) |
GET |
/api/arenas/:id/perturbations |
owner | List perturbation specs + log |
GET |
/api/arenas/:id/summary |
owner | Full post-game report |
DELETE |
/api/arenas/:id |
owner | Delete arena from memory and disk |
GET |
/api/health |
— | Liveness probe ({"ok": true, "ts": "…"}) |
A.2.1 Arena Creation Payload
Logistic arena (default engine; the P-sweeps in §3 use the same setup but with useHiddenReserve: false):
{
"model": {
"name": "fishery_commons_v1",
"engineMode": "logistic",
"scheduler": "simultaneous",
"maxPlayers": 4,
"minPlayers": 4,
"maxTurns": 30,
"commonsInitial": 150,
"commonsCapacity": 150,
"regenRate": 0.12,
"threshold": 10,
"useHiddenReserve": true,
"hiddenReserveInitial": 20,
"collapseDie": { "sides": 6, "successThreshold": 4 }
},
"observability": {
"commonsStock": { "visible": true, "noise": 0.05 },
"regenRate": { "visible": false },
"nPlayers": { "visible": true, "noise": 0.0 },
"othersWealth": { "visible": true, "noise": 0.1 },
"othersHarvest": { "visible": true, "noise": 0.0 }
},
"agentTimeout": 30000
}Seneca arena (Bardi 2011 three-variable ODE):
{
"model": {
"name": "seneca_lab_01",
"engineMode": "seneca",
"scheduler": "simultaneous",
"maxPlayers": 4,
"minPlayers": 2,
"maxTurns": 80,
"commonsInitial": 1.0,
"seneca": { "k1": 0.03, "k2": 0.30, "l2": 0.01 }
},
"observability": {
"capitalStock": { "visible": true, "noise": 0.0 },
"pollutionLevel": { "visible": false }
}
}The collapseDie and useHiddenReserve fields (logistic engine only) configure the stochastic collapse mechanism: when an agent attempts to harvest beyond the visible stock, each excess unit triggers a die roll. A result at or below successThreshold draws one unit from the hidden reserve; a result above it causes immediate, irreversible collapse. Hidden reserve and the Forest Die are not implemented for the Seneca engine in the current version; Seneca arenas collapse when \(R_t \to 0\) via the ODE dynamics alone. The agentTimeout field (milliseconds) sets the maximum wait time for HTTP agent responses; exceeding it triggers the default fallback harvest. For Seneca arenas, commonsCapacity is ignored (no fixed carrying capacity exists in the Bardi ODE); commonsInitial sets \(R_0\) and serves as the reference for the commonsRatio observation. Note: the Bardi canonical parameters produce a full Seneca cycle (Capital rise \(\to\) peak \(\to\) crash \(\to\) Pollution peak) over ~3000 turns at dt = 0.10 (Capital peak near turn 1450, Pollution peak near turn 1820, at the engine default capitalSeed = 0.01; the standalone numerical figure Figure 4.1 integrates from a smaller capital seed and so peaks correspondingly later, near Bardi \(t \approx 230\)); the example maxTurns: 80 shown above is illustrative, and substantive Seneca exploration requires maxTurns \(\geq\) 3000.
A.3 Agent Types
A.3.1 Built-in Heuristics
Five deterministic strategies are available as reference baselines. All share the same decide(observation) -> harvest interface.
rcp strategy requires the Seneca engine and will fall back to conservative behaviour if capitalStock is not observable.
| Strategy | Behaviour | Engine |
|---|---|---|
aggressive |
Requests \(h = \min\!\left(\text{capacity}(w_{i,t}),\ i \cdot S_t^{\text{obs}}\right)\), where \(i \in (0,1]\) is the intensity parameter (default \(i = 0.5\)) |
any |
conservative |
Uses a three-tier step function on the commons ratio \(\rho = S_t^{\text{obs}} / K\): \(h = \text{capacity}(w_{i,t}) \times f(\rho)\), where \(f(\rho) = 0.60\) if \(\rho > 0.6\), \(\ 0.35\) if \(0.4 < \rho \leq 0.6\), and \(0.10\) if \(\rho \leq 0.4\) | any |
adaptive |
Maintains a baseline 40% extraction; reduces it by 20% (single-step fall) or 30% (two consecutive falls) of reductionFactor \(f\) when stock is declining |
any |
random |
Samples uniformly from \([0, \text{capacity}(w_{i,t})]\) | any |
rcp |
Tracks \(C_t\) (capitalStock) and \(P_t\) (pollutionLevel) to anticipate the Seneca cliff; reduces harvest proportionally as capital peaks and pollution rises |
seneca |
A.3.2 HTTP Agents
HTTP agents receive the full (observability-filtered) state as a JSON POST to a configurable callback URL and must respond within agentTimeout milliseconds with {"harvest": <number>}. This interface supports any external decision process — reinforcement learning models, language models via bdpd_agent.py, or custom heuristics in any language. On timeout or network error, the agent falls back to capacity(w) \(\times\) 0.2.
A.3.3 Sandboxed Code Agents
Code agents allow users to submit a JavaScript function directly through the web UI or the API. The function is executed in a sandboxed environment with a 50ms wall-clock timeout, no access to the file system, network, or global Node.js APIs. The function signature is:
function decide(obs, memory) {
// obs: the observability-filtered state (object)
// memory: a persistent private object, survives across turns
// return: a number (the requested harvest)
}The memory object is the key feature that distinguishes code agents from built-in heuristics: it is a plain JavaScript object that persists across turns within a single arena run, allowing the agent to maintain state — trend history, running averages, turn counters, or any other private information. It is not shared with other agents and is not exposed through any API endpoint.
A.4 Worked Example: Sandboxed Code Agent with Private Memory
The following example implements a trend-aware conservative strategy. The agent tracks the commons stock over a configurable window, estimates the depletion trend, and modulates its harvest accordingly. When the trend is negative (commons declining), it reduces its demand proportionally; when the trend is stable or positive, it extracts up to its capacity. Additionally, it records its own harvest history to avoid oscillations.
function decide(obs, memory) {
// -- Initialise memory on first turn -------------------------------
if (!memory.stockHistory) memory.stockHistory = [];
if (!memory.harvestHistory) memory.harvestHistory = [];
if (memory.turn === undefined) memory.turn = 0;
memory.turn += 1;
// -- Record current observation -------------------------------------
const stock = obs.commonsStock ?? 100; // may be noisy
const capacity = obs.myCapacity ?? 1.0; // 1 + 0.05 * myWealth
const nPlayers = obs.nPlayers ?? 4;
memory.stockHistory.push(stock);
// Keep only the last 5 observations
const WINDOW = 5;
if (memory.stockHistory.length > WINDOW) {
memory.stockHistory.shift();
}
// -- Estimate depletion trend ---------------------------------------
// Simple linear slope over the observation window (OLS, x = turn index)
let trend = 0;
const hist = memory.stockHistory;
if (hist.length >= 2) {
const n = hist.length;
const xBar = (n - 1) / 2;
const yBar = hist.reduce((a, b) => a + b, 0) / n;
let num = 0, den = 0;
for (let i = 0; i < n; i++) {
num += (i - xBar) * (hist[i] - yBar);
den += (i - xBar) ** 2;
}
trend = den > 0 ? num / den : 0;
}
// -- Sustainable share heuristic ------------------------------------
// Naive sustainable share: each player takes 1/nPlayers of regeneration.
// Regeneration is not directly observed (hidden), so we estimate it as
// regenRate * stock * (1 - stock/K), where K=150 is assumed known.
const K = 150;
const regenEst = 0.12 * stock * (1 - stock / K);
const myShareSus = regenEst / nPlayers;
// -- Harvest decision -----------------------------------------------
// Base: sustainable share, capped by capacity.
let harvest = Math.min(myShareSus, capacity);
// If trend is strongly negative, further reduce by the severity of decline.
if (trend < -2) {
const reductionFactor = Math.max(0.3, 1 + trend / stock);
harvest *= reductionFactor;
}
// Never harvest less than 0.1. The "strategic vacuum" emerges in the
// Arena even without an explicit rule: yielding fully to other agents
// cedes the surplus to them, so a small positive floor preserves a
// minimal share. (The card-game version of this idea — the explicit
// Vacuum Effect — is documented in §2.2.)
harvest = Math.max(0.1, harvest);
// -- Log to private memory ------------------------------------------
memory.harvestHistory.push(parseFloat(harvest.toFixed(3)));
memory.lastTrend = parseFloat(trend.toFixed(3));
return harvest;
}This agent illustrates several features of the code agent interface:
- Private memory initialisation (
memory.stockHistory,memory.harvestHistory,memory.turn) is guarded withif (!memory.x)checks, which is the idiomatic pattern sincememoryis an empty object on turn 1 and populated thereafter. - Trend estimation uses a simple OLS slope over the last five observations. Since the commons stock is observed with Gaussian noise (0.05 standard deviation in the example arena), the windowed average smooths out observation noise without introducing significant lag.
- Regeneration estimation uses the logistic formula with assumed \(K = 150\) and \(r = 0.12\). An agent operating under full observability could use
obs.regenRatedirectly; under partial observability, this estimation is the only available signal. - Strategic-vacuum avoidance — the agent never harvests exactly 0, since fully yielding to other agents cedes the available stock to them. There is no explicit Vacuum-Effect rule in the Arena (that mechanic lives only in the card game, §2.2), but the strategic vacuum of P8 still applies: a reactive concession is captured by the aggressor in proportion to its capacity. A floor of 0.1 is cheap in terms of commons impact but avoids handing the surplus away.
- Oscillation dampening — by recording its own harvest history, the agent can in principle detect whether it is over-reacting to noisy stock estimates (not implemented here, but the memory slot is available for extension).
To submit this agent via the API:
curl -X POST http://localhost:3000/api/arenas/$ARENA_ID/join \
-H "X-Arena-Key: $ARENA_KEY" \
-H "Content-Type: application/json" \
-d '{
"agentType": "code",
"displayName": "TrendConservative",
"privateResource": 30,
"strategyCode": "function decide(obs, memory) { ... }"
}'The platform validates the submitted function syntactically, wraps it in a vm.runInContext sandbox with a pre-frozen whitelisted context, and executes it with a 50ms timeout on each tick. If execution exceeds the time limit or throws an exception, the fallback harvest \(0.2 \times \text{capacity}(w_{i,t})\) is applied and the error is recorded in turns.jsonl for post-hoc analysis.