Skip to content

Editor Plugins

A plugin adds your own tools to the editor: a command in the Tools menu, a docked panel, an extra section in the Inspector, a gizmo drawn in the viewport, a new asset type. Plugins are TypeScript, they live in your project, and there is no build step — the editor compiles them, and re-compiles the moment you save.

Everything a plugin registers goes through the same registries the editor’s own features use. A contributed command is a command; a contributed panel is a dock panel with a tab, a close button and pop-out. There is no reduced “plugin version” of anything.

  1. Open Window ▸ Plugins and choose New plugin (or search the command palette for it).

  2. Give it a name. The id is derived from it and can be edited — dotted and lowercase, like acme.level-tools. Choose whether it installs into the project (versioned with it, shared by the team) or into your user folder (personal, available in every project).

  3. Tick the samples you want — command, panel, inspector section, viewport gizmo, viewport tool — and choose Create.

The editor writes a plugin that already runs, and loads it:

  • Directory.esengine/
    • Directoryplugins/
      • Directoryacme.level-tools/
        • plugin.json
        • tsconfig.json
        • Directorysrc/
          • editor.ts
plugin.json
{
"id": "acme.level-tools",
"name": "Level Tools",
"version": "0.1.0",
"engines": { "editor": "^0.34" },
"main": { "editor": "src/editor.ts" }
}
src/editor.ts
import { definePlugin, type PluginContext } from '@estella/editor-api';
export default definePlugin({
activate(ctx: PluginContext) {
ctx.commands.register({
id: 'acme.level-tools.hello',
title: { en: 'Say hello', 'zh-CN': '打个招呼' },
menu: 'tools',
run: () => ctx.ui.toast(`${ctx.scene.getSelectionIds().length} selected`),
});
},
});

Your command is now in the Tools menu and the command palette. Edit editor.ts and save: the editor recompiles and re-activates it, and any panel it contributed reopens on the new build.

The plugin API is experimental, and it is not part of Estella’s 1.x compatibility contract — it will keep changing after 1.0. That is a decision, and VERSIONING.md states it alongside the SDK’s own stability tiers: the extension points are still converging on one mechanism, a plugin runs as trusted code in the editor’s renderer rather than in isolation, and no shipped plugin yet holds any of these shapes up.

Three things you can rely on while that is true:

  • engines.editor is honoured. A plugin outside its declared range is refused with a stated reason and never half-loaded, so a change on our side costs you a version bump — never a user a broken editor.
  • Breaking changes are written down, in the CHANGELOG under Editor plugin API, with what to change.
  • Removal is deprecated first — a contribution point being withdrawn keeps working, and says so, for at least one minor release.

Parts of this surface will be frozen individually, as shipped plugins come to exercise them, rather than all at once by a version number.

Every running plugin has a Contributes row in the Plugins panel. Expand it for everything it has registered right now — commands, panels, settings, tools, gizmos, inspector sections, asset types, entity templates, menu items — each with its id.

“Why isn’t my panel showing up?” is usually answered here: either it isn’t in the list (activate never reached that line), or it is, under an id you didn’t expect.

Export in the Plugins panel packs one plugin into a single .esplugin file — a ZIP, so renaming it to .zip opens it anywhere. node_modules, dist, and the generated typings are left out.

The other side installs it with Import, which first lists what is inside — the manifest, the declared capabilities, every file — without unpacking or writing anything. Only after that does it land on disk, and installing is not running: it arrives needing trust, so loading it stays a separate decision.

A plugin can also be an npm package the project depends on:

Terminal window
npm install estella-plugin-tiled

Any direct dependency that ships a plugin.json at its root is a plugin. The editor lists it like any other, needing trust the same way — and because approval covers a version, an update asks again.

A package is also the way to ship a plugin’s two halves together: the editor half the manifest points at, and runtime code the game imports itself.

  • Directorynode_modules/estella-plugin-tiled/
    • package.json
    • plugin.json "main": { "editor": "editor/index.js" }
    • Directoryeditor/
      • index.js
    • Directoryruntime/
      • index.js
src/main.ts
import { addPlugin } from 'esengine';
import { TiledPlugin } from 'estella-plugin-tiled';
addPlugin(TiledPlugin);

