Patterns & Tips
Updated: Sep 4, 2026
Use small components and focused systems. Keep relationships in ECS data instead of recovering them from the Three.js scene graph.
Compose behavior with components
An entity can participate in several systems at once.
player.addComponent(Health);
player.addComponent(Wallet);
player.addComponent(PlayerState, { value: 'walking' });
Each system queries only the components it owns. Adding or removing a component changes behavior without changing the entity’s class.
React to query membership
Use qualify and disqualify for work that occurs when an entity enters or leaves a query.
init() {
this.cleanupFuncs.push(
this.queries.panels.subscribe('qualify', (entity) => {
attachPanelResources(entity);
}),
this.queries.panels.subscribe('disqualify', (entity) => {
detachPanelResources(entity);
}),
);
}
Store each returned unsubscribe function or add it to the system’s cleanup collection.
Represent entity relationships explicitly
Use Types.Entity when one entity refers to another.
const SquadMember = createComponent('SquadMember', {
leader: { type: Types.Entity, default: null },
});
member.addComponent(SquadMember, { leader });
A system can query SquadMember and read its leader field. This remains valid even when the Three.js hierarchy contains non-entity objects. Keep this relationship in application data instead of relying on reverse Object3D lookup.
Use scalar fields for filtered state
Value predicates are reevaluated when a field is changed with setValue().
const WorkState = createComponent('WorkState', {
dirty: { type: Types.Boolean, default: true },
});
entity.setValue(WorkState, 'dirty', false);
Direct mutation through a vector view does not notify value predicates. Mirror a derived scalar when a query must filter on it.
Keep intrinsic transform state attached to a scene object. Toggle an application tag to move an entity between active and inactive queries.
const Projectile = createComponent('Projectile', {});
const Pooled = createComponent('Pooled', {});
class ProjectileSystem extends createSystem({
active: { required: [Projectile, Transform], excluded: [Pooled] },
inactive: { required: [Projectile, Transform, Pooled] },
}) {
init() {
for (let index = 0; index < 100; index++) {
const entity = this.world.createTransformEntity();
entity.addComponent(Projectile);
entity.addComponent(Pooled);
entity.object3D!.visible = false;
}
}
spawn(position: [number, number, number]) {
const entity = this.queries.inactive.entities.values().next().value;
if (!entity) return;
entity.getVectorView(Transform, 'position').set(position);
entity.removeComponent(Pooled);
entity.object3D!.visible = true;
}
release(entity: Entity) {
entity.object3D!.visible = false;
entity.addComponent(Pooled);
}
}
query.entities is set-like. Iterate it or use its iterator; do not index it with [0].
Avoid repeated cross-query scans
Build an index when one large entity set must refer to another. Update the index from query membership events instead of nesting two full loops each frame.
private byId = new Map<string, Entity>();
init() {
this.cleanupFuncs.push(
this.queries.named.subscribe('qualify', (entity) => {
this.byId.set(entity.getValue(Name, 'value')!, entity);
}),
this.queries.named.subscribe('disqualify', (entity) => {
this.byId.delete(entity.getValue(Name, 'value')!);
}),
);
}
Keep hot paths allocation-free
- Reuse vectors and arrays in
update(). - Iterate the query set directly.
- Cache stable query results used by event handlers.
- Measure on the target headset before changing data structures.