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 both are computed from the node: reading position asks the node where it is, and writing it moves the node. Nothing copies one onto the other, and update() writes no transform. See Computed state.

MemberOnMeaning
idGameEntityThe entity's ID, minted by the simulation
nameGameEntityThe registry 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.

Computed state ​

A field holds a value. It may instead be computed: the class that declares it redefines the field on the state bag as a getter and a setter over something that class owns, and from then on reading the field asks that thing and writing the field tells it. There is one truth rather than a copy kept in step with one, which is what a class does when the thing it owns is authoritative: a physics body whose position the simulation decides, a scene node a level placed, or a Set it keeps for the lookups a list cannot give it.

The primitive is computed(field, { get, set }). A behavior declares its computations in onStateReady, and an entity class declares its own in declareComputedState, which runs first. A field can carry more than one, and the newest answers while it stands: a physics body's position over a NodeEntity's node.

Nothing about state changes for anyone reading it. A snapshot asks the getters and records plain data, and restore assigns, so the setters are told. A field computed from a Set answers with the array it serializes to, and one computed from a node or a body answers with a live view whose components write through, so state.position.x += 1 moves what assigning the whole record moves.

Destroying an entity removes every computation on its state, in reverse order of declaration, before any behavior hears that the entity is going away, so the state of a destroyed entity is data. See Entities and Behaviors.

Composition ​

An entity class declares its behavior list statically, as a static readonly behaviors tuple naming the behavior classes in attachment order. NodeEntity<Behaviors> takes that tuple's type as a generic parameter and types state and ops from it, so a subclass declares nothing further:

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

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

export class PlayerEntity extends NodeEntity<typeof behaviors>
{
    static override readonly behaviors = 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 ​

Extended bare, with no generic, NodeEntity types state and ops as object. Naming the tuple in NodeEntity<typeof behaviors> re-types them over the class's own composition, so that instanceof PlayerEntity narrows state and ops at every call site:

typescript
for(const entity of simulation.entities)
{
    if(entity instanceof PlayerEntity)
    {
        console.log(entity.state.health);
    }
}

GameEntity, in sage-core, has no such generic: a class with no scene node that wants the same narrowing declares state and ops again itself, typed with StateFor<typeof behaviors> and OpsFor<typeof behaviors>, over a plain readonly behaviors tuple.

Entity definitions ​

An entity registry 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 GameSimulationView, and both are compile errors without one. A game rarely calls either: simulation.spawn calls create, and buildFromSnapshot calls restore.

IDs ​

A simulation mints entity IDs from a counter it owns. An ID is the simulation's own name, then the counter: a simulation named arena mints arena-1, arena-2, and so on. The name is a required construction option with no default, so there is no fallback prefix. 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.

An ID is unique for the simulation's whole life. A despawned ID is retired: minting skips it, and handing it back to spawn throws rather than reusing it. An ID the simulation still holds is rejected the same way.

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.

Names ​

An entity's name is the registry 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 entity registry 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<typeof behaviors>
{
    static override readonly behaviors = behaviors;
}

const badger = simulation.spawn('FrigateEntity', { 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 registry ​

A simulation is constructed with a registry 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 { NodeEntityRegistry } from '@skewedaspect/sage';

export const entityClasses = {
    PlayerEntity,
    EnemyEntity,
    CollectibleEntity,
} satisfies NodeEntityRegistry;

NodeEntityRegistry is sage's name for the registry type a NodeEntity class satisfies; GameEntityRegistry<Context>, in sage-core, is the general form for any GameSimulationView. GameEntityType is a looser shape a view or a query names a class by, without the construction signature a registry's entries need; a registry typed by it does not compile where createGameEngine or simulation.spawn expect one.

Writing the registry with satisfies NodeEntityRegistry, rather than typing it : NodeEntityRegistry outright, keeps each key's literal type. That literal typing is what carries through to createGameEngine's generic parameter, so spawn is typed against the real keys instead of any string (Engine).

The registry'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.

GameSimulationView is the interface an entity is created with: the simulation with its entity registry unknown, carrying the event bus, getEntity, random, and the rest of what a simulation answers. The simulation implements it. The entity keeps it in a protected context, reachable from the entity class's own code and nowhere else; a behavior reaches the same simulation through this.simulation.

Destruction ​

Destroying an entity removes every computation on its state, then 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 after the computations come off, then disposes the node, returns it to the pool, or leaves it in place for an adopted node.

An entity despawned partway through a fixed step runs nothing further in that step. That holds two ways: an entity another entity's update despawned never starts its own, and an entity whose own behavior despawns it stops there, so the behaviors after that one in the tuple do not run their onUpdate.

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', { 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<typeof behaviors>
{
    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.

An entity can be destroyed before its mesh source finishes loading. If its node is disposed, because the class is not poolable or the set was returned past the pool size limit, a mesh that arrives afterwards is disposed as it arrives. If the pool keeps the set, the late mesh is attached to it for the next spawn that claims it.

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.

The pool itself is a NodePool, and what it holds is a NodeSet, a node and the mesh loaded onto it. Both are exported. NodeSimulation takes a pool as a required option, and createGameEngine builds a fresh one for every simulation it makes, so a game constructs its own when it builds a simulation by hand.

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

const pool = new NodePool();
const claimed : NodeSet | undefined = pool.take('BulletEntity');

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.