Skip to content

Editor Extensions

The editor provides a registry-based extension system. Place TypeScript files in your project’s src/editor/ directory and the editor automatically compiles and loads them. Extensions can register menus, panels, gizmos, statusbar items, settings, and more.

Getting Started

Create a TypeScript file in src/editor/:

my-project/
├── src/
│ ├── game.ts ← game script (bundled into runtime)
│ └── editor/
│ └── my-extension.ts ← editor extension (NOT bundled into runtime)
├── assets/
└── scenes/

The src/editor/ directory is excluded from runtime bundling — only the editor loads these files. All .ts files under src/ are compiled by the editor, but only files outside editor/ are included in the game build. File changes trigger automatic hot-reload.

Accessing the API

Import from @esengine/editor to access all editor and SDK APIs. The editor’s build system shims this module automatically:

import {
// Registry APIs
registerMenu,
registerMenuItem,
registerPanel,
registerGizmo,
registerStatusbarItem,
registerSettingsSection,
registerSettingsItem,
getSettingsValue,
setSettingsValue,
registerPropertyEditor,
registerComponentSchema,
registerBoundsProvider,
registerContextMenuItem,
registerInspectorSection,
registerComponentInspector,
// UI utilities
showToast,
showSuccessToast,
showErrorToast,
showContextMenu,
showConfirmDialog,
showInputDialog,
icons,
// Editor access
getEditorInstance,
getEditorStore,
// Drawing and rendering (same as esengine SDK)
Draw,
Geometry,
Material,
BlendMode,
DataType,
ShaderSources,
PostProcess,
registerDrawCallback,
unregisterDrawCallback,
// Cleanup
onDispose,
} from '@esengine/editor';

You can also import from esengine — both modules expose the same combined API in the editor context.

Register a Menu

Add a new top-level menu to the menu bar:

registerMenu({
id: 'tools',
label: 'Tools',
order: 10,
});
FieldTypeRequiredDescription
idstringYesUnique menu identifier
labelstringYesDisplay text
ordernumberNoSort order (lower = further left)

Register a Menu Item

Add items to existing or custom menus:

registerMenuItem({
id: 'tools.export-png',
menu: 'tools',
label: 'Export as PNG',
shortcut: 'Ctrl+Shift+E',
order: 0,
action: () => {
showSuccessToast('Exported!');
},
});
registerMenuItem({
id: 'tools.clear-cache',
menu: 'tools',
label: 'Clear Cache',
order: 1,
separator: true,
enabled: () => cacheSize > 0,
action: () => clearCache(),
});
FieldTypeRequiredDescription
idstringYesUnique item identifier
menustringYesParent menu ID (file, edit, view, help, or custom)
labelstringYesDisplay text
iconstringNoSVG icon string
shortcutstringNoKeyboard shortcut display text
separatorbooleanNoShow a separator line above this item
ordernumberNoSort order within the menu
enabled() => booleanNoDynamic enable/disable
action() => voidYesClick handler

Built-in menus: file, edit, view, help.

Panels

Register custom panels that appear in the editor layout:

registerPanel({
id: 'my-debug-panel',
title: 'Debug Info',
icon: icons.settings(14),
position: 'right',
order: 10,
defaultVisible: false,
factory: (container, store) => {
const div = document.createElement('div');
div.style.padding = '8px';
div.textContent = 'Entity count: ' + store.entities.length;
container.appendChild(div);
const interval = setInterval(() => {
div.textContent = 'Entity count: ' + store.entities.length;
}, 1000);
return {
dispose() {
clearInterval(interval);
container.innerHTML = '';
},
onShow() {},
onHide() {},
};
},
});
FieldTypeRequiredDescription
idstringYesUnique panel identifier
titlestringYesPanel tab title
iconstringNoSVG icon string
position'left' | 'right' | 'center' | 'bottom'YesLayout position
defaultVisiblebooleanNoShow on first load (default: false)
ordernumberNoTab order within position
factory(container, store) => PanelInstanceYesCreates the panel UI

