Skip to content

Entities

SAGE uses a composition-based entity system. Instead of building game objects through deep inheritance chains, you assemble them from small, reusable behaviors that each handle one concern. A player character is just an entity with movement, health, and input behaviors attached. Swap the input behavior for an AI behavior and you have an enemy.

What is an Entity?

An entity is any object in your game world: a player, an enemy, a door, a projectile, a trigger volume. Each entity is an instance of GameEntity with the following structure:

PropertyTypeDescription
idstring16-character hex string, unique within the session. Generated automatically.
typestringThe definition type this entity was created from (e.g. 'enemy:goblin').
namestring | undefinedOptional human-readable name. Not required to be unique.
tagsSet<string>Strings for categorization and querying (e.g. 'hostile', 'interactive').
stateobjectArbitrary data: health, speed, inventory -- whatever the entity needs.
behaviorsGameEntityBehavior[]Attached behavior instances that define what the entity can do.
nodeTransformNode | undefinedOptional BabylonJS scene node for visual representation.
parentGameEntity | nullParent entity in a hierarchy, or null for root entities.
childrenGameEntity[]Child entities.

Behaviors are the building blocks. Each behavior handles a single aspect of functionality -- movement, health, combat, audio -- and can subscribe to events, update every frame, and read/write entity state. See the Behaviors guide for the full story on writing behaviors.

Entity Definitions

Before you can create entities, you register a definition (blueprint) with the EntityManager. Definitions describe the type, default state, and behaviors for a class of entities.

typescript
interface GameEntityDefinition<State>
{
    type : string;
    name? : string;
    tags? : string[];
    defaultState : State;
    behaviors : GameEntityBehaviorConstructor[];
    actions? : Action[];

    // Lifecycle hooks
    onBeforeCreate? : (state : State) => Promise<State | undefined> | State | undefined;
    onCreate? : (state : State) => Promise<State | undefined> | State | undefined;
    onBeforeDestroy? : (state : State) => Promise<State | undefined> | State | undefined;
    onDestroy? : (state : State) => Promise<void> | void;
}

Registering and Creating

typescript
// Register a definition
entityManager.registerEntityDefinition({
    type: 'weapon:energySword',
    name: 'Energy Sword',
    tags: [ 'weapon', 'melee' ],
    defaultState: {
        color: 'blue',
        isActive: false,
        damage: 50,
        owner: null,
    },
    behaviors: [ GlowingBehavior, SoundEffectBehavior, DamageBehavior ],
});

// Create an instance with defaults
const sword = await entityManager.createEntity('weapon:energySword');
console.log(sword.name);            // 'Energy Sword'
console.log(sword.state.color);     // 'blue'
console.log([ ...sword.tags ]);     // [ 'weapon', 'melee' ]

// Create a customized instance
const playerSword = await entityManager.createEntity('weapon:energySword', {
    initialState: { color: 'green', damage: 75 },
    name: "Player's Blade",
    tags: [ 'equipped', 'legendary' ],
});
console.log(playerSword.state.damage);  // 75
console.log([ ...playerSword.tags ]);   // [ 'weapon', 'melee', 'equipped', 'legendary' ]

CreateEntityOptions

When creating an entity, you can pass options to customize the instance:

typescript
interface CreateEntityOptions<State>
{
    initialState? : Partial<State>;     // Merged with defaultState
    name? : string;                     // Overrides definition name
    tags? : string[];                   // Added to definition tags
    node? : TransformNode;              // Scene node to attach
}

Tags from the definition and from creation options are merged together. State is shallow-merged, with creation values overriding definition defaults.

Definition Inheritance

Entity definitions can extend other definitions using the extends field. This lets you build specialized types from a common base without duplicating configuration. The parent must be registered first.

typescript
// Base definition
entityManager.registerEntityDefinition({
    type: 'enemy:base',
    name: 'Enemy',
    tags: [ 'enemy', 'hostile' ],
    defaultState: { health: 100, speed: 5, damage: 10 },
    behaviors: [ AiBehavior, CombatBehavior ],
});

