Skip to content

Project setup

The editor needs three things from a project: a description of how to build its worlds, a page that starts the editor, and a Vite plugin that lets the editor read and write the asset folder. examples/editor in the repository is a complete project set up this way.

To see it running before you set up your own:

Terminal window
pnpm install
cd examples/editor
pnpm dev

Open http://localhost:5180/ for the editor; http://localhost:5180/play is the game itself. Your first scene walks through the editor.

One object tells both the game and the editor how to build a world.

src/project.ts
import { Engine2D } from '@shell/engine-2d';
import { GamePlugin } from './game-plugin';
export const Project = {
name: 'My Game',
/** Folder the editor may read and write, relative to the project root. */
assetRoot: 'public/assets',
/** URL prefix that folder is served from when the game runs. */
assetBaseUrl: '/assets',
/** Asset path of the scene the game loads and the editor opens. */
startScene: 'scenes/main.scene.json',
engine: (canvas: HTMLCanvasElement) => Engine2D({ canvas, resizeTo: canvas, spawnCamera: false }),
plugins: [GamePlugin],
} as const;
FieldMeaning
nameShown in the editor’s toolbar.
assetRootFolder of scenes, prefabs, textures, and other files.
assetBaseUrlWhere the game loads those files from. Component fields store URLs under this prefix.
startSceneAsset path of the first scene.
engineThe engine plugins for a canvas. Called once per world.
pluginsThe project’s own plugins: components, systems, resources.
prefabsOptional. Code-defined prefabs, by key. The editor lists them and registers them in its worlds; the game’s entry registers them too (see below).
playOptional. (world, scene) => Promise<void>: boots a play world your own way. By default the scene is loaded into the play world’s root.

A scene refers to a code-defined prefab by its key, for example in a spawner’s StoreType.Prefab() field. The editor registers prefabs in its own worlds; the game’s entry does the same before it loads a scene:

src/main.ts
for (const [key, prefab] of Object.entries(Project.prefabs)) {
world.get('prefabs').register(key, prefab);
}

The type is ProjectDefinition from @shell/editor. Import it with import type if you annotate the object, so the game carries no editor code.

Two conventions make a project editable:

  • Scene content comes from scene files, not from startup systems. The editor never runs startup schedules.
  • Gameplay systems are not marked .runInEditMode(), so the edit world shows the scene as authored. Systems that only draw, such as the renderer’s, are marked.

The editor gets its own HTML page and entry module, next to the game’s. It is the only source file that imports editor packages.

editor.html
<div id="editor"></div>
<script type="module" src="/src/editor.ts"></script>
src/editor.ts
import { createEditor, RpcAssetSource } from '@shell/editor';
import { EditorViewport2D } from '@shell/editor-viewport-2d';
import { Project } from './project';
await createEditor({
target: document.querySelector<HTMLElement>('#editor')!,
project: Project,
viewport: EditorViewport2D,
assets: import.meta.hot ? new RpcAssetSource(import.meta.hot) : undefined,
// Optional: lets an agent (an MCP client) work on the open editor. See "Agents" below.
hot: import.meta.hot,
});

The target element needs a size; the editor fills it. Give html, body, and the element height: 100%.

createEditor options:

OptionMeaning
targetElement the editor fills.
projectThe project definition.
viewportThe scene view plugin. EditorViewport2D.config({ gameView: { width, height } }) sets the size cameras are drawn at.
assetsWhere files come from. Without it the editor reads by URL and cannot save.
pluginsFurther plugins for the edit world. See Extending the editor.

It resolves to { world, destroy() }: the edit world, and a function that removes the editor.

Do not list editor.html as a build input. A production build then contains no editor code.

vite.config.ts
import { shellEditor } from '@shell/editor/vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [svelte(), shellEditor({ assetRoot: 'public/assets' })],
});
  • svelte() compiles the editor’s interface. The project needs svelte and @sveltejs/vite-plugin-svelte as dev dependencies.
  • shellEditor() lets the editor page list, read, and write files under assetRoot, and tells it when they change. Use the same folder as Project.assetRoot.

Start the dev server and open /editor.html.

To have the editor at the dev server’s root, name its page: shellEditor({ assetRoot: 'public/assets', page: '/editor.html' }). The root then shows the editor and /play the game. Each page stays at its file’s name, and a build is not changed: index.html is still the game.

A script is a system or a component in a file of its own, which no plugin lists.

vite.config.ts
import { shellScripts } from '@shell/scripts/vite';
plugins: [svelte(), shellEditor({ assetRoot: 'public/assets' }), shellScripts()],
src/project.ts
import { ProjectScripts } from 'virtual:shell/scripts';
plugins: [GamePlugin, ProjectScripts],
  • @shell/scripts is a dependency of the game, not a dev dependency: shellScripts() runs in the build too, which is what puts the scripts in the shipped game. Add "@shell/scripts/client" to the types of tsconfig.json.
  • A script is a file in src/scripts (shellScripts({ scriptRoot }) names another folder): spin.system.ts with one export made with Define.system(), or spin.component.ts with one made with Define.component(). Any other .ts file there is a helper that scripts import.
  • A system that reacts to an event says so: Define.system().observes(OnDamage).
  • A changed system is replaced in place, also in a game that is playing, and starts over with new locals. A changed component loads the page anew.
  • A script that is wrong is left out, or stays as it last worked, and is named in the editor’s console. Vite’s error overlay is off in a project with shellEditor(), unless the project sets server.hmr.overlay.
  • editor.html?scripts=off opens the editor with no script loaded, for a script that hangs the page.
  • In the editor, the Scripts panel, a tab beside Assets, lists the scripts: its buttons make a system, a component, or a folder, and a double click opens a script in the Code panel. Ctrl or Cmd+S there saves the script. The Code panel underlines type errors, lists what can follow a dot, and shows a declaration under the pointer; the dev server’s TypeScript answers that from the project’s own tsconfig.json. A type error does not stop a script from running.
  • A script renamed or moved in the Scripts panel takes the import lines that name it along, in scripts and in the project’s other code.
  • A script made in the editor imports from the project’s engine package: the first of @shell/engine-2d, @shell/engine-3d, and @shell/engine-minimal that its package.json lists, else @shell/ecs. The engine package has every name of @shell/ecs, @shell/transform, and the rest, so the one import line of a script reaches all of them, and the Code panel lists them inside its braces.

