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.
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).
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.js — commons.stock proxy, metrics, checkEnd |
get capacity |
yes | arena.js — commons.capacity proxy |
get regenRate |
yes | arena.js — commons.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.