Platform

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:

MethodCalled 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 keyTypeEffect
transientbooleantrue - excluded from toJSON / fromJSON serialization
minnumberLower bound hint for the auto-generated slider
maxnumberUpper bound hint
valuesstring[] / 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:

PropertyTypeDefaultEffect
graphics_control_viewport_sizeObservedBooleantruewhen true, the engine sizes the view stack to the nearest sized ancestor (or the window) and keeps it in sync on resize
simulation_speedVector11declared, but nothing in the engine reads it - wire it yourself
input_mouse_sensitivityVector15same: a declared holder with no engine-side consumer

engine.graphics (a GraphicsEngine) carries the renderer-facing ones:

PropertyTypeNotes
output_resolutionVector2read-only; the viewport size times window.devicePixelRatio, floored at one texel
autoDrawbooleantrue by default: draw every tick. Turning it off is how menus and tools stop burning frames
needDrawbooleanwith autoDraw off, this is what gates a single redraw
frameIndexnumberframes submitted since start
dynamic_resolutionDynamicResolutionScalinggetter; trades internal resolution for frame time, and does nothing until a frame costs more than about 33 ms
viewportEmptyViewthe view whose size drives everything above
domElementHTMLCanvasElement | nullthe live canvas, or null before start()
rendererRenderergetter; 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.