Skip to content

Plugins

A plugin extends the editor itself with new tools and UI - comparable to a Unity ProBuilder or a procedural terrain tool - and is distributed as a .awakenpkg package that carries one or more plugin scripts plus a manifest describing what they add.

Where an editor script is a one-off tool you run once against the current scene, a plugin is a persistent extension: once enabled it registers panels, commands, viewport tools, components, or importers that stay in the editor's UI until you disable it. The editor ships with one real example of this, the Scatter tool, covered below.

The kind: "plugin" script

A plugin is written as a script asset, same as any runtime or editor script, but with its kind set to "plugin":

ts
interface ScriptAsset {
  name: string;
  source: string;
  kind?: "runtime" | "editor" | "plugin"; // absent = "runtime"
}

A kind: "plugin" script is editor-only: it is never compiled as a runtime ScriptBehavior, never appears in the Inspector's Script component picker, and is stripped from every export - game.html and folder builds both filter out kind !== "editor" && kind !== "plugin" scripts before compiling behaviours. A plugin cannot end up in a shipped game by any path.

The module a plugin script compiles to exports three things:

ts
export const manifest: PluginManifest = { /* ... */ };
export function activate(ctx: PluginContext): void | Promise<void> { /* ... */ }
export function deactivate(): void { /* optional */ }
  • manifest is DATA: the plugin's id, name, version, declared permissions, and contributes (which panels/commands/tools/components/importers it adds). It is read without running any of the plugin's other code.
  • activate(ctx) runs once, when the plugin is enabled, and does the actual work: it calls ctx.register* for each contribution and wires up behaviour (event handlers, tool logic).
  • deactivate() is optional and best-effort; the editor calls it (if present) before tearing down the plugin's registered contributions.

The manifest

ts
type PluginPermission = "scene:read" | "scene:write" | "assets:read" | "assets:write" | "ui:panel" | "ui:viewport";

interface PluginManifest {
  id: string; name: string; version: string;
  description?: string; author?: string; engine?: string;
  permissions: PluginPermission[];
  contributes: {
    panels?: { id: string; title: string; controls: unknown[] }[];
    commands?: { id: string; title: string; icon?: string; toolbar?: boolean }[];
    tools?: { id: string; title: string; cursor?: string }[];
    components?: { name: string; fields: { name: string; type: string; default: unknown }[] }[];
    importers?: { id: string; extensions: string[] }[];
  };
}

Every contribution id must be unique across ALL kinds in the manifest - the editor validates this at install/enable time and rejects a duplicate.

permissions is declared and displayed - shown as chips on the plugin's row in the Plugins panel and listed in the consent prompt when you enable it - but it is not enforced in this release. Nothing gates a ctx.scene or ctx.assets call by which permissions the manifest declared; once a plugin is enabled it gets the full PluginContext surface regardless of what it asked for. Treat permissions as documentation for the person reviewing the consent prompt, not a sandbox boundary.

Contributions

A plugin's activate(ctx) registers zero or more of these. Each ctx.register* call returns a disposer, and every disposer also runs automatically when the plugin is disabled - you don't need to call them yourself on teardown.

Panels

ctx.registerPanel(decl, opts?) adds a dockable panel, built from a tree of declarative controls - plain data, not plugin-supplied React - so the editor renders every plugin panel with the same generic widget renderer:

kindRenders as
sectionA titled group of child controls (same card chrome as an Inspector component)
labelA line of hint text
sliderA range input plus a live numeric readout (min / max / step)
numberA number input
textA text input
checkboxA checkbox
dropdownA <select> built from options: { value, label }[]
colorA color swatch input
assetPickerA <select> listing the project's mesh/material/texture/prefab names, filtered by assetKind
buttonA button that runs a registered command by id
separatorA horizontal rule

opts.state seeds the panel's initial control values (defaults come from each control's own default, falling back to min for a range control, then a type default). opts.onChange(key, value) fires on every edit. A plugin reads the live values back with ctx.state(panelId).get(key) from inside a tool or command handler.

Commands

ctx.registerCommand({ id, title, icon?, toolbar?, run }) adds an entry to the plugin's submenu in the Toolbar's Windows ▾ menu (shown while the plugin is enabled). A panel's button control can also trigger the same command by id, so a "Clear" button inside your panel and a menu entry can share one run().

