Systems & scheduling
How to write a System, declare component dependencies, use link/unlink for reactive per-entity setup, and how execution order is derived from access declarations.
A System is a class you extend. It declares which components it cares about and implements any combination of four methods: startup, shutdown, link/unlink for reactive per-entity logic, and update/fixedUpdate for time-step logic.
Extending System
import { System } from "@woosh/meep-engine/src/engine/ecs/System.js";
import { ResourceAccessKind } from "@woosh/meep-engine/src/core/model/ResourceAccessKind.js";
import { ResourceAccessSpecification } from "@woosh/meep-engine/src/core/model/ResourceAccessSpecification.js";
class MovementSystem extends System {
// Entities with BOTH Transform64 and Velocity trigger link/unlink.
dependencies = [Transform64, Velocity];
// Optional: declare how this system accesses each component type.
// The scheduler uses this to derive execution order.
components_used = [
ResourceAccessSpecification.from(Transform64, ResourceAccessKind.Write),
ResourceAccessSpecification.from(Velocity, ResourceAccessKind.Read),
];
update(timeDelta) {
const ecd = this.entityManager.dataset;
ecd.traverseEntities(
[Transform64, Velocity],
(transform, velocity, entity) => {
transform.setTranslation(
transform.translation_x + velocity.x * timeDelta,
transform.translation_y + velocity.y * timeDelta,
transform.translation_z + velocity.z * timeDelta,
);
// a Transform64 carries no signals; the writer announces the move
t64_announce_change(ecd, entity);
}
);
}
}
dependencies, link, and unlink
dependencies is an array of component classes. When an entity acquires all of them, link is called with the component instances followed by the entity ID. When any of them is removed (or the entity is destroyed), unlink is called with the same arguments.
class FallDamageSystem extends System {
dependencies = [Health, RigidBody];
#listeners = []; // per-entity storage
link(health, rigidBody, entity) {
const onContact = () => { health.value -= 10; };
const ecd = this.entityManager.dataset;
ecd.addEntityEventListener(entity, PhysicsEvents.ContactBegin, onContact);
this.#listeners[entity] = onContact;
}
unlink(health, rigidBody, entity) {
const listener = this.#listeners[entity];
const ecd = this.entityManager.dataset;
ecd.removeEntityEventListener(entity, PhysicsEvents.ContactBegin, listener);
delete this.#listeners[entity];
}
}
link and unlink are the right place to subscribe and unsubscribe from component-level signals, attach physics callbacks, start coroutines, and so on. They are never called during update - they fire when the component tuple becomes complete or breaks.
update and fixedUpdate
Override update(timeDelta) for frame-rate logic (rendering, camera, interpolation). Override fixedUpdate(timeDelta) for simulation logic that must be deterministic. The EntityManager drives both:
updateis called once perem.update(dt)call with the real elapsed delta.fixedUpdateis called zero or more times perem.update(dt)call, each time with the fixed step size (em.fixedUpdateStepSize, default ≈ 16.7 ms). Leftover time accumulates and pays off in a future frame.
Every system’s fixedUpdate for a given step runs before any system’s update for the cycle, so a tick is a clean simulate-then-render split and every system sees the same em.fixedStepTick.
If a system overrides neither, EntityManager skips it in the hot path entirely - the check is a fast function identity compare against a shared no-op.
startup and shutdown
startup(entityManager) and shutdown(entityManager) are async methods called by EntityManager during em.startup() / em.shutdown() / em.addSystem(). They receive the EntityManager instance. startup is a good place to fetch assets, create worker threads, or obtain handles to other systems via entityManager.getSystem(SomeSystemClass).
Registering a system
import { EntityManager } from "@woosh/meep-engine/src/engine/ecs/EntityManager.js";
const em = new EntityManager();
em.addSystem(new MovementSystem());
em.addSystem(new FallDamageSystem());
em.attachDataset(ecd); // EntityComponentDataset
em.startup(); // starts all systems in parallel
// game loop:
em.update(0.016); // drive both update and fixedUpdate
addSystem returns a Promise that resolves once the system’s startup completes. If the entity manager is already running, the system starts immediately; otherwise it starts when em.startup() is called.
em.simulate(dt) is a deprecated alias of em.update(dt); write update.
How execution order is derived
You do not set an execution order manually. EntityManager derives it from each system’s components_used declarations, and re-derives it at the top of the next em.update(dt) after the set of registered systems changes.
A system that writes a component depends on systems that read it: the writer must run after all readers have consumed the previous values. The scheduler scores systems by how many such incoming dependencies their written components accumulate and sorts highest score first.
ResourceAccessKind values that affect scheduling:
| Kind | Bit | Meaning |
|---|---|---|
Read | 1 | Reads component data, does not write |
Write | 2 | Mutates component data |
Create | 4 | Creates new component instances |
Create carries the highest scheduling weight. Write carries medium weight. Read only contributes through incoming-edge counts.
Declaring components_used is optional - the engine works without it - but declaring it accurately gives the scheduler enough information to pipeline your systems correctly.
Accessing other systems
From inside a system method, this.entityManager is the attached EntityManager. Use it to reach sibling systems:
async startup(entityManager) {
this.physics = await entityManager.promiseSystem(PhysicsSystem);
}
promiseSystem(Class) returns a Promise that resolves as soon as a system of that class is running, whether it is already present or added later.
Dataset attachment hooks
Two optional methods let a system respond when a dataset is swapped in or out at runtime (for scene transitions):
handleDatasetAttached(dataset)- called after the dataset is wired in and before any entities are linked.handleDatasetDetached(dataset)- called after all entities are unlinked and before the dataset is removed.
Systems that run off-thread
WorkerSystem (in src/engine/ecs/async/WorkerSystem.js) is a System whose per-tick work happens on a worker thread over a SharedArrayBuffer, while its hot path on the main thread stays synchronous - no async fixedUpdate, no promise that resolves a microtask too late to be useful.
Each fixed tick T the system does exactly two things:
- join step
T - 1- a load and anapplycall, never a wait; - dispatch step
T-collectwrites the command words, oneAtomics.storepublishes the request.
Between dispatch and the next join the worker owns the shared state and the main thread must not touch it. What makes that safe for the simulation is a fixed-step gate: on startup the system registers itself with the entity manager, and the fixed loop refuses to advance into tick T + 1 until step T has landed. So every system’s fixedUpdate runs with the worker idle, and exactly one step is ever in flight.
A gate changes when a step runs in wall-clock terms, never what it computes - determinism is unaffected. When a result isn’t ready the gate spins for join_spin_budget_ms (0.25 ms by default) and then gives up for this cycle: update breaks out of the catch-up loop with the unconsumed time still in the accumulator, so the step runs on a later call rather than being skipped.
Subclasses implement four hooks:
| Hook | When | Purpose |
|---|---|---|
createWorker() | once, during startup | Spawn the worker. Kept as a hook so the import.meta.url spawn expression stays out of modules a test imports |
collect(command, tick, dt) | on tick, worker idle | Prepare shared state, write the Float64 command words. Return false to skip dispatching this tick |
apply(tick) | on tick, worker idle | Integrate a completed step. Nothing to do when results live entirely in shared memory |
buildBootPayload() / handleWorkerMessage(data) | boot / cold plane | Optional one-off setup payload and rare out-of-band messages |
The worker half is SystemWorkerHost, which owns the protocol and calls into a plain “core” object that does the work:
// my.worker.js
import { SystemWorkerHost } from "@woosh/meep-engine/src/engine/ecs/async/SystemWorkerHost.js";
SystemWorkerHost.serve({
boot(payload) { /* one-off setup */ },
step(tick, command) { /* hot plane: runs once per dispatched tick */ },
});
Practical notes:
- The page must be cross-origin isolated. The bridge allocates a real
SharedArrayBufferand refuses to degrade to a privateArrayBuffer- serve withCross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp, or construction throws with that message. - A hung worker fails the bridge, it doesn’t freeze the game. A step outstanding past
step_deadline_ms(2 s) is declared dead; the boot handshake times out at 15 s;shutdownwaits 1 s for in-flight work before terminating. Sustained slowness is handled by deferring steps, not by failing. - Cold-plane messages are expensive. A worker parked in
Atomics.waitisn’t running its event loop, sopostToWorkercosts a scheduling round trip. Keep it for structural changes; use the command region for per-tick data. SystemWorkerLoopbackruns a real host, core, and shared buffer in-process, so a test can drive message delivery and step execution in any order without a worker thread.
Custom gates use the same contract directly - entityManager.registerFixedStepGate(gate) with a gate.may_advance(next_tick) => boolean, and unregisterFixedStepGate(gate) to remove it. A gate must only ever answer false transiently; one that never opens freezes the simulation.
Where to go next
- Queries -
traverseEntitiesand friends. - Hierarchy & attachment - parent-child transforms.
- Scenes - multiple datasets and scene transitions.