Skip to content

Entities

An entity is an ID, one shared state bag, and an ordered list of behavior instances. It composes behaviors, owns the state they read and write, and dispatches to them. The only logic that belongs to the entity itself is the order it runs its behaviors in.

GameEntity and NodeEntity

GameEntity is the base class, in sage-core. It never has a scene node, and a game uses it for anything that has no place in the scene.

NodeEntity extends GameEntity and lives in sage. It always has a Babylon TransformNode, created with the entity in the level's scene and never null. A mesh is separate and optional, built by the entity's name and its state through the mesh source the level handed to rebuildSimulation. It loads asynchronously and attaches under the node when it arrives, and nothing waits on it for an entity to have a position.

Spatial state lives in the state bag like every other fact. NodeEntityState declares position and rotation, and NodeEntity.update writes both onto the node each fixed step.

MemberOnMeaning
idGameEntityThe entity's ID, minted by the simulation
nameGameEntityThe class-table key it was spawned or restored under
stateGameEntityThe state bag, object until narrowed
opsGameEntityThe entity's operations, nothing callable until narrowed
nodeNodeEntityThe Babylon TransformNode, never null
meshNodeEntityThe loaded mesh, or undefined until it arrives
audioNodeEntityThe game's AudioManager, or undefined for a game with no audio channels

State

State is JSON-compatible data: null, booleans, finite numbers, strings, arrays, and plain records with string keys. Functions, class instances, maps, sets, bigints, undefined values, NaN, and infinities do not belong in state, and a value outside that set, supplied at creation or at restoration, rejects the creation.

An entity owns its state outright. Creation deep-copies behavior defaults and supplied state, a snapshot deep-copies entity state, and restore deep-copies snapshot state. Merging defaults and supplied state is shallow by field, so a supplied field replaces the default whole.

Each behavior declares defaults for its own slice, typed against that slice, and the defaults are required. Two behaviors may declare the same field, and that shared bag is how behaviors share facts. Where two defaults overlap, the later behavior's default wins, in attachment order.

Writing a state field notifies nobody. There is no reactive state watching.

Composition

An entity class declares its behavior list statically, as a static readonly behaviors tuple naming the behavior classes in attachment order:

typescript
import { NodeEntity } from '@skewedaspect/sage';
import type { OpsFor, StateFor } from '@skewedaspect/sage';

import { HealthBehavior, MovementBehavior, ShieldBehavior } from '../behaviors/index.ts';

const behaviors = [ ShieldBehavior, HealthBehavior, MovementBehavior ] as const;

export class PlayerEntity extends NodeEntity
{
    static override readonly behaviors = behaviors;

    declare readonly state : StateFor<typeof behaviors>;
    declare readonly ops : OpsFor<typeof behaviors>;
}

The compiler checks the composition. An unknown operation, a behavior whose required operations nothing on the entity provides, two behaviors exposing the same operation name, an event name outside the vocabulary, and two behaviors declaring one state field with incompatible types are all compile errors.

Construction re-checks what the compiler proved, for compositions assembled where the type checker never looked, and it catches the one thing the compiler cannot: a behavior declaring an operation with no method behind it.

Narrowing the class

GameEntity types state and ops as object. The two declare readonly lines re-type them over the class's own tuple, so that instanceof PlayerEntity narrows state and ops at every call site:

typescript
for(const entity of simulation.entities)
{
    if(entity instanceof PlayerEntity)
    {
        entity.state.health -= 10;
    }
}

Without them, only the value PlayerEntity.create returns carries the composed type, and a call site holding a plain GameEntity needs a ComposedOf<typeof PlayerEntity> cast.

Entity definitions

A class table entry need not be written by hand. defineEntity turns a plain object into an entity class with the same compiler-checked composition. See Authoring.

Creation and restoration

create(id, context, state) is the only way to build an entity. The constructor is protected, so nothing constructs one directly, and the supplied state is typed against the composition, so state the composition never declared does not compile.

restore(id, context, state) builds an entity from a snapshot. It wires the entity the same way and runs no onCreate.

Both take a GameSimulationContext, and both are compile errors without one. A game rarely calls either: simulation.spawn calls create, and fromSnapshot calls restore.

IDs

A simulation mints entity IDs from a counter it owns. The counter is part of a snapshot, so IDs minted after a restore continue past every ID that snapshot held, and a spawn that happens again in a replay mints the ID it minted the first time. The default form is entity-1, entity-2, and so forth.

A game may supply an allocator when it constructs a simulation, and the simulation mints through that instead. spawn mints unless its caller hands it an ID, and an ID the simulation already holds is rejected.

Names

An entity's name is the class-table key it was spawned or restored under, stamped once as a readonly property. Every spawn of a class carries the same name. A caller building an entity with no class table to name it from gets the class's own name instead.

A game that wants to name one instance declares a state field for that. NamedBehavior, from @skewedaspect/sage-core, declares displayName:

typescript
import { NamedBehavior } from '@skewedaspect/sage-core';

const behaviors = [ HealthBehavior, NamedBehavior ] as const;