// Child overrides specific fields
entityManager.registerEntityDefinition({
    type: 'enemy:goblin',
    extends: 'enemy:base',
    name: 'Goblin',
    defaultState: { health: 50, speed: 8 },
    behaviors: [ AiBehavior, CombatBehavior, StealBehavior ],
    tags: [ 'small' ],
});

Merge Rules

FieldStrategyDetails
defaultStateShallow mergeChild properties override base; base-only properties preserved.
tagsConcatenate and deduplicateBoth arrays combined into a unique set.
behaviorsReplaceChild list replaces base entirely. Omit to inherit.
actionsReplaceSame as behaviors.
childrenReplaceSame as behaviors.
name, mesh, poolable, poolSizeChild winsChild value if provided, otherwise base.
Lifecycle hooksChild winsChild overrides if provided.

The resolved enemy:goblin from the example above:

typescript
{
    type: 'enemy:goblin',
    name: 'Goblin',                                         // child overrides
    tags: [ 'enemy', 'hostile', 'small' ],                  // concatenated, deduplicated
    defaultState: { health: 50, speed: 8, damage: 10 },     // shallow merge
    behaviors: [ AiBehavior, CombatBehavior, StealBehavior ], // child replaces entirely
}

Recursive Chains

Inheritance chains work to any depth. If A extends B and B extends C, register them in order: C, then B, then A. Each registration resolves against its already-resolved parent.

typescript
entityManager.registerEntityDefinition({
    type: 'unit:base',
    defaultState: { health: 100, speed: 5 },
    behaviors: [ MoveBehavior ],
});

entityManager.registerEntityDefinition({
    type: 'unit:soldier',
    extends: 'unit:base',
    defaultState: { armor: 20 },
    behaviors: [ MoveBehavior, CombatBehavior ],
});

entityManager.registerEntityDefinition({
    type: 'unit:elite-soldier',
    extends: 'unit:soldier',
    defaultState: { health: 200 },
});

// Resolved elite-soldier:
//   state: { health: 200, speed: 5, armor: 20 }
//   behaviors: [ MoveBehavior, CombatBehavior ] (inherited from soldier)

If the base type is not registered when a child is registered, SAGE throws immediately -- no silent failures at entity creation time.

Parent-Child Hierarchy

Entities can be organized into parent-child trees. When both parent and child have scene nodes, the child's node is automatically parented under the parent's node in the BabylonJS scene graph.

Declaring Children in Definitions

typescript
entityManager.registerEntityDefinition({
    type: 'vehicle:tank',
    defaultState: { health: 500, speed: 3 },
    behaviors: [ VehicleBehavior ],
    children: [
        {
            type: 'weapon:turret',
            name: 'main_turret',
            position: { x: 0, y: 1.5, z: 0 },
        },
        {
            type: 'weapon:machineGun',
            name: 'coax_mg',
            initialState: { ammo: 500 },
            position: { x: 0.3, y: 1.6, z: 0.5 },
        },
    ],
});

// Creating the tank also creates both children
const tank = await entityManager.createEntity('vehicle:tank');
console.log(tank.children.length);      // 2
console.log(tank.children[0].name);     // 'main_turret'

Each child entry accepts:

FieldDescription
typeRequired. The registered entity type to create.
nameOptional name override for the child instance.
initialStateOptional state merged with the child's defaultState.
positionOptional { x, y, z } stored as _position in initial state.
rotationOptional { x, y, z } stored as _rotation in initial state.

Runtime Hierarchy Management

typescript
const ship = await entityManager.createEntity('vehicle:ship');
const cannon = await entityManager.createEntity('weapon:cannon');

// Attach as child
entityManager.addChild(ship.id, cannon.id);
console.log(cannon.parent === ship);            // true
console.log(ship.children.includes(cannon));    // true

// Detach later
entityManager.removeChild(ship.id, cannon.id);
console.log(cannon.parent);                     // null

Lifecycle Cascade