shellEditor() writes to your disk on behalf of a web page, so it is deliberately narrow:

  • Dev server only. It does nothing in vite build or vite preview.
  • Local only. If the dev server is reachable from other machines (--host), the plugin registers nothing and logs why. Set allowRemote: true to accept that risk.
  • One folder. Every path is checked against assetRoot: absolute paths, .., hidden files, and symbolic links leading outside are refused.
  • No half-written files. A write goes to a temporary file that is then renamed over the target.
  • No silent overwrite. A save states the file’s modification time as the editor last saw it. If the file changed or vanished since, the save is refused and the editor asks what to do.
  • Nothing is overwritten or destroyed. Creating, importing, and moving refuse a path that is taken. Deleting moves the file into .shell-editor/trash/ in the project, under the time it was deleted; add .shell-editor/ to .gitignore. The trash is never emptied for you.
  • Files over 32 MB are refused.

With hot: import.meta.hot passed to createEditor, shellEditor() also serves an MCP endpoint at /__shell/mcp. An agent (Claude Code, or any MCP client) uses it to read and change the open editor:

Terminal window
claude mcp add --transport http shell-editor http://localhost:5173/__shell/mcp
Agent toolsWhat they do
world_listComponents, world_listEntities, world_getEntity, world_query, world_snapshot, …Read the open scene, or the running game during play
world_spawn, world_setFields, world_insertComponent, world_despawn, world_reparent, …Change the scene. Each call is one undo step, labelled Agent: …
batchSeveral changes as one undo step: all of them, or none
run_actionAny editor action: file.save, play.start, play.stop, edit.undo, …
play_stepAdvance the game by a count of frames and leave it paused
play_keysPress and release keys of the game as a player would, held over calls, then advance it
read_log, get_selection, set_selection, screenshotThe console, the selection, and a picture of the scene view or the game
move_panelPut a panel of the editor in another place, as dragging its tab does
get_editor_state, open_document, create_sceneWhich scene is open and whether it is saved; open another, or a new one
wait_for_reloadAfter a code change: answered once the new code is running, or with what is wrong with it
check_codeThe type errors of the files it names, or of every script, as tsc finds them
list_assets, move_asset, delete_assetThe files of the asset folder, each with the URL a component field stores; move, rename, or delete one
instantiate_prefab, create_prefabAdd an instance of a prefab (a file, or code:<key>) to the scene; make a prefab file from an entity
  • The agent writes components and systems with its own file tools. A code change reloads the page; the agent’s next call waits up to 15 seconds for the new page. The reload does not ask about unsaved scene changes: they survive it. The undo history does not.
  • world_listComponents lists the components shown in the editor. keys asks for particular ones, shown or not, and all for every registered one.
  • screenshot answers with the picture, its size in pixels, and the rectangle of the world it shows, so the agent can tell that an entity is outside the picture.
  • move_asset updates every scene and prefab that refers to the file, and the open scene as one undo step. A file the agent moves with its own file tools keeps none of its references. delete_asset leaves references as they are, and the dev server keeps the file in .shell-editor/trash.
  • get_editor_state includes codeAt, the time the page last took new code: when it loaded, or when it replaced a system script in place. The agent reads it before a code change and passes it to wait_for_reload after: that call is answered by the page running the new code, however soon it is made. startedAt is the time the page loaded.
  • Code that cannot be built is what the agent’s next call is told (“The editor page failed to start: …”). An error thrown while a project module loads is only in the browser’s console.
  • The endpoint tells an agent how to work with these tools when it connects, so the project needs no instructions of its own.
  • To check a game the same way every time, the agent starts it paused (run_action with play.startPaused) and advances it with play_step. A frame is the fixed timestep, so 60 frames are one second of the game whatever the machine is doing. A screenshot of a paused game shows it as it is.
  • The editor page must be open in a browser. It does not have to be visible: the game keeps running in a hidden tab, and a screenshot is still drawn.
  • The endpoint follows the same rules as the file server: dev server only, localhost only. It also refuses a request that carries another site’s Origin, so a web page you have open cannot edit your scene.
  • With several editor tabs open, the one that loaded last answers.

A plugin adds its own agent tool with world.get('editorAgentTools').register({ name, description, inputSchema, handler }).

  1. /editor.html (or the root, with page) shows the start scene’s entities in the hierarchy.
  2. Change a value in the inspector and press Mod+S. The scene file changes on disk and the page does not reload.
  3. Edit the scene file in a text editor. The editor shows the change.
  4. Press Play. The game runs in the Game view; Stop returns to the scene as authored.

If the Save button is disabled, the editor is running on the read-only fallback: check that shellEditor() is in the Vite config and that assets is passed to createEditor.