The runtime half needs no mechanism of its own — it is an ordinary module, bundled into your game like the rest of src/. Declare esengine as a peer dependency, never a regular one: the project’s bundler leaves every esengine import external so the whole game shares one engine instance, and a copy vendored inside your package would be a second component registry whose systems register into nothing.

Only direct dependencies are considered. A package that arrives because something else depends on it is not something the project asked to run in its editor.

The editor writes @estella/editor-api’s typings into your project at .esengine/plugins/.types/editor-api.d.ts every time it opens the project, so they always match the editor you’re running. Your plugin’s tsconfig.json points at them — New plugin writes this for you; it is spelled out here for anyone authoring one by hand:

{
"compilerOptions": {
"strict": true,
"moduleResolution": "bundler",
"paths": { "@estella/editor-api": ["../.types/editor-api.d.ts"] }
},
"include": ["src"]
}

Nothing to install, and no copy that can go stale.

Every register returns a disposable, and everything is retracted automatically when the plugin unloads — you rarely need to keep the handle.

Land in the command palette, and optionally a menu. Label, shortcut hint, enablement and checked state all come from this one declaration.

ctx.commands.register({
id: 'acme.level-tools.bake',
title: { en: 'Bake Occlusion' },
keybinding: 'mod+alt+b',
menu: 'tools',
isEnabled: () => ctx.scene.getSelectionIds().length > 0,
run: () => { /* … */ },
});

mount gets a plain host element and returns its teardown. The editor owns the tab, the error boundary and pop-out. Style against the editor’s CSS variables (--bg, --text, --text-dim, --accent, …) and the panel matches the surrounding chrome in both light and dark.

ctx.panels.register({
id: 'acme.level-tools.budget',
title: { en: 'Level Budget' },
placement: 'bottom',
mount: (host) => {
host.textContent = 'hello';
return () => { /* teardown */ };
},
});

You may use React — the editor injects its own instance, so hooks work and there is no second copy:

import { createRoot } from 'react-dom/client';

A panel reachable only through a menu is a panel nobody opens. One button on the far-left rail, beside the ones that reveal the editor’s own panels:

ctx.activityBar.register({
id: 'acme.level-tools.rail',
title: { en: 'Level Budget' },
// Inline SVG, drawn at 19px. `currentColor` is what makes it follow the rail
// through hover and both themes.
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">'
+ '<path d="M4 20V10M10 20V4M16 20v-8M22 20H2"/></svg>',
run: () => ctx.panels.open('acme.level-tools.budget'),
});

Leave icon out and you get the plug every contributed thing wears — which is also what two plugins would collide on, so ship one.

The rail is short and shared. Contribute a button for the thing your plugin is; anything else is a command, and the Tools menu is where commands go.

Adds a section under a component (or for an asset type), built from rows the editor renders with its own property UI — so it looks native with no styling.

ctx.inspector.register({
kind: 'component',
id: 'budget',
component: 'Sprite',
title: { en: 'Budget' },
build: (entity, ui) => {
ui.info({ en: 'Texture' }, String(ctx.scene.getFieldValue(entity, 'Sprite', 'texture') ?? ''));
ui.number('weight', { en: 'Weight' }, 1, { min: 0, max: 10 });
},
write: (entity, key, value) => { /* an edit to a row you built */ },
});

Drawn every frame, in world coordinates — the editor projects them, so a gizmo follows the scene through pan and zoom, and a radius in world units scales with it. Stroke widths and text stay in screen pixels.

ctx.overlays.register({
id: 'acme.spawn-radius',
render: (g) => {
const id = ctx.scene.getSelection();
if (id == null) return;
const p = ctx.scene.getFieldValue(id, 'Transform', 'position');
if (!Array.isArray(p)) return;
g.circle({ x: p[0], y: p[1] }, 120, { color: 'var(--accent)', dashed: true });
},
});

While armed, a tool gets first refusal on every pointer stroke. Returning true from onPointerDown claims the stroke and its move/up. Picking any built-in tool disarms it, so you can never get stuck in a plugin’s tool.

