Appendix B — Annotated Experiment Script

This appendix reproduces, with pedagogical annotations, the complete BDPD\(^1\) D1 pilot script (single-arena cliff configuration: 6 players, \(K = 150\), \(r = 0.12\) — see BDPD\(^1\) (Brunelli 2026) §3 Cell A vs Cell B). The script is included here as a self-contained illustration of the platform API used throughout the BDPD\(^2\) vignettes as well: the arena lifecycle, builtin vs LLM agent interface, and the metric read-out are identical across both papers. It is executable as-is:

DEEPSEEK_API_KEY=sk-... node scripts/pilot_d1.mjs

The code illustrates three platform design choices that recur across all vignettes: (i) the Arena as a self-contained game loop with pluggable agents, (ii) builtin vs HTTP agent interfaces sharing the same tick semantics, and (iii) governance metrics computed at every turn boundary.

B.1 Preamble and configuration

import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { Arena } from '../platform/arena.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, '..');
const OUT_DIR = path.join(PROJECT_ROOT, 'data', 'pilot', 'd1');
fs.mkdirSync(OUT_DIR, { recursive: true });

The only platform import is Arena — a single entry point that encapsulates the game engine, agent orchestration, and metric computation. No framework, no DI container: one class, one game.

const SEED      = 17;
const MAX_TURNS = 30;
const N_AGENTS  = 6;

const CONFIG = {
  name: 'pilot-d1',
  engineMode: 'logistic',        // pluggable: 'logistic' | 'seneca'
  commonsInitial: 150,           // S_0 = K (full commons)
  commonsCapacity: 150,          // carrying capacity K
  regenRate: 0.12,               // logistic r
  minPlayers: 1,
  maxPlayers: 8,
  maxTurns: MAX_TURNS,
  threshold: 10,                 // gate: S_final > 10 or nobody wins
  scheduler: 'simultaneous',     // all agents decide before state update
  useHiddenReserve: false,       // deterministic; no Forest Die
};

const PRIVATE = 30;  // each player's private buffer (absorbs early sanctions)

These parameters reproduce the canonical “single-aggressor cliff” from BDPD\(^0\) (P1–P2). The threshold = 10 gate creates the collective-action dilemma: individual accumulation is rational only if the commons survives.

B.2 Cell A — Builtin agents (deterministic baseline)

const arenaA = new Arena({ model: { ...CONFIG }, seed: SEED });

// 5 conservative + 1 aggressive — the canonical cliff composition.
// Builtin agents are pure heuristics: no LLM, no communication channel,
// no tool access. Their behaviour is fully determined by strategy + state.
for (let i = 0; i < N_AGENTS - 1; i++) {
  arenaA.addPlayer({
    agentType: 'builtin',
    strategy: 'conservative',     // 3-tier: 60/35/10% of capacity by stock/cap ratio (>0.6 / >0.4 / else)
    displayName: `cons_${i + 1}`,
    privateResource: PRIVATE,
  });
}
arenaA.addPlayer({
  agentType: 'builtin',
  strategy: 'aggressive',         // harvests capacity (= 1 + 0.05×wealth)
  displayName: 'aggr_1',
  privateResource: PRIVATE,
});

// The game loop is synchronous for builtin agents: tick() returns
// immediately because no network I/O is involved.
arenaA.start();
while (arenaA.status === 'running') await arenaA.tick();

Cell A produces the deterministic cliff: the aggressive agent extracts at full capacity every turn, the commons crosses below threshold around turn 24, and the gate fails. This establishes the counterfactual: what happens without communication?

B.3 Cell B — LLM agents with cheap-talk tools

// Each LLM agent is an independent Python process (bdpd_agent.py)
// listening on its own HTTP port. The Arena communicates via POST
// requests — the same interface used for any external agent (RL model,
// human proxy, remote service).

const BASE_PORT = 5201;
const AGENTS = [];
for (let i = 0; i < N_AGENTS - 1; i++) {
  AGENTS.push({
    port: BASE_PORT + i,
    archetype: 'conservative',  // system-prompt nudge, not a hard constraint
    name: `cons_${i + 1}`,
  });
}
AGENTS.push({
  port: BASE_PORT + N_AGENTS - 1,
  archetype: 'aggressive',
  name: 'aggr_1',
});

The archetype tag is a prompt-level nudge (“you tend toward conservation” / “you tend toward aggressive extraction”). It does not constrain the harvest decision — the LLM can override it. The experimental question is whether communication tools alter outcomes that the archetype alone cannot prevent.

// Spawn one bdpd_agent.py per player.
// Key flags:
//   --model-name deepseek-v4-flash   (cheap, fast, no thinking)
//   --max-history 32                 (context window for conversation)
//   --archetype conservative|aggressive

