Runtime Entity Management
From a Behaviour script you have full control over the entity lifecycle: spawning new objects, adding or removing components on existing ones, re-parenting entities in the hierarchy, and deleting with proper resource cleanup.
The ObjectManagementSystem
Every world exposes ObjectManagementSystem via this.world.getObjectManagementSystem(). It is the hub for all runtime entity and component creation:
const oms = this.world.getObjectManagementSystem();Always null-check — oms returns null if the world has not been set up yet.
1. Spawning new objects
Use requestCreate to queue a new entity from a built-in kind. The factory runs on the next frame and the optional onCreated callback fires with the new entityId:
import { Behaviour } from "@relu-interactives/spatial-ecs";
export default class Spawner extends Behaviour {
private spawnedId: number | null = null;
protected init() {
const oms = this.world.getObjectManagementSystem();
oms?.requestCreate("cube", {
name: "RuntimeCube",
transform: { position: { x: 0, y: 1, z: 0 } },
}, (entityId) => {
this.spawnedId = entityId;
});
}
dispose(world: any) {
if (this.spawnedId !== null) {
this.world.getObjectManagementSystem()?.deleteEntity(this.world, this.spawnedId, true);
}
}
}Supported kinds: "cube", "sphere", "plane", "capsule", "cylinder", "empty", "model", "image", "sprite", "video", "audio", "camera", "ambient-light", "directional-light", "point-light", "spot-light", "area-light", "instanced-mesh".
See the Spawning reference for per-kind payload schemas.
2. Adding components to existing entities
world.addComponent — simple data components
Use world.addComponent to attach data-only components that carry no runtime handles:
import { Behaviour } from "@relu-interactives/spatial-ecs";
import { Name } from "@relu-interactives/spatial-ecs/components";
export default class Tagger extends Behaviour {
protected init() {
const entity = this.world.getEntityByName("Target");
if (!entity) return;
this.world.addComponent(entity.entityId, new Name({ value: "Tagged" }));
}
}oms.createComponentNow — factory-initialized components
For components that need runtime setup (Rapier physics bodies, Web Audio nodes, Three.js material instances, AnimationMixers) use createComponentNow. It calls the same registered factory used during scene construction, so all runtime handles are correctly wired up:
import { Behaviour } from "@relu-interactives/spatial-ecs";
export default class AddPhysics extends Behaviour {
protected init() {
const oms = this.world.getObjectManagementSystem();
if (!oms) return;
oms.createComponentNow("physics", this.world, this.entityId, {
rigidbody: { type: "dynamic", mass: 1, gravityScale: 1 },
collider: { shape: "box" },
});
}
}When to use which:
| Scenario | Method |
|---|---|
| Name, EntityType, Transform (data only) | world.addComponent(id, new ComponentClass(...)) |
| Physics with Rapier body/collider | oms.createComponentNow("physics", world, id, config) |
| Audio with Web Audio node | oms.createComponentNow("audio", world, id, path, options, ctx) |
| Material with Three.js material instance | oms.createComponentNow("material", world, id, values) |
| Animation with clip catalog | oms.createComponentNow("animation", world, id) |
Registered factory kinds:
"animation", "audio", "camera", "physics", "environment", "entityType", "image", "imageTarget", "imageTargetAnchor", "light", "material", "mesh", "meshGeometry", "model", "name", "object3DRef", "parentId", "postprocessing", "script", "sprite", "transform", "video", "instancedMesh".
3. Removing components
Remove a component by passing its class to world.removeComponent. The associated runtime handles are not disposed — call system-specific cleanup first if needed:
import { Behaviour } from "@relu-interactives/spatial-ecs";
import { PhysicsComponent } from "@relu-interactives/spatial-ecs/components";
export default class Freezer extends Behaviour {
protected init() {
// Remove physics from this entity to freeze it in place
this.world.removeComponent(this.entityId, PhysicsComponent);
}
}4. Re-parenting at runtime
oms.setParent(world, entityId, parentId, options?) updates the ParentId component and reconciles the Three.js scene graph in the same frame. By default (preserveWorldTransform: true) the entity's world-space position and rotation are preserved by adjusting the local transform:
import { Behaviour } from "@relu-interactives/spatial-ecs";
export default class Attach extends Behaviour {
protected init() {
const oms = this.world.getObjectManagementSystem();
if (!oms) return;
const anchor = this.world.getEntityByName("Anchor");
if (!anchor) return;
// Attach this entity under Anchor, preserving world position
oms.setParent(this.world, this.entityId, anchor.entityId);
}
}Detach from parent (become a direct child of the scene root):
oms.setParent(this.world, this.entityId, null);setParent guards against cycles and returns false if the operation would create a loop or if either entity does not exist.
5. Deleting entities and freeing GPU memory
oms.deleteEntity(world, entityId, dispose?) removes the entity from the ECS world and its Object3D from the Three.js scene graph:
- Pass
dispose = false(default) when geometry or materials are shared — GPU resources stay allocated. - Pass
dispose = truefor dynamically spawned, one-off objects to immediately freeBufferGeometryandMaterialGPU memory.
// Safe remove (shared geometry/materials stay on GPU)
oms.deleteEntity(this.world, entityId);
// Full cleanup (frees GPU memory — use for unique spawned objects)
oms.deleteEntity(this.world, entityId, true);Always delete spawned entities in dispose() to prevent leaks when the script is removed:
dispose(world: any) {
if (this.spawnedId !== null) {
this.world.getObjectManagementSystem()?.deleteEntity(this.world, this.spawnedId, true);
}
}6. Async loading patterns
Models and audio load asynchronously. The entity is created immediately but the asset is not available until the loader fires. Use the onCreated callback for setup that doesn't need the asset, and poll component state in onUpdate for setup that does:
import { Behaviour } from "@relu-interactives/spatial-ecs";
import { AnimationComponent } from "@relu-interactives/spatial-ecs/components";
export default class ModelSpawner extends Behaviour {
private modelId: number | null = null;
private animSetup = false;
protected init() {
const oms = this.world.getObjectManagementSystem();
oms?.requestCreate("model", {
name: "Character",
path: "localasset://assets/character.glb",
transform: { position: { x: 0, y: 0, z: 0 } },
}, (entityId) => {
this.modelId = entityId;
// Safe immediately — entity exists, physics factory is synchronous
oms.createComponentNow("physics", this.world, entityId, {
rigidbody: { type: "dynamic", mass: 1 },
collider: { shape: "capsule" },
});
});
}
protected onUpdate() {
if (this.modelId === null || this.animSetup) return;
// availableClips populates after the GLB finishes loading
const anim = this.world.getComponent(this.modelId, AnimationComponent);
if (anim && anim.availableClips.length > 0) {
anim.addState("Idle", anim.availableClips[0], true);
anim.switchState("Idle");
this.animSetup = true;
}
}
dispose(world: any) {
if (this.modelId !== null) {
this.world.getObjectManagementSystem()?.deleteEntity(this.world, this.modelId, true);
}
}
}7. Complete worked example
Spawn a model, add physics, parent it under an AR anchor, set up animation once clips are ready, and clean everything up on dispose:
import { Behaviour } from "@relu-interactives/spatial-ecs";
import { AnimationComponent } from "@relu-interactives/spatial-ecs/components";
export default class ARCharacter extends Behaviour {
private characterId: number | null = null;
private animReady = false;
protected init() {
const oms = this.world.getObjectManagementSystem();
if (!oms) return;
const anchor = this.world.getEntityByName("ImageTargetAnchor");
oms.requestCreate("model", {
name: "ARCharacter",
path: "localasset://assets/character.glb",
transform: { position: { x: 0, y: 0, z: 0 } },
}, (entityId) => {
this.characterId = entityId;
// Parent under AR anchor so it tracks with the target
if (anchor) {
oms.setParent(this.world, entityId, anchor.entityId);
}
// Add physics (Rapier body wired immediately by the factory)
oms.createComponentNow("physics", this.world, entityId, {
rigidbody: { type: "kinematic-position" },
collider: { shape: "capsule" },
});
});
}
protected onUpdate() {
if (this.characterId === null || this.animReady) return;
const anim = this.world.getComponent(this.characterId, AnimationComponent);
if (anim && anim.availableClips.length > 0) {
if (anim.availableClips.includes("Idle")) anim.addState("Idle", "Idle", true);
if (anim.availableClips.includes("Walk")) anim.addState("Walk", "Walk", true);
anim.crossFadeDuration = 0.3;
anim.switchState("Idle");
this.animReady = true;
}
}
dispose(world: any) {
if (this.characterId !== null) {
this.world.getObjectManagementSystem()?.deleteEntity(this.world, this.characterId, true);
}
}
}See also
- Spawning reference — per-kind payload schemas for
requestCreate - ObjectManagementSystem — system internals and API reference
- Accessing Components — reading and writing component state
- World API — entity queries, input, raycasting, scene access

