Writing plugins
Plugins extend debuff through small, typed contracts (ADR 0004). This guide covers runtime plugins, which run in the browser. Tooling plugins (asset importers, CLI commands, scenario steps) come in phase 2.
The contract
import type { Plugin } from "@debuffjp/core";
interface Plugin {
readonly name: string; // unique, kebab-case
install(game: Game): (() => void) | undefined; // optional cleanup, run on game.dispose()
}Install with game.use(plugin). It returns the game, so calls chain. A plugin may only touch Game's public API:
| Use | For |
|---|---|
game.hooks.beforeUpdate / afterUpdate(fn(dt)) | simulation work around the scene's update |
game.hooks.beforeRender / afterRender(fn(ctx, alpha)) | drawing under or over the scene. afterRender is in logical units, outside the camera |
game.hooks.sceneChange(fn(next, previous)) | reacting to scene switches |
game.params.define("<name>", schema) | tunable settings, visible to tools and agents |
game.inspect.register("<name>", () => json) | state for dev tools and scenarios (__DEBUFF_DEV__.state().plugins.<name>) |
game.camera, game.random, game.clock, game.stats | shared services. Use game.random for anything that affects gameplay or visuals, so runs stay deterministic |
Every hook registration returns an unsubscribe function. Return a cleanup that calls them all.
Example
import type { Plugin } from "@debuffjp/core";
export interface DebugGridOptions {
size?: number;
color?: string;
}
export function debugGrid(options: DebugGridOptions = {}): Plugin {
return {
name: "debug-grid",
install(game) {
const params = game.params.define("debug-grid", {
size: { type: "number", default: options.size ?? 16, min: 4, max: 128, units: "px" },
visible: { type: "boolean", default: true },
});
let lines = 0;
const offRender = game.hooks.afterRender((draw) => {
if (!params.values.visible) return;
lines = 0;
for (let x = 0; x <= game.width; x += params.values.size, lines++) draw.line(x, 0, x, game.height, 1, options.color ?? "#ffffff1a");
});
const offInspect = game.inspect.register("debug-grid", () => ({ lines }));
return () => {
offRender();
offInspect();
};
},
};
}Rules
- Name everything after the plugin. Use the same id for the plugin, its param group and its inspect provider.
- Clean up fully. After
game.dispose()nothing of yours should still be listening or attached to the DOM. - No global state, no monkey-patching. Don't modify
Gameor other plugins. - Dev-only code stays behind
import.meta.env.DEV.debuff buildfails if it leaks. - Pay for what you use. Ship ESM with
"sideEffects": falseand no work at import time. - Factories take options. Export
myPlugin(options): Plugin, not a pre-built object, so games can configure it.
Publishing
- Name:
debuff-plugin-<name>(or@scope/debuff-plugin-<name>). First-party plugins use@debuffjp/plugin-<name>. - Add the
debuff-pluginkeyword, and@debuffjp/coreas a peer dependency. - Expose browser code at
".". Tooling code (phase 2) will go at"./tooling".
Reference implementation
@debuffjp/dev ships as the dev-tools plugin (game.use(devTools({ input }))). Read its source for a complete example with cleanup.
Asset importers
Tooling plugins go in debuff.config.ts. An importer claims file suffixes and returns manifest entries. It can be a TypeScript function, or any program that reads a SourceFile as JSON on stdin and prints an ImportResult as JSON on stdout.
import { defineConfig } from "@debuffjp/cli";
export default defineConfig({
plugins: [
{
name: "maps",
apiVersion: 1,
assets: [{ name: "maps", extensions: [".map"], command: ["python", "tools/import_map.py"] }],
},
],
});Results are cached by content hash. Importers can generate files and claim sibling sources (so a sheet's PNG isn't also imported as a plain image).