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 Transform and Velocity trigger link/unlink.
dependencies = [Transform, Velocity];
// Optional: declare how this system accesses each component type.
// The scheduler uses this to derive execution order.
components_used = [
ResourceAccessSpecification.from(Transform, ResourceAccessKind.Write),
ResourceAccessSpecification.from(Velocity, ResourceAccessKind.Read),
];
update(timeDelta) {
this.entityManager.dataset.traverseEntities(
[Transform, Velocity],
(transform, velocity, entity) => {
transform.position.x += velocity.x * timeDelta;
transform.position.y += velocity.y * timeDelta;
transform.position.z += velocity.z * timeDelta;
}
);
}
}
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; };
rigidBody.onContact.add(onContact);
this.#listeners[entity] = onContact;
}
unlink(health, rigidBody, entity) {
const listener = this.#listeners[entity];
rigidBody.onContact.remove(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.simulate(dt)call with the real elapsed delta.fixedUpdateis called zero or more times persimulatecall, each time with the fixed step size (em.fixedUpdateStepSize, default ≈ 16.7 ms). Leftover time accumulates and pays off in a future frame.
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 RenderSystem());
em.attachDataset(ecd); // EntityComponentDataset
em.startup(); // starts all systems in parallel
// game loop:
em.simulate(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.
How execution order is derived
You do not set an execution order manually. EntityManager derives it from each system’s components_used declarations before every batch of simulate calls.
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.
Where to go next
- Queries —
traverseEntitiesand friends. - Hierarchy & attachment — parent-child transforms.
- Scenes & references — multiple datasets and scene transitions.