Skip to content

Engine Plugin System

The BDPD platform uses a plugin registry to select the differential equation model that drives the shared commons. Every arena is backed by exactly one engine; the engine is chosen at creation time and cannot be changed while the arena is running.


Architecture Overview

spec.model.engineMode
┌────────────────────┐
│  buildEngine()     │  engine/engine-registry.js
│  ENGINE_REGISTRY   │  { logistic: LogisticEngine, seneca: SenecaEngine, … }
└────────────────────┘
┌────────────────────┐
│  BaseEngine        │  engine/base-engine.js
│  (interface)       │  step() · stepOne() · applyRegen()
│                    │  stock · capacity · regenRate
│                    │  extraObservations() · checkEnd() · toJSON()
└────────────────────┘
   ┌────┴────┐
   ▼         ▼
Logistic   Seneca   … future engines

arena.js calls buildEngine(this.model) once in the constructor and delegates every resource mutation to this._engine. It never inspects engineMode again. Adding a new engine means writing one file and registering one line.


Available Engines

logistic (default)

Single-stock logistic growth with optional stochastic collapse.

\[S_{t+1} = \min\!\left(K,\; (S_t - H_t) + r\,(S_t - H_t)\!\left(1 - \frac{S_t - H_t}{K}\right)\right)\]

Harvest is applied first and regeneration acts on the post-harvest stock, matching engine/commons.js (applyHarvests then regenerate). Documenting the pre-harvest form would diverge from the implementation for any non-trivial harvest.

Parameter spec.model key Default
Initial stock commonsInitial 150
Carrying capacity commonsCapacity 150
Regen rate regenRate 0.12
Collapse threshold threshold 10
Hidden reserve useHiddenReserve false
Reserve size hiddenReserveInitial 20
Forest Die collapseDie {sides:6, successThreshold:4}

Observation fields added by the engine: none (capitalStock and pollutionLevel are always null for logistic arenas).


seneca

Bardi (2011) three-variable ODE model (Resources, Capital, Pollution).

\[ \begin{aligned} \dot{R} &= -k_1 R C - l_3 R \\ \dot{C} &=\phantom{-} k_1 R C - k_2 C P - l_1 C \\ \dot{P} &=\phantom{-} k_2 C P - l_2 P \end{aligned} \]

The Seneca cliff (\(R\) drops sharply after a period of apparent growth in \(C\)) emerges when \(l_3 = 0\) and \(k_2 > 0\).

Parameter spec.model key Default
Initial resources commonsInitial 1.0
Initial capital seed capitalSeed 0.01
Initial pollution seed pollutionSeed 0.001
ODE parameters seneca object see below

seneca parameter object (all optional, override SENECA_DEFAULTS):

Key Symbol Default Role
k1 \(k_1\) 0.03 Resource extraction rate
k2 \(k_2\) 0.30 Pollution generation rate
l1 \(l_1\) 0 Capital depreciation
l2 \(l_2\) 0.01 Pollution decay
l3 \(l_3\) 0 Resource autonomous decay (keep 0 for Seneca cliff)
dt \(\Delta t\) 0.10 Euler step size (ODE time per game turn)
steps 10 Sub-steps per tick

Observation fields added by the engine:

Field Default visibility Description
capitalStock Visible Current \(C_t\) (4 decimal places)
pollutionLevel Hidden Current \(P_t\) — hidden by default (advance intel)

commonsCapacity is ignored for Seneca

The Seneca ODE has no fixed carrying capacity — resources decline monotonically by construction. commonsCapacity is therefore ignored; commonsInitial (\(R_0\)) serves as the reference for commonsRatio.

If you pass commonsCapacity with a value different from commonsInitial, the arena creation response will include a warnings array and SenecaEngine will log a console.warn.


API Usage

Logistic arena (backward-compatible)

POST /api/arenas
Content-Type: application/json

{
  "model": {
    "commonsInitial": 150,
    "commonsCapacity": 150,
    "regenRate": 0.12,
    "maxTurns": 60,
    "scheduler": "simultaneous"
  }
}

No engineMode → defaults to logistic. All existing scripts and experiments are unaffected.


Seneca arena

POST /api/arenas
Content-Type: application/json

{
  "model": {
    "engineMode": "seneca",
    "commonsInitial": 1.0,
    "maxTurns": 80,
    "scheduler": "simultaneous",
    "seneca": {
      "k1": 0.03,
      "k2": 0.30,
      "l2": 0.01
    }
  },
  "observability": {
    "capitalStock":   { "visible": true,  "noise": 0 },
    "pollutionLevel": { "visible": true,  "noise": 0 }
  }
}

Agents receive per-tick observation:

{
  "turn": 12,
  "myWealth": 0.187,
  "myLastHarvest": 0.018,
  "commonsStock": 0.831,
  "commonsRatio": 0.831,
  "regenRate": null,
  "capitalStock": 0.0423,
  "pollutionLevel": 0.0071,
  "nPlayers": 3,
  "othersWealth": [...],
  "othersLastHarvest": [...]
}

The rcp built-in strategy tracks capitalStock and pollutionLevel to anticipate the Seneca cliff before \(R\) visibly declines.


