Skip to content

Systems

A system holds logic that runs as part of the game loop. You declare one by extending SceneSystem or WorldSystem and naming it with a decorator.

Most systems act on actors and find them with a query, which is what the example below does. Not all of them do. The built-in UIBridge connects the engine to your interface layer and never looks at an actor. Every hook is optional too, so a system that only reacts to events implements neither update nor fixedUpdate.

Pick the base class by asking whether the system must survive a scene change. If yes, WorldSystem; if no, SceneSystem. Systems has the full comparison.

import { ActorQuery, SceneSystem, CharacterBody, DefineSystem } from 'dacha';
import type { Scene, SceneSystemOptions } from 'dacha';
import Movement from '../../components/movement/movement.component';
import { MoveRequest } from '../../events';
import type { MoveRequestEvent } from '../../events';
@DefineSystem({
name: 'MovementSystem',
})
export default class MovementSystem extends SceneSystem {
private scene: Scene;
private actorQuery: ActorQuery;
constructor(options: SceneSystemOptions) {
super(options);
this.scene = options.scene;
this.actorQuery = new ActorQuery({
scene: options.scene,
filter: [Movement, CharacterBody],
});
this.scene.addEventListener(MoveRequest, this.handleMoveRequest);
}
onSceneDestroy(): void {
this.scene.removeEventListener(MoveRequest, this.handleMoveRequest);
}
private handleMoveRequest = (event: MoveRequestEvent): void => {
const movement = event.target.getComponent(Movement);
if (!movement) {
return;
}
movement.direction = event.direction;
};
fixedUpdate(): void {
for (const actor of this.actorQuery.getActors()) {
const movement = actor.getComponent(Movement);
const body = actor.getComponent(CharacterBody);
body.velocity.x = movement.direction * movement.speed;
}
}
}

That file follows three rules the systems page explains in full. The query is built once and kept. It is never destroyed, because it dies with its scene. The listener added in the constructor comes off in onSceneDestroy, which matters most for a listener you put on the world, since that one outlives the system.

One detail is a choice rather than a rule. Movement sits in fixedUpdate, not update. Anything whose outcome must not depend on frame rate goes on the fixed clock. See the game loop.

A system’s settings come from the same options object. The field decorator puts them in the editor, and the engine fills them:

import { SceneSystem, DefineSystem, DefineField } from 'dacha';
@DefineSystem({
name: 'SpawnerSystem',
})
export default class SpawnerSystem extends SceneSystem {
@DefineField({ initialValue: 2 })
spawnInterval!: number;
@DefineField({ initialValue: 10 })
maxEnemies!: number;
}

A system with its own constructor passes options to super. Then the settings still fill.

The decorator works the same way it does on a component, and inspector fields applies here too. The difference is where the values live. A component’s fields belong to one actor and are edited in the inspector. A system’s settings belong to the whole game and are edited under systems & global options.

onSceneLoad is async, and the engine waits for it. Use it for anything that must be in memory before the first frame:

private level?: LevelData;
async onSceneLoad(): Promise<void> {
const response = await fetch('/data/levels/forest.json');
this.level = (await response.json()) as LevelData;
}

Every system’s onSceneLoad runs at the same time, and the scene becomes active only when all of them have resolved. So a slow one delays the scene for everybody. onWorldLoad is the same hook for a world system, and it runs once for the whole game.

A world system that other code needs to reach registers an API object. The API is a plain class with no decorator and no file-name suffix. Its constructor is the key other systems look it up by:

import { WorldSystem, DefineSystem } from 'dacha';
import type { WorldSystemOptions } from 'dacha';
export class ScoreAPI {
points = 0;
add(value: number): void {
this.points += value;
}
}
@DefineSystem({
name: 'ScoreSystem',
})
export default class ScoreSystem extends WorldSystem {
constructor(options: WorldSystemOptions) {
super();
options.world.systemApi.register(new ScoreAPI());
}
}

A world system registers in its constructor, because it is built once and lives as long as the world. A scene system cannot do that. It is rebuilt for every scene, and register throws when the same class is already registered, so a scene system registers in onSceneEnter and unregisters in onSceneExit. The built-in physics system does exactly that.

Systems do not hold references to each other. Ask the world for the API instead:

import { PhysicsAPI } from 'dacha';
const physics = this.world.systemApi.get(PhysicsAPI);

Resolve it where you use it rather than in the constructor, because the providing system registers its API as it starts up and may not have done so yet. See Scenes & the world.

Nothing, if the file is named *.system.ts and the class is the default export. The auto-registration convention collects it.

The system also has to be listed in the configuration for it to run, which the editor does for you under systems & global options. Order matters there: a system that reads what another writes must come after it.