ECS Systems
Systems implement behavior. They declare queries (entity sets) and optional config schema (reactive signals).
Mental model: behavior that reacts to data
Systems are functions over sets of entities. Think of them as database stored procedures that run every frame:
Traditional Game Loop: ECS System Approach:
────────────────────── ───────────────────
for each object: for each relevant entity set:
object.update(dt) system.update(dt, entities)
Problems: Benefits:
- Tight coupling - Data/behavior separation
- Hard to optimize - Cache-friendly iteration
- Difficult to disable - Easy to enable/disable
- Hard to test - Systems are pure functions
Queries pick the rows (entities) to process, and systems implement the logic of what to do with that data each frame.
import { Types, createSystem } from '@iwsdk/core';
export class MySystem extends createSystem(
{
groupA: { required: [CompA], excluded: [CompB] },
groupB: { required: [CompC] },
},
{
speed: { type: Types.Float32, default: 1 },
},
) {
init() {
this.queries.groupA.subscribe('qualify', (e) => {
/*…*/
});
}
update(dt: number) {
for (const e of this.queries.groupB.entities) {
/*…*/
}
}
destroy() {
/* cleanup */
}
}
System config values are reactive signals powered by @preact/signals. Each config key is a Signal<T> at this.config.key.
export class CameraShake extends createSystem(
{},
{
intensity: { type: Types.Float32, default: 0 },
decayPerSecond: { type: Types.Float32, default: 1 },
},
) {
update(dt: number) {
const i = this.config.intensity.peek();
if (i <= 0) return;
// apply random offset scaled by i
this.camera.position.x += (Math.random() - 0.5) * 0.01 * i;
this.config.intensity.value = Math.max(
0,
i - this.config.decayPerSecond.peek() * dt,
);
}
}
.value — set and track..peek() — read without tracking to avoid incidental subscriptions..subscribe(fn) — run when the value changes.
const unsubscribe = this.config.intensity.subscribe((v) => console.log(v));
// later
unsubscribe();
Config signals are ideal for developer tools and UI controls:
// debugging slider
document.getElementById('intensity')!.addEventListener('input', (e) => {
this.config.intensity.value = Number((e.target as HTMLInputElement).value);
});
Creating/destroying entities in systems
const e = this.createEntity();
e.addComponent(CompA);
e.destroy(); // remove when done
Systems run each frame in ascending priority (more negative runs earlier). IWSDK registers some priorities by default when you enable features in World.create:
- Locomotion: −5 (if enabled)
- Input: −4 (always)
- Grabbing: −3 (if enabled)
You control your system’s order through { priority } when registering:
world.registerSystem(MySystem, { priority: -2 });
Query events and resource management
Use qualify/disqualify to perform one‑time setup/teardown per entity. Cache handles on the entity (for example, through a component or a symbol) instead of recomputing every frame.
- Avoid creating objects in
update loops. Reuse vectors/arrays. - Keep derived numbers in components if they are consumed by multiple systems.
- Log query sizes:
console.debug('N panels', this.queries.panels.entities.size). - Temporarily increase verbosity of specific systems with a debug config signal.
Sharing data through globals
this.globals is a reference to world.globals. It’s a simple shared object store. Use sparingly for cross‑system coordination.
this.globals.navMesh = myNavMesh;