Skip to content

Prefabs, Bundles & Commands

Reference documentation for prefabs, bundles, commands, and entity mutation.


Prefabs are immutable templates for spawning entities with predefined component data and optional child hierarchies.

Use Define.prefab() to create a PrefabBuilder. Every builder method returns a new instance — builders are immutable.

import { Define } from '@shell/ecs';
const PlayerPrefab = Define.prefab()
.with(Position, { x: 0, y: 0 })
.with(Velocity, { x: 0, y: 0 })
.with(Texture, { source: 'player.png' });
  • Components without explicit data use the component’s schema defaults.
MethodReturnsDescription
.with(Component, data?)new prefabAdd a component with optional initial data. Partial data merges with defaults.
.with(CompA, CompB, CompC)new prefabAdd multiple components without data (schema defaults).
.withComponents(otherPrefab)new prefabMerge root components from another prefab. Children are not included.
.withChild(name, childPrefab)new prefabAdd a named child slot. Replaces existing slot with the same name.
.withChild(undefined, childPrefab)new prefabAdd an anonymous child (parented but not in slot map).

.with() returns a new prefab — the original is unchanged:

// Override specific fields (others keep prefab defaults)
const { root } = commands.spawn(PlayerPrefab.with(Position, { x: 100 }));

.withComponents() merges only root components from another prefab. Children are not included:

const EnemyPrefab = Define.prefab()
.withComponents(PlayerPrefab) // merge root components only
.with(Texture, { source: 'enemy.png' }) // override Texture
.with(Health, { hp: 50 }); // add new component

.with() does NOT accept a PrefabBuilder — use .withComponents() for prefab composition.

const TagPrefab = Define.prefab().with(ComponentA, ComponentB, ComponentC);

Prefabs can declare named or anonymous child entities:

const SwordPrefab = Define.prefab()
.with(Position, { x: 10 })
.with(Texture, { source: 'sword.png' });
const ShieldPrefab = Define.prefab()
.with(Position, { x: -10 })
.with(Texture, { source: 'shield.png' });
const ArmedPlayerPrefab = Define.prefab()
.with(Position, { x: 0, y: 0 })
.withChild('sword', SwordPrefab) // named slot
.withChild('shield', ShieldPrefab) // named slot
.withChild(undefined, LabelPrefab); // anonymous — parented but no slot

Named slots bubble up through nested hierarchies. A grandchild slot 'gem' on a child 'sword' is accessible directly on the top-level SpawnedPrefab:

const Gem = Define.prefab().with(Texture, { source: 'gem.png' });
const Sword = Define.prefab().with(Position).withChild('gem', Gem);
const Player = Define.prefab().with(Position).withChild('sword', Sword);
const spawned = commands.spawn(Player);
spawned.get('sword'); // Entity
spawned.get('gem'); // Entity — bubbled up from grandchild
  • Builders are immutable — .with() and .withChild() always return a new PrefabBuilder.
  • Overrides are partial — only specified fields are replaced, others keep defaults.
  • .withChild('name', newPrefab) replaces an existing slot with the same name.
  • Slot names are flat — named slots from nested children bubble up to the top-level SpawnedPrefab.

Bundles are immutable reusable collections of component builders plus optional initial component data. They are root-component-only templates: no children, no slots.

Use Define.bundle() to create a BundleBuilder. Like prefabs, every builder method returns a new instance.

import { Define } from '@shell/ecs';
const PhysicsBundle = Define.bundle().with(Position, { x: 0, y: 0 }).with(Velocity, { x: 0, y: 0 });
const RenderBundle = Define.bundle().with(Sprite).with(Texture);

Bundles can be nested inside other bundles using .with():

const ActorBundle = Define.bundle()
.with(PhysicsBundle)
.with(RenderBundle)
.with(Health, { hp: 100 });

When every argument to .with() is a builder (bundle or component), no data object is needed:

const TagBundle = Define.bundle().with(ComponentA, ComponentB, ComponentC);

.with() on a prefab accepts a BundleBuilder. This is the preferred way to compose reusable component sets into prefabs:

const EnemyPrefab = Define.prefab()
.with(ActorBundle)
.with(Texture, { source: 'enemy.png' })
.with(Health, { hp: 50 });

Important: .with() on a prefab does not accept another PrefabBuilder. Use .withComponents() for prefab-to-prefab root composition.

Bundles can be added to existing entities via commands or direct entity methods:

// Via commands
commands.addComponent(entity, ActorBundle);
// Via direct entity method
entity.addComponent(ActorBundle);