ctx.tools.register({
id: 'acme.measure',
title: { en: 'Measure' },
onPointerDown: (p, tc) => { tc.capture(p.pointerId); return true; },
onPointerMove: (p) => { const w = ctx.viewport.viewportToWorld(p.x, p.y); },
onPointerUp: (p, tc) => tc.release(p.pointerId),
});
ctx.tools.activate('acme.measure');
  • ctx.settings.register(…) — a row under Settings ▸ Plugins ▸ your plugin.
  • ctx.assets.registerType(…) — a new asset type: extensions, tile badge, double-click action, and a New ▸ … entry.
  • ctx.entities.registerTemplate(…) — a ready-made entity in the Create picker.
  • ctx.contextMenus.register(…) — a row in the Outliner or Content Browser right-click menu, gated by the thing that was clicked.

An importer turns a file the engine cannot read into assets it can. The editor calls it when a claimed file appears or changes, and on Reimport in the Content Browser:

ctx.assets.registerImporter({
id: 'ldtk', // becomes acme.level-tools.ldtk
extensions: ['ldtk'],
async import(path) {
const level = JSON.parse(await ctx.fs.readProject(path));
await ctx.fs.writeProject(path.replace(/\.ldtk$/, '.tmj'), toTiled(level));
},
});

What you write is an ordinary project asset, so nothing downstream — the registry, the inspector, cooking, the shipped build — learns your format. Declare the fs:project capability, or there is nowhere to put the output.

Throwing (or rejecting) reports the failure against your plugin in the Output Log and leaves the other importers running. A file is never imported twice at once, so an import that takes a while cannot be re-entered by the writes it is making.

The built-in agent’s entire vocabulary is the tool catalog. A plugin that adds a capability without adding a tool has added it for the person and not for the agent — so if your plugin can bake occlusion, teach the agent to ask for it:

ctx.agentTools.register({
// Your plugin id with its dots as `_` — plugin `acme.level-tools` prefixes
// its tools with `acme_level_tools_`.
name: 'acme_level_tools_bake-occlusion',
description: 'Bake occlusion for the open scene. Use after moving walls.',
schema: { type: 'object', properties: { quality: { type: 'number' } } },
effect: 'undoable',
run: ({ quality }) => bake(quality ?? 1),
});

description is not documentation — it is the only thing the model reads when deciding whether this is the right tool. Say what it does and when to reach for it.

effect decides whether the person is asked first, and the tiers are drawn where going back stops working. read and undoable run unasked — the turn’s checkpoint is the approval. So does journaled, which is what to declare when your tool writes project files through ctx.fs.writeProject: those are captured before they land, so the turn’s Revert takes them back with the scene. Reserve irreversible for what no checkpoint reaches — work outside the open project, or code whose effects nobody enumerated; it stops and asks.

Nothing verifies the field, and that is not a gap — your plugin already runs with the whole editor surface in reach, so it could do the same work through a command. The boundary is the trust prompt at install. What the field buys is the confirm gate behaving correctly for an honest plugin, so declare it honestly: a tool that calls itself journaled while writing outside ctx.fs claims a safety net that is not holding it.

The name must be namespaced with your plugin id, and may contain only letters, digits, _ and - — up to 64 of them. Both halves of that matter. An un-namespaced tool could shadow a built-in, and a model calling delete_entity believes it knows what happens next; a name outside the character set is one the model’s endpoint refuses, and it refuses the whole request rather than the tool, which takes every conversation down with it.

Plugin ids are dotted by convention and tool names cannot be, so the prefix is your id with its dots folded to _: plugin acme.level-tools owns the acme_level_tools_ prefix. A tool that breaks any of these rules is refused with the reason in the Output Log, rather than dropped silently.

Whatever run returns is JSON-encoded for the model; throwing reports the message as a failed call, which the model can read and act on. A tool that appears while a conversation is open joins the NEXT one — the tool list renders first in the prompt, so changing it mid-conversation would throw away every cached byte after it.

Always go through ctx.scene. Those writes run through the editor’s command layer, which means they land in the undo history and survive Play → Stop. Reaching around it to the live engine loses both.

