Extending the editor
The editor is an ECS world, so it is extended the way a game is: with plugins that bind resources and register things. Pass them to createEditor({ plugins: [...] }).
Describe your components
Section titled “Describe your components”The inspector builds itself from component schemas. Metadata on a field or a component adjusts how it is shown. The engine stores metadata and never reads it, so a game declares it without importing the editor.
import { Define, StoreType } from '@shell/ecs';
export const Spin = Define.component('spin') .withSchema({ speed: { type: StoreType.Float32, default: 1, meta: { min: 0, step: 0.1, unit: 'rad/s' } }, axis: { type: StoreType.Int8, meta: { widget: 'enum', options: [ { label: 'Horizontal', value: 0 }, { label: 'Vertical', value: 1 }, ], }, }, glow: { type: StoreType.Int32, default: 0xffffff, meta: { widget: 'color' } }, }) .withMeta({ category: 'Gameplay' }) .serializable('example:spin') .showInEditor();A component appears in the inspector’s “Add component” list when it is .serializable() and .showInEditor().
| Field metadata | Effect |
|---|---|
label | Display name. Default: the field name in words. |
description | Tooltip. |
min, max | Limits of a number field. |
step | Increment for arrow keys. |
unit | Suffix after the value, such as px. |
widget | Overrides the widget chosen from the store type. See below. |
options | With widget: 'enum': the choices, as { label, value }. |
assetKind | With widget: 'asset': the kind of asset accepted, such as texture. |
readOnly | Show the value without letting it be edited. |
hidden | Keep the field out of the inspector. |
Component metadata: label, category, description.
Which widget a field gets
Section titled “Which widget a field gets”In order: meta.widget on the field, then a registration for that field (below), then the store type.
| Store type | Widget |
|---|---|
Int8, Int16, Int32 | integer |
Float32, Float64 | number |
Boolean | checkbox |
String | text |
Entity | entity: a list of entities, and a drop target |
Prefab() | prefab: a list of the registered prefabs |
| anything else | a read-only preview |
Widget kinds to ask for by name: color (an integer 0xRRGGBB), angle (degrees shown, radians stored), enum, asset, multiline, readonly.
A component with exactly the number fields x, y or x, y, z is shown on one row.
For a component whose source you cannot change:
world.get('editorInspector').registerWidget('tint', 'color', 'color'); // component key, field, kindAdd a field widget
Section titled “Add a field widget”A widget is a Svelte component that takes FieldProps. Register it under a kind, then ask for that kind in meta.widget.
<script lang="ts"> import type { FieldProps } from '@shell/editor-ui';
let { id, value, readOnly, meta, onchange, oncommit }: FieldProps = $props();</script>
<input {id} type="range" min={meta.min ?? 0} max={meta.max ?? 1} step={meta.step ?? 0.01} value={typeof value === 'number' ? value : 0} disabled={readOnly} oninput={(event) => onchange?.(event.currentTarget.valueAsNumber)} onchange={() => oncommit?.()}/>world.get('editorWidgets').register('slider', SliderField);The contract:
idgoes on the control, so the inspector’s label points at it.- Call
onchange(value)for every intermediate value andoncommit()when the edit is finished. Everything between is one undo step.oncancel()abandons the edit and restores the value from before it. - A widget never changes the scene itself; the inspector turns these calls into a history transaction.
mixedis true when several entities are selected and their values differ.valueis then the primary entity’s; show “Mixed” instead (a placeholder, an indeterminate checkbox, an extra list entry). The firstonchangegives every selected entity the same value.
Add an asset kind
Section titled “Add an asset kind”An importer tells the asset database which files are of a kind and, optionally, how to tell a broken one.
world.get('editorAssets').registerImporter({ kind: 'level', matches: (path) => path.endsWith('.level.json'), // Return what is wrong, phrased to follow the file's name, or nothing when the file is fine. validate: (text) => typeof JSON.parse(text).rows === 'number' ? undefined : 'its "rows" is not a number',});- The kind is shown in the assets panel and is what
assetKindon an asset field filters by. - A file is checked when it appears and again when it changes. A broken file is marked in the assets panel, the preview says why, and a warning goes to the console. A
validatethat throws counts as “it could not be read”. - Scene and prefab files are validated this way out of the box.
Add a panel
Section titled “Add a panel”import { Define } from '@shell/ecs';import StatsPanel from './StatsPanel.svelte';
export const StatsPlugin = Define.plugin('example:stats').build((world) => { world.get('editorPanels').register({ id: 'example.stats', title: 'Stats', slot: 'bottom', // where it starts: 'left' | 'center' | 'right' | 'bottom' component: StatsPanel, order: 10, });});The panels that start in one slot are tabs of one group. The slot is only where a panel starts: the person at the editor drags its tab wherever they want it, and the editor remembers. A panel is made anew when it comes to the front or moves, so keep what it must not lose in a resource. Inside the panel, editor resources are Svelte stores:
<script lang="ts"> import { getEditor } from '@shell/editor-ui';
const { world } = getEditor(); const selection = world.get('editorSelection'); // declare stores at the top level const session = world.get('editorSession');</script>
<p>{$selection.uids.size} of {$session.entities.size} entities selected</p>Style with the --ed-* custom properties (--ed-bg, --ed-panel, --ed-border, --ed-text, --ed-text-muted, --ed-focus, --ed-input, --ed-selected), so the panel follows the editor’s theme.
Add an action
Section titled “Add an action”Everything the user can do is an action. An action registered in a group appears in the menu bar under that group, and its shortcut works everywhere outside text fields.
import { Method } from '@shell/editor-protocol';
world.get('editorActions').register({ id: 'example.resetPosition', label: 'Reset Position', group: 'entity', // 'file' | 'edit' | 'entity' | 'tool', or a new group for a new menu shortcuts: ['Shift+Mod+R'], // Mod is Cmd on macOS, Ctrl elsewhere enabled: () => world.get('editorSelection').uids.size > 0, run: async () => { const session = world.get('editorSession'); const uids = [...world.get('editorSelection').uids]; await world.get('editorHistory').execute( 'Reset Position', uids.map((uid) => session.call(Method.SetFields, { uid, component: 'position', fields: { x: 0, y: 0 } }), ), ); },});The shortcuts are defaults: the user can change them in Settings (Mod+,). To show a shortcut, read world.get('editorActions').shortcutsOf(id), not the action’s own list.
Put it together
Section titled “Put it together”A project adds to the editor with an ordinary plugin, passed to createEditor:
export const SpinTools = Define.plugin('example:spin-tools').build((world) => { world.get('editorActions').register({ /* … */ }); world.get('editorPanels').register({ /* … */ });});
// src/editor.tsawait createEditor({ target, project: Project, viewport: EditorViewport2D, plugins: [SpinTools] });examples/editor/src/editor/ is a complete one: a “Reverse Spin” action and a “Stats” panel. Keep such files out of the game’s imports, so the game build carries no editor code.
Follow the theme
Section titled “Follow the theme”The editor has a dark and a light theme. Style panels and widgets with the --ed-* custom properties and they follow it:
| Property | Use |
|---|---|
--ed-bg, --ed-panel, --ed-input | Backgrounds: window, panel, field |
--ed-text, --ed-text-muted | Text. Not muted text on --ed-selected. |
--ed-border, --ed-focus | Lines, and the focus ring |
--ed-selected, --ed-hover, --ed-accent | Selected row, hovered row, emphasis |
--ed-warn, --ed-error | Warning and error text |
--ed-warn-fill with --ed-on-warn | A filled warning banner and the text on it |
--ed-font, --ed-font-mono | Fonts |
Changing the scene from your code
Section titled “Changing the scene from your code”Two rules keep undo and saving correct:
- Read the scene through
editorSession, not through ECS queries on the edit world. - Change it through
editorHistory. Build calls withsession.call(method, params)and pass them tohistory.execute(label, calls). Oneexecuteis one undo step.
For a change that builds up over time, such as a drag:
const transaction = history.begin('Drag Handle');// on every pointer move, computed from the values at the start of the drag:void transaction.execute([session.call(Method.SetFields, { uid, component, fields })]);// on release:await transaction.commit(); // one undo step. transaction.cancel() takes it all backCommon entity operations are ready-made on editorEntities: create(parent?), remove(uids), duplicate(uids), rename(uid, name), move(uids, { parent, before }).
Resources
Section titled “Resources”| Key | What it is for |
|---|---|
editorSession | The scene’s entities and components, live |
editorSelection | Selected entities |
editorHistory | Undo, redo, and every change to the scene |
editorEntities | Create, remove, duplicate, rename, move |
editorActions | Actions and shortcuts |
editorClipboard | Copy, cut, paste |
editorDocuments | The open scene file: open, save, revert |
editorAssets | The asset list, kinds, URLs |
editorTools | Active scene-view tool, axes, snapping |
editorViewport | The scene view’s camera and grid |
editorPreferences | Settings remembered between sessions |
editorPlay | Play mode: play(), pause(), step(), stop(), state |
editorLog | Messages for the console panel and notifications |
editorInspector | Which widget kind each field gets |
editorPanels | Panels, and the layout they are in |
editorWidgets | Field widget components by kind |