Skip to content

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;
});
});
MethodDescription
.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.

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 control when a system runs during the frame. Schedule is a dual type+value constant object (not an enum).

ScheduleValueDescription
Schedule.BeforeStartup1 << 0Runs first during startup.
Schedule.Startup1 << 1Main startup schedule.
Schedule.PostStartup1 << 2Runs after startup.

Per-tick execution order: First -> PreUpdate -> fixed phase x N -> Update -> PostUpdate -> PreRender -> Render -> PostRender -> Last.

ScheduleValueDescription
Schedule.First1 << 3First per-tick schedule.
Schedule.PreUpdate1 << 4Before the main update phase.
Schedule.Update1 << 5Main update phase (default).
Schedule.PostUpdate1 << 6After the main update phase.
Schedule.PreRender1 << 7Before rendering.
Schedule.Render1 << 8Rendering phase.
Schedule.PostRender1 << 9After rendering.
Schedule.Last1 << 10Last per-tick schedule.

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.

ScheduleValueDescription
Schedule.FixedFirst1 << 11First fixed-timestep schedule.
Schedule.FixedPreUpdate1 << 12Before the fixed update phase.
Schedule.FixedUpdate1 << 13Main fixed update phase.
Schedule.FixedPostUpdate1 << 14After the fixed update phase.
Schedule.FixedLast1 << 15Last fixed-timestep schedule.

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);
// Unregister
world.unregisterSystem(MovementSystem);
// An explicit schedule overrides the builder schedule
world.registerSystem(Schedule.FixedUpdate, MovementSystem);
world.unregisterSystem(Schedule.FixedUpdate, MovementSystem);
// The existing array form is also supported
world.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.

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 state
const 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();
});

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.

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 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 ordering
PhysicsSet.before(RenderSet);
// Set-level run conditions
PhysicsSet.runIf(({ resources }) => !resources.get('paused'));
// Nested sets
PhysicsSet.inSet(ParentSet); // parent conditions cascade
// Systems declare set membership
const GravitySystem = Define.system()
.inSet(PhysicsSet)
.queries({
/* ... */
})
.update(({ queries }) => {
/* ... */
});

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.

const IsNotPaused = ({ resources }: RunConditionParams) => !resources.get('paused');

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;
});

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 */
});

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.

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;
});
});
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}`);
});
// Register
world.registerSystem(Schedule.FixedUpdate, GravitySystem);
world.registerSystem(Schedule.FixedUpdate, MovementSystem);
world.registerSystem(Schedule.Update, LoggerSystem);