ctx.scene.transact('Rename Markers', () => {
for (const node of ctx.scene.getSceneTree()) {
if (node.name === 'Entity') ctx.scene.renameEntity(node.id, 'Marker');
}
});

Everything inside one transact is a single undo step, however many edits it makes.

Open Window ▸ Plugins. Every plugin the editor found is listed — including the broken ones, with the reason: a manifest that failed to parse, a compile error, or an id another plugin already claimed. Errors thrown by your plugin appear in the Output Log tagged plugin:<your-id>, with a stack.

If a plugin keeps throwing, the editor disables it rather than letting it break a surface on every render. Fix it and press Reload.

  • <project>/.esengine/plugins/<id>/ — versioned with the project and shared with your team. This is the usual place.
  • an npm package the project depends on — see From npm above.
  • <userData>/plugins/<id>/ — your own tools, across every project.

On an id collision the first of those three wins, and the others are listed saying who took it.

A .esengine/platforms/<id>.mjs packaging profile (see Mini-Game Platforms) is imported into the editor’s main process with full system access. It is listed in the Plugins panel and needs the same approval — until you approve it, its target shows as not ready in the Package dialog.

Some runtimes arrive as WASM: a vector-animation player, a solver, anything with a C++ heritage and an official emscripten build. The engine already loads five of its own that way — physics, the Basis transcoder, the per-version Spine runtimes — and a project can add its own on the same terms.

Fetching one by hand works on the web and nowhere else. A mini-game has no fetch and needs the binary inside the package; a playable has no files at all. A project module gets the treatment the engine’s own get: acquired through one call, staged into every package, required by name in the generated mini-game entry, and loaded in Play so you can develop against it.

<project>/.esengine/modules/rive/
module.json { "file": "rive", "globalName": "RiveModule" }
web/rive.js rive.wasm ← web, desktop, playable, and Play
wechat/rive.js rive.wasm ← every mini-game vendor

The directory name is the id you acquire by. module.json is optional: with no manifest the artifact base name defaults to the directory name, so a module whose files are rive/web/rive.js needs nothing else. globalName is the emscripten EXPORT_NAME — set it when the glue was built with MODULARIZE and a named export, omit it when the glue is an ES module whose default is the factory.

Per-platform directories are not bureaucracy. A mini-game host needs its own emscripten build (WXWebAssembly glue, a lower es-target) — which is why the engine builds its own modules twice too. The editor will not substitute the web build for a mini-game one: that produces a package that builds clean and dies on a device, so it is refused, with a warning naming the file it looked for.

acquire hands back the emscripten instance. It is typed as Record<string, unknown> — the engine cannot know your module’s exports — so declare the surface you use and wrap it once, which is exactly what the built-in Spine integration does:

interface RiveModule {
cwrap(name: string, ret: string | null, args: string[]): (...a: unknown[]) => unknown;
_malloc(size: number): number;
_free(ptr: number): void;
HEAPU8: Uint8Array;
}
const raw = await app.sideModules?.acquire('rive');
if (!raw) return; // not packaged for this target — degrade
const rive = raw as unknown as RiveModule;
const version = rive.cwrap('rive_version', 'string', [])() as string;

acquire caches per id — including the failure, so a missing artifact is not re-fetched every frame — and returns null rather than throwing when the module is unavailable. Gate on it the way gameplay code gates on Ads.available: a target without the module should degrade, not crash.

To draw what such a runtime produces, hand its triangles to Mesh2D — positions, UVs, colors and indices go through the same path the engine’s own Spine integration uses, so it renders on every backend the engine supports.

Target Project modules
Web / Desktop / Playable staged from web/
WeChat & other mini-games staged from wechat/ (or a directory named for your vendor id)
Play (in the editor) staged from web/, so you develop against the real module
Android / iOS not supported — the native host links its modules into the app binary, so one arriving with the content has no way to load. Build it into your native host instead.

A module that could not be staged is never declared to the runtime. That is deliberate: a declaration whose binary is absent would report a missing file instead of an unsupported target, which is much harder to understand from a phone.

Two ids are reserved in the sense that you cannot take them: registering over one the engine owns (physics, spine:4.2, …) is refused, because physics meaning two different binaries depending on load order is not a capability.