Chapter 7: Custom Systems
Updated: Sep 4, 2026
IWSDK uses an Entity Component System (ECS). Components store data, entities receive components, and systems run behavior for matching entities.
The generated starter demonstrates this pattern with a Robot tag component and a RobotSystem.
Create components with createComponent(name, schema). An empty schema creates a tag:
import { createComponent } from '@iwsdk/core';
export const Robot = createComponent('Robot', {});
Add schema fields when the component must store data:
import { Types, createComponent } from '@iwsdk/core';
export const Health = createComponent('Health', {
current: { type: Types.Float32, default: 100 },
maximum: { type: Types.Float32, default: 100 },
regenerating: { type: Types.Boolean, default: false },
});
Read and write scalar fields with getValue() and setValue():
const current = entity.getValue(Health, 'current');
entity.setValue(Health, 'current', Math.max(0, current - 10));
For vector fields, use getVectorView() when you need a mutable typed-array view.
Export project components
The generated project declares its component module in iwsdk.config.json:
{
"components": {
"module": "./src/components"
}
}
Export the components that native scenes and the runtime can use from src/components.ts:
import { defineComponents } from '@iwsdk/core';
import { Health } from './health-component.js';
import { Robot } from './robot-component.js';
export default defineComponents([Health, Robot]);
The native scene editor uses the same component schemas when it validates and edits scene data.
Pass named query descriptors to createSystem(). Each query can require components, exclude components, or filter component fields with operators such as eq.
import { Types, createSystem, eq } from '@iwsdk/core';
import { Health } from './health-component.js';
export class HealthSystem extends createSystem(
{
living: {
required: [Health],
where: [eq(Health, 'regenerating', true)],
},
},
{
rate: { type: Types.Float32, default: 5 },
enabled: { type: Types.Boolean, default: true },
},
) {
update(delta: number): void {
if (!this.config.enabled.value) {
return;
}
for (const entity of this.queries.living.entities) {
const current = entity.getValue(Health, 'current');
const maximum = entity.getValue(Health, 'maximum');
entity.setValue(
Health,
'current',
Math.min(maximum, current + this.config.rate.value * delta),
);
}
}
}
System configuration fields are Signals. Read and update them through their value property.
Respond to query membership
A query subscription runs when an entity starts or stops matching that query:
init(): void {
this.queries.living.subscribe('qualify', (entity) => {
console.log('Started regenerating', entity.index);
});
this.queries.living.subscribe('disqualify', (entity) => {
console.log('Stopped regenerating', entity.index);
});
}
Publish an initial state separately if your system must process entities that already match when the system initializes.
A custom system can implement these lifecycle methods:
init(): Runs when the system is initialized.update(delta, time): Runs every frame. Both arguments are measured in seconds.play(): Runs when the system resumes.stop(): Runs when the system pauses.destroy(): Releases system-owned resources.
The system base also exposes world, scene, camera, player, input, visibilityState, queries, config, and cleanupFuncs.
Store teardown callbacks in cleanupFuncs:
init(): void {
const unsubscribe = this.visibilityState.subscribe((visibility) => {
console.log('World visibility changed:', visibility);
});
this.cleanupFuncs.push(unsubscribe);
}
Follow the generated Robot example
The current starter keeps the component and system in separate files. src/robot-component.ts defines the tag:
import { createComponent } from '@iwsdk/core';
export const Robot = createComponent('Robot', {});
src/robot.ts implements the behavior:
import { AudioUtils, Pressed, Vector3, createSystem } from '@iwsdk/core';
import { Robot } from './robot-component.js';
export class RobotSystem extends createSystem({
robot: { required: [Robot] },
robotClicked: { required: [Robot, Pressed] },
}) {
private lookAtTarget = new Vector3();
private robotPosition = new Vector3();
init(): void {
this.queries.robotClicked.subscribe('qualify', (entity) => {
AudioUtils.play(entity);
});
}
update(): void {
for (const entity of this.queries.robot.entities) {
const robot = entity.object3D;
if (robot == null) {
continue;
}
this.player.head.updateWorldMatrix(true, false);
robot.updateWorldMatrix(true, false);
this.lookAtTarget.setFromMatrixPosition(this.player.head.matrixWorld);
this.robotPosition.setFromMatrixPosition(robot.matrixWorld);
this.lookAtTarget.y = this.robotPosition.y;
robot.lookAt(this.lookAtTarget);
}
}
}
The example uses Pressed as a transient query tag, plays the entity’s configured audio when the tag appears, and updates each robot’s Three.js object.
The generated src/index.ts creates the world from the project manifest and registers application systems:
import { World } from '@iwsdk/core';
import projectOptions from 'virtual:iwsdk-project';
import { HealthSystem } from './health.js';
import { RobotSystem } from './robot.js';
const world = await World.create(
document.getElementById('scene-container') as HTMLDivElement,
projectOptions,
);
world.registerSystem(RobotSystem);
world.registerSystem(HealthSystem, {
priority: 10,
configData: {
rate: 2,
enabled: true,
},
});
Lower numeric priorities run earlier. Use nonnegative priorities for application systems unless a documented dependency requires another order.