Plugins & options
How to load and manage engine plugins at runtime, and how to define and persist hierarchical reactive options with auto-generated UI.
Meep extends the core engine through two parallel systems: plugins, which add services to a running engine and declare their own dependencies, and options, a hierarchical reactive settings tree that can persist to storage and render into a built-in UI panel.
Plugins
What a plugin is
An EnginePlugin is a lifecycle object managed by engine.plugins
(EnginePluginManager). Each plugin has three steady states - initialized,
running, and finalized - and an async transition between each:
| Method | Called when |
|---|---|
initialize(engine) | plugin first acquired; receives the Engine reference |
startup() | engine starts (or immediately, if the engine is already running) |
shutdown() | last reference to the plugin is released |
Plugins can declare dependencies (an array of other plugin classes). The
manager initializes dependencies first and shuts them down last, so a plugin
can safely call engine.plugins.getPlugin(OtherPlugin) in its own startup.
import { EnginePlugin } from "@woosh/meep-engine/src/engine/plugin/EnginePlugin.js";
class AudioPlugin extends EnginePlugin {
constructor() {
super();
this.id = "audio";
}
get dependencies() {
return []; // list other EnginePlugin classes here
}
async startup() {
await super.startup();
// set up services on this.engine
}
async shutdown() {
await super.shutdown();
}
}
Acquiring a plugin
engine.plugins.acquire(PluginClass) is the only way to obtain a plugin
instance. It returns a Promise<Reference<T>>:
const ref = await engine.plugins.acquire(AudioPlugin);
const plugin = ref.getValue(); // the AudioPlugin instance
// when you no longer need it:
ref.release();
The manager is reference-counted. The first call to acquire instantiates the
plugin and brings it to the running state. Subsequent calls with the same class
increment the reference count and return a new Reference to the same
instance. When the count reaches zero, the plugin is shut down and removed.
acquireMany(classes) is the parallel form - it calls acquire for each
class and resolves when all transitions are complete.
Registering plugins at configuration time
The more common pattern is to declare plugins in the EngineConfiguration
callback passed to EngineHarness.bootstrap. That runs acquire for each
declared plugin before the engine starts. AchievementManager is the one
EnginePlugin the engine itself ships, so it makes a concrete example:
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { AchievementManager }
from "@woosh/meep-engine/src/engine/achievements/AchievementManager.js";
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
config.addPlugin(AchievementManager);
}
});
EngineConfiguration.apply runs in a fixed order - asset loaders, then static
knowledge tables, then plugins, then systems - so a plugin is acquired and
running before any ECS system is added. See
Game services for what AchievementManager does
once it is up.
Locating an active plugin
// by class (returns the instance or undefined)
const achievements = engine.plugins.getPlugin(AchievementManager);
// by string id (AchievementManager sets id = "achievements")
const sameThing = engine.plugins.getById("achievements");
What a plugin is not
Plugins are a service-lifetime mechanism, not the renderer’s extension point.
Work that has to happen inside the frame is a RenderExtension registered
against the graphics facade:
engine.graphics.add_extension(myExtension);
add_extension / remove_extension / extension_count(phase) live on
engine.graphics and have nothing to do with engine.plugins - no reference
counting, no dependency resolution, no startup / shutdown. Extensions are
held by the facade rather than by the device, so they survive a
graphics.stop() / graphics.start() cycle and can be registered before a
device exists. See
Render extensions.
Nothing stops a plugin from registering an extension in its startup and
removing it in shutdown; that is a reasonable way to make an effect
reference-counted. The two systems just are not the same system.
Options
The tree
engine.options is an OptionGroup - the root of the settings tree. The tree
is a hierarchy of groups (branches) and leaf Option nodes. Each node has a
string id; the path from the root uniquely identifies it.
The engine registers no options of its own. engine.options is an empty
root group; everything in it is something your application put there. The
example below is illustrative application code, not shipped API - the read and
write functions are yours, and so are the paths. For the knobs the engine
actually exposes, see Graphics settings are not options
below.
import { OptionGroup } from "@woosh/meep-engine/src/engine/options/OptionGroup.js";
// engine.options is already an OptionGroup at engine startup.
// Add a sub-group and two options backed by your own application state:
const graphics = engine.options.addGroup("graphics");
graphics.add(
"shadowQuality",
() => shadowQuality, // read
(v) => { shadowQuality = v; }, // write
{ values: ["low", "medium", "high"] }
);
graphics.add(
"fov",
() => camera.fov,
(v) => { camera.fov = v; },
{ min: 30, max: 120 }
);
addGroup(id) returns the new OptionGroup so you can chain child additions.
add(id, read, write, settings) attaches a leaf Option and also returns the
group, so further .add(…) calls can be chained on the same group.
Option settings
The settings object on a leaf option controls both persistence and optional
UI rendering:
| Setting key | Type | Effect |
|---|---|---|
transient | boolean | true - excluded from toJSON / fromJSON serialization |
min | number | Lower bound hint for the auto-generated slider |
max | number | Upper bound hint |
values | string[] / number[] | Allowed discrete values - renders as a dropdown |
Reactive writes
Every Option exposes an on.written signal that fires after the value is
successfully written, and an on.writeFailed signal that fires if the write
throws or rejects:
const fov = engine.options.resolve(["graphics", "fov"]);
fov.on.written.add((v) => console.log("FOV changed to", v));
fov.write(75); // fires on.written with 75
resolve(path) walks the tree by segment array and returns the matching group
or option, throwing if any segment is not found.
Persistence
OptionGroup.attachToStorage(key, storage) wires the whole subtree to a
storage backend (the engine’s engine.storage). On attach it loads and
deserializes any previously stored values; from that point every subsequent
write serializes the tree back to storage automatically:
await engine.options.attachToStorage("myapp.options", engine.storage);
Transient options are excluded. toJSON serializes the tree to a plain object;
fromJSON restores it without triggering on.written.
Engine.start() already attaches the root group, at the tail of startup, under
a hardcoded key:
lazykitty.komrade.options
There is no way to change it. attachToStorage subscribes to on.written on
every option it can reach at the moment it is called, so calling it a second
time with a key of your own adds a second store rather than replacing the
first, and, more importantly, options added after the attach are not
persisted. The engine attaches at the tail of start(), so a tree built
during configuration is covered and one built after start() resolves is not.
Register your options before starting, or attach a sub-group of your own to a
key you control.
Auto-generated UI
OptionsView renders any OptionGroup into a
dat.GUI panel. It needs the options
tree and a Localization instance (option labels come from localization keys
of the form system_option.<path>, e.g. system_option.graphics.fov):
import OptionsView from "@woosh/meep-engine/src/engine/options/OptionsView.js";
const view = new OptionsView({
options: engine.options,
localization: engine.localization,
// inclusions: [["graphics"]] // optional: only show a sub-tree
});
document.body.appendChild(view.el);
view.link();
Pass inclusions as a list of path arrays to limit which branches appear.
When omitted, every non-transient option in the tree is shown. The panel
updates immediately when any option is written externally, and localizes its
own labels whenever the locale changes.
Traversing options in code
engine.options.traverseOptions((option) => {
console.log(option.computePath().join("."), "=", option.read());
});
computePath() returns the segment array from the root to the node.
Graphics settings are not options
The knobs the engine exposes are plain properties, not entries in the options
tree. A settings screen wires them into engine.options itself, with its own
read and write functions.
engine.settings holds three observable values:
| Property | Type | Default | Effect |
|---|---|---|---|
graphics_control_viewport_size | ObservedBoolean | true | when true, the engine sizes the view stack to the nearest sized ancestor (or the window) and keeps it in sync on resize |
simulation_speed | Vector1 | 1 | declared, but nothing in the engine reads it - wire it yourself |
input_mouse_sensitivity | Vector1 | 5 | same: a declared holder with no engine-side consumer |
engine.graphics (a GraphicsEngine) carries the renderer-facing ones:
| Property | Type | Notes |
|---|---|---|
output_resolution | Vector2 | read-only; the viewport size times window.devicePixelRatio, floored at one texel |
autoDraw | boolean | true by default: draw every tick. Turning it off is how menus and tools stop burning frames |
needDraw | boolean | with autoDraw off, this is what gates a single redraw |
frameIndex | number | frames submitted since start |
dynamic_resolution | DynamicResolutionScaling | getter; trades internal resolution for frame time, and does nothing until a frame costs more than about 33 ms |
viewport | EmptyView | the view whose size drives everything above |
domElement | HTMLCanvasElement | null | the live canvas, or null before start() |
renderer | Renderer | getter; the escape hatch to Shade itself |
There is no pixelRatio on the facade. Render scale is the renderer’s internal_resolution_scale, reached through renderer.
Per-frame toggles - shadows, ambient occlusion, motion blur, depth of field,
temporal resolve, the upscaler - are renderer.feature_* booleans reached
through that last getter. They are catalogued in
Frame features.
GraphicsEngine is constructed in the Engine constructor but started from
Engine.start() and stopped last in shutdown, so start / stop / start
yields a working renderer.