This applies all components (and nested bundles) from the bundle to the entity in a single operation.

  • Builders are immutable — .with() always returns a new BundleBuilder.
  • Bundles are root-only — they cannot declare children or slots.
  • Overrides are partial — only specified fields are replaced when using .with(Component, data).
  • Components without explicit data use the component’s schema defaults.

Commands execute entity operations. Available via the commands parameter in system update functions, or via the testWorld fixture.

commands.spawn() always returns a SpawnedPrefab, even for empty spawns. Use .root to get the root entity.

// Empty entity
const { root } = commands.spawn();
// From a prefab
const { root } = commands.spawn(PlayerPrefab);
// With explicit UID (for serialization/networking)
const { root } = commands.spawn(PlayerPrefab, 'player-1');
// Hierarchical prefab — access named children
const spawned = commands.spawn(ArmedPlayerPrefab);
spawned.root; // Entity — the player
spawned.get('sword'); // Entity — the sword child (type-safe)
spawned.get('shield'); // Entity — the shield child (type-safe)
spawned.getAll(); // Map<string, Entity> — all named slots
interface SpawnedPrefab<TSlots extends string = never> {
root: Entity; // always present
get(slot: TSlots): Entity; // type-safe named child lookup
getAll(): Map<string, Entity>; // all named slots
}

For childless prefabs, TSlots is never, making .get() unreachable at the type level.

// By entity object
commands.despawn(entity);
// By UID string
commands.despawn('player-1');
commands.addComponent(entity, Position, { x: 10, y: 20 });
commands.addComponent(entity, ActorBundle); // add all components from a bundle
commands.removeComponent(entity, Position);

Commands that receive an exact Entity object route to that entity’s owning registry anywhere in the same root/scene world family. This allows a scene system to mutate root or sibling entities returned by shared queries. spawn() and UID-only operations remain local to the calling world’s registry, and entities from an independent World are rejected. Every entity in one updateComponents() call must share the same registry.

Lifecycle event commands, hierarchy links, and stored entity references remain registry-local so scene teardown cannot leak into another owner.


Entities also expose component operations directly. These delegate to commands under the hood.

entity.addComponent(Position, { x: 10, y: 20 });
entity.addComponent(ActorBundle); // add all components from a bundle
entity.addComponents(Position, Velocity, Sprite);
entity.removeComponent(Position);
entity.hasComponent(Position); // boolean
const position = entity.getComponent(Position);
position?.x; // number
position?.y; // number
entity.setParent(parentEntity);
parentEntity.addChild(childEntity);
entity.getChildren(); // Entity[]
entity.getParent(); // Entity | undefined
entity.getChildCount(); // number
PropertyTypeDescription
entity.idEntityIdMonotonic runtime ID within the world family
entity.indexnumberCompact registry index, reusable after despawn
entity.generationnumberLifecycle generation for the reusable index
entity.rownumberRow in the current archetype’s storage
entity.uidstringLazy persistent identifier for external formats
entity.archetypeArchetypeCurrent archetype
entity.markedForRemovalbooleanPending despawn
entity.destroyedbooleanAlready removed
entity.childrenEntity[]Child entities (hierarchy)

An Entity object represents exactly one lifecycle. Its ID, UID (once materialized), registry ID, index, and generation never change, including after despawn. Reading entity.uid for the first time generates and registers it; entities.getByUid() can discover only explicitly supplied or already materialized UIDs. A first UID read after destruction remains stable but is not registered.

IDs are unique and never reused within a root world and its descendant scenes. Independent root worlds may reuse the same numeric IDs, so use UIDs for serialization, protocols, and networking. A later entity can reuse the numeric index with a higher generation, but it is a different JavaScript object. Structural mutations through the old object and writes through one of its retained component proxies throw instead of targeting the replacement.


In tests, entity spawns and component changes are committed when the scheduler ticks. Always call scheduler.tick() after mutations to see their effect in queries.

const spawned = commands.spawn(PlayerPrefab);
scheduler.tick(); // commit spawn
expect(spawned.root.hasComponent(Position)).toBe(true);

  • Prefabs are immutable templates created with Define.prefab().
  • Bundles are immutable root-only component collections created with Define.bundle().
  • .with() and .withChild() return new builders; the original is unchanged.
  • .withComponents() merges root components only; children are not copied.
  • Named child slots bubble up through nested hierarchies and are type-safe.
  • commands.spawn() always returns a SpawnedPrefab with .root and .get().
  • Entity methods (addComponent, removeComponent, hasComponent, getComponent) delegate to commands.
  • Call scheduler.tick() in tests after spawning to commit changes.