Develop

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.

Define a component

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.

Create a system

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.

Use the system lifecycle

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.

Register a system

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.