Systems
A system is a function that runs each frame. You declare what it needs — a query over components, plus resources and events — and Estella calls it with exactly those, in the schedule.
import { defineComponent, defineSystem, addSystem, Query, Mut, Res, Time, Transform} from 'esengine';
const Speed = defineComponent('Speed', { value: 200 });
addSystem(defineSystem( [Res(Time), Query(Mut(Transform), Speed)], (time, query) => { for (const [entity, transform, speed] of query) { transform.position.x += speed.value * time.delta; } }));Parameters
Section titled “Parameters”A system’s first argument is its parameter list — the data it will receive, in order:
Query(...)— iterate every entity that has all listed components. Each iteration yields[entity, ...components].Mut(Component)— a component the system writes. Unwrapped components are read-only.Res(Resource)/ResMut(Resource)— a shared resource, read-only or writable (such asTimefor frame timing).Resgives you the resource itself;ResMutgives you a handle around it —.get()/.set(v)/.modify(fn).Commands()— deferred structural changes (spawn / despawn / add / remove), applied when the declaring system returns — the same frame, so a system running later that frame already sees them.EventReader(E)/EventWriter(E)— read or send a typed event stream.
Running order
Section titled “Running order”Systems live in a schedule of ordered phases. Register into the default
Update phase with addSystem, into Startup (runs once) with addStartupSystem,
or into any phase with addSystemToSchedule(Schedule.X, system):
| Phase | When it runs |
|---|---|
Startup |
Once, before the first frame. |
First → PreUpdate → Update → PostUpdate → Last |
Every frame, in this order. |
FixedPreUpdate → FixedUpdate → FixedPostUpdate |
On the fixed timestep (physics / replication cadence) — 0..n times per frame. |
Within a phase, systems run in registration order — a straight-line flow needs no annotations at all. Beyond that, reach for the coarsest lever that expresses what you mean.
Phases first
Section titled “Phases first”Most “run this after that” is really “this belongs in the next phase”. Order
across phases is fixed, so it costs no edges: read input in PreUpdate, move in
Update, follow with the camera in PostUpdate.
Ordering edges
Section titled “Ordering edges”Where one system genuinely depends on another inside the same phase, say so.
Edges reference system names, so name the systems you want to order. Ordering
declared on defineSystem travels with the definition — which is how the
top-level addSystem, which takes no options of its own, gets ordered:
const move = defineSystem([...], moveFn, { name: 'move' });const camera = defineSystem([...], cameraFn, { name: 'camera', runAfter: ['move'], // camera follows movement});
addSystem(move);addSystem(camera);Registering on the App takes the same edges plus runIf, and adds to
whatever the definition declared rather than replacing it:
import { Schedule } from 'esengine';
app.addSystemToSchedule(Schedule.Update, camera, { runAfter: ['input'], // on top of the 'move' edge above runIf: () => isPlaying, // skip this system when the condition is false});An edge naming a system that isn’t in that phase is ignored, and if the edges ever
form a loop the scheduler throws with the offending path —
Circular system dependency: move → camera → move.
System sets
Section titled “System sets”Naming a dependency once per system gets old. defineSystemSet gives a group
one name: the set’s runIf and edges apply to every member, and any other system
can constrain the whole group by referring to that name.
import { Schedule, defineSystemSet } from 'esengine';
const physics = defineSystemSet('physics', { systems: [applyForces, integrateVelocity, resolveContacts], runBefore: ['render'], // all three run before 'render' runIf: () => !paused, // ...and none of them run while paused});
app.addSystemSetToSchedule(Schedule.FixedUpdate, physics);
// One edge waits for the entire set:app.addSystemToSchedule(Schedule.FixedUpdate, debugDraw, { runAfter: ['physics'] });Members keep the order they are listed in. app.addSystemSet(set) is the shortcut
for the Update phase.
Priority numbers
Section titled “Priority numbers”There is no order: 100 option, and none is needed: registration order is the
default order, so a numeric scheme is a sort at the call site.
const ordered = [ { order: 100, system: input }, { order: 200, system: move }, { order: 300, system: camera },];for (const { system } of ordered.sort((a, b) => a.order - b.order)) { app.addSystemToSchedule(Schedule.Update, system);}The engine does not build this in because a number is an implicit global protocol:
values collide, inserting between neighbours needs a magic gap, and deleting a
system quietly takes an implied dependency with it. runAfter: ['move'] states the
dependency itself — local, composable across plugins, and checkable.
Scene systems only run while their scene is active, so multiple scenes can coexist in one world without interfering.
Filtering & change detection
Section titled “Filtering & change detection”A query can narrow beyond “has these components” and react to changes:
// Extra presence filters — components you require/exclude but don't read:Query(Transform, Mut(Velocity)).without(Frozen).with(Enemy)
// Boolean combinations via filter():import { Query, With, Without, Or, Not } from 'esengine';Query(Transform).filter(Or(With(Player), With(Ally)))
// Change detection — only entities whose component was added/changed since// this system last ran:import { Query, Added, Changed } from 'esengine';Query(Added(Health)) // newly-added this frameQuery(Changed(Transform)) // written since the last runRemoved(C) is its own query that yields entities that lost C:
import { defineSystem, Removed } from 'esengine';defineSystem([Removed(Health)], (removed) => { // Health is your own component for (const entity of removed) { /* cleanup */ }});A query result also has convenience methods beyond for…of:
| Method | Returns |
|---|---|
query.single() |
The one match (or null) — for a lone player/camera. |
query.count() / query.isEmpty() |
Match count / whether empty. |
query.toArray() |
All results as an array. |
query.forEach((e, ...c) => …) |
Callback per match. |
What a system touches
Section titled “What a system touches”A system’s parameter list is also its declaration: Query(Mut(Transform))
says “I write Transform”, Res(Time) says “I read Time”. The schedule reads that
to answer two questions — whether two systems could ever run at the same time,
and whether the order they do run in was decided by anyone.
GetWorld() is the escape hatch, and it answers neither: a system holding the
World may touch anything, so it conflicts with everything. Where you need it,
say what you reach for and get the answer back:
defineSystem([GetWorld()], (world) => { /* ... */ }, { name: 'Perception', touches: { reads: ['Perceiver', 'Transform'], writes: ['Perception'], },});Components are named by string here, because the reason to reach through the World is usually a type registered later than the system. Three things this supports:
opaque: true— you genuinely cannot say. Different from leavingtouchesout (both mean “assume everything”), but only this one says so on purpose.- A function instead of an object, re-read every time the access is. That is
how a system running authored data answers from what is actually loaded: the
state-machine system’s reach is the union over the leaves the loaded
.esfsmgraphs name. defineBehavior({ touches }), for the same reason — a behaviour’s body is your code, and undeclared it is assumed to touch anything.
Two systems that wait
Section titled “Two systems that wait”Systems start in schedule order, and a synchronous one runs to completion before
the next starts — nothing overlaps there. An async system is different: while
it is parked on an await, the schedule may start the next system, provided that
one neither depends on it nor touches what it touches.
So two async systems loading different things wait at the same time instead of
one after another. If you need a particular order between them, that is an
ordering edge (runAfter), not something to infer from registration order.
Finding the pairs nobody ordered
Section titled “Finding the pairs nobody ordered”Two systems in one phase that touch the same data with no edge between them run in whatever order registration produced — so adding a third system, or moving a plugin in the build list, can change what your game does with nothing to point at. Ask:
app.scheduleAmbiguities(Schedule.Update);// [{ a: 'Move', b: 'Cull', over: ['Transform'] }]
app.scheduleBatches(Schedule.Update);// [['Move', 'Spin'], ['Cull']] — how much of the phase is inherently sequentialFix one by deciding the order (runAfter) or by making the overlap not exist.
Events
Section titled “Events”Events are a typed, decoupled message stream — one system sends, another reads
next frame, with no direct reference between them. Define an event, register it on
the app, then take an EventWriter / EventReader as a system parameter:
import { defineEvent, defineSystem, EventWriter, EventReader, Commands } from 'esengine';
const Damaged = defineEvent<{ entity: number; amount: number }>('Damaged');app.addEvent(Damaged);
// Producer:addSystem(defineSystem([EventWriter(Damaged)], (damaged) => { damaged.send({ entity: 12, amount: 5 });}));
// Consumer (reads what was sent since it last ran):addSystem(defineSystem([EventReader(Damaged), Commands()], (damaged, cmds) => { for (const evt of damaged) { /* evt.entity, evt.amount */ }}));A reader is iterable and also offers .isEmpty() / .toArray(). See the
event-system example for the full producer/consumer pattern.