ECS with IWSDK
This is a practical, from‑zero introduction to Entity‑Component‑System (ECS) as used in IWSDK. It assumes no prior ECS knowledge and uses only IWSDK code — no pseudocode. By the end you will be able to design data‑driven features with components, write systems that react to entities via queries, and run everything inside a World.
- If you prefer concept deep‑dives, see the focused pages:
IWSDK’s ECS is powered by the elics runtime. IWSDK layers WebXR, Three.js scene ownership, and convenient helpers on top, but the core mental model is the same.
The ECS Mental Model
- Data over inheritance: Components are small, flat data definitions (no behavior). You attach them to Entities.
- Behavior is in Systems: Systems query for entities that have specific components, then update them every frame.
- Composition wins: You build features by composing components on entities, not by subclassing.
- Columnar memory: Each component field is stored in a packed array for cache‑friendly iteration.
How IWSDK Extends ECS
- World coordinates Three.js rendering, WebXR session, input rig, asset loading, and the ECS scheduler.
- Entities can carry
object3D
and a syncedTransform
when created viacreateTransformEntity()
. - Systems can run with XR‑aware priorities (input/locomotion first, visuals after) before the renderer draws the frame.
- Built‑in components/systems for Transform, Visibility, Input, UI, Audio, Levels, etc.
Key Concepts Overview
Core Architecture:
- World: the coordinator between ECS data, Three.js rendering, WebXR session, and assets. Owns the render loop and system priorities.
- Entity: a lightweight container that can hold components, be created/destroyed, parented (scene vs level), and optionally carry a 3D
object3D
. - Component: typed, packed data schemas with no behavior. Think database columns — each field stored in efficient arrays.
- System: pure behavior that processes entities via queries. Runs each frame in priority order to implement game logic.
Advanced Concepts:
- Queries: live, efficient sets of entities that update automatically as components change. Support complex filtering with value predicates.
- Lifecycle: understand when things happen — world boot sequence, system initialization, per-frame execution order, and cleanup.
- Patterns & Tips: proven composition patterns, performance optimizations, and debugging techniques for production use.
Quick start: a complete feature in ~40 lines
We will implement a simple “Regeneration” feature: if an entity has Health
, replenish it gradually.
import {
World,
Types,
createComponent,
createSystem,
Entity,
} from '@iwsdk/core';
// 1) Data
export const Health = createComponent('Health', {
current: { type: Types.Float32, default: 100 },
max: { type: Types.Float32, default: 100 },
});
// 2) Behavior
export class HealthRegenSystem extends createSystem(
{ withHealth: { required: [Health] } },
{ regenPerSecond: { type: Types.Float32, default: 5 } },
) {
init() {
// react to config changes
this.config.regenPerSecond.subscribe((v) => console.log('Regen now', v));
}
update(dt: number) {
for (const e of this.queries.withHealth.entities) {
const cur = e.getValue(Health, 'current')!;
const max = e.getValue(Health, 'max')!;
if (cur < max)
e.setValue(
Health,
'current',
Math.min(max, cur + dt * this.config.regenPerSecond.peek()),
);
}
}
}
// 3) Running in a world
const container = document.getElementById('scene') as HTMLDivElement;
const world = await World.create(container);
world.registerComponent(Health);
world.registerSystem(HealthRegenSystem);
// Create an entity with Health
const player = world.createTransformEntity();
player.addComponent(Health, { current: 25, max: 100 });
That’s the whole loop: components define data, systems query and mutate that data, the world runs it.
Mental Models in Action
Data flow each frame:
Input → Core logic systems → Feature systems → UI/Render systems → Renderer
(higher priority ↖ earlier; more negative = earlier)
Memory model (columnar storage):
Health.current: [100, 25, 80, ...]
Health.max: [100, 100, 150, ...]
entity 0 1 2
ECS vs OOP (why composition):
- Add features by adding components, not by subclassing a deep hierarchy.
- Multiple orthogonal features coexist on the same entity without inheritance diamonds.
Entities: attaching data and a 3D object
Entities are created from the world. In IWSDK they can also carry a Three.js object (object3D
) and a built‑in Transform
component when created via createTransformEntity
.
// Persistent entity (parented under the scene) with an Object3D
const hud = world.createTransformEntity();
// Level‑scoped entity (parented under active level) with an existing object
import { Object3D } from '@iwsdk/core';
const mesh = new Object3D();
const gltfEntity = world.createTransformEntity(mesh);
Add/remove components at any time:
gltfEntity.addComponent(Health, { current: 50, max: 150 });
gltfEntity.removeComponent(Health);
Get or set component values inside systems:
const hp = e.getValue(Health, 'current');
e.setValue(Health, 'current', hp! - 10);
Components: typed schemas (no behavior)
Components declare fields using Types.*
, defaults, and optional enums. IWSDK re‑exports elics types.
import { Types, createComponent } from '@iwsdk/core';
export const DamageOverTime = createComponent('DamageOverTime', {
dps: { type: Types.Float32, default: 10 },
duration: { type: Types.Float32, default: 3 },
});
Under the hood components are registered with the world and used by queries.
Systems: queries + lifecycle + config signals
Use createSystem(queries, schema)
to define:
queries
: named sets withrequired
(and optionallyexcluded
) components.schema
: system config options; each key becomes a reactiveSignal
atthis.config.<key>
.
export class DamageSystem extends createSystem(
{
ticking: { required: [Health, DamageOverTime] },
},
{
// Config is reactive: this.config.tickRate.value etc.
tickRate: { type: Types.Float32, default: 10 },
},
) {
private timeAcc = 0;
init() {
// React when an entity newly matches a query
this.queries.ticking.subscribe('qualify', (e) =>
console.log('Damage starts', e.index),
);
}
update(dt: number) {
this.timeAcc += dt;
const step = 1 / this.config.tickRate.peek();
while (this.timeAcc >= step) {
this.timeAcc -= step;
for (const e of this.queries.ticking.entities) {
const dps = e.getValue(DamageOverTime, 'dps')!;
const cur = e.getValue(Health, 'current')!;
e.setValue(Health, 'current', Math.max(0, cur - dps * step));
}
}
}
}
Common lifecycle hooks:
init()
— set up event handlers, one‑time wiring, load assets.update(delta, time)
— per‑frame logic; prefer iteratingthis.queries.<name>.entities
.destroy()
— clean up handlers and disposables.
Queries: thinking in sets
Queries are declarative filters defined once; the ECS keeps their membership up‑to‑date as entities gain/lose components.
export class HUDSystem extends createSystem({
panels: { required: [PanelUI], excluded: [ScreenSpace] },
}) {
init() {
// Called whenever an entity first satisfies the query
this.queries.panels.subscribe('qualify', (e) =>
console.log('panel ready', e.index),
);
}
}
Filter by values using predicates:
import { lt, isin } from '@iwsdk/core';
export class DangerHUD extends createSystem({
lowHealth: { required: [Health], where: [lt(Health, 'current', 30)] },
status: {
required: [Status],
where: [isin(Status, 'phase', ['combat', 'boss'])],
},
}) {
/* … */
}
Config and Signals: runtime tuning
System config values are reactive signals (@preact/signals
). Update them on the fly (from UI, devtools, etc.).
const damage = world.registerSystem(DamageSystem);
damage.config.tickRate.value = 20;
If you need a reactive vector view from a component field (e.g., Types.Vec3
), use getVectorView
:
const v = e.getVectorView(Transform, 'position'); // Float32Array view
v[0] += 1; // move +X
World: the runtime container
The world owns Three.js, input, player rig, render loop, and runs systems.
import { World, SessionMode } from '@iwsdk/core';
const container = document.getElementById('scene') as HTMLDivElement;
const world = await World.create(container, {
xr: { sessionMode: SessionMode.ImmersiveVR },
features: { enableLocomotion: true, enableGrabbing: true },
level: '/glxf/Composition.glxf',
});
Useful world helpers:
createTransformEntity(object?, parentOrOptions?)
— create an entity plusobject3D
with a parent.getActiveRoot()
/getPersistentRoot()
— use for attaching Three.js nodes.loadLevel(url)
— request a GLXF level; LevelSystem performs the work.
Real-World Example: Interactive VR Objects
This shows how ECS handles a complete interactive VR feature — objects that glow when looked at, can be grabbed, and react to being touched:
import { World, Types, createComponent, createSystem, lt } from '@iwsdk/core';
// Components: pure data schemas
export const Interactable = createComponent('Interactable', {
glowIntensity: { type: Types.Float32, default: 0 },
maxGlow: { type: Types.Float32, default: 2 },
});
export const GazeTarget = createComponent('GazeTarget', {
isGazedAt: { type: Types.Boolean, default: false },
});
// System: behavior that reacts to data
export class InteractiveGlowSystem extends createSystem(
{
// Entities that can glow but aren't at max intensity yet
glowable: {
required: [Interactable, GazeTarget],
where: [lt(Interactable, 'glowIntensity', 2)],
},
},
{
glowSpeed: { type: Types.Float32, default: 3 },
},
) {
init() {
// React when objects start/stop being gazed at
this.queries.glowable.subscribe('qualify', (entity) => {
console.log('Object can now glow:', entity.index);
});
}
update(dt: number) {
for (const entity of this.queries.glowable.entities) {
const isGazed = entity.getValue(GazeTarget, 'isGazedAt')!;
const current = entity.getValue(Interactable, 'glowIntensity')!;
const max = entity.getValue(Interactable, 'maxGlow')!;
// Glow up when gazed at, fade when not
const target = isGazed ? max : 0;
const newIntensity =
current + (target - current) * this.config.glowSpeed.peek() * dt;
entity.setValue(Interactable, 'glowIntensity', newIntensity);
// Update Three.js material (IWSDK handles the binding)
if (entity.object3D) {
(entity.object3D as any).material.emissiveIntensity = newIntensity;
}
}
}
}
Why this showcases ECS power:
- Composition: Any entity can be made interactive by adding
Interactable + GazeTarget
- Reactive queries: System automatically processes objects as they enter/leave gaze
- Performance: Packed arrays make iterating thousands of objects fast
- Tunable:
glowSpeed
can be adjusted at runtime via config signals - Decoupled: Gaze detection, grabbing, audio feedback could be separate systems
Where to Go Next
Learn each concept in depth:
World — scene ownership, level roots, XR lifecycle.
Entity — lifecycle, parenting, object3D.
Component — schemas, enums, vectors, defaults.
System — queries, lifecycle, config, cleanup.
Queries — required/excluded, qualify/disqualify events.
Patterns & Tips — common patterns, pitfalls, and performance notes.
Architecture — deep dive into performance, memory layout, and WebXR integration.
Explore the API pages under "API → core → UI/Scene/etc." to see real systems and components used by IWSDK.