export class FrigateEntity extends NodeEntity
{
    static override readonly behaviors = behaviors;

    declare readonly state : StateFor<typeof behaviors>;
    declare readonly ops : OpsFor<typeof behaviors>;
}

const badger = simulation.spawn('FrigateEntity', undefined, { displayName: 'HMS Badger' });

Tags

Every entity carries an ordered list of unique tags, a state field like any other. A duplicate tag rejects the creation, the way any invalid state does. simulation.getEntitiesByTag(tag) scans the entity list.

The entity class table

A simulation is constructed with a table of entity classes keyed by name. spawn looks a class up by name; a snapshot records the name of every entity; restore looks each one up again.

typescript
import type { GameEntityType } from '@skewedaspect/sage';

export const entityClasses : Readonly<Record<string, GameEntityType>> = {
    PlayerEntity,
    EnemyEntity,
    CollectibleEntity,
};

The table's keys are the names. Using each class's own name as its key is a convenience, not a requirement.

Cross-entity access

Relationships are IDs in state. A live reference is resolved at the moment of use and never held across fixed steps.

Cross-entity access is read state and call operations. One entity never writes another's state, and enforcement is by type. An un-narrowed state is object, and a narrowed state is deep read-only, so a write at any depth fails to compile. Nothing checks a write while the simulation runs.

getEntity(id) answers with a GameEntity, or with undefined when no entity holds that ID. The caller decides what a miss means.

hasBehavior(SomeBehavior) is a method on GameEntity. It is true when one of the entity's attached behavior instances is an instance of SomeBehavior, counting subclasses, and when it is true it narrows the entity: state becomes that behavior's state, deep read-only, and ops becomes that behavior's operations. Un-narrowed, a GameEntity's state is object and its ops carry nothing callable, so narrowing is the only way to use one.

typescript
class ButtonBehavior extends Behavior<ButtonState>
{
    readonly defaults : ButtonState = { doorID: '', pressed: false };

    onUpdate() : void
    {
        if(!this.state.pressed) { return; }

        const door = this.entity.getEntity(this.state.doorID);

        if(door !== undefined && door.hasBehavior(LockBehavior) && door.ops.isLocked())
        {
            door.ops.unlock('1234');
        }

        this.state.pressed = false;
    }
}

this.entity is the behavior's own GameEntity. A behavior reaches a sibling the same way it reaches a stranger: this.entity.hasBehavior(Other) narrows to that sibling's operations and read-only state. A behavior writes its own slice through this.state.

onCreate does not call getEntity. Spawn order is not a contract.

GameSimulationContext is the interface an entity is created with. It carries the event bus and getEntity. The simulation manager implements it. It is protected on the entity: an entity class's own code sees it, behaviors do not, and nothing outside the entity does.

Destruction

Destroying an entity unsubscribes it from the event bus. simulation.despawn(id) destroys the entity and drops it from the list. On a NodeEntity, destroy runs every behavior's onDestroy first, then disposes the node, returns it to the pool, or leaves it in place for an adopted node.

The parent-child hierarchy

The hierarchy is NodeEntity's. A child's parent ID is a state field on the child, parentID, an ID in state like every other relationship. Node parenting follows that relationship, so Babylon composes world transforms from it.

typescript
const ship = simulation.spawn('ShipEntity');
const turret = simulation.spawn('TurretEntity', undefined, { parentID: ship.id });

There is no update cascade. The simulation's flat loop updates every entity in insertion order, and a parent that needs its children updated in a particular order overrides update().

Pooling

Pooling lives in sage. What it pools is the Babylon TransformNode and the mesh loaded onto it, bound to a freshly constructed entity. GameEntity instances and behavior instances are never recycled: every spawn constructs both fresh.

typescript
export class BulletEntity extends NodeEntity
{
    static override readonly poolable = true;
    static override readonly poolSize = 32;
    static override readonly behaviors = behaviors;
}

On spawn of a poolable class, sage asks the pool for a node and mesh set matching the class before loading anything new. A pooled set arrives already loaded and enabled. On despawn, the set returns to the pool instead of being disposed: detached from the scene and hidden, held for the next spawn of the same class.

A pool is keyed by name, and each one is capped. poolSizeLimit is the default cap, and an entity class overrides it for its own name by declaring poolSize. A set returned past whichever limit applies is disposed rather than kept.

A game fills a pool ahead of any spawn with simulation.prewarm(name, count, state), which loads meshes the way a spawn would and gives the sets straight to the pool without constructing an entity. A game empties a pool with simulation.pool.clear(name) or simulation.pool.clearAll(). A level transition builds a fresh pool for the new scene on its own.

A node adopted from a level's authored scene never reaches the pool. See Authoring.

Entity picking

NodeSimulation answers what a ray, a screen position, or a camera's forward direction hits, in entity terms. See Picking.

Where to go next

  • Behaviors for what a behavior declares and the hooks it implements
  • Events for the bus and delivery
  • Authoring for defineEntity, the standard mesh source, and scene-node adoption

Released under the MIT License.