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.
Menus
Register a Menu
Add a new top-level menu to the menu bar:
registerMenu({ id: 'tools', label: 'Tools', order: 10,});| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique menu identifier |
label | string | Yes | Display text |
order | number | No | Sort 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(),});| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique item identifier |
menu | string | Yes | Parent menu ID (file, edit, view, help, or custom) |
label | string | Yes | Display text |
icon | string | No | SVG icon string |
shortcut | string | No | Keyboard shortcut display text |
separator | boolean | No | Show a separator line above this item |
order | number | No | Sort order within the menu |
enabled | () => boolean | No | Dynamic enable/disable |
action | () => void | Yes | Click 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() {}, }; },});| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique panel identifier |
title | string | Yes | Panel tab title |
icon | string | No | SVG icon string |
position | 'left' | 'right' | 'center' | 'bottom' | Yes | Layout position |
defaultVisible | boolean | No | Show on first load (default: false) |
order | number | No | Tab order within position |
factory | (container, store) => PanelInstance | Yes | Creates 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
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique gizmo identifier |
name | string | Yes | Display name in toolbar |
icon | string | Yes | SVG icon string |
shortcut | string | No | Keyboard shortcut |
order | number | No | Toolbar order |
draw(ctx) | function | Yes | Render the gizmo overlay |
hitTest(worldX, worldY, ctx) | function | Yes | Test if mouse hits the gizmo |
onDragStart(worldX, worldY, hitData, ctx) | function | No | Drag start handler |
onDrag(worldX, worldY, hitData, ctx) | function | No | Drag move handler |
onDragEnd(worldX, worldY, hitData, ctx) | function | No | Drag end handler |
onHover(worldX, worldY, hitData, ctx) | function | No | Mouse hover handler |
getCursor(hitData) | function | No | Custom cursor for hover |
GizmoContext
The ctx parameter provides editor state and utilities:
| Property / Method | Description |
|---|---|
store | Editor store (entities, selection, etc.) |
ctx | Canvas 2D rendering context for drawing overlays |
zoom | Current 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
| Type | Description | Extra fields |
|---|---|---|
boolean | Toggle switch | — |
number | Number input | min, max, step |
string | Text input | — |
color | Color picker | — |
select | Dropdown | options: [{ label, value }] |
range | Slider | min, 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() {}, }; },});| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique identifier |
position | 'left' | 'right' | Yes | Bar position |
order | number | No | Sort order |
render | (container) => { dispose, update? } | Yes | Create 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
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique identifier |
location | string | Yes | Where the item appears (see below) |
label | string | Yes | Display text |
icon | string | No | SVG icon string |
shortcut | string | No | Keyboard shortcut display text |
group | string | No | Group name — a separator is inserted between different groups |
order | number | No | Sort order within the group |
visible | (ctx) => boolean | No | Show/hide based on context |
enabled | (ctx) => boolean | No | Enable/disable based on context |
action | (ctx) => void | Yes | Click handler |
children | ContextMenuContribution[] | No | Sub-menu items |
Built-in Locations
| Location | Trigger |
|---|---|
hierarchy.entity | Right-click an entity in the hierarchy |
hierarchy.background | Right-click empty space in the hierarchy |
content-browser.asset | Right-click a file in the content browser |
content-browser.folder | Right-click a folder in the content browser |
inspector.component | Right-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:
| Property | Type | Description |
|---|---|---|
location | string | The location that triggered the menu |
entity | Entity | Entity ID (hierarchy locations) |
entityData | EntityData | Full entity data (hierarchy locations) |
assetPath | string | Asset file path (content browser locations) |
assetType | string | Asset type like 'image', 'script' (content browser locations) |
componentType | string | Component type name (inspector locations) |
Sub-menus
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
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique identifier |
title | string | Yes | Section header text |
icon | string | No | SVG icon in the header |
order | number | No | Sort order (higher = further down) |
target | 'entity' | 'asset' | 'both' | Yes | Where the section appears |
visible | (ctx) => boolean | No | Show/hide based on context |
render | (container, ctx) => InspectorSectionInstance | Yes | Create the section UI |
InspectorSectionInstance
The render function must return:
interface InspectorSectionInstance { dispose(): void; update?(): void; // called when properties change}InspectorContext
| Property | Type | Description |
|---|---|---|
store | EditorStore | Editor store |
entity | Entity | Entity ID (entity target) |
assetPath | string | Asset file path (asset target) |
assetType | string | Asset 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
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique identifier |
componentType | string | Yes | Component type to override |
render | (container, ctx) => ComponentInspectorInstance | Yes | Create the inspector UI |
ComponentInspectorContext
| Property | Type | Description |
|---|---|---|
store | EditorStore | Editor store |
entity | Entity | Entity ID |
componentType | string | Component type name |
componentData | Record<string, unknown> | Current component data |
onChange | (property, oldValue, newValue) => void | Report 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-cleanedResources 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
- Custom Draw — Draw API for extension overlays
- Materials & Shaders — shader creation for custom gizmos
- Post-Processing — post-process passes from extensions