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.
Your first plugin
Section titled “Your first plugin”-
Open Window ▸ Plugins and choose New plugin (or search the command palette for it).
-
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). -
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
{ "id": "acme.level-tools", "name": "Level Tools", "version": "0.1.0", "engines": { "editor": "^0.34" }, "main": { "editor": "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.
What this API promises
Section titled “What this API promises”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.editoris 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.
Seeing what a plugin contributed
Section titled “Seeing what a plugin contributed”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.
Sharing a plugin
Section titled “Sharing a plugin”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.
From npm
Section titled “From npm”A plugin can also be an npm package the project depends on:
npm install estella-plugin-tiledAny 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
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.
Typings
Section titled “Typings”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.
What a plugin can contribute
Section titled “What a plugin can contribute”Every register returns a disposable, and everything is retracted automatically
when the plugin unloads — you rarely need to keep the handle.
Commands
Section titled “Commands”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: () => { /* … */ },});Panels
Section titled “Panels”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 button on the rail
Section titled “A button on the rail”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.
Inspector sections
Section titled “Inspector sections”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 */ },});Viewport gizmos
Section titled “Viewport gizmos”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 }); },});Viewport tools
Section titled “Viewport tools”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');The rest
Section titled “The rest”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.
Importers
Section titled “Importers”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.
Lending the agent a tool
Section titled “Lending the agent a tool”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.
Editing the scene
Section titled “Editing the scene”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.
When something goes wrong
Section titled “When something goes wrong”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.
Where plugins live
Section titled “Where plugins live”<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.
Project platform profiles
Section titled “Project platform profiles”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.
Project native modules
Section titled “Project native modules”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.
Laying one out
Section titled “Laying one out”<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 vendorThe 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.
Using it
Section titled “Using it”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 — degradeconst 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.
What each target does
Section titled “What each target does”| 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.