Skip to content

Entities ​

GameEntity is the entity base class, in sage-core. NodeEntity extends it in sage with a Babylon TransformNode. Both are abstract; a game subclasses one and declares a behavior tuple. The concepts behind them are on the Entities page.

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

GameEntity ​

Instance members ​

MemberTypeMeaning
idstringThe entity's ID
namestringThe registry key it was spawned or restored under
stateobjectThe state bag, deep read-only once typed. Narrow with hasBehavior, or type it on the subclass. See Typing state and ops on a subclass.
opsobjectThe operations. Same.
hasBehavior(Class)this is NarrowedTo<Class>True when an attached behavior is an instance of Class, counting subclasses. Narrows state and ops.
getEntity(id)GameEntity | undefinedResolves another entity through the simulation context
emit(type, payload, targetID?)voidQueues an event with this entity as sender
update(dt)voidRuns each behavior's onUpdate in attachment order. Overridable.
destroy()voidUnsubscribes from the bus

Two members are protected, reachable from the entity class's own code and nowhere else:

MemberMeaning
contextThe GameSimulationView the entity was created with: the simulation, with its entity registry unknown
attachedBehaviors()A copy of the behavior instances, in attachment order
declareComputedState(suppliedState)Where an entity class declares the fields it computes itself. See Computed state.
computed(field, { get, set })Declares one computed field

attachedBehaviors is how an update() override runs a hook every behavior declares, which unique operation names keep out of ops.

Statics ​

StaticMeaning
behaviorsThe behavior tuple, static override readonly behaviors = [ ... ] as const
create(id, context, state?, name?)Builds an entity and runs onCreate
restore(id, context, state, name?)Builds an entity from snapshot state, without onCreate

The constructor is protected. simulation.spawn calls create; buildFromSnapshot calls restore. Both check the composition at construction and throw for a behavior that declares an operation with no method behind it, or two behaviors that expose the same operation name.

Typing state and ops on a subclass ​

A subclass names its behavior tuple as a static, and the tuple types state and ops. How the class says so depends on which base it extends.

NodeEntity<Behaviors> takes the tuple as a type parameter and declares both from it, so a sage entity class names the tuple twice and declares nothing:

typescript
const behaviors = [ HealthBehavior ] as const;

export class PlayerEntity extends NodeEntity<typeof behaviors>
{
    static override readonly behaviors = behaviors;
}

GameEntity carries no such type parameter, so a class extending it directly declares both itself, and state names DeepReadonly around StateFor:

typescript
const behaviors = [ HealthBehavior ] as const;

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

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

A declaration of state as a bare StateFor<typeof behaviors> also compiles, and it opts the class out of deep read-only everywhere. The declaration intersects with the deep read-only type spawn, getEntity, and restore hand back, the writable side wins, and a write at any depth from any caller compiles. One entity writing another's state is what deep read-only refuses, and nothing checks the write while the simulation runs.

ops has no read-only question. It holds functions, and OpsFor is the whole of it.

Overriding update ​

An entity class may override update() and order its work explicitly. super.update(dt) runs the default walk:

typescript
export class VehicleEntity extends NodeEntity<typeof behaviors>
{
    static override readonly behaviors = behaviors;

    override update(dt : number) : void
    {
        super.update(dt);
        this.ops.applyVelocity(dt);
    }
}

Here applyVelocity is an operation one of the vehicle's behaviors exposes, run after every behavior's onUpdate. An override that needs to run a hook every behavior declares, which unique operation names keep out of ops, walks this.attachedBehaviors() instead.

The simulation loop calls update() on every entity in insertion order and knows nothing else about what happens inside. An entity never calls another entity's update or destroy.

Computed state ​

A state field usually 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, so 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.

computed(field, { get, set }) declares one. An entity class declares its own in declareComputedState(suppliedState), which runs before any behavior's onStateReady. The hook is handed the state its caller supplied, which is what tells a field the spawn asked for from one composition filled in with a default.

