Simulation
A simulation is one fixed-tick loop over a set of entities. GameSimulationManager, in sage-core, runs one. NodeSimulation, in sage, extends it with a scene, a node pool, a mesh loader, and picking, and it is what engine.simulation holds.
import { GameSimulationManager } from '@skewedaspect/sage-core';
import { NodeSimulation } from '@skewedaspect/sage';Construction
interface GameSimulationOptions
{
fixedStepDuration : number;
entityClasses : Readonly<Record<string, GameEntityType>>;
allocator ?: IDAllocator;
}
type IDAllocator = () => string;| Option | Meaning |
|---|---|
fixedStepDuration | Seconds per fixed step. Must be finite and greater than zero. Set once for the simulation's life. |
entityClasses | The entity class table, keyed by name. See Entities. |
allocator | Mints entity IDs in place of the simulation's own counter. An allocator that is not deterministic gives up a replayed spawn minting the same ID. |
NodeSimulationOptions adds scene, loadMesh, pool, and an optional audio. createGameEngine builds a NodeSimulation for each scene a level hands to engine.rebuildSimulation(scene, meshSource), with the fixedStepDuration from SageOptions (default 1 / 60).
Surface
| Member | Answers |
|---|---|
bus | The EventBus |
tick | The count of completed fixed steps |
entities | A copy of the entity list, in insertion order |
spawn(name, id?, state?) | The new GameEntity |
despawn(id) | Nothing. An ID nothing holds is not an error. |
getEntity(id) | The GameEntity, or undefined |
getEntitiesByTag(tag) | Every entity whose tags include tag, scanning the list |
runStepsForElapsedTime(elapsed) | Nothing. Runs whole fixed steps for the elapsed seconds. |
snapshot() | A GameSimulationSnapshot |
fromSnapshot(snapshot) | A new simulation holding what the snapshot describes |
NodeSimulation adds scene, pool, loadMesh, audio, prewarm(name, count, state?), and the three picks described under Picking.
Spawn and despawn
spawn looks the class up in the class table, mints an ID unless the caller hands it one, creates the entity against the simulation's own context, stamps it with the name, and adds it to the end of the entity list. Insertion order is update order, and nothing reorders the list.
const player = simulation.spawn('PlayerEntity', undefined, { health: 100, tags: [ 'character' ] });
const rock = simulation.spawn('RockEntity', 'rock-7');A name the table does not hold rejects the spawn, and so does an ID the simulation already holds. spawn returns a GameEntity; narrow it with instanceof against the class.
despawn destroys the entity, which runs onDestroy on a NodeEntity's behaviors and unsubscribes it from the bus, and drops it from the list.
The fixed step
The caller owns the clock. runStepsForElapsedTime adds the elapsed time to the accumulator and runs a whole fixed step for each full interval the accumulator holds, leaving the remainder for the next call.
A fixed step is two things in order: every entity's update() in insertion order, then the bus drain. Events emitted during updates are queued, never delivered inline. The tick counter counts completed fixed steps.
A call that would run more steps than dilationLimit allows stops there. It discards the whole intervals still owed and carries the fraction into the next call. Chronic overrun degrades into simulation time running slower than wall clock.
GameManager's frame loop makes this call once a frame with the frame's delta, after the routing loop and before the frame callbacks. A game running its own loop calls it itself:
let last = performance.now();
function frame(now : number) : void
{
simulation.runStepsForElapsedTime((now - last) / 1000);
last = now;
requestAnimationFrame(frame);
}Failure
A throw from an entity's update() propagates out of runStepsForElapsedTime immediately. Nothing catches it there: no per-entity isolation, no rollback of what the step already did, and no update for whichever entities insertion order had not yet reached.
The simulation is dead once that throw escapes it. The two moves against a simulation in that state are to discard it, or to build a replacement with fromSnapshot from a snapshot taken before the failing call. Calling anything further on the dead instance is out of contract, snapshot() included.
The drain behaves differently: a throwing handler never stops delivery, and the errors surface together as an AggregateError once the drain completes. A throw from onCreate destroys the entity being created and propagates, before it joins the entity list.
Snapshot and restore
A snapshot is a persisted format:
interface GameSimulationSnapshot
{
tick : number;
idCounter : number;
entities : GameEntitySnapshot[];
}
interface GameEntitySnapshot
{
id : string;
name : string;
state : StateBag;
}It is deep-copied at a fixed-step boundary. Behavior instance fields are not in it, and neither is the accumulator.
fromSnapshot(snapshot) builds a new simulation. It carries over the entity class table, the fixed-step duration, and the allocator of the simulation it was called on, and it holds the entities the snapshot describes, each wired without running onCreate. Its tick counter and ID counter come from the snapshot and its accumulator starts at zero. The caller swaps the new simulation in. The simulation fromSnapshot was called on is untouched, and a build that fails at any entity leaves nothing behind.
const saved = simulation.snapshot();
// later
const restored = simulation.fromSnapshot(saved);Replaying N fixed steps from a snapshot produces state identical to running those N steps straight through.
buildReplacementSimulation
Building the replacement is a protected factory, buildReplacementSimulation(options), that a subclass overrides so its fromSnapshot returns its own kind. NodeSimulation overrides it to carry over its scene, pool, mesh loader, and audio manager:
class FlavoredSimulation extends GameSimulationManager
{
readonly flavor : string;
constructor(options : GameSimulationOptions & { flavor : string })
{
super(options);
this.flavor = options.flavor;
}
protected override buildReplacementSimulation(options : GameSimulationOptions) : FlavoredSimulation
{
return new FlavoredSimulation({ ...options, flavor: this.flavor });
}
}sage's save manager wraps a snapshot in a versioned envelope. See Saves.
Running headless
sage-core runs anywhere JavaScript runs. A GameSimulationManager built from GameEntity classes needs no scene and no renderer:
import { Behavior, GameEntity, GameSimulationManager } from '@skewedaspect/sage-core';
interface CounterState
{
updates : number;
}
class CounterBehavior extends Behavior<CounterState>
{
readonly defaults : CounterState = { updates: 0 };
onUpdate() : void
{
this.state.updates += 1;
}
}
class CounterEntity extends GameEntity
{
static override readonly behaviors = [ CounterBehavior ] as const;
}
const simulation = new GameSimulationManager({
fixedStepDuration: 1 / 20,
entityClasses: { CounterEntity },
});
const counter = simulation.spawn('CounterEntity');
simulation.runStepsForElapsedTime(0.25);
console.log(simulation.tick);A server that wants levels, meshes, or physics runs all of sage on Babylon's NullEngine through createGameEngine(null, entityClasses, { havokBinary }) instead.
Determinism
The simulation calls no Math.random and no Date.now, and nothing in a fixed step iterates without a defined order. A behavior that rolls receives its roll function from the game and never imports one, and a behavior that needs the wall clock receives that too.
Tuning constants
| Constant | Value | Counts |
|---|---|---|
dilationLimit | 5 | Fixed steps in one runStepsForElapsedTime |
drainPassLimit | 64 | Passes in one drain |
operationCallDepthLimit | 32 | Nested operation calls on one entity |
poolSizeLimit | 8 | Node and mesh sets one pool holds per class, unless the class declares poolSize |
The first three are exported from @skewedaspect/sage-core.
