Plugin API Reference

Everything a Fey-Gate plugin can do, with examples. Plugins are plain ES modules — no build step, no dependencies, no framework.

description

Plugin Anatomy

One ES Module Per Plugin

A plugin is a single JavaScript file (an ES module) in your campaign's plugins/ folder. It starts with a header comment shown in the trust prompt, and default-exports the plugin object:

/*! feygate-plugin
name: Compass Rose
description: Draws a compass marker that follows the selected heading.
version: 1.0.0
*/

export default {
  id: 'compass-rose',       // stable, kebab-case, unique
  name: 'Compass Rose',
  version: '1.0.0',
  description: 'Draws a compass marker.',
  apiVersion: 1,            // the API major version you target

  onLoad(api) {
    // acquire everything through `api` — see below
  },

  onUnload() {
    // optional; the runtime force-releases api resources anyway
  },
};

Lifecycle

onLoad(api) runs once when the plugin is enabled (at app start for trusted plugins, or immediately after approval). The api object is built for your plugin alone — api.apiVersion and api.pluginId echo what it was built against. onUnload() runs when the user disables it. Every subscription, canvas layer, ticker callback, and panel acquired through the api is tracked and force-released on unload — clean up your own timers and DOM listeners in onUnload, and let the runtime handle the rest.

Errors Disable You

If onLoad or any of your api callbacks throws, the runtime unloads and disables your plugin and shows the error to the user. Fail loudly during development; catch what you can recover from in production.

map

api.session & api.map

Session Role

api.session.isDM is true for the DM and for solo (offline) use — gate DM-only features on it. api.session.connected tells you whether a multiplayer session is live. api.session.localPeerId is this client's peer id (or null offline) — compare it against token.ownerId and the fromPeerId of net messages to reason about ownership.

Your Plugin Runs on Players Too

The DM's enabled plugins are offered to every connected player (see the trust model). Once a player accepts, the same code runs on their client with api.session.isDM === false. Structure onLoad for both roles: the DM owns authoritative state and api.storage; players render, and submit intents over api.net for the DM to validate (e.g. against token.ownerId).

Grid Geometry

Token positions are grid cells; canvas drawing is world pixels. api.map.gridSize gives pixels per cell, and the converters translate between the two:

const { x, y } = api.map.gridToWorld(3, 5);   // centre of cell (3,5), in px
const { gx, gy } = api.map.worldToGrid(x, y); // → { gx: 3, gy: 5 }

api.map.onMapChanged((mapId) => {
  // a different map was loaded (mapId is null when cleared)
});
token

api.tokens

Reading

list() and get(id) return detached copies — mutating them does nothing. All changes go through the mutators, which also broadcast to connected players automatically.

Writing

