Systems
Systems are defined with the Define.system() builder. Builders are immutable — each chained method returns a new instance.
import { Define, Schedule, Mut } from '@shell/ecs';
const MovementQuery = Define.query({ data: [Mut(Transform), Velocity] });
const MovementSystem = Define.system() .queries({ entities: MovementQuery }) .update(({ queries, commands, resources, events, time, locals }) => { queries.entities.forEach((entity) => { const t = entity.$transform; const v = entity.$velocity; t.x += v.x * time.delta; t.y += v.y * time.delta; }); });SystemBuilder methods
Section titled “SystemBuilder methods”| Method | Description |
|---|---|
.queries(map) | Define named queries. Each key becomes queries.<key> in the update function. |
.schedule(Schedule.X) | Set the schedule used by schedule-less registration. Defaults to Schedule.Update. |
.update(fn) | The update function. Receives { commands, queries, resources, locals, events, time }. |
.cleanup(fn) | Dispose per-instance state with { commands, queries, resources, locals, events }. |
.locals(initialOrFactory) | Per-system local state. Accepts an object or () => object. |
.before(...targets) | Run before the given systems or system sets. |
.after(...targets) | Run after the given systems or system sets. |
.inSet(set) | Add this system to a system set. |
.runIf(condition) | Only run when the condition returns true. |
.runInEditMode() | Allow this system to run in edit mode. |
.forEachWorld() | Clone a startup registration to instantiate for its owner and each descendant. |
Queries in systems
Section titled “Queries in systems”Bind query names with .queries(). Each value can be a reusable definition from Define.query() or
an inline query config. Inline configs are converted to immutable definitions when they are added to
the system. Each name becomes a key on the queries object passed to .update().
const PlayersQuery = Define.query({ data: [Mut(Health), PlayerTag] });
const CombatSystem = Define.system() .queries({ players: PlayersQuery, enemies: { data: [Mut(Health), EnemyTag] }, }) .update(({ queries }) => { queries.players.forEach((entity) => { /* ... */ }); queries.enemies.forEach((entity) => { /* ... */ }); });Each binding creates independent runtime and temporal state, even when names or systems reuse the same definition. Systems dispose their runtime queries when they are unregistered or destroyed.
System queries see matching entities from the complete shared root-world tree. Use marker components and structural filters when a system needs a logical partition.
Schedules
Section titled “Schedules”Schedules control when a system runs during the frame. Schedule is a dual type+value constant object (not an enum).
Startup schedules (run once on mount)
Section titled “Startup schedules (run once on mount)”| Schedule | Value | Description |
|---|---|---|
Schedule.BeforeStartup | 1 << 0 | Runs first during startup. |
Schedule.Startup | 1 << 1 | Main startup schedule. |
Schedule.PostStartup | 1 << 2 | Runs after startup. |
Per-tick schedules
Section titled “Per-tick schedules”Per-tick execution order: First -> PreUpdate -> fixed phase x N -> Update -> PostUpdate -> PreRender -> Render -> PostRender -> Last.
| Schedule | Value | Description |
|---|---|---|
Schedule.First | 1 << 3 | First per-tick schedule. |
Schedule.PreUpdate | 1 << 4 | Before the main update phase. |
Schedule.Update | 1 << 5 | Main update phase (default). |
Schedule.PostUpdate | 1 << 6 | After the main update phase. |
Schedule.PreRender | 1 << 7 | Before rendering. |
Schedule.Render | 1 << 8 | Rendering phase. |
Schedule.PostRender | 1 << 9 | After rendering. |
Schedule.Last | 1 << 10 | Last per-tick schedule. |
Fixed timestep schedules
Section titled “Fixed timestep schedules”Fixed timestep schedules run between PreUpdate and Update and are repeated as many times as needed to catch up with elapsed time.
Configure their immutable duration when constructing the root world. It defaults to 60 Hz, and every
fixed system receives the configured millisecond duration as time.delta:
const world = new World({ fixedTimestepMs: 1000 / 120 });
const FixedMovement = Define.system().update(({ time }) => { const deltaSeconds = time.delta / 1000; // Integrate fixed-step state using deltaSeconds.});
world.registerSystem(Schedule.FixedUpdate, FixedMovement);world.get('ticker').step() advances exactly one configured fixed timestep and ignores playback
speed modifiers. This keeps paused/editor stepping deterministic.
| Schedule | Value | Description |
|---|---|---|
Schedule.FixedFirst | 1 << 11 | First fixed-timestep schedule. |
Schedule.FixedPreUpdate | 1 << 12 | Before the fixed update phase. |
Schedule.FixedUpdate | 1 << 13 | Main fixed update phase. |
Schedule.FixedPostUpdate | 1 << 14 | After the fixed update phase. |
Schedule.FixedLast | 1 << 15 | Last fixed-timestep schedule. |
Registering systems
Section titled “Registering systems”Register systems directly to use their builder schedules. Multiple systems can be passed as separate arguments and each resolves its own schedule.
const MovementSystem = Define.system() .schedule(Schedule.Update) .update(() => {});
world.registerSystem(MovementSystem);world.registerSystem(SystemA, SystemB);
// Unregisterworld.unregisterSystem(MovementSystem);
// An explicit schedule overrides the builder scheduleworld.registerSystem(Schedule.FixedUpdate, MovementSystem);world.unregisterSystem(Schedule.FixedUpdate, MovementSystem);
// The existing array form is also supportedworld.registerSystem(Schedule.Update, [SystemA, SystemB]);Systems without .schedule() use Schedule.Update. When the first argument is a Schedule, that
schedule applies to every supplied system and overrides their builder schedules.
Use .forEachWorld() when a root-owned startup registration must run as a fresh instance for its
owner and every descendant world lifecycle. The method returns an immutable clone, so the original
builder remains owner-scoped and can still be registered normally on a per-tick schedule:
world.registerSystem(Schedule.Startup, ObstacleSyncSystem.forEachWorld());world.registerSystem(Schedule.Update, ObstacleSyncSystem);The marked clone can only be registered on BeforeStartup, Startup, or PostStartup. Registering
it on a per-tick schedule throws during registration. Ordinary startup registrations remain
owner-scoped and do not rerun when child scenes mount.
System locals
Section titled “System locals”Per-system mutable state that persists across ticks. Use .locals() with an object or a factory function.
const TrackingSystem = Define.system() .locals({ frameCount: 0, lastPosition: { x: 0, y: 0 } }) .update(({ locals, time }) => { locals.frameCount++; locals.lastPosition.x += time.delta; });
// Factory for non-cloneable stateconst SystemWithFactory = Define.system() .locals(() => ({ controller: new AbortController(), cache: new Map() })) .update(({ locals }) => { locals.cache.set('key', 'value'); }) .cleanup(({ locals }) => { locals.controller.abort(); locals.cache.clear(); });System cleanup
Section titled “System cleanup”Use .cleanup() to release state owned by a runtime system instance, such as workers, timers,
subscriptions, or objects stored in locals. The callback receives the same commands, queries,
resources, locals, and events as the update function, but no time because cleanup is not a
scheduled execution.
Cleanup is synchronous and runs exactly once per instance when that instance is unregistered,
rejected during registration, or destroyed by world or scene unload, reload, or disposal. Queries
and resources remain live for the duration of the callback and are released afterward. Duplicate
registrations and .forEachWorld() registrations each own independent locals and cleanup calls.
Pausing a world or retaining it with unmount(false) does not dispose its systems, so cleanup does
not run at those boundaries. Cleanup errors propagate after the engine has released the affected
system state. Returned promises are not awaited.
System-level ordering
Section titled “System-level ordering”Use .before() and .after() to enforce execution order between systems or system sets. The scheduler topologically sorts all systems within the same schedule.
const InitSystem = Define.system().update(() => { /* ... */});
const MainSystem = Define.system() .after(InitSystem) .update(() => { /* ... */ });
const CleanupSystem = Define.system() .after(MainSystem) .update(() => { /* ... */ });Ordering targets can be other SystemBuilder instances or SystemSetBuilder instances.
System sets
Section titled “System sets”System sets group systems for shared ordering constraints and run conditions. Mutable — methods modify in place and return this for chaining.
const PhysicsSet = Define.systemSet('physics');const RenderSet = Define.systemSet('render');
// Set-level orderingPhysicsSet.before(RenderSet);
// Set-level run conditionsPhysicsSet.runIf(({ resources }) => !resources.get('paused'));
// Nested setsPhysicsSet.inSet(ParentSet); // parent conditions cascade
// Systems declare set membershipconst GravitySystem = Define.system() .inSet(PhysicsSet) .queries({ /* ... */ }) .update(({ queries }) => { /* ... */ });Run conditions
Section titled “Run conditions”Run conditions gate whether a system or set executes. Multiple .runIf() conditions are AND-ed — all must return true.
If a run condition skips a system, the system’s queries are not evaluated and temporal changes continue to accumulate. A failed update also leaves its query windows unconsumed.
Plain function
Section titled “Plain function”const IsNotPaused = ({ resources }: RunConditionParams) => !resources.get('paused');Stateful (with locals)
Section titled “Stateful (with locals)”Each .runIf() attachment gets an independent copy of locals.
const EveryMs = (ms: number) => Define.runCondition() .locals({ lastRun: 0 }) .evaluate(({ time, locals }) => { if (time.now - locals.lastRun < ms) return false; locals.lastRun = time.now; return true; });Query-backed
Section titled “Query-backed”Run-condition builders support the same named query definitions as systems. Their queries are evaluated whenever the condition is checked and are disposed with the system or system set that owns the condition.
const PlayerIsSafe = Define.runCondition() .queries({ players: { data: [Player], where: With(Safe) }, }) .evaluate(({ queries }) => queries.players.numOfEntities > 0);const MySystem = Define.system() .runIf(IsNotPaused) .runIf(EveryMs(1000)) .update(() => { /* runs at most once per second while not paused */ });Edit mode
Section titled “Edit mode”An edit world is built with new World({ editMode: true }). The option is fixed for the world’s lifetime and inherited by its scenes. In an edit world, systems do not run unless they are marked .runInEditMode(), and startup schedules never run.
const GameplaySystem = Define.system().update(() => { /* skipped in edit mode */});
const EditorSystem = Define.system() .runInEditMode() .update(() => { /* runs in edit mode */ });Startup systems cannot be marked .runInEditMode(). Content for an edit world comes from scene data or plugin build() code. To run the authored scene, build a second world without editMode and load a snapshot into it.
Fixed timestep systems
Section titled “Fixed timestep systems”Systems registered on fixed schedules run at a constant timestep, independent of frame rate. They are repeated as many times as needed to catch up with elapsed time.
const PhysicsBodies = Define.query({ data: [Mut(Transform), Mut(Velocity)] });
const PhysicsSystem = Define.system() .queries({ bodies: PhysicsBodies }) .schedule(Schedule.FixedUpdate) .update(({ queries, time }) => { queries.bodies.forEach((entity) => { entity.$transform.x += entity.$velocity.x * time.delta; }); });Complete example
Section titled “Complete example”import { Define, Schedule, Mut } from '@shell/ecs';
const PhysicsSet = Define.systemSet('physics');const IsNotPaused = ({ resources }) => !resources.get('paused');
const Transform = Define.component('transform').withSchema({ x: { type: StoreType.Float32 }, y: { type: StoreType.Float32 },});
const Velocity = Define.component('velocity').withSchema({ x: { type: StoreType.Float32 }, y: { type: StoreType.Float32 },});
const GravityQuery = Define.query({ data: [Mut(Velocity)] });const MovementQuery = Define.query({ data: [Mut(Transform), Velocity] });
const GravitySystem = Define.system() .inSet(PhysicsSet) .queries({ bodies: GravityQuery }) .update(({ queries, time }) => { queries.bodies.forEach((entity) => { entity.$velocity.y += 9.8 * time.delta; }); });
const MovementSystem = Define.system() .inSet(PhysicsSet) .after(GravitySystem) .queries({ movers: MovementQuery }) .update(({ queries, time }) => { queries.movers.forEach((entity) => { entity.$transform.x += entity.$velocity.x * time.delta; entity.$transform.y += entity.$velocity.y * time.delta; }); });
const LoggerSystem = Define.system() .runIf(IsNotPaused) .locals({ frameCount: 0 }) .update(({ locals }) => { locals.frameCount++; console.log(`Frame ${locals.frameCount}`); });
// Registerworld.registerSystem(Schedule.FixedUpdate, GravitySystem);world.registerSystem(Schedule.FixedUpdate, MovementSystem);world.registerSystem(Schedule.Update, LoggerSystem);