Skip to content

Protocol

@shell/editor-protocol lets a tool inspect and change a world through JSON-RPC 2.0. The editor uses it for everything it does to a scene; the same methods serve a test, a script, or a remote inspector.

import { EditorTargetPlugin, MessagePortTransport, Method, RpcPeer } from '@shell/editor-protocol';
world.addPlugin(EditorTargetPlugin); // root world; binds the `editorProtocol` resource
const [clientTransport, serverTransport] = MessagePortTransport.pair();
world.get('editorProtocol').connect(serverTransport);
const client = new RpcPeer(clientTransport);
const entities = await client.request(Method.ListEntities);

EditorTargetPlugin adds only the protocol server, so it is safe in any world that should be inspectable. Inside the editor, use editorSession instead of a peer of your own: session.request(method, params) and session.call(method, params) address the open scene for you.

  • Every method takes an optional scene: the name of a child scene to target. Without it the world’s own entities are targeted.
  • Entities are addressed by uid, components by their serial key. Only serializable components can be addressed.
  • A call made while a system is running takes effect after the tick, so a method never changes data a system is iterating.
MethodParamsResult
world/describenoneVersions, whether the world is in edit mode, scenes, method names
world/listComponentsnoneEach component: key, flags, required components, metadata, fields
world/listEntities{ scene? }Each entity: uid, name, parent, ordered children, component keys
world/getEntity{ uid, scene? }Component data of one entity, and the keys of its runtime components
world/query{ with?, without?, any?, scene? }{ uids }
world/snapshot{ roots?, scene? }Scene data in stable form; roots limits it to those subtrees

Every method in this table except world/applyScene returns { result, inverse }. inverse is a list of calls that takes the change back exactly; sending it returns the calls that redo it.

const { inverse } = await client.request(Method.SetFields, {
uid,
component: 'position',
fields: { x: 40 },
});
await client.batch(inverse); // x is what it was
MethodParamsResultInverse
world/setFields{ uid, component, fields }nonesetFields with the old values
world/insertComponent{ uid, component, fields? }noneremoveComponent, with the requirements it added
world/removeComponent{ uid, component, also? }noneinsertComponent with the removed data
world/spawn{ uid?, components?, parent?, before? }{ uid }despawn
world/despawn{ uid } (children go with it)noneinstantiate of the subtree, references restored
world/instantiate{ data, remap, parent?, before? }{ roots, uidMap }despawn per root
world/reparent{ uid, parent: uid or null, before? }nonereparent to the old parent and place
world/applyScene{ data, scene? }{ entities }none: it replaces the scene’s content
  • removeComponent removes only what it names, and is refused while another component on the entity requires that one.
  • instantiate with remap: true gives every entity a new UID and rewrites references inside the data. With remap: false the recorded UIDs are used and must be free.
  • before places an entity among its siblings. Root entities have no order in the world.
  • client.batch(calls) runs calls in order and stops at the first failure. The error carries the results of the calls that ran, so they can be undone with their inverses.

Each of these returns { running, timeScale, tick }.

MethodParamsEffect
runtime/statenoneReads the state
runtime/pausenoneStops the ticker: no system runs
runtime/resumenoneStarts the ticker
runtime/step{ frames? }Pauses, then advances frames (1 to 600) fixed timesteps
runtime/setTimeScale{ value }Playback speed of a running world, 0 to 100

A watch returns the current state and then notifies the client that created it, at most once per tick. A batch of calls is reported once, before the batch’s answer arrives. An entity counts as changed when one of its saved components was written, or a component was added or removed; the derived state that systems rewrite every tick does not count.

MethodParamsResultThen notifies
world/watchStructure{ scene? }{ subscription, tick, entities }world/structureChanged
world/watchEntity{ uid, scene? }{ subscription, tick, state }world/entityChanged
world/unwatch{ subscription }{ removed }nothing

structureChanged carries entities (every entity that is new or whose name, parent, children, or component set changed) and despawned (UIDs). entityChanged carries the entity’s whole state, or despawned: true.

A failed call rejects with an RpcError that has a code:

ErrorCodeMeaning
InvalidParamsMalformed params, an unknown field, or a value of the wrong type
EntityNotFoundNo live entity with that UID in the targeted scene
ComponentNotRegisteredNo component with that key
ComponentNotSerializableThe component exists but cannot be addressed
ComponentNotFoundThe entity does not have the component
ComponentAlreadyPresentThe entity already has the component
ComponentRequiredAnother component on the entity requires this one
SceneNotFoundNo child scene with that name
InvalidHierarchyThe reparent would make an entity its own ancestor
MethodNotFoundUnknown method

Requests are untrusted input: params are validated before anything changes, and an unexpected failure inside a method is answered with a generic internal error.

world.get('editorProtocol').register('example/countEntities', (params, { target }) => {
const scene = target((params as { scene?: string }).scene);
return { count: scene.get('entities').numOfEntities };
});

Throw new RpcError(ErrorCode.InvalidParams, message) for an expected failure. A method that changes the world should return { result, inverse } so it can take part in undo.