Simulation
A simulation is one fixed-tick loop over a set of entities. GameSimulation, in sage-core, runs one. NodeSimulation, in sage, extends it with a scene, a node pool, a mesh loader, collider debugging, and picking, and it is what engine.simulation holds.
import { GameSimulation } from '@skewedaspect/sage-core';
import { NodeSimulation } from '@skewedaspect/sage';Construction
interface GameSimulationOptions<Registry extends GameEntityRegistry<never> = GameEntityRegistry>
{
id : string;
fixedStepDuration : number;
entityClasses : Registry;
allocator ?: IDAllocator;
seed ?: RandomSeed;
bus ?: EventBus;
}
type IDAllocator = () => string;
type RandomSeed = number | string;| Option | Meaning |
|---|---|
id | The simulation's name, with no default. global is reserved. See The simulation's name. |
fixedStepDuration | Seconds per fixed step. Must be finite and greater than zero. Set once for the simulation's life. |
entityClasses | The entity registry, keyed by name and typed by Registry. 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. |
seed | What the simulation's GameRandom is seeded from, the simulation's own name when none is supplied. See Random numbers. |
bus | The engine bus to attach to once bound. A simulation built with no bus holds its own. See Events. |
GameSimulation<Registry> is generic in the same registry; constructing one ties entityClasses to ValidEntityRegistry<Registry>, so the composition check every class in the registry carries runs at construction. createGameEngine carries the registry it is handed through to every NodeSimulation it builds. See Engine.
Registry is invariant. It types both what spawn takes and what the constructor demands, so a simulation assigns only to a simulation over its own registry: a GameSimulation<typeof entityClasses> does not assign to a bare GameSimulation, and a bare one does not assign to it. NodeSimulation, GameEngine, and every manager and level carrying the registry behave the same way. Code that spawns by a typed name holds the game's own instantiation; name the registry once as a type alias and annotate with it:
type GameSim = GameSimulation<typeof entityClasses>;
let current : GameSim | null = null;
function bind(simulation : GameSim) : void
{
current = simulation;
}Code that holds a simulation without knowing what it spawns takes a GameSimulationView instead: every member of GameSimulation whose type does not name the registry, which is everything but spawn and buildFromSnapshot. A GameSimulation of any registry and a NodeSimulation are both assignable to it, so a library manager, a debug tool, or a composable names it and takes any game's simulation:
class ScoreKeeper
{
#simulation : GameSimulationView;
constructor(simulation : GameSimulationView)
{
this.#simulation = simulation;
}
get playerCount() : number
{
return this.#simulation.getEntitiesByTag('player').length;
}
}spawnByName is on the view, answering a GameEntity, since a name known only at run time never needed the registry's typing; spawn is not.
NodeSimulationOptions adds scene, loadMesh, pool, colliders, an optional audio, and an optional logger. createGameEngine fills logger from the engine's own logging utility, so a game supplies one only when it builds a NodeSimulation itself; a simulation built without one logs nowhere. createGameEngine builds a NodeSimulation for each scene a level hands to engine.rebuildSimulation(scene, name, { meshSource, fixedStepDuration }). Both options are optional. Left unset, fixedStepDuration is the one from SageOptions (default 1 / 60), and a level's simulation takes the fixedStepDuration its LevelConfig names before it falls back to that.
The simulation's name
A simulation is named by whoever constructs it, as id, with no default. The level manager names a level's simulation after the level. No two simulations attached to one bus share a name, and global is reserved, for a persistent simulation designed but not yet built.
The name is the first half of every ID the simulation mints: <name>-<n>. It travels with a snapshot, so a restored simulation keeps every ID it was saved with.
Members
| Member | Answers |
|---|---|
id | The simulation's name |
bus | The EventBus |
attached | Whether this simulation is attached to the engine bus |
attach() | Attaches to the engine bus it was built with. Re-registers every held subscriber and appends every held emission. |
detach() | Detaches from the engine bus, reversing attach(). Held subscribers move back to the simulation's own bus, and so does whichever of this simulation's emissions the engine bus has not drained. Entities register and emit against the simulation's own bus until it attaches again. |
tick | The count of completed fixed steps |
entities | A copy of the entity list, in insertion order |
spawn(name, state?, id?) | The new GameEntity, typed by the entity registry |
spawnByName(name, state?, id?) | The new GameEntity, for a name known only at run time |
despawn(id) | Whether there was an entity under that ID |
getEntity(id) | The GameEntity, or undefined |
getEntitiesByTag(tag) | Every entity whose tags include tag, scanning the list |
getEntitiesOfClass(Class) | Every entity of that class, in insertion order, typed as it |
runStepsForElapsedTime(elapsed) | Nothing. Runs whole fixed steps for the elapsed seconds. |
runFixedSteps(count) | Nothing. Runs exactly count fixed steps. See The fixed step. |
stepFraction | The accumulator as a fraction of the fixed-step duration. See The fixed step. |
random | The simulation's GameRandom, seeded from seed or from the simulation's name |
snapshot() | A GameSimulationSnapshot |
buildFromSnapshot(snapshot) | A new simulation holding what the snapshot describes, detached, over the same engine bus |
NodeSimulation adds scene, pool, loadMesh, audio, colliders, logger, prewarm(name, count, state?), getEntityForMesh(mesh), and the three picks described under Picking. Those first six are what NodeSimulationContext carries, so an entity and its behaviors reach every one of them through this.simulation. logger is where a mesh load that fails reports itself (Behaviors). getEntityForMesh(mesh) runs the same ancestor walk a pick does, on a mesh a game already holds -- one a trigger's intersection reported, say -- and answers with the owning NodeEntity or undefined.
Spawn and despawn
spawn looks the class up in the entity registry, 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', { health: 100, tags: [ 'character' ] });
const rock = simulation.spawn('RockEntity', undefined, 'rock-7');A name the entity registry does not hold rejects the spawn, and so does an ID the simulation already holds. So does an ID the simulation has already despawned: an ID is unique for the simulation's whole life, and minting skips every one it has retired. spawn returns a GameEntity; narrow it with instanceof against the class.
A retired ID stays retired for that simulation. Bringing an entity back under an ID a simulation has already despawned means building a replacement simulation, which starts with nothing retired, and spawning into it. See Snapshot and restore.
spawnByName(name, state?, id?) is the same spawn for a name known only at run time -- a scene marker's, or one typed at the debug console. The entity registry checks the name when the call runs, in place of the compiler checking it at the call site, and the entity comes back as a GameEntity. NodeSimulation narrows the answer to NodeEntity, since every class its registry holds is one.
const spawned = simulation.spawnByName(markerName, { position: { x: 0, y: 1, z: 0 } });despawn despawns the entity's children first, each found by the parent ID in its state, recursively, then destroys the entity, which runs onDestroy on a NodeEntity's behaviors and unsubscribes it from the bus, and drops it from the list. It answers whether there was an entity under that ID; an unknown ID is not an error. A parent ID cycle -- an entity descended from itself through some chain of parent IDs -- despawns each entity in the cycle once rather than recursing without end.
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 every entity's update() in insertion order, then the bus drain. sage-core carries nothing between the two; NodeSimulation steps the scene's physics engine there instead, so what a behavior tells a body during an update takes effect in the step it was told and what physics reports drains in the step it happened. 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.
runFixedSteps(count) runs exactly count fixed steps with no dilation limit, so a replay that owes forty steps runs forty. It neither reads nor changes the accumulator: stepFraction holds what it held, and time handed to runStepsForElapsedTime carries on as if the call never happened. A count of 0 runs nothing. A count that is negative, not a whole number, or not finite throws a RangeError before any step runs. A throw from a step propagates the way it does from runStepsForElapsedTime (Failure).
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);
}stepFraction is the accumulator as a fraction of the fixed-step duration. It is 0 when the accumulator holds nothing and below 1 whenever runStepsForElapsedTime has returned, since each call runs every whole interval it can and discards the rest at the dilation limit. A game drawing between fixed steps blends the last two steps' state by it. It is read-only, it is not in a snapshot, so a simulation built from one starts at 0, and GameSimulationView does not carry it.
A game reads it from a frame callback, which runs after the frame's fixed steps. This one draws a mesh between the previous step's position and the current step's:
engine.managers.gameManager.registerFrameCallback(() =>
{
const simulation = engine.simulation;
if(simulation === null)
{
return;
}
const blend = simulation.stepFraction;
mesh.position.x = previousPosition.x + (currentPosition.x - previousPosition.x) * blend;
mesh.position.y = previousPosition.y + (currentPosition.y - previousPosition.y) * blend;
mesh.position.z = previousPosition.z + (currentPosition.z - previousPosition.z) * blend;
});Failure
A throw from an entity's update() propagates out of runStepsForElapsedTime or runFixedSteps 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 buildFromSnapshot 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
{
id : string;
tick : number;
idCounter : number;
randomState : RandomState;
entities : GameEntitySnapshot[];
}
interface GameEntitySnapshot
{
id : string;
name : string;
state : StateBag;
}id is the simulation's own name. randomState is the four words of the simulation's generator (Random numbers). The snapshot is deep-copied at a fixed-step boundary. Behavior instance fields are not in it, and neither is the accumulator.
buildFromSnapshot(snapshot) builds a new simulation. It carries over the entity registry, the fixed-step duration, the allocator, the seed, and the bus of the simulation it was called on, sets the replacement's generator to the snapshot's randomState, takes its name from the snapshot, and 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. A snapshot whose randomState is missing, or is one state refuses, throws before anything is built. The simulation buildFromSnapshot was called on is untouched, and a build that fails at any entity leaves nothing behind.
The replacement is built detached, and it retires nothing: an ID the original had despawned is free again. The caller swaps the new simulation in and attaches it. Until attach(), its entities register and emit on its own bus and nothing outside it hears them. engine.bindSimulation does both halves of the swap under sage, detaching the simulation the engine held and attaching the new one.
bus is the engine bus the original was constructed with, and nothing else. A simulation built with no bus rebuilds with no bus. attach() on that replacement changes nothing and does not throw, since it already holds every registration and emission that attaching would move. Subscribers registered on the original's own bus keep hearing the original and hear nothing from the replacement.
const saved = simulation.snapshot();
// later
const restored = simulation.buildFromSnapshot(saved);
simulation.detach();
restored.attach();Replaying N fixed steps from a snapshot produces state identical to running those N steps straight through. runFixedSteps(N) on the simulation buildFromSnapshot built is that replay. A client that holds a server's snapshot rebuilds its prediction simulation from it and runs the steps the snapshot is behind:
const rebuilt = prediction.buildFromSnapshot(serverSnapshot);
rebuilt.runFixedSteps(owedSteps);buildReplacementSimulation
Building the replacement is a protected factory, buildReplacementSimulation(options), that a subclass overrides so its buildFromSnapshot returns its own kind. NodeSimulation overrides it to carry over its scene, pool, mesh loader, colliders, audio manager, and logger:
class FlavoredSimulation extends GameSimulation
{
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.
Which simulation the engine holds
Loading a level builds its simulation without binding the engine to it, so a preload, or the level a transition is still deciding on, leaves the current level's simulation running. Activating a level binds the engine to that level's simulation. Two simulations are therefore alive at once for as long as a preload or a transition lasts; only the bound one is attached to the bus and stepped. See Events.
Running headless
sage-core runs anywhere JavaScript runs. A GameSimulation built from GameEntity classes needs no scene and no renderer:
import { Behavior, GameEntity, GameSimulation } 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 GameSimulation({
id: 'lab',
fixedStepDuration: 1 / 20,
entityClasses: { CounterEntity },
});
const counter = simulation.spawn('CounterEntity');
simulation.runStepsForElapsedTime(0.25);
console.log(simulation.tick, counter.id);A server that wants levels, meshes, or physics runs all of sage on Babylon's NullEngine through createGameEngine(null, entityClasses, { havokBinary }) instead.
A headless simulation built with no bus option has no engine bus to attach to, so attach() changes nothing: it already holds every registration and every emission attaching would move. A simulation that never crosses events with another never calls it; one that does is built with a bus.
Determinism
The simulation reads no wall clock, and nothing in a fixed step iterates without a defined order: entities update in insertion order, behaviors run in attachment order, and events drain first in, first out.
Randomness is handed in. The simulation holds a seeded GameRandom as random, and a behavior rolls through it rather than importing a generator of its own. A behavior that needs the wall clock receives that from the game the same way. See Random numbers.
import { Behavior } from '@skewedaspect/sage-core';
export class LootBehavior extends Behavior<{ dropped : boolean }>
{
readonly defaults = { dropped: false };
onCreate() : void
{
this.state.dropped = this.simulation.random.chance(0.25);
}
}Random numbers
GameRandom is sage-core's seeded generator: xoshiro128** over four 32-bit words, spread from one seed word by splitmix32. A simulation builds one from its seed option, or from its own name when there is no option, so a simulation named arena rolls the same sequence every run. A seed fixes the sequence of draws, not how many have happened by a moment on the wall clock: a reading taken after a settle in seconds follows an uncontrolled number of fixed steps and is not reproducible. Measure after a fixed count of steps.
import { GameRandom, GameSimulation } from '@skewedaspect/sage-core';
const simulation = new GameSimulation({
id: 'arena',
fixedStepDuration: 1 / 60,
entityClasses,
seed: 'run-7',
});
const rolls = simulation.random;
rolls.nextFloat(); // 0 <= n < 1
rolls.between(1, 3); // 1 <= n < 3
rolls.wholeBetween(1, 6); // a die: 1 through 6
rolls.chance(0.25); // true a quarter of the time
rolls.normal(100, 15); // mean 100, deviation 15
rolls.pick([ 'sword', 'shield' ]); // one of them
rolls.shuffle([ 1, 2, 3, 4 ]); // a new array, the one handed in untouched
const loot : GameRandom = rolls.fork(); // a child sequence of its ownA seed is a number or a string. A string is hashed to a 32-bit word, a number is reduced to one, and seed answers with that word, so new GameRandom(other.seed) rolls what other rolls. A GameRandom built with no seed at all seeds from the wall clock, which is the one sequence that does not repeat.
| Call | Answers with | Draws |
|---|---|---|
nextInt() | An unsigned 32-bit word | 1 |
nextFloat() | A number in [0, 1) | 1 |
between(min, max) | A number in [min, max) | 1 |
wholeBetween(min, max) | A whole number in [min, max], both ends included | 1 |
chance(probability) | true with that probability | 1 |
normal(mean?, deviation?) | A draw from the normal distribution, mean 0 and deviation 1 by default | 2 |
pick(items) | One element of the array | 1 |
shuffle(items) | A new array of the same elements, the input untouched | One per element past the first |
fork() | A child GameRandom, seeded from one draw of this one | 1 |
Every call above nextInt draws through nextFloat, so a sequence is reproducible from a seed and a count of draws. A max below its min, a probability outside [0, 1], a negative deviation, and a pick on an empty array all throw.
state reads and writes the four words, so a run rewinds a generator or restores one:
const stood = rolls.state;
rolls.nextFloat();
rolls.nextFloat();
rolls.state = stood; // the next two draws are those same two againstate refuses anything other than four whole numbers from 0 to 2^32 − 1 as a, b, c, and d. A negative, fractional, out-of-range, NaN, infinite, non-number, or missing word throws a RangeError. So do four zeros, since xoshiro128** never leaves that state and no seed reaches it. A refused set leaves the generator's state as it was.
A snapshot carries the generator's state as randomState, taken at the same fixed-step boundary as the rest of it. buildFromSnapshot builds the replacement on the calling simulation's seed and sets its generator to that state, so the replacement rolls what the simulation that took the snapshot would have rolled next. A loaded save rolls on from where the saved game stood, and a simulation rebuilt from another's snapshot, a client's from a server's, rolls what that one rolls. random.seed on the replacement is still the calling simulation's seed.
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; poolSizeLimit is sage's own.
