Skip to content

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

  1. Pick a category. Each lives in a folder under plugins/ (create it if it does not exist: mkdir -p plugins):
  2. engines/ — resource dynamics ODE
  3. perturbations/ — mid-game shock or override
  4. schedulers/ — turn-order policy
  5. observation_transforms/ — per-variable transform on observations
  6. metrics/ — welfare, inequality, commons statistics
  7. victories/ — end-of-game evaluation
  8. Copy the matching starter from examples/plugins/<category>/ to plugins/<category>/<your-name>.js and edit.
  9. Validate: node tools/plugin-check.mjs plugins/<category>/<your-name>.js.
  10. 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-figure after 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.