Positions are grid coordinates and may be fractional (useful mid-animation). rotation is radians (0 = image's natural "up", clockwise). Set imageFit: 'contain' for elongated, rotatable sprites like ships and vehicles:

const id = api.tokens.add({
  name: 'Sloop',
  x: 4, y: 7,
  size: 'large',
  imageUrl: '...',        // any URL or data: URI
  imageFit: 'contain',    // full image, unmasked, rotatable
  rotation: Math.PI / 2,  // facing east
});

api.tokens.move(id, 4.5, 7.25);              // fractional = smooth
api.tokens.update(id, { rotation: Math.PI }); // any Token fields
api.tokens.remove(id);

Events

onAdded, onMoved, onUpdated, onRemoved fire for every change — local and remote alike. Each returns an unsubscribe function (also auto-released on unload).

draw

api.canvas

Your Own Overlay Layer

createLayer() returns a PixiJS Container rendered above tokens and fog, in world coordinates, non-interactive. Draw with the PixiJS classes exposed on api.canvas.PIXI:

const layer = api.canvas.createLayer();
const gfx = new api.canvas.PIXI.Graphics();
layer.addChild(gfx);

const p = api.map.gridToWorld(10, 10);
gfx.circle(p.x, p.y, api.map.gridSize * 3)
   .stroke({ color: 0xffffff, alpha: 0.6, width: 2 });

Per-Frame Ticks

onTick(cb) registers a per-frame callback receiving the frame delta in milliseconds — the building block for animations. Rebuild geometry rarely; move containers per frame (allocation-free updates keep the canvas smooth):

let elapsed = 0;
const off = api.canvas.onTick((deltaMs) => {
  elapsed += deltaMs / 1000;
  marker.position.set(startX + speed * elapsed, startY);
  if (elapsed >= duration) off();   // unsubscribe when done
});
space_dashboard

api.ui

Toolbar Panels

registerPanel() adds a button to the VTT toolbar and gives you a plain DOM element to render into — no framework required. dmOnly (default true) hides it from connected players:

const body = api.ui.registerPanel({
  id: 'controls',
  title: 'Compass Rose',
  iconPath: 'M8 1v14M1 8h14',   // SVG path in a 16×16 viewBox
  dmOnly: true,
});
body.innerHTML = '<button id="go">Go</button>';
body.querySelector('#go').onclick = () => { /* ... */ };

Data Manager Sections

registerDataSection() adds an entry to the Data Manager's rail navigation with a full content pane — the home for plugin configuration and content editors (the naval-battle ship designer lives here). Same contract as registerPanel: you get a plain element to render into, and dmOnly defaults to true:

const body = api.ui.registerDataSection({
  id: 'ship-designer',
  title: 'Ship Classes',
  iconPath: 'M2 10h12l-2 4H4l-2-4zM8 2v8',
});
body.innerHTML = '...your editor...';

Token Inspector Sections

registerTokenSection() adds a collapsible section to the token detail window, below the built-in ones, on every client. match limits it to relevant tokens; render fills a plain element (return a cleanup function if you need one); update refreshes in place when the shown token changes — omit it to re-render from scratch instead. Gate edits on ctx.canEdit (true for the DM and for the token's owning player):

api.ui.registerTokenSection({
  id: 'ship',
  title: 'Ship',
  match: (token) => isShip(token.id),
  render(el, ctx) {
    el.innerHTML = `<input ${ctx.canEdit ? '' : 'disabled'} ...>`;
    // ctx = { token, canEdit, isDM }
    return () => { /* optional cleanup */ };
  },
  update(el, ctx) { /* same-token data changed */ },
});

Replacing the Inspector

registerTokenInspector() takes over the entire body of the detail window for matching tokens (the header with name and close button stays). Same match/render/update contract as sections; if several plugins claim the same token, the first registration wins.

Toasts & Confirmation

api.ui.toast({ message, type }) shows an app toast (info | success | warning | error). await api.ui.confirm(message) asks a yes/no question.

Shared HUD Compass

The one piece of plugin UI that every session peer sees, plugin or not: a directional indicator at the bottom-right of the VTT. Pass the direction of flow (radians, 0 = north, clockwise) — e.g. where the wind blows toward. It syncs to all clients, survives reconnects, and clears when your plugin unloads:

api.hud.setCompass({ dirRad: windTo, label: 'Wind' });
api.hud.setCompass(null);   // clear
database

api.storage & api.net

Campaign-Scoped Storage

Async key/value storage, namespaced to your plugin and the active campaign. Values must be JSON-serializable. Data persists in the browser and mirrors into the campaign folder (db/pluginData/) when folder sync is on. Keys are limited to letters, digits, . _ - (max 64 chars):

await api.storage.set('wind', { dirRad: Math.PI / 2, strength: 2 });
const wind = await api.storage.get('wind');   // null when missing
const keys = await api.storage.keys();
await api.storage.delete('wind');

Multiplayer Messages

api.net.send(event, payload) delivers to every session peer that also runs your plugin; peers without it silently ignore the traffic. Since the DM's plugins sync to consenting players automatically, this normally means everyone. Players send via the DM, who re-broadcasts — so the DM sees every message and can validate. Never trust incoming payloads: check fromPeerId against token.ownerId before applying a player's intent. Payloads must be JSON-serializable:

api.net.send('fire-broadside', { shipId, target });

api.net.on('fire-broadside', (payload, fromPeerId) => {
  // runs on every OTHER peer with this plugin enabled
});

Storage Is Per-Device

api.storage is not synced between peers — a player client sees its own (empty) store, not the DM's. Keep authoritative state on the DM and mirror it to players over api.net (broadcast on change, answer a hello from late joiners). The naval-battle example shows the full pattern.

school

A Complete Minimal Plugin

Heading Marker

Everything together — a marker ring that follows whichever token moved last:

/*! feygate-plugin
name: Last Mover
description: Rings the token that moved most recently.
version: 1.0.0
*/
export default {
  id: 'last-mover',
  name: 'Last Mover', version: '1.0.0',
  description: 'Rings the token that moved most recently.',
  apiVersion: 1,

  onLoad(api) {
    const layer = api.canvas.createLayer();
    const ring = new api.canvas.PIXI.Graphics();
    layer.addChild(ring);

    api.tokens.onMoved((token) => {
      const p = api.map.gridToWorld(token.x, token.y);
      ring.clear()
          .circle(p.x, p.y, api.map.gridSize)
          .stroke({ color: 0xffcc00, width: 3, alpha: 0.9 });
    });
  },
};

Testing Your Plugin

Because a plugin is a plain module that receives everything through api, it is unit-testable with a mock api object — no app required. See examples/plugins/naval-battle.test.ts in the repository for a full pattern using Vitest.

Learn From the Flagship

The Naval Battle Simulator (examples/plugins/naval-battle.js in the repository) exercises the whole API surface: tokens as vehicles, a deterministic simulation, ticker-driven animation, overlay drawing, panels, and storage.