Parent-child relationships cascade through several operations:

  • Update: The frame loop only calls $update() on root entities. Each root cascades to its children automatically.
  • Destroy: Destroying a parent destroys all children first, depth-first.
  • Node attachment: When a parent is attached to a node, children's nodes are re-parented under it. Detaching the parent detaches children from the scene graph hierarchy.
  • Reset: When a poolable entity is reset, all children reset recursively.

Entity-Node Attachment

Entities can be attached to BabylonJS TransformNode objects, linking game logic to visual representation.

Attaching at Creation

typescript
const doorMesh = await SceneLoader.ImportMeshAsync('door', 'models/', 'door.glb');
const doorNode = doorMesh.meshes[0] as TransformNode;

const doorEntity = await entityManager.createEntity('object:door', {
    node: doorNode,
    name: 'main_door',
    tags: [ 'interactive', 'locked' ],
});

Runtime Attachment

typescript
const entity = await entityManager.createEntity('object:pickup');

// Attach to a mesh
const meshNode = scene.getMeshByName('health_pack');
entityManager.attachToNode(entity, meshNode);

// Detach
entityManager.detachFromNode(entity);

Behavior Lifecycle Hooks

When entities are attached to or detached from nodes, all behaviors receive lifecycle hooks:

  • onNodeAttached(node : TransformNode, gameEngine : GameEngine) -- called when a node is attached (at creation, via attachToNode(), or when a behavior is added to an entity that already has a node).
  • onNodeDetached() -- called when the node is removed.
typescript
class ParticleEffectBehavior extends GameEntityBehavior
{
    name = 'ParticleEffectBehavior';
    private particleSystem : ParticleSystem | null = null;

    onNodeAttached(node : TransformNode, _gameEngine : GameEngine) : void
    {
        const scene = node.getScene();
        this.particleSystem = new ParticleSystem('particles', 2000, scene);
        this.particleSystem.emitter = node;
    }

    onNodeDetached() : void
    {
        this.particleSystem?.dispose();
        this.particleSystem = null;
    }
}

Looking Up Entities by Node

After raycasting or scene interaction, find the entity attached to a picked mesh:

typescript
scene.onPointerDown = (evt, pickResult) =>
{
    if(pickResult.hit && pickResult.pickedMesh)
    {
        let node = pickResult.pickedMesh as TransformNode;
        let entity = entityManager.getByNode(node);

        // Walk up the hierarchy if needed
        while(!entity && node.parent)
        {
            node = node.parent as TransformNode;
            entity = entityManager.getByNode(node);
        }

        if(entity)
        {
            console.log(`Clicked on entity: ${ entity.name ?? entity.id }`);
        }
    }
};

Node Hierarchy Navigation

Entities provide helpers for navigating their attached node's children:

typescript
// Get a direct child node by name
const weaponSlot = character.getChildNode('weapon_slot');

// Find a descendant by slash-separated path
const fingerBone = character.findNode('armature/hand_R/finger_index');

Both return undefined if the entity has no attached node or the path is not found.

Querying Entities

The EntityManager provides indexed lookups for finding entities. All primary lookups are O(1).

By ID

typescript
const entity = entityManager.getEntity('8cd88e1a9b13aac0');

By Name

typescript
// First match
const mainDoor = entityManager.getByName('main_entrance');

// All matches (names are not required to be unique)
const guardDoors = entityManager.getEntitiesByName('guard_room_door');

By Type

typescript
const allGoblins = entityManager.getByType('character:goblin');

By Tag

typescript
// Single tag
const enemies = entityManager.getByTag('enemy');

// Multiple tags -- 'all' mode (default): must have ALL tags
const hostileElites = entityManager.getByTags([ 'enemy', 'elite' ], 'all');

// 'any' mode: must have AT LEAST ONE tag
const threats = entityManager.getByTags([ 'enemy', 'trap', 'hazard' ], 'any');

By Node

typescript
const entity = entityManager.getByNode(pickedMesh);

Iterating All Entities

typescript
for(const entity of entityManager.getAllEntities())
{
    console.log(`${ entity.id } (${ entity.type })`);
}