The hook runs inside GameEntity's own construction, before the subclass's fields exist, so what a class computes over is something it can reach without reading a field of its own. An entity's state is typed by its behaviors rather than by the fields the class declares beside them, so the class names its own state type at the call: this.computed<BeaconState, 'intensity'>(...).

typescript
// A `#` field would not exist yet when the hook runs, so the light is held beside the class.
const lights = new WeakMap<BeaconEntity, SpotLight>();

export class BeaconEntity extends NodeEntity
{
    static override readonly behaviors = [] as const;

    get light() : SpotLight
    {
        const light = lights.get(this);

        if(light === undefined)
        {
            throw new Error(
                `BeaconEntity: entity "${ this.id }" is asked for its light before construction builds it.`,
            );
        }

        return light;
    }

    protected override declareComputedState(suppliedState : object) : void
    {
        super.declareComputedState(suppliedState);

        const scene = (this.context as NodeSimulationContext).scene;
        const light = new SpotLight('beacon', Vector3.Zero(), Vector3.Down(), Math.PI / 3, 2, scene);

        light.parent = this.node;
        light.intensity = (suppliedState as Partial<BeaconState>).intensity ?? 1;
        lights.set(this, light);

        this.computed<BeaconState, 'intensity'>('intensity', {
            get: () => light.intensity,
            set: (value) => { light.intensity = value; },
        });
    }
}

The light is the fact and intensity is how the entity system reads it. A snapshot records what the light is set to, and a restore assigns the field, which sets the light.

NodeEntity uses the hook for position and rotation, and acquires its node there, which is what puts a node behind position for every hook that follows.

A field can carry more than one computation. A behavior computing a field its entity class already computes lands over the entity's and answers for it while it stands, and the entity's own answers again once the one over it is gone. A physics body's position over a NodeEntity's node is that arrangement. See Behaviors for the behavior side.

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. What a getter answers is finite JSON-compatible data or the snapshot rejects it, naming the entity and the field.

A computation over a field that holds a record may answer with a live view: the same object on every read, whose components are themselves getters and setters over the same thing the field's own setter writes. state.position.x += 1 then moves what state.position = { ... } moves. Not every computation answers with one. A field computed from a Set answers with the array it serializes to, a fresh list each read, because an element of that list has nothing under it to write through to.

Destroying an entity removes every computation on its state, in reverse order of declaration, before any behavior hears that the entity is going away. Each removal restores what was beneath it: the computation beneath it where there is one, and a plain value where there is none, holding what the removed computation last answered with. The state of a destroyed entity is data. There is no removal of one computation on its own.

NodeEntity ​

Instance members ​

MemberTypeMeaning
nodeTransformNodeThe entity's node in the level's scene, never null
meshAbstractMesh | undefinedThe loaded mesh, undefined until it arrives and always for an adopted node
audioAudioManager | undefinedThe game's audio manager, when it opted into one
showCollider(config?, color?)voidShows the physics colliders under the entity's node. See Collider Debugging.
hideCollider(name?)voidHides them, all or by node name

update() on a NodeEntity runs the behavior walk and writes no transform at all. position and rotation are computed from the node, so there is no copy to write.

destroy() removes every computation on the entity's state, runs every behavior's onDestroy, unsubscribes from the bus, then returns the node and mesh to the pool for a poolable class, disposes the node otherwise, or leaves an adopted node in place. onDestroy reads state as the plain data it has become.

Statics ​

StaticDefaultMeaning
poolablefalseWhether despawn returns the node and mesh set to the pool
poolSizeundefinedThe cap for this class's pool, in place of poolSizeLimit
meshConfigundefinedA MeshConfig for the standard mesh source. See Authoring.
actionsundefinedThe input actions this class's entities carry, as Action declarations

NodeEntityState ​