PanelInstance

The factory must return an object with at least a dispose method:

interface PanelInstance {
dispose(): void;
onShow?(): void;
onHide?(): void;
}

Optionally implement resize() to handle layout changes.

Gizmos

Register custom scene view gizmos for visual editing tools:

registerGizmo({
id: 'my-anchor-gizmo',
name: 'Anchor',
icon: icons.circle(16),
shortcut: 'A',
order: 20,
draw(ctx) {
const { store, ctx: canvas, zoom } = ctx;
const selected = store.selectedEntity;
if (selected === null) return;
const transform = ctx.getWorldTransform(selected);
canvas.beginPath();
canvas.arc(transform.x, transform.y, 8 / zoom, 0, Math.PI * 2);
canvas.strokeStyle = '#ff0';
canvas.lineWidth = 2 / zoom;
canvas.stroke();
},
hitTest(worldX, worldY, ctx) {
const selected = ctx.store.selectedEntity;
if (selected === null) return { hit: false };
const transform = ctx.getWorldTransform(selected);
const dx = worldX - transform.x;
const dy = worldY - transform.y;
const dist = Math.sqrt(dx * dx + dy * dy);
return { hit: dist < 10 / ctx.zoom, data: { entityId: selected } };
},
onDragStart(worldX, worldY, hitData, ctx) {},
onDrag(worldX, worldY, hitData, ctx) {},
onDragEnd(worldX, worldY, hitData, ctx) {},
getCursor(hitData) { return 'crosshair'; },
});

GizmoDescriptor

FieldTypeRequiredDescription
idstringYesUnique gizmo identifier
namestringYesDisplay name in toolbar
iconstringYesSVG icon string
shortcutstringNoKeyboard shortcut
ordernumberNoToolbar order
draw(ctx)functionYesRender the gizmo overlay
hitTest(worldX, worldY, ctx)functionYesTest if mouse hits the gizmo
onDragStart(worldX, worldY, hitData, ctx)functionNoDrag start handler
onDrag(worldX, worldY, hitData, ctx)functionNoDrag move handler
onDragEnd(worldX, worldY, hitData, ctx)functionNoDrag end handler
onHover(worldX, worldY, hitData, ctx)functionNoMouse hover handler
getCursor(hitData)functionNoCustom cursor for hover

GizmoContext

The ctx parameter provides editor state and utilities:

Property / MethodDescription
storeEditor store (entities, selection, etc.)
ctxCanvas 2D rendering context for drawing overlays
zoomCurrent viewport zoom level
screenToWorld(clientX, clientY)Convert screen coordinates to world
getWorldTransform(entityId)Get entity’s world transform
getEntityBounds(entityData)Get entity’s visual bounds
requestRender()Request a scene redraw

Since ctx.ctx is a standard Canvas 2D context, you can use fillText() to draw viewport text directly inside draw():

draw(ctx) {
const { ctx: canvas, zoom } = ctx;
canvas.font = `${12 / zoom}px sans-serif`;
canvas.fillStyle = '#fff';
canvas.fillText('Label', worldX, worldY);
},

Settings

Register a Settings Section

registerSettingsSection({
id: 'my-extension',
title: 'My Extension',
order: 10,
});

Register Settings Items

registerSettingsItem({
id: 'my-extension.showOverlay',
section: 'my-extension',
label: 'Show Overlay',
type: 'boolean',
defaultValue: true,
order: 0,
onChange: (value) => {
// React to changes
},
});
registerSettingsItem({
id: 'my-extension.opacity',
section: 'my-extension',
label: 'Overlay Opacity',
type: 'range',
defaultValue: 0.8,
min: 0,
max: 1,
step: 0.1,
order: 1,
});
registerSettingsItem({
id: 'my-extension.theme',
section: 'my-extension',
label: 'Theme',
type: 'select',
defaultValue: 'dark',
options: [
{ label: 'Dark', value: 'dark' },
{ label: 'Light', value: 'light' },
],
order: 2,
});