console.log(`Total: ${ entityManager.entityCount }`);

Performance Table

MethodPerformanceNotes
getEntity(id)O(1)Direct Map lookup
getByName(name)O(1)Indexed by name
getEntitiesByName(name)O(1) + O(n)Index lookup + array copy
getByType(type)O(1) + O(n)Index lookup + array copy
getByTag(tag)O(1) + O(n)Index lookup + array copy
getByNode(node)O(1)Direct Map lookup
getByTags(tags, 'all')O(n*m)Intersection of tag sets
getByTags(tags, 'any')O(n*m)Union of tag sets

Where n is the number of matching entities and m is the number of tags.

Naming and Tags

Names

Names are optional, human-readable identifiers. Multiple entities can share the same name.

typescript
const door1 = await entityManager.createEntity('object:door', { name: 'guard_room_door' });
const door2 = await entityManager.createEntity('object:door', { name: 'guard_room_door' });
// Both share the same name -- use getEntitiesByName() to find all of them

Tags

Tags are strings for categorization. Definition tags and creation tags are merged.

typescript
// Definition has tags: [ 'enemy', 'hostile' ]
const elite = await entityManager.createEntity('character:goblin', {
    tags: [ 'elite', 'boss' ],
});

// Merged result: [ 'enemy', 'hostile', 'elite', 'boss' ]
console.log(elite.hasTag('enemy'));     // true
console.log(elite.hasTag('elite'));     // true

Runtime Tag Manipulation

typescript
// Add (returns true if added, false if already present)
entityManager.addTag(entity, 'invisible');
entityManager.addTag(entity.id, 'invulnerable');    // also accepts entity ID

// Check
entity.hasTag('invisible');     // true

// Remove (returns true if removed, false if not present)
entityManager.removeTag(entity, 'invisible');

Object Pooling

For entities that are created and destroyed frequently -- bullets, particles, pickups -- object pooling avoids repeated allocation and garbage collection. SAGE provides built-in pooling that is transparent to the rest of your code.

Enabling Pooling

typescript
entityManager.registerEntityDefinition({
    type: 'projectile:bullet',
    poolable: true,
    defaultState: { speed: 50, damage: 10, lifetime: 2.0 },
    behaviors: [ ProjectileBehavior ],
});

With poolable: true, calling destroyEntity() resets the entity and stores it in an internal pool. The next createEntity('projectile:bullet') pulls from the pool instead of allocating. Your game code does not change -- createEntity and destroyEntity work exactly the same way.

Prewarming

Avoid allocation hitches during gameplay by pre-creating pooled entities at registration time:

typescript
entityManager.registerEntityDefinition({
    type: 'projectile:bullet',
    poolable: true,
    poolSize: 50,       // Pre-creates 50 bullets in the pool
    defaultState: { speed: 50, damage: 10, lifetime: 2.0 },
    behaviors: [ ProjectileBehavior ],
});

// Or prewarm manually at any time
await entityManager.prewarm('projectile:bullet', 100);

The onReset Hook

When a poolable entity is recycled, SAGE resets its state to defaultState, clears and restores default tags, detaches from any node, and calls onReset() on every attached behavior. Use this hook to clean up internal behavior state:

typescript
class ProjectileBehavior extends GameEntityBehavior
{
    name = 'ProjectileBehavior';
    eventSubscriptions = [];

    private trailParticles : ParticleSystem | null = null;

    onReset(state : { lifetime : number }) : void
    {
        this.trailParticles?.dispose();
        this.trailParticles = null;
    }

    // ...
}

Draining Pools

When transitioning levels or cleaning up, drain a pool to actually destroy the entities and free memory:

typescript
await entityManager.drainPool('projectile:bullet');

SAGE's $teardown() drains all pools automatically, so you typically don't need to do this manually.

Parent-Child Pooling

When a poolable parent is destroyed (pooled), its children are also destroyed -- and if the children's types are poolable, they go into their own pools. When the parent is recycled, children declared in the definition are re-created (potentially also recycled). Define both parent and child types as poolable and the system handles it.

ID Generation

