Skip to content

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:

ts
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:

ts
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:

ts
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:

ts
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:

ScenarioMethod
Name, EntityType, Transform (data only)world.addComponent(id, new ComponentClass(...))
Physics with Rapier body/collideroms.createComponentNow("physics", world, id, config)
Audio with Web Audio nodeoms.createComponentNow("audio", world, id, path, options, ctx)
Material with Three.js material instanceoms.createComponentNow("material", world, id, values)
Animation with clip catalogoms.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:

ts
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:

ts
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):

ts
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 = true for dynamically spawned, one-off objects to immediately free BufferGeometry and Material GPU memory.
ts
// 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:

ts
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:

ts
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:

ts
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