Quickstart — Extend the engine¶
You want to plug in your own resource dynamics, perturbation type, scheduler, metric, observation transform, or victory function. BDPD's plugin system is designed to make this drop-in: write one file, restart BDPD, your plugin is on the same footing as the built-ins.
The two-minute version¶
- Pick a category. Each lives in a folder under
plugins/(create it if it does not exist:mkdir -p plugins): engines/— resource dynamics ODEperturbations/— mid-game shock or overrideschedulers/— turn-order policyobservation_transforms/— per-variable transform on observationsmetrics/— welfare, inequality, commons statisticsvictories/— end-of-game evaluation- Copy the matching starter from
examples/plugins/<category>/toplugins/<category>/<your-name>.jsand edit. - Validate:
node tools/plugin-check.mjs plugins/<category>/<your-name>.js. - Restart BDPD. Your plugin appears in its registry on next launch.
That's it. No code changes in the BDPD core. No build step. No config file to edit.
What goes in the file¶
One default export, a plain object with type and name:
// plugins/engines/my_dynamics.js
import { BaseEngine } from '../../engine/base-engine.js';
class MyEngine extends BaseEngine {
// ...implement step / stepOne / stock / capacity / regenRate / ...
}
export default {
type: 'engine',
name: 'my_dynamics',
description: 'Short prose for humans.',
factory: (modelSpec) => new MyEngine(modelSpec),
};
The type must match the folder (loader rejects mismatches). The
name is what you'll write in arena specs: engineMode: 'my_dynamics'.
The required fields per type are listed in
examples/plugins/README.md.
Every starter in examples/plugins/ is a full, compiling reference
for its category.
Validating before deployment¶
The conformance runner exercises both the contract (required fields) and a smoke test specific to the type. Engines get instantiated and stepped once; perturbations are applied to a stub arena; metrics get a fixed wealth array; etc.
$ node tools/plugin-check.mjs plugins/engines/my_dynamics.js
PASS my_dynamics.js
✓ contract type=engine name=my_dynamics
✓ smoke stock=100.00 step→1
A failing check tells you exactly which field is missing or which method threw. Run it before every commit; the workflow is identical to running unit tests.
Worked example — Atkinson inequality as a plugin¶
The Gini coefficient is built in; Atkinson is not. To add it:
// plugins/metrics/atkinson.js
function atkinsonE1(wealth) {
if (!wealth || wealth.length === 0) return 0;
const eps = 1e-6;
const w = wealth.map(v => Math.max(v, 0) + eps);
const n = w.length;
const mean = w.reduce((s, v) => s + v, 0) / n;
const logGeom = w.reduce((s, v) => s + Math.log(v), 0) / n;
return 1 - (Math.exp(logGeom) / mean);
}
export default {
type: 'metric',
name: 'atkinson',
description: 'Atkinson inequality index, ε=1.',
compute: (state) => atkinsonE1(state.wealth ?? []),
aggregate: (values) => values.reduce((s, v) => s + v, 0) / Math.max(1, values.length),
};
After restart, atkinson shows up in the metric registry. A future
atkinson_e2 variant with ε=2 is just another file. No coordination
with the core team is needed; no shared state to merge.
There is a working copy of this exact plugin at
examples/plugins/metrics/atkinson.js — copy it, rename it, modify
it. The conformance runner already validates it.
Override semantics¶
If a plugin's name collides with a built-in (logistic, gini, ...),
the plugin wins, and a warning is printed to stderr at startup. This
is intentional: it lets you swap in a modified built-in for an
experiment without forking the core. But be deliberate — override
breaks anyone else relying on the built-in's behaviour.
To avoid surprises, prefix experimental plugins with your initials or
group name: plugins/engines/rb_logistic_v2.js rather than
plugins/engines/logistic.js.
What plugins can not do (yet)¶
- Cross-cutting state between plugin categories. A perturbation cannot directly mutate a metric registry entry, for instance. Communication is via the arena object passed to handlers.
- Adding a new plugin category. The six categories are fixed
for v0.6. A new category requires a small loader change (one
entry in
TYPE_HANDLERS); proposals welcome. - PNG-side provenance is half-wired. Plugins that produce
figures (Python plot scripts) should call
tools/manifest_helper.py register-figureafter writing the PNG; v0.6.5 will wire this automatically into the canonical plot path.
Promoting a plugin into the core¶
When a plugin proves general enough to belong in the platform, it
moves from plugins/<category>/<name>.js into the matching in-tree
location (engine/<name>.js, platform/perturbation.js, etc.). The
spec format is intentionally compatible with the in-tree registry
assignments — only the import path changes. Submit a PR.
See also¶
examples/plugins/README.md— per-type contract reference.tools/plugin-loader.mjs— the loader itself (180 lines, easy to read).tools/plugin-check.mjs— the conformance runner.BDPD_ultimate_edition.md§5 (workspace design doc, not in the published repo) — the eight "nested-safe" architectural decisions that constrain what plugin signatures look like, so v1.0 nested-arena dispatch lands without breaking your code.