SAGE uses hexoid for ID generation, producing 16-character hex strings. This is roughly 200x faster than crypto.randomUUID(), so entity creation is never a bottleneck.

typescript
import { generateId } from '@skewedaspect/sage';

const id = generateId();    // "8cd88e1a9b13aac0"

The generateId utility is exported for use in your own code when you need fast, unique identifiers.

Entity Messaging

The event bus is great for broadcasting, but sometimes you need to talk directly to a specific entity. Entity messaging provides two patterns for this: fire-and-forget and request-response. Both bypass the event bus entirely -- they dispatch directly to the target entity's behaviors.

Fire-and-Forget: send()

send() delivers a one-way message to a target entity. The target's behaviors process it through processEvent(), just like a bus event, but only the target entity sees it.

typescript
// From game code: tell a specific door to open
const door = entityManager.getByName('vault_door');
await door.send(door.id, 'action:open', { speed: 2.0 });

// From inside a behavior: message another entity by name
await this.entity.send('vault_door', 'action:open', { speed: 2.0 });

// Or by ID, if you have it
await this.entity.send(targetId, 'action:open', { speed: 2.0 });

Request-Response: request()

request() sends a message and waits for a response. The target's behaviors are checked in order -- the first behavior that implements processRequest() and returns a non-undefined value provides the response.

typescript
const result = await this.entity.request<boolean>('chest_01', 'query:is-locked');
if(result.success)
{
    console.log(`Locked: ${ result.value }`);
}

The return type is RequestResult<T>:

typescript
type RequestResult<T> =
    | { success : true; value : T }
    | { success : false; error : string };

A request fails (success: false) when no behavior handles it or when a behavior throws an error.

Handling Requests in Behaviors

Behaviors respond to requests by implementing processRequest(). Return a value to respond, or undefined to pass the request to the next behavior in the chain.

typescript
class LockBehavior extends GameEntityBehavior<{ locked : boolean }>
{
    name = 'lock';
    eventSubscriptions = [ 'action:unlock' ];

    processEvent(event : GameEvent, state : { locked : boolean }) : boolean
    {
        if(event.type === 'action:unlock')
        {
            state.locked = false;
            return true;
        }
        return false;
    }

    processRequest(event : GameEvent, state : { locked : boolean }) : unknown | undefined
    {
        if(event.type === 'query:is-locked')
        {
            return state.locked;
        }

        // Not our request -- pass to next behavior
        return undefined;
    }
}

Address Resolution

Both send() and request() accept a target string that is resolved in two steps:

  1. ID lookup -- checked against the entity ID map (O(1))
  2. Name fallback -- if no ID matches, checked against the name index (O(1))

This means you can address entities by either their generated ID or their human-readable name. When targeting by name, the first entity with that name is used.

Messaging vs. the Event Bus

Event BusEntity Messaging
DeliveryBroadcast to all subscribersDirect to one entity
ResponseNo built-in response mechanismrequest() returns a value
CouplingLoose -- publishers and subscribers are anonymousTighter -- caller must know the target
Best forSystem-wide notifications, cross-cutting concernsTargeted commands, queries, inter-entity dialogue

Use the event bus for things like "the player died" or "a level was loaded" -- events that many systems care about. Use messaging when you need to tell a specific entity to do something, or ask it a question.

Best Practices

  1. Single-responsibility behaviors. Each behavior handles one aspect of functionality.
  2. Minimize behavior interdependencies. Behaviors should work independently when possible.
  3. Use events for communication. Prefer the event bus for broadcasts. Use entity messaging for targeted commands and queries.
  4. Test behaviors individually. Validate in isolation before integration.
  5. Document state requirements. Each behavior should be clear about which state properties it reads/writes.
  6. Consider behavior order. The order behaviors are attached affects event processing priority.
  7. Prefer tags over type checks. For cross-cutting queries ('enemy', 'interactive'), tags are more flexible than type strings.
  8. Use pooling for high-frequency entities. Bullets, particles, effects -- anything created and destroyed rapidly.

Released under the MIT License.