Every NodeEntity carries these state fields whatever its behaviors declare:

typescript
interface NodeEntityState extends GameEntityState
{
    tags : string[];
    position : Vector3State;
    rotation : QuaternionState;
    parentID ?: string;
    nodeName ?: string;
    debugCollider ?: ColliderDebugConfig;
}
FieldMeaning
tagsThe entity's tags, an ordered list of unique strings
position{ x, y, z }, computed from the node. Defaults to the origin.
rotation{ x, y, z, w }, computed from the node. Defaults to no rotation, { x: 0, y: 0, z: 0, w: 1 }.
parentIDThe parent entity's ID. The node is parented under the parent's node.
nodeNameThe authored node this entity adopts. Written by a level, never by a game.
debugColliderApplied through showCollider once the entity's mesh loading settles. See Collider Debugging.

A behavior that moves the entity declares position in its own slice and writes it there.

position and rotation are computed from the node, whether the node was built with the entity, claimed from the pool, or adopted from the level. Reading position asks the node where it is, writing it moves the node, and the two answer in the node's own space, under whatever parent the node has. Nothing copies one onto the other and update() writes no transform.

Both answer with a live view, so position.x += 1 moves the node exactly as assigning the whole record does. The node exists before any hook runs, so onStateReady and onCreate both read a position that is already the node's.

Construction writes the composed state through the setters, behavior defaults included, so a built or pooled entity stands where state says from the moment it exists. An adopted node is the one exception: the transform authoring gave it is the value, and only a position or a rotation the spawn state supplies is written over that. See Authoring.

The hierarchy ​

A child names its parent in parentID at spawn:

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

Node parenting follows the field, so Babylon composes world transforms. Restore parents the node from the same field. There is no update cascade.

Node behavior hooks ​

Behaviors on a NodeEntity may implement onMeshLoaded() and onDestroy(). NodeEntity recognizes each by name on whichever of its behaviors implements it. See Behaviors.

Composition types ​

All of these come from @skewedaspect/sage-core, and @skewedaspect/sage re-exports every one of them, so a game working in either package imports from the one it already depends on.

TypeMeaning
GameEntityTypeWhat an entity registry holds: a constructor with a behaviors tuple and a GameEntity prototype
GameEntityClass<Context>GameEntityType plus create and restore declared as function-typed properties taking Context. It is what a typed entity registry holds, so a class narrowing its context to a NodeSimulationContext is checked at the registry rather than at the spawn.
GameEntityRegistry<Context>An entity registry itself: entity classes keyed by name, each constructable with Context. A game on sage-core alone names its registry with this; NodeEntityRegistry is the same type with sage's NodeSimulationContext filled in.
ValidEntityRegistry<Registry>Registry when every class in it composes cleanly, or an error type naming the problem, per class
StateFor<Behaviors>The composed state of a behavior tuple, tags included
OpsFor<Behaviors>The composed operations of a behavior tuple
ComposedOf<Type>An instance of an entity class with state and ops typed over its tuple. What create and restore return.
NarrowedTo<Class>A GameEntity narrowed to one behavior: deep read-only state, that behavior's operations. What hasBehavior narrows to.
GameEntityState{ tags : string[] }
StateBagRecord<string, StateValue>, the state bag's own shape
StateValueOne JSON-compatible value: null, a boolean, a finite number, a string, an array of these, or a record of these
OpsOf<Class>One behavior's operations. Used as a Requires argument.
StateSliceOf<Class>One behavior's state slice
DeepReadonly<Value>Recursively applies readonly
ValidTuple<Behaviors>The tuple when the composition checks, or an error type naming the problem

Vector3State and QuaternionState ​

typescript
interface Vector3State { x : number; y : number; z : number }
interface QuaternionState { x : number; y : number; z : number; w : number }

toVec3Object, toVector3, toQuatObject, and toQuaternion convert between these and Babylon's own types.

Released under the MIT License.