Adding a New Engine

Four steps, no changes to arena.js or observability.js.

1. Write engine/my-engine.js

import { BaseEngine } from './base-engine.js';

export class MyEngine extends BaseEngine {

  constructor(modelSpec) {
    super();
    const m = modelSpec ?? {};
    // initialise your ODE state here
  }

  // ── Required getters ──────────────────────────────────────────────────
  get stock()    { return /* primary resource variable */; }
  get capacity() { return /* reference capacity */; }
  get regenRate(){ return /* characteristic rate */; }

  // ── Setters (used by perturbation engine) ──────────────────────────────
  setStock(stock)         { this._stock = stock; }
  setCapacity(capacity)   { this._capacity = capacity; }
  setRegenRate(regenRate) { this._regenRate = regenRate; }
  // setThreshold() is optional — default is no-op

  // ── Simultaneous turn: apply all demands at once ──────────────────────
  step(demands, rng, context = {}) {
    // run ODE, return:
    return {
      actual:        /* number[] — actual harvest per player */,
      regen:         /* number  — regeneration proxy this tick */,
      scaled:        /* boolean — true if demands were rationed */,
      collapsed:     false,
      collapseReason: null,
    };
  }

  // ── Sequential turn: one demand at a time ─────────────────────────────
  stepOne(demand, rng, context = {}) {
    const r = this.step([demand], rng, context);
    return { actual: r.actual[0], collapsed: false, collapseReason: null };
  }

  // ── Regen applied at end of sequential tick ───────────────────────────
  // Return 0 if regen is already integrated into step() (like Seneca).
  applyRegen() { return 0; }

  // ── Extra observation fields (merged into commons each tick) ──────────
  extraObservations() {
    return { myVar: this._myState };
  }

  // ── End condition ─────────────────────────────────────────────────────
  checkEnd(turn, maxTurns, threshold) {
    if (this.stock <= 0 || turn >= maxTurns) {
      return { ended: true, reason: turn >= maxTurns ? 'max_turns' : 'exhausted' };
    }
    return { ended: false, reason: null };
  }

  toJSON() { return { /* state snapshot */ }; }
}

2. Register in engine/engine-registry.js

import { MyEngine } from './my-engine.js';

export const ENGINE_REGISTRY = {
  logistic: LogisticEngine,
  seneca:   SenecaEngine,
  my_engine: MyEngine,        // ← add this line
};

3. Add observability fields in platform/observability.js

Add your new fields to DEFAULTS and to buildObservation(), following the same null-safe pattern as capitalStock:

const DEFAULTS = {
  // ... existing fields ...
  myVar: { visible: true, noise: 0 },   // add here
};

// In buildObservation(), after the pollutionLevel block:
if (commons.myVar !== undefined && commons.myVar !== null) {
  obs.myVar = cfg.myVar?.visible ? +commons.myVar.toFixed(4) : null;
} else {
  obs.myVar = null;
}

Add 'myVar' to the senecaOnly set in describeObservability() if the field is engine-specific and should not appear in logistic arena descriptions.

4. Handle commonsCapacity default in platform/arena.js

If your engine does not use commonsCapacity (or uses a different default), update the model construction in arena.js:

commonsCapacity: m.commonsCapacity ?? (
  (m.engineMode ?? 'logistic') === 'my_engine'
    ? (m.commonsInitial ?? MY_DEFAULT)   // engine-specific default
    : (m.engineMode === 'seneca')
      ? (m.commonsInitial ?? 1.0)
      : 150                              // logistic default
),

Why this matters

Arena always fills commonsCapacity with a default (150 for logistic). BaseEngine subclasses receive modelSpec = this.model, so modelSpec.commonsCapacity is always defined — never undefined. If your engine ignores commonsCapacity, add a console.warn in your constructor and set the arena default to match commonsInitial here. Otherwise SenecaEngine-style mismatches will silently pollute logs.


Engine Contract Reference

Full interface defined in engine/base-engine.js:

Method / getter Required Called by
get stock yes arena.jscommons.stock proxy, metrics, checkEnd
get capacity yes arena.jscommons.capacity proxy
get regenRate yes arena.jscommons.regenRate proxy
step(demands, rng, context) yes _simultaneousTurn
stepOne(demand, rng, context) yes _sequentialTurn (per-agent harvest)
applyRegen() yes _sequentialTurn (end-of-tick regen)
setStock(stock) yes perturbation engine (commons_shock)
setCapacity(capacity) yes perturbation engine (capacity_shock)
setRegenRate(regenRate) yes perturbation engine (regen_shock)
setThreshold(threshold) no perturbation engine (threshold_shift); default no-op
extraObservations() no _arenaState() — merged into commons
checkEnd(turn, maxTurns, threshold) yes tick()
toJSON() no persistence / meta.json snapshot
get hiddenReserve no arena.js — only used by LogisticEngine
get collapsed no arena.js — only used by LogisticEngine

The context parameter on step() and stepOne() is reserved for nested-arena dispatch (cross-arena state, federation context). In single-arena mode it is always {}. Engines should accept it but may ignore it.