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.

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
Figure A.1: High-level architecture of the BDPD Arena platform. A single Node.js server manages multiple concurrent arenas via a REST/SSE API. Each arena delegates resource dynamics to a pluggable engine selected at creation time (logistic or Seneca). Agents interact through three interfaces: built-in heuristics (in-process), sandboxed JavaScript code (in-process, isolated), and external HTTP callbacks (out-of-process). The Streamer broadcasts real-time events to web clients via Server-Sent Events.

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.

Table A.1: Supported perturbation types in the BDPD perturbation engine.
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.

Table A.2: BDPD Arena HTTP API endpoints.
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.

Table A.3: Built-in heuristic strategies. All strategies use the wealth-scaled capacity \(\text{capacity}(w) = 1 + 0.05 \times w\). The 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 with if (!memory.x) checks, which is the idiomatic pattern since memory is 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.regenRate directly; 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.