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:
| Property | Type | Description |
|---|---|---|
id | string | 16-character hex string, unique within the session. Generated automatically. |
type | string | The definition type this entity was created from (e.g. 'enemy:goblin'). |
name | string | undefined | Optional human-readable name. Not required to be unique. |
tags | Set<string> | Strings for categorization and querying (e.g. 'hostile', 'interactive'). |
state | object | Arbitrary data: health, speed, inventory -- whatever the entity needs. |
behaviors | GameEntityBehavior[] | Attached behavior instances that define what the entity can do. |
node | TransformNode | undefined | Optional BabylonJS scene node for visual representation. |
parent | GameEntity | null | Parent entity in a hierarchy, or null for root entities. |
children | GameEntity[] | 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.
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
// 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:
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.
// 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
| Field | Strategy | Details |
|---|---|---|
defaultState | Shallow merge | Child properties override base; base-only properties preserved. |
tags | Concatenate and deduplicate | Both arrays combined into a unique set. |
behaviors | Replace | Child list replaces base entirely. Omit to inherit. |
actions | Replace | Same as behaviors. |
children | Replace | Same as behaviors. |
name, mesh, poolable, poolSize | Child wins | Child value if provided, otherwise base. |
| Lifecycle hooks | Child wins | Child overrides if provided. |
The resolved enemy:goblin from the example above:
{
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.
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
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:
| Field | Description |
|---|---|
type | Required. The registered entity type to create. |
name | Optional name override for the child instance. |
initialState | Optional state merged with the child's defaultState. |
position | Optional { x, y, z } stored as _position in initial state. |
rotation | Optional { x, y, z } stored as _rotation in initial state. |
Runtime Hierarchy Management
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); // nullLifecycle 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
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
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, viaattachToNode(), or when a behavior is added to an entity that already has a node).onNodeDetached()-- called when the node is removed.
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:
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:
// 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
const entity = entityManager.getEntity('8cd88e1a9b13aac0');By Name
// 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
const allGoblins = entityManager.getByType('character:goblin');By Tag
// 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
const entity = entityManager.getByNode(pickedMesh);Iterating All Entities
for(const entity of entityManager.getAllEntities())
{
console.log(`${ entity.id } (${ entity.type })`);
}
console.log(`Total: ${ entityManager.entityCount }`);Performance Table
| Method | Performance | Notes |
|---|---|---|
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.
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 themTags
Tags are strings for categorization. Definition tags and creation tags are merged.
// 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')); // trueRuntime Tag Manipulation
// 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
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:
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:
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:
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.
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.
// 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.
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>:
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.
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:
- ID lookup -- checked against the entity ID map (O(1))
- 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 Bus | Entity Messaging | |
|---|---|---|
| Delivery | Broadcast to all subscribers | Direct to one entity |
| Response | No built-in response mechanism | request() returns a value |
| Coupling | Loose -- publishers and subscribers are anonymous | Tighter -- caller must know the target |
| Best for | System-wide notifications, cross-cutting concerns | Targeted 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
- Single-responsibility behaviors. Each behavior handles one aspect of functionality.
- Minimize behavior interdependencies. Behaviors should work independently when possible.
- Use events for communication. Prefer the event bus for broadcasts. Use entity messaging for targeted commands and queries.
- Test behaviors individually. Validate in isolation before integration.
- Document state requirements. Each behavior should be clear about which state properties it reads/writes.
- Consider behavior order. The order behaviors are attached affects event processing priority.
- Prefer tags over type checks. For cross-cutting queries (
'enemy','interactive'), tags are more flexible than type strings. - Use pooling for high-frequency entities. Bullets, particles, effects -- anything created and destroyed rapidly.