for (const cfg of AGENTS) {
  const p = spawn('python3', [
    path.join(PROJECT_ROOT, 'agents', 'bdpd_agent.py'),
    '--port', String(cfg.port),
    '--model-url', 'https://api.deepseek.com',
    '--model-name', 'deepseek-v4-flash',
    '--api-key-env', 'DEEPSEEK_API_KEY',
    '--max-history', '32',
    '--archetype', cfg.archetype,
  ], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
  procs.push(p);
}

// Wait for all agents to respond to /health before starting the game.
for (const cfg of AGENTS) {
  await waitForHealth(`http://127.0.0.1:${cfg.port}`);
}

The agent Python process exposes two endpoints: /health (readiness probe) and /decide (the Arena’s callback). On each tick, the Arena POSTs the observable state (commons stock, player wealth, message history, available tools) and the agent responds with a harvest decision plus optional tool calls (broadcast, announce, propose pact).

// Register LLM players in the arena — same interface as builtin,
// only agentType and callbackUrl differ.
const arenaB = new Arena({ model: { ...CONFIG }, seed: SEED });
for (const cfg of AGENTS) {
  arenaB.addPlayer({
    agentType: 'http',
    callbackUrl: `http://127.0.0.1:${cfg.port}/decide`,
    displayName: cfg.name,
    privateResource: PRIVATE,
  });
}

// The tick loop is now async: each tick awaits HTTP responses from
// all agents (with timeout fallback). Governance metrics are computed
// at the turn boundary — cooperation_index, announce_frequency,
// silent_defection — and printed in real time.
arenaB.start();
while (arenaB.status === 'running') {
  await arenaB.tick();
  const t = arenaB.history[arenaB.history.length - 1];
  const g = t.governance ?? {};
  process.stdout.write(
    `\r  T${t.turn}/${MAX_TURNS}  stock=${t.commons?.stockAfter?.toFixed(1)}  ` +
    `coop=${g.cooperation_index?.toFixed(2) ?? '—'} ` +
    `silDef=${g.silent_defection?.toFixed(2) ?? '—'}`
  );
}

The key insight: the game loop is identical for builtin and LLM agents. The Arena does not know or care what drives the decisions. This separation is what makes the platform engine-agnostic — a design constraint documented in BDPD\(^0\).

B.4 Metric extraction and comparison

function summarise(arena, label) {
  const summary = arena.fullSummary();

  // Governance metrics are per-turn; we report the mean across the game.
  const meanOf = (key) => {
    const vals = arena.history
      .map(t => t.governance?.[key])
      .filter(v => typeof v === 'number');
    return vals.length > 0
      ? vals.reduce((a, b) => a + b, 0) / vals.length
      : null;
  };

  return {
    label,
    turns:             summary.turns,
    collapsed:         !!summary.collapsed,
    finalStock:        summary.commons.stock,
    finalCommonsRatio: summary.commons.stock / CONFIG.commonsCapacity,
    gini:              summary.metrics.gini,
    welfareScore:      summary.metrics.welfareScore,
    governanceMeans: {
      cooperation_index:  meanOf('cooperation_index'),
      announce_frequency: meanOf('announce_frequency'),
      silent_defection:   meanOf('silent_defection'),
    },
  };
}

// Side-by-side delta: Cell A (builtin, no talk) vs Cell B (LLM, talk).
// Note: two factors change simultaneously (architecture + talk);
// see BDPD^1^ §3 Cell C (LLM, no-talk) for the decomposition.
const cmp = {
  cellA: summarise(arenaA, 'cell_A_off'),
  cellB: summarise(arenaB, 'cell_B_on'),
  delta: { /* B minus A for each metric */ },
};
fs.writeFileSync(path.join(OUT_DIR, 'comparison.json'),
                 JSON.stringify(cmp, null, 2));

The comparison.json artifact is what the analysis scripts consume. Each vignette produces one such file; the N=5 seed sweep (pilot_d1_n5.mjs) wraps this logic in a loop over seeds {17, 23, 29, 31, 37} and reports mean \(\pm\) sd.

B.5 Running the full vignette suite

# Single seed (functional verification, ~2 min, ~$0.03):
DEEPSEEK_API_KEY=sk-... node scripts/pilot_d1.mjs

# N=5 seed sweep (statistical, ~10 min, ~$0.15):
DEEPSEEK_API_KEY=sk-... node scripts/pilot_d1_n5.mjs

The full per-vignette pilot commands for BDPD\(^2\) live in docs/platform/reproducibility.md alongside the BDPD\(^1\) recipe; no umbrella runner script is shipped because each vignette is typically run in isolation during iteration.

Total LLM cost for the three LLM-driven vignettes (V2, V3, V4) reported in this paper: $1.15 (see Table A.3 in Appendix A for the per-pilot cost breakdown).