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.
import { GameEntity, NodeEntity } from '@skewedaspect/sage';GameEntity
Instance members
| Member | Type | Meaning |
|---|---|---|
id | string | The entity's ID |
name | string | The registry key it was spawned or restored under |
state | object | The state bag, deep read-only once typed. Narrow with hasBehavior, or type it on the subclass. See Typing state and ops on a subclass. |
ops | object | The 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 | undefined | Resolves another entity through the simulation context |
emit(type, payload, targetID?) | void | Queues an event with this entity as sender |
update(dt) | void | Runs each behavior's onUpdate in attachment order. Overridable. |
destroy() | void | Unsubscribes from the bus |
Two members are protected, reachable from the entity class's own code and nowhere else:
| Member | Meaning |
|---|---|
context | The 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
| Static | Meaning |
|---|---|
behaviors | The 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:
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:
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:
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'>(...).
// 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
| Member | Type | Meaning |
|---|---|---|
node | TransformNode | The entity's node in the level's scene, never null |
mesh | AbstractMesh | undefined | The loaded mesh, undefined until it arrives and always for an adopted node |
audio | AudioManager | undefined | The game's audio manager, when it opted into one |
showCollider(config?, color?) | void | Shows the physics colliders under the entity's node. See Collider Debugging. |
hideCollider(name?) | void | Hides 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
| Static | Default | Meaning |
|---|---|---|
poolable | false | Whether despawn returns the node and mesh set to the pool |
poolSize | undefined | The cap for this class's pool, in place of poolSizeLimit |
meshConfig | undefined | A MeshConfig for the standard mesh source. See Authoring. |
actions | undefined | The input actions this class's entities carry, as Action declarations |
NodeEntityState
Every NodeEntity carries these state fields whatever its behaviors declare:
interface NodeEntityState extends GameEntityState
{
tags : string[];
position : Vector3State;
rotation : QuaternionState;
parentID ?: string;
nodeName ?: string;
debugCollider ?: ColliderDebugConfig;
}| Field | Meaning |
|---|---|
tags | The 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 }. |
parentID | The parent entity's ID. The node is parented under the parent's node. |
nodeName | The authored node this entity adopts. Written by a level, never by a game. |
debugCollider | Applied 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:
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.
| Type | Meaning |
|---|---|
GameEntityType | What 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[] } |
StateBag | Record<string, StateValue>, the state bag's own shape |
StateValue | One 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
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.