Viewport tools

ctx.registerTool({ id, title, cursor?, onPointerDown?, onPointerMove?, onPointerUp?, onActivate?, onDeactivate? }) adds a pointer tool. You activate a tool from the Toolbar's Windows ▾ menu (under the plugin's submenu) or from the plugin's row in the Plugins panel; each is a toggle (re-select it, or press Esc, to deactivate), and its onActivate / onDeactivate hooks fire on the switch. While a tool is active, the Viewport routes every pointer-down/move/up to it instead of its own default camera-orbit/selection handling, and hands each event a ToolPointer:

ts
interface ToolPointer {
  ray: { origin: [number, number, number]; dir: [number, number, number] };
  hit: { point: [number, number, number]; normal: [number, number, number]; entity?: number } | null;
  button: number;
}

hit on the event itself still comes from a ray-vs-ground-plane intersection (y = 0), the same fallback the Viewport already uses for drop placement - not a per-mesh pick. hit.normal on this field is therefore always straight up ([0, 1, 0]). For a real surface pick, call ctx.viewport.raycast(e.ray) yourself - it does a per-mesh test now; see below.

Components

ctx.registerComponent(decl, inspector?) adds a data-only ECS component type to the entity registry, so Obj.addComponent(decl.name) can attach it and every field renders through the same generic field editor every built-in component uses in the Inspector - no plugin-specific inspector code runs. Field type is one of "number" | "text" | "checkbox" | "color", mapped onto the engine's richer field types (number / string / bool / color); an unrecognised type falls back to a plain string field rather than failing. The inspector argument is accepted for a future richer schema but unused today.

Importers

ctx.registerImporter({ id, extensions, import }) claims one or more file extensions in the editor's file-open dispatch. The check runs before the built-in .fbx/.gltf/etc. branches, so a plugin can add support for a new extension; it can't override the handling of an extension the editor already imports natively.

The PluginContext (ctx)

Everything activate(ctx) gets to extend the editor with:

