Skip to content

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: [...] }).

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 metadataEffect
labelDisplay name. Default: the field name in words.
descriptionTooltip.
min, maxLimits of a number field.
stepIncrement for arrow keys.
unitSuffix after the value, such as px.
widgetOverrides the widget chosen from the store type. See below.
optionsWith widget: 'enum': the choices, as { label, value }.
assetKindWith widget: 'asset': the kind of asset accepted, such as texture.
readOnlyShow the value without letting it be edited.
hiddenKeep the field out of the inspector.

Component metadata: label, category, description.

In order: meta.widget on the field, then a registration for that field (below), then the store type.

Store typeWidget
Int8, Int16, Int32integer
Float32, Float64number
Booleancheckbox
Stringtext
Entityentity: a list of entities, and a drop target
Prefab()prefab: a list of the registered prefabs
anything elsea 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, kind

A widget is a Svelte component that takes FieldProps. Register it under a kind, then ask for that kind in meta.widget.

SliderField.svelte
<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:

  • id goes on the control, so the inspector’s label points at it.
  • Call onchange(value) for every intermediate value and oncommit() 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.
  • mixed is true when several entities are selected and their values differ. value is then the primary entity’s; show “Mixed” instead (a placeholder, an indeterminate checkbox, an extra list entry). The first onchange gives every selected entity the same value.

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 assetKind on 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 validate that throws counts as “it could not be read”.
  • Scene and prefab files are validated this way out of the box.
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.

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.

A project adds to the editor with an ordinary plugin, passed to createEditor:

src/editor/spin-tools.ts
export const SpinTools = Define.plugin('example:spin-tools').build((world) => {
world.get('editorActions').register({
/* … */
});
world.get('editorPanels').register({
/* … */
});
});
// src/editor.ts
await 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.

The editor has a dark and a light theme. Style panels and widgets with the --ed-* custom properties and they follow it:

PropertyUse
--ed-bg, --ed-panel, --ed-inputBackgrounds: window, panel, field
--ed-text, --ed-text-mutedText. Not muted text on --ed-selected.
--ed-border, --ed-focusLines, and the focus ring
--ed-selected, --ed-hover, --ed-accentSelected row, hovered row, emphasis
--ed-warn, --ed-errorWarning and error text
--ed-warn-fill with --ed-on-warnA filled warning banner and the text on it
--ed-font, --ed-font-monoFonts

Two rules keep undo and saving correct:

  1. Read the scene through editorSession, not through ECS queries on the edit world.
  2. Change it through editorHistory. Build calls with session.call(method, params) and pass them to history.execute(label, calls). One execute is 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 back

Common entity operations are ready-made on editorEntities: create(parent?), remove(uids), duplicate(uids), rename(uid, name), move(uids, { parent, before }).

KeyWhat it is for
editorSessionThe scene’s entities and components, live
editorSelectionSelected entities
editorHistoryUndo, redo, and every change to the scene
editorEntitiesCreate, remove, duplicate, rename, move
editorActionsActions and shortcuts
editorClipboardCopy, cut, paste
editorDocumentsThe open scene file: open, save, revert
editorAssetsThe asset list, kinds, URLs
editorToolsActive scene-view tool, axes, snapping
editorViewportThe scene view’s camera and grid
editorPreferencesSettings remembered between sessions
editorPlayPlay mode: play(), pause(), step(), stop(), state
editorLogMessages for the console panel and notifications
editorInspectorWhich widget kind each field gets
editorPanelsPanels, and the layout they are in
editorWidgetsField widget components by kind