Skip to content

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

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 as Time for frame timing).
  • Commands() — deferred structural changes (spawn / despawn / add / remove), applied at the next flush.
  • EventReader(E) / EventWriter(E) — read or send a typed event stream.

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.
FirstPreUpdateUpdatePostUpdateLast Every frame, in this order.
FixedPreUpdateFixedUpdateFixedPostUpdate On the fixed timestep (physics / replication cadence) — 0..n times per frame.

Within a phase, systems run in registration order. To constrain that, register on the App with ordering options — these reference system names, so name the systems you want to order:

import { Schedule } from 'esengine';
const move = defineSystem([...], moveFn, { name: 'move' });
const camera = defineSystem([...], cameraFn, { name: 'camera' });
app.addSystemToSchedule(Schedule.Update, move);
app.addSystemToSchedule(Schedule.Update, camera, {
runAfter: ['move'], // camera follows movement
runIf: () => isPlaying, // skip this system when the condition is false
});

Scene systems only run while their scene is active, so multiple scenes can coexist in one world without interfering.

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 frame
Query(Changed(Transform)) // written since the last run

Removed(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.

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.