MemberWhat it does
registerPanel(decl, opts?)Adds a dock panel. Returns a disposer.
registerCommand(cmd)Adds an entry to the plugin's Windows ▾ submenu. Returns a disposer.
registerTool(tool)Adds a viewport pointer tool. Returns a disposer.
registerComponent(decl, inspector?)Adds an ECS component type. Returns a disposer.
registerImporter(importer)Claims a file extension. Returns a disposer.
sceneThe EditorApi - the same scene-editing surface editor scripts get: find / all / selection / create / combine / select, object handles with setField / addComponent / setParent / delete, all routed through the project's real undo system.
assetsaddMesh(id, data), addMaterial(id, m), addTexture(id, data), updateMesh(id, positions, normals?), addBlob(id, data) / getBlob(id), and instantiatePrefab(name, opts?) (undoable, returns the new root entity's id or -1).
viewport.raycast(ray)Casts ray against the scene's meshes and returns the nearest surface hit, or null. See below.
runGroup(label, fn)Runs fn, collapsing every edit it makes into one undo entry labeled label - so an entire drag stroke or multi-step operation is a single Ctrl-Z.
pushUndo(label, doFn, undoFn)Registers one undoable action directly: doFn runs now (and again on redo), undoFn reverses it. For an edit that isn't itself a ctx.scene/ctx.assets call - see below.
state(panelId){ get(key), set(key, value) } against a registered panel's live control values.
log(...args)Writes a line to the Console.

ctx.viewport.raycast(ray) tests every mesh in the scene against its actual triangles (not a bounding box) and returns the nearest hit: { point: [x, y, z], normal: [x, y, z], entity: number }, with the normal following the surface. If the ray hits no mesh it falls back to the ground-plane (y = 0) test, returning { point, normal: [0, 1, 0] } with no entity; it returns null if the ray hits neither. This is a real per-mesh pick - call it with a tool's e.ray to place or paint on the actual surface under the pointer, not just the ground. It's independent of the hit field a viewport tool receives automatically on ToolPointer (still ground-plane only, above); a tool that wants a real surface hit calls ctx.viewport.raycast itself, the way the terrain example plugin does.

ctx.assets.updateMesh(id, positions, normals?) rewrites the positions (and, if given, normals) of a mesh the plugin already created with addMesh, in place: it updates the plugin's own MeshData reference - so a later save uses the new geometry, not the shape from when addMesh was called - and pushes the new buffers to the GPU without a full re-upload. It's for a mesh the plugin deforms after creating it (a heightfield sculpt, a live procedural preview); it's a no-op for a mesh id it doesn't own.

ctx.assets.addBlob(id, data) and ctx.assets.getBlob(id) store and read back a plugin-owned Float32Array or Uint8Array under an id - generic scratch storage for data that isn't itself a mesh/material/texture asset (a heightmap, a lookup table, any baked buffer). A blob is saved into the project's .awaken file, so it survives save and reload the same as any other project asset - but it's editor-only: a blob is never written into an exported game (game.html or a folder build).

ctx.pushUndo(label, doFn, undoFn) is for an edit that doesn't go through ctx.scene/ctx.assets' own undo-tracked calls - a blob or mesh mutation, for instance. Use runGroup to batch several already-undoable calls into one entry; use pushUndo when the edit itself needs a hand-written undo/redo pair.

Trust and permissions

A plugin's trust is decided by where it came from, not by a signature or its id:

  • Bundled with the editor - trusted.
  • Authored here (you wrote it; its source is a project script you edit) - trusted. It is your own code, the same trust as your game and editor scripts, which already run unsandboxed.
  • Imported from a .awakenpkg someone else made - untrusted, blocked from running until the signing + sandbox update (a later release).

Trusted plugins run in-process on the editor's main thread, gated by a consent prompt. Untrusted plugins install but never compile or run.

Installing never executes an untrusted plugin's code, even to read its manifest. Every project plugin installs with a static placeholder manifest built from the script asset's id and name ({ id, name, version: "0.0.0", permissions: [], contributes: {} }) - the real contributions come from activate(ctx) when the plugin is enabled, not from the manifest. So importing a .awakenpkg, or reopening a project, that carries ten imported plugins runs zero lines of their code. Provenance is persisted and sticky: an imported plugin stays untrusted even if you edit its source. To run someone else's plugin locally, copy its source into a new authored plugin - you take responsibility by owning it.

Enabling a trusted plugin (from the Plugins panel or the Windows ▾ menu) shows a consent prompt - "Enable (plugin name)? It runs code and can modify your project" - with a Trust & Enable button, then compiles the script's current source and calls activate(ctx). Editing the script and re-enabling reloads the latest code. Declining leaves it disabled.

Enabling an untrusted (imported) plugin fails immediately, and its row shows an error: "This plugin is unsigned/unaudited. Running untrusted plugins requires the signing + sandbox update (a later release)." Package signing and a sandboxed run mode (a Web Worker, distinct from today's "main" mode) for untrusted third-party plugins are planned for that later release.

So today you can enable and run bundled plugins (Scatter, below) and plugins you author in the project. A plugin imported from a package installs and appears in the Plugins panel with an untrusted badge, but stays inert until signing + sandbox ships.

Disabling a running plugin calls its optional deactivate() (best-effort - a broken deactivate() doesn't block teardown of the rest), then disposes every contribution it registered, so its panels/commands/tools/components/importers all disappear from the editor UI at once.

📸 Screenshot - save as img/editor-plugins-panel.png

The Plugins panel with two rows: Scatter (bundled, trusted badge, enabled toggle on, permission chips scene:read/scene:write/ui:panel/ui:viewport) and a second, untrusted plugin installed from a package (untrusted badge, toggle off, error text about the signing + sandbox update).

Lifecycle

The Plugins panel is the management view. It is not docked by default; open it from Windows ▾ → Plugins. Each row lists an installed plugin: its name, "by author · vversion" (plus "· bundled" for a first-party plugin), a trust badge, its declared permission chips, an enable/disable toggle, and any load or activation error inline. An enabled plugin's row also carries launcher buttons (open its panels, activate its tools). A non-bundled plugin has an Uninstall button that removes it from the project (disables it, drops it from the registry, and deletes its script asset so it does not reinstall on load); a bundled plugin re-registers at startup, so it has no Uninstall.

Enable/disable is also available per plugin in the Windows ▾ menu: a disabled plugin's submenu offers Enable, an enabled one offers Disable above its panels/tools/commands. Uninstall is deliberately kept out of that menu - it is a destructive management action, not something the plugin does, so it lives only in the Plugins panel.

An enabled non-bundled plugin's on/off state is saved into the project bundle's enabledPlugins list and restored (re-activated, skipping the consent prompt) the next time you open that project. Bundled plugins are excluded from that list; they are (re-)enabled automatically every time the editor starts instead. In this release that persistence only matters once a plugin has cleared the trust check above - see Trust and permissions.

On a project load, this restore-and-activate step for every enabled plugin runs before the project's scene is deserialized into the world - so a plugin-registered component type (ctx.registerComponent) is already in the editor's component registry by the time a scene entity referencing it is parsed. Registering it later would silently drop that entity's component data. The consequence for activate(ctx): it should only call ctx.register* and set up handlers. It must not synchronously read scene, prefab, or material state during activation - ctx.scene.all(), ctx.scene.find(...), an asset lookup - because on a project load that state isn't applied to the world yet; a read inside activate sees an empty or stale scene. Do that kind of read inside a tool's onPointerDown / onActivate, a command's run, or a panel's onChange instead, all of which run after the editor has finished loading.

The bundled Scatter tool

Scatter (awaken.scatter) is the one plugin that ships with the editor today, and the framework's own dogfood: it exercises panels, a viewport tool, a command, prefab instantiation, and one-undo-per-stroke through the real PluginContext, not a special-cased path.

It contributes:

  • A Scatter panel: a prefab assetPicker, Radius and Density sliders, Min scale / Max scale sliders, a Rotation jitter slider, an Align to normal checkbox, and a "Clear last stroke" button.
  • A Scatter Brush viewport tool: pick it from Windows ▾ → Scatter (or the Scatter row in the Plugins panel) to activate it, then drag across the ground. It accumulates a ray-ground hit point on every pointer-down/move, then on pointer-up places density prefab copies (rounded, minimum 1) near each accumulated point - scattered within radius of it using a seeded PRNG (mulberry32, so a given stroke's placement is reproducible), each instance scaled randomly between Min scale and Max scale and yawed by up to Rotation jitter degrees. The whole flush runs inside one ctx.runGroup("Scatter", ...) call, so an entire drag - however many prefabs it places - is a single undo entry.
  • A Clear last stroke command that deletes exactly the entity ids the last flushed stroke placed, itself as one more undo group.

Scatter reads e.hit off the ToolPointer directly rather than calling ctx.viewport.raycast itself, so its hits still come from the ground-plane test described above; Align to normal only ever aligns straight up in this release.

📸 Screenshot - save as img/editor-scatter-tool.png

The Scatter panel open in the dock with a prefab picked and non-default slider values, and the Viewport mid-drag showing a cluster of scattered prefab instances under the brush.

Writing and packaging a plugin

Create a plugin with + New Plugin - from the empty Code panel (alongside New Game Script and New Editor Script), the Code panel sidebar's + menu, or the Asset Browser's Scripts tab. It seeds a kind: "plugin" script from a working template: a manifest declaring one panel and one command, and an activate that registers them. It also installs the plugin (disabled) into the Plugins panel straight away. The Monaco editor injects an additional .d.ts (covering PluginContext, the Control union, and the manifest shape) whenever the open script's kind is "plugin", alongside the usual engine types, so ctx. autocompletes accurately as you write activate.

A plugin you author is trusted (it is your own code), so you can enable and run it locally with no signing: open the Plugins panel, toggle it on, accept the consent prompt, and its panels/tools appear. Iterate by editing the script and re-enabling - that reloads the latest source. See Trust and permissions.

The Plugins panel also has a Load plugin from file button, which reads a local .ts or .js source file from disk and installs it the same way + New Plugin does - as your own authored plugin, at the same trust - distinct from importing a signed .awakenpkg package, which installs untrusted. A plugin loaded this way is enabled and running immediately: picking the file is the consent (it's your own code, and you're the one who selected it), so there's no separate Enable toggle or consent prompt afterward.

A plugin script isn't compiled or run from the Code panel (a plugin is enabled from the Plugins panel, not attached to an object): its action button is Package… instead of Compile/Run. Packaging exports a .awakenpkg to share or publish; note that when someone else imports your package it installs untrusted on their machine (it runs for you because you authored it, but is blocked for them until the signing + sandbox update).

A minimal plugin - a manifest plus an activate that registers one panel and one viewport tool:

ts
export const manifest = {
  id: "acme.propstamp",
  name: "Prop Stamp",
  version: "1.0.0",
  permissions: ["scene:write", "assets:write", "ui:panel", "ui:viewport"],
  contributes: {
    panels: [{ id: "propstamp", title: "Prop Stamp", controls: [] }],
    tools: [{ id: "propstamp.brush", title: "Prop Stamp" }],
  },
};

export function activate(ctx: PluginContext) {
  ctx.registerPanel({
    id: "propstamp",
    title: "Prop Stamp",
    controls: [
      { kind: "assetPicker", key: "prefab", label: "Prefab", assetKind: "prefab" },
    ],
  });

  ctx.registerTool({
    id: "propstamp.brush",
    title: "Prop Stamp",
    cursor: "crosshair",
    onPointerDown(e) {
      const prefab = ctx.state("propstamp").get("prefab");
      if (!prefab || !e.hit) return;
      ctx.runGroup("Stamp prop", () => {
        ctx.assets.instantiatePrefab(String(prefab), { position: e.hit!.point });
      });
    },
  });
}

The id (acme.propstamp) doesn't affect trust - provenance does. Authored in your project, this plugin is trusted and runs locally. Packaged and imported by someone else, the same plugin installs untrusted on their machine until the signing + sandbox update. See Trust and permissions.

Once written, open the Create Package dialog and expand its Plugins section, which lists every kind: "plugin" script in the project; select it (and anything it depends on, like a prefab it instantiates) to bundle it into a .awakenpkg for sharing or publishing.

The terrain example plugin

examples/plugins/terrain.plugin.ts, in the editor repo, is a Gaia-style heightfield terrain tool distributed as an example - not bundled with the editor - built entirely on the generic APIs above: registerComponent, registerCommand, registerTool, registerPanel, ctx.scene, ctx.assets (addMesh, updateMesh, addBlob, getBlob), ctx.viewport.raycast, and ctx.pushUndo / runGroup. Nothing named "terrain" exists anywhere in the engine packages - the heightfield math, the grid-mesh builder, and the undo bookkeeping all live in the plugin file itself.

Load it like any plugin source you write yourself: open the Plugins panel, click Load plugin from file, and pick examples/plugins/terrain.plugin.ts. It installs and starts running immediately. Open Windows ▾ → Terrain for its panel and commands:

  • Add terrain to scene (a button in the panel, and a command in the Windows ▾ → Terrain submenu) creates a flat terrain entity: a 129 by 129 vertex grid, 512 metres per side, with a 150 metre height scale.
  • Pick a brush tool from Windows ▾ → Terrain - Raise, Lower, Smooth, or Flatten - then set Brush size, Strength, and Falloff in the panel.
  • Drag across the terrain in the Viewport to sculpt. Each dab reads the surface hit from ctx.viewport.raycast(e.ray), so painting follows the terrain's actual, already-deformed surface rather than a flat plane.
  • Ctrl-Z undoes one whole stroke, not one dab: the plugin snapshots the heights a stroke touches on pointer-down and registers a single ctx.pushUndo entry on pointer-up, regardless of how many dabs the drag contained.
  • The height data lives in a plugin blob (ctx.assets.addBlob), tied to the terrain's Terrain component. Save the project and reopen it: the terrain's shape, heights, and component all persist, so you see the same sculpted terrain you left.

The terrain is grey and untextured in this version - there's no material or texture painting.

See also

  • Editor-Only Scripts - the simpler one-off tool a plugin's ctx.scene surface is built on
  • Scripting Overview - runtime vs editor vs plugin script kinds
  • Asset Browser - where installed content, including plugin scripts, lives in your project
  • Prefabs - what ctx.assets.instantiatePrefab places, and how a prefab becomes its own .awakenpkg
  • Console - where a plugin's ctx.log(...) output and activation errors appear

Awaken — browser-native WebGPU game engine.