Settings Item Types

TypeDescriptionExtra fields
booleanToggle switch
numberNumber inputmin, max, step
stringText input
colorColor picker
selectDropdownoptions: [{ label, value }]
rangeSlidermin, max, step

Reading and Writing Settings

const show = getSettingsValue<boolean>('my-extension.showOverlay');
setSettingsValue('my-extension.opacity', 0.5);

Settings are persisted to localStorage automatically.

Statusbar Items

Add widgets to the bottom status bar:

registerStatusbarItem({
id: 'my-fps-counter',
position: 'right',
order: 0,
render: (container) => {
const span = document.createElement('span');
span.style.fontSize = '11px';
container.appendChild(span);
let lastTime = performance.now();
let frameCount = 0;
const tick = () => {
frameCount++;
const now = performance.now();
const elapsed = now - lastTime;
if (elapsed >= 1000) {
span.textContent = `FPS: ${Math.round(frameCount * 1000 / elapsed)}`;
frameCount = 0;
lastTime = now;
}
handle = requestAnimationFrame(tick);
};
let handle = requestAnimationFrame(tick);
return {
dispose() { cancelAnimationFrame(handle); },
update() {},
};
},
});
FieldTypeRequiredDescription
idstringYesUnique identifier
position'left' | 'right'YesBar position
ordernumberNoSort order
render(container) => { dispose, update? }YesCreate the widget

UI Utilities

Toast Notifications

showToast({ title: 'Hello', message: 'World', type: 'info', duration: 3000 });
showSuccessToast('Done!', 'Build completed');
showErrorToast('Error', 'Failed to compile');

Dialogs

const name = await showInputDialog({
title: 'Rename Entity',
label: 'New name:',
defaultValue: 'Entity',
});
const confirmed = await showConfirmDialog({
title: 'Delete Entity',
message: 'Are you sure?',
});

Context Menu

showContextMenu({
x: event.clientX,
y: event.clientY,
items: [
{ label: 'Copy', action: () => copy() },
{ label: 'Paste', action: () => paste() },
{ separator: true },
{ label: 'Delete', action: () => del() },
],
});

Component Schemas

Register custom component schemas for the inspector panel:

registerComponentSchema({
name: 'Health',
category: 'script',
properties: [
{ name: 'current', type: 'number', min: 0, max: 1000 },
{ name: 'max', type: 'number', min: 1, max: 1000 },
{ name: 'regenRate', type: 'number', min: 0, step: 0.1 },
],
});

Property Editors

Register custom inspector widgets for property types:

registerPropertyEditor('my-gradient', (container, ctx) => {
const input = document.createElement('input');
input.type = 'color';
input.value = ctx.value as string;
input.addEventListener('input', () => ctx.onChange(input.value));
container.appendChild(input);
return {
update(value) { input.value = value as string; },
dispose() { container.innerHTML = ''; },
};
});

Then reference the type in a component schema:

registerComponentSchema({
name: 'Gradient',
category: 'script',
properties: [
{ name: 'startColor', type: 'my-gradient' },
{ name: 'endColor', type: 'my-gradient' },
],
});

Bounds Providers

Register custom bounds calculation for gizmo display:

registerBoundsProvider('CircleCollider', {
getBounds(data: any) {
const r = data.radius ?? 50;
return { width: r * 2, height: r * 2 };
},
});

Context Menu Contributions

Add items to the editor’s built-in context menus (hierarchy, content browser, etc.):

registerContextMenuItem({
id: 'my-ext.log-entity',
location: 'hierarchy.entity',
label: 'Log Entity Info',
icon: icons.settings(14),
group: 'debug',
order: 0,
visible: (ctx) => ctx.entity !== undefined,
action: (ctx) => {
console.log('Entity:', ctx.entity, ctx.entityData);
},
});

ContextMenuContribution

FieldTypeRequiredDescription
idstringYesUnique identifier
locationstringYesWhere the item appears (see below)
labelstringYesDisplay text
iconstringNoSVG icon string
shortcutstringNoKeyboard shortcut display text
groupstringNoGroup name — a separator is inserted between different groups
ordernumberNoSort order within the group
visible(ctx) => booleanNoShow/hide based on context
enabled(ctx) => booleanNoEnable/disable based on context
action(ctx) => voidYesClick handler
childrenContextMenuContribution[]NoSub-menu items

Built-in Locations

LocationTrigger
hierarchy.entityRight-click an entity in the hierarchy
hierarchy.backgroundRight-click empty space in the hierarchy
content-browser.assetRight-click a file in the content browser
content-browser.folderRight-click a folder in the content browser
inspector.componentRight-click a component header in the inspector

You can also use custom location strings for your own panels.

ContextMenuContext

The ctx object passed to visible, enabled, and action contains:

PropertyTypeDescription
locationstringThe location that triggered the menu
entityEntityEntity ID (hierarchy locations)
entityDataEntityDataFull entity data (hierarchy locations)
assetPathstringAsset file path (content browser locations)
assetTypestringAsset type like 'image', 'script' (content browser locations)
componentTypestringComponent type name (inspector locations)

Use children to create nested menus:

registerContextMenuItem({
id: 'my-ext.add-collider',
location: 'hierarchy.entity',
label: 'Add Collider',
group: 'components',
action: () => {},
children: [
{
id: 'my-ext.add-box-collider',
location: 'hierarchy.entity',
label: 'Box Collider',
action: (ctx) => {
const store = getEditorStore();
store.addComponent(ctx.entity!, 'BoxCollider', {});
},
},
{
id: 'my-ext.add-circle-collider',
location: 'hierarchy.entity',
label: 'Circle Collider',
action: (ctx) => {
const store = getEditorStore();
store.addComponent(ctx.entity!, 'CircleCollider', { radius: 50 });
},
},
],
});

Inspector Sections

Add custom collapsible sections to the entity or asset inspector:

registerInspectorSection({
id: 'my-ext.entity-stats',
title: 'Entity Stats',
icon: icons.settings(14),
target: 'entity',
order: 100,
visible: (ctx) => ctx.entity !== undefined,
render: (container, ctx) => {
const div = document.createElement('div');
div.className = 'es-property-row';
function refresh() {
const data = ctx.store.getEntityData(ctx.entity!);
div.textContent = data
? `Components: ${data.components.length}`
: '';
}
refresh();
return {
update() { refresh(); },
dispose() { container.innerHTML = ''; },
};
},
});

InspectorSectionDescriptor

FieldTypeRequiredDescription
idstringYesUnique identifier
titlestringYesSection header text
iconstringNoSVG icon in the header
ordernumberNoSort order (higher = further down)
target'entity' | 'asset' | 'both'YesWhere the section appears
visible(ctx) => booleanNoShow/hide based on context
render(container, ctx) => InspectorSectionInstanceYesCreate the section UI

InspectorSectionInstance

The render function must return:

interface InspectorSectionInstance {
dispose(): void;
update?(): void; // called when properties change
}

InspectorContext

PropertyTypeDescription
storeEditorStoreEditor store
entityEntityEntity ID (entity target)
assetPathstringAsset file path (asset target)
assetTypestringAsset type (asset target)

Asset Inspector Section

For asset-targeted sections, use the assetType field to filter:

registerInspectorSection({
id: 'my-ext.image-analysis',
title: 'Image Analysis',
target: 'asset',
order: 200,
visible: (ctx) => ctx.assetType === 'image',
render: (container, ctx) => {
const div = document.createElement('div');
div.textContent = `Analyzing: ${ctx.assetPath}`;
container.appendChild(div);
return { dispose() { container.innerHTML = ''; } };
},
});

Component Inspectors

Replace the default property editor UI for a specific component type with a fully custom inspector:

registerComponentInspector({
id: 'my-ext.health-inspector',
componentType: 'Health',
render: (container, ctx) => {
const bar = document.createElement('div');
bar.style.cssText = 'height:20px; background:#333; border-radius:4px; overflow:hidden;';
const fill = document.createElement('div');
fill.style.cssText = 'height:100%; background:#4caf50; transition:width 0.2s;';
bar.appendChild(fill);
const label = document.createElement('div');
label.style.cssText = 'font-size:11px; margin-top:4px; color:#aaa;';
container.appendChild(bar);
container.appendChild(label);
function refresh(data: Record<string, unknown>) {
const current = (data.current as number) ?? 0;
const max = (data.max as number) ?? 100;
const pct = max > 0 ? (current / max) * 100 : 0;
fill.style.width = `${pct}%`;
fill.style.background = pct > 50 ? '#4caf50' : pct > 25 ? '#ff9800' : '#f44336';
label.textContent = `${current} / ${max}`;
}
refresh(ctx.componentData);
return {
update(data) { refresh(data); },
dispose() { container.innerHTML = ''; },
};
},
});

When a ComponentInspector is registered for a component type, it completely replaces the default property rows. The custom inspector receives all component data and an onChange callback for modifications.

ComponentInspectorDescriptor

FieldTypeRequiredDescription
idstringYesUnique identifier
componentTypestringYesComponent type to override
render(container, ctx) => ComponentInspectorInstanceYesCreate the inspector UI

ComponentInspectorContext

PropertyTypeDescription
storeEditorStoreEditor store
entityEntityEntity ID
componentTypestringComponent type name
componentDataRecord<string, unknown>Current component data
onChange(property, oldValue, newValue) => voidReport a property change

ComponentInspectorInstance

interface ComponentInspectorInstance {
dispose(): void;
update?(data: Record<string, unknown>): void; // called with new data
}

Resource Cleanup

The editor automatically cleans up extension-registered menus, panels, gizmos, and settings on reload. For custom resources (timers, event listeners, GPU resources), use onDispose:

const interval = setInterval(tick, 100);
onDispose(() => clearInterval(interval));
const shader = Material.createShader(vertSrc, fragSrc);
// Shaders created through the extension API are auto-cleaned
registerDrawCallback('my-overlay', drawFn);
// Draw callbacks registered through the extension API are auto-cleaned

Resources tracked automatically:

  • Draw callbacks (registerDrawCallback)
  • Post-process passes (PostProcess.addPass)
  • Shaders (Material.createShader)

Example: Origin Crosshair

A complete extension that draws a crosshair at the world origin with a settings toggle:

import {
registerSettingsSection,
registerSettingsItem,
getSettingsValue,
registerDrawCallback,
Draw,
} from '@esengine/editor';
registerSettingsSection({ id: 'debug-overlay', title: 'Debug Overlay', order: 20 });
registerSettingsItem({
id: 'debug-overlay.showOrigin',
section: 'debug-overlay',
label: 'Show Origin Crosshair',
type: 'boolean',
defaultValue: true,
});
registerSettingsItem({
id: 'debug-overlay.size',
section: 'debug-overlay',
label: 'Crosshair Size',
type: 'range',
defaultValue: 50,
min: 10,
max: 200,
step: 10,
});
registerDrawCallback('origin-crosshair', () => {
if (!getSettingsValue('debug-overlay.showOrigin')) return;
const size = getSettingsValue<number>('debug-overlay.size');
const red = { r: 1, g: 0, b: 0, a: 1 };
const green = { r: 0, g: 1, b: 0, a: 1 };
Draw.line({ x: 0, y: 0 }, { x: size, y: 0 }, red, 2);
Draw.line({ x: 0, y: 0 }, { x: 0, y: size }, green, 2);
Draw.circleOutline({ x: 0, y: 0 }, 4, { r: 1, g: 1, b: 1, a: 1 }, 1);
});

Next Steps