Skip to content

Migration Guide ​

Migrating from 0.9 to 0.10.0 ​

SAGE 0.10 replaces the 0.9 entity system, input system, and save manager. The entity system now lives in @skewedaspect/sage-core, a package with no Babylon and no DOM dependency, and @skewedaspect/sage builds its Babylon-facing layer on top of it. Every game built on 0.9's entity, input, or save APIs needs porting. This guide maps each 0.9 API onto its 0.10 replacement.

Install the packages ​

bash
npm install @skewedaspect/sage @skewedaspect/sage-core @babylonjs/core @babylonjs/havok @babylonjs/loaders
npm install @skewedaspect/sage-vue   # the Vue integration, for a game that uses it

Clear the bundler's dependency cache after the bump ​

Both packages are ESM only, so Vite pre-bundles them and caches the result under node_modules/.vite. The cache survives an install, so a bump leaves TypeScript reading the new package's types while the browser loads the pre-bundle built from the old one. The symptom is a run-time TypeError naming a method the types have and the running code does not, simulation.attach is not a function among them. Clear the cache yourself after installing:

bash
rm -rf node_modules/.vite

@skewedaspect/sage re-exports Behavior, GameEntity, NodeEntity, NamedBehavior, StateMachineBehavior, defineStates, and the composition types, so most game code imports from @skewedaspect/sage alone. The event map augmentation names either package: EventMap is declared at sage-core's root, and sage re-exports that same interface, so a declare module against either one merges into it.

Six type names live only at @skewedaspect/sage-core, and a game that needs one imports it from there: StateDefinition, EventsMatching, Subscriber, GameEntitySnapshot, IDAllocator, and StateValue.

The entity registry replaces entity definitions ​

0.9 registered entity definitions with the entity manager at runtime. 0.10 hands createGameEngine an entity registry, a record of entity classes keyed by name, and the simulation spawns from that registry.

An earlier 0.10 pre-release named the registry's own types GameEntityClassTable, NodeEntityClassTable, and ValidEntityClassTable. They are GameEntityRegistry, NodeEntityRegistry, and ValidEntityRegistry now, and the generic parameter every one of them carries is Registry, not Table.

A game on sage-core alone, with no Babylon and no createGameEngine, names its registry GameEntityRegistry and hands it to a GameSimulation it constructs itself. NodeEntityRegistry is that same type with sage's NodeSimulationContext filled in, so every rule on this page applies to both under whichever name the game uses.

In 0.9:

typescript
const engine = await createGameEngine(canvas, [], options);
engine.managers.entityManager.registerEntityDefinition(playerDefinition);
const player = await engine.managers.entityManager.createEntity('player', { name: 'Hero' });

In 0.10:

typescript
const entityClasses = { PlayerEntity, EnemyEntity };
const engine = await createGameEngine(canvas, entityClasses, options);
await engine.managers.levelManager.activateLevel('arena');

const simulation = engine.simulation;

if(simulation === null)
{
    throw new Error('No level has built a scene yet.');
}

const player = simulation.spawn('PlayerEntity', { displayName: 'Hero' });

spawn takes the state before an optional ID it mints one for when none is given: spawn(name, state?, id?). The entity registry a simulation is built with types the call, so a name outside it, a state field no behavior declares, and a misread of what comes back are all compile errors. spawn is synchronous and returns a GameEntity; narrow it with instanceof or a cast. The samples on this page reuse the simulation variable from here on.

engine.simulation is null until a level builds a scene. GameLevel.buildScene calls the level's own rebuildSimulation(scene, meshSource) for you, which threads the level's name through to the engine. A custom Level subclass calls that same method itself, once its scene exists; the raw engine.rebuildSimulation(scene, name, { meshSource }) is for a game building a simulation outside the level system entirely, and takes the name itself since there is no level to supply one.

typescript
class DemoLevel extends Level
{
    protected async buildScene() : Promise<Scene>
    {
        const scene = this.context.sceneEngine.createScene();
        this.rebuildSimulation(scene, loadMesh);
        return scene;
    }
}

Typing the engine by its entity registry ​

createGameEngine is generic in the entity registry it is handed. The engine it answers with, that engine's simulation, rebuildSimulation, and bindSimulation, the level manager, every level context, and every Level carry that registry, so a game that types its registry spawns through the engine, and inside its own levels, typed the way it spawns on a simulation it built itself. A registry handed in with no literal type of its own leaves spawn a string name over a plain state bag, on the engine as anywhere else.

Give the registry a literal type with satisfies NodeEntityRegistry, and name the engine by it with typeof:

typescript
import { createGameEngine } from '@skewedaspect/sage';
import type { GameEngine, NodeEntityRegistry } from '@skewedaspect/sage';

const entityClasses = { PlayerEntity, EnemyEntity } satisfies NodeEntityRegistry;

let engine : GameEngine<typeof entityClasses> | null = null;

async function boot() : Promise<void>
{
    engine = await createGameEngine(canvas, entityClasses, {});
}

A Level subclass that spawns from the same registry names it the same way:

typescript
class ArenaLevel extends GameLevel<typeof entityClasses>
{
}

GameSimulationManager is GameSimulation ​

The class that runs a simulation drops its suffix: it is not one of the engine's managers.*, and its file, its NodeSimulation subclass, and the design already called it the simulation. Every import, option type, and page follows: GameSimulation, GameSimulationOptions, GameSimulationView, GameSimulationSnapshot.

GameSimulationContext is GameSimulationView ​

The two-member interface an entity was created with is gone. GameSimulationView is the one interface: the simulation with its entity registry unknown, which is both what an entity is created with and what code holding a simulation without knowing what it spawns takes. Rename every import, annotation, and implements clause. A GameSimulationView carries bus and getEntity as the old interface did, and the rest of what a simulation answers beside them, so nothing an entity did before stops compiling.

typescript
import type { GameSimulationView } from '@skewedaspect/sage-core';

export function report(context : GameSimulationView) : number
{
    return context.entities.length;
}

NodeSimulationContext keeps its name and now extends GameSimulationView, so a NodeEntity and its behaviors reach the scene, the pool, the mesh loader, the audio manager, and collider debugging exactly as before. A hand-written double that implemented the old two-member interface implements the whole view now, or builds a GameSimulation instead.

A behavior reaches the simulation as this.simulation ​

A behavior no longer has to go through its entity for the simulation: this.simulation is protected on Behavior and answers the simulation the entity was created with. A behavior declaring NodeEntity reads a NodeSimulationContext there. Rolling goes through it: this.simulation.random is the simulation's own seeded generator, so a behavior that took a roll function in its own state stops needing one. See Random numbers.

GameRandom and the seed option ​

SAGE ships a seeded generator. GameSimulationOptions.seed takes a number or a string, the simulation builds a GameRandom from it, and simulation.random is that generator. A simulation constructed with no seed seeds from its own name, so every run of a simulation named arena rolls the same sequence.

typescript
import { GameRandom } from '@skewedaspect/sage-core';

const rolls = new GameRandom('run-7');

const damage = rolls.wholeBetween(4, 10);
const crit = rolls.chance(0.1);
const drop = rolls.pick([ 'sword', 'shield', 'potion' ]);

GameRandom and the seed option are new since 0.10.0-rc.3. A game on that release has no generator on its simulation and rolls through Math.random or one of its own; those calls become draws on this.simulation.random, and the run repeats from then on. The engine itself names Math.random nowhere, and a game that wants a sequence that does not repeat builds a GameRandom with no seed.

The registry parameter is invariant ​

Registry types both what spawn takes and what the constructor demands, so a simulation is assignable to a simulation over one registry only: its own. A NodeSimulation<typeof entityClasses> does not assign to a bare NodeSimulation, and a bare NodeSimulation does not assign to it either. The same holds for GameSimulation, GameEngine, and every manager and level that carries the registry.

Every variable, field, parameter, and return type that holds a simulation therefore names the registry, which is the widest edit a port makes. Write the name once as a type alias and annotate with the alias:

typescript
type ArenaSimulation = NodeSimulation<typeof entityClasses>;

let current : ArenaSimulation | null = null;

function bind(simulation : ArenaSimulation) : void
{
    current = simulation;
}

A game on sage-core alone writes the same alias over GameSimulation:

typescript
type GameSim = GameSimulation<typeof entityClasses>;

const simulation : GameSim = new GameSimulation({
    id: 'arena',
    fixedStepDuration: 1 / 60,
    entityClasses,
});

Code that never spawns by a typed name does not need the alias at all. It takes a GameSimulationView, the members of GameSimulation whose types do not name the registry, and any game's simulation is assignable to it. A manager in a shared library, a debug tool, or a UI composable is written against the view, and only the code that calls spawn holds the alias. See Simulation.

Spawning, despawning, and entity IDs ​

A simulation mints entity IDs as <simulation>-<n>: the simulation's own name, then a counter it owns. IDs are unique across every simulation attached to one bus, and a replayed spawn mints the same one it minted the first time.

despawn(id) despawns an entity's children first, each found by the parentID in its state, then the entity itself, and answers whether there was an entity under that ID:

typescript
const removed = simulation.despawn(player.id);

if(!removed)
{
    console.warn('Nothing was spawned under that ID.');
}

An entity despawned partway through a fixed step runs nothing further in that step. 0.9's entityManager.destroyEntity(id) is gone with the entity manager; despawn is its replacement.

A despawned ID is retired ​

Despawning retires the ID for the rest of that simulation's life. Spawning under it again throws, naming the ID, and the minting counter skips it as well, so no later spawn is handed it by accident. 0.9 let a game destroy an entity and recreate it under the same ID; 0.10 refuses.

Destroy-and-recreate under a fixed ID is the pattern this breaks. Games that used it to reset an entity have two replacements. Where the entity should keep existing, despawn nothing and write its state back to the starting values instead. Where it genuinely has to be recreated, rebuild the simulation and spawn into the replacement, which starts with nothing retired:

typescript
const turret = simulation.getEntity('turret-a');

if(turret === undefined)
{
    throw new Error('No turret under that ID.');
}

if(!turret.hasBehavior(HealthBehavior))
{
    throw new Error('That entity is not a turret.');
}

const carried = { health: turret.state.health };

simulation.despawn('turret-a');

const replacement = simulation.buildFromSnapshot(simulation.snapshot());
replacement.spawn('TurretEntity', carried, 'turret-a');

Read the state off before the despawn. The snapshot is taken after it, so the despawned entity is not in the snapshot, and the replacement holds every other entity exactly as the snapshot describes.

Whoever held the old simulation has to be pointed at the replacement, and the replacement has to be attached to the bus before anything outside it hears an event. See Rebuilding a simulation from a snapshot.

Entity classes ​

A 0.9 GameEntityDefinition becomes either a hand-written class or a defineEntity call. Both register in the entity registry the same way.

In 0.9:

typescript
const playerDefinition : GameEntityDefinition<PlayerState> = {
    type: 'player',
    name: 'Player',
    tags: [ 'character' ],
    defaultState: { health: 100, maxHealth: 100 },
    behaviors: [ ShieldBehavior, HealthBehavior ],
};

In 0.10, hand-written:

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

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

In 0.10, from a definition:

typescript
export const PlayerEntity = defineEntity({
    type: 'PlayerEntity',
    tags: [ 'character' ],
    defaultState: { health: 100, maxHealth: 100 },
    behaviors: [ ShieldBehavior, HealthBehavior ] as const,
});

Naming the tuple as NodeEntity's type parameter is what types state and ops. instanceof PlayerEntity then narrows both everywhere downstream, and a call site never needs a ComposedOf<typeof PlayerEntity> cast. defineEntity does the same thing for a definition.

Re-declaring state opts out of deep read-only ​

A class can instead declare the state type itself:

typescript
export class PlayerEntity extends NodeEntity
{
    static override readonly behaviors = behaviors;

    declare readonly state : StateFor<typeof behaviors>;
}

That compiles, and it opts the class out of deep read-only state. StateFor is the composed state with every field writable; the deep read-only version is DeepReadonly<StateFor<...>>, which is what NodeEntity's type parameter produces and what spawn and getEntity hand back. A declaration on the class intersects with that, and the writable side wins, so player.state.health = 0 and a write to a nested field both compile from anywhere. One entity writing another's state is what deep read-only exists to refuse, and nothing checks the write while the simulation runs. Delete the declaration and name the tuple as the type parameter.

GameEntity, in sage-core, carries no such type parameter. A class extending it directly declares the type itself and has to write DeepReadonly around it:

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

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

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

The definition's shape changed:

0.9 field0.10
typetype, still the registry key
nameRemoved. A per-instance label is NamedBehavior's displayName state field. See Names.
tagstags
defaultStatedefaultState, now optional. Each behavior declares its own defaults.
behaviorsbehaviors, an as const tuple. The compiler checks the composition.
actionsactions, carried as a static on the class
meshmesh, a MeshConfig. See Meshes.
poolable, poolSizeStatics on the class. See Pooling.
extendsRemoved. Compose behaviors instead.
childrenRemoved. A child sets parentID in its own state. See The hierarchy.
onBeforeCreate, onCreate, onBeforeDestroy, onDestroyRemoved. Behaviors declare onCreate and, on a NodeEntity, onDestroy.
debugColliderA NodeEntityState field now, supplied at spawn or by a level's spawn and entity definitions. See Collider debug visualization.

Composition errors are compile errors now: a behavior whose required operations nothing on the entity provides, two behaviors exposing the same operation name, an event name outside the event map, and two behaviors declaring one state field with incompatible types.

A NodeEntity also narrows the context it is created with: create and restore require a NodeSimulationContext, the context carrying the scene a node goes in, rather than the plain GameSimulationView GameEntity accepts. defineEntity and a hand-written NodeEntity subclass both satisfy this without doing anything extra. It bites a game that puts a NodeEntity class in a plain GameSimulation's registry, or calls create on one with a plain context: both are compile errors. The other direction is fine: a class written directly against GameEntity goes in a NodeSimulation's registry and spawns there with no node of its own, for an entity that has state and behaviors but nothing to draw. The simulation it spawns from is still a level's NodeSimulation: the engine binds no other kind, so a game whose entities are in a scene puts them in a level, and a game with no scene at all runs a GameSimulation on its own clock without the engine. See Engine.

position and rotation are computed from the node ​

0.9 had no spatial state field: an entity's place was its node's, read and written as entity.node.position. 0.10 declares position and rotation on NodeEntityState and computes both from the node: reading position asks the node where it is, writing it moves the node, and update() writes no transform at all.

A game that moved a node directly is unaffected. The move is the fact, and reading state.position gives it straight back. Writing the node and writing the field both work, and neither is undone a step later. A snapshot records where the node actually is.

0.10.0-beta.2 held both fields as values and wrote them onto the node once a fixed step, for every entity that had not adopted its node. That write is gone: a game built against that pre-release writes the node or the field, and nothing overwrites it.

Both fields answer with a live view, so state.position.x += 1 moves the node exactly as assigning the whole record does. See Entities.

The behavior model ​

GameEntityBehavior is gone. A behavior extends Behavior<State, Requires>. State is the slice of the entity's state bag the behavior declares and writes. Requires names the operations it calls on its siblings.

In 0.9:

typescript
export class HealthBehavior extends GameEntityBehavior<HealthState>
{
    name = 'health';
    eventSubscriptions = [ 'action:damage', 'action:heal' ];

    processEvent(event : GameEvent, state : HealthState) : boolean
    {
        const payload = event.payload as { targetId ?: string; amount ?: number } | undefined;

        if(event.type === 'action:damage')
        {
            if(payload?.targetId && payload.targetId !== this.entity.id) { return false; }

            state.health = Math.max(0, state.health - (payload?.amount ?? 10));
            this.$emitStateChanged(state, { health: state.health });
            return true;
        }

        return false;
    }
}

In 0.10:

typescript
export interface HealthState
{
    health : number;
    maxHealth : number;
}

export class HealthBehavior extends Behavior<HealthState>
{
    static readonly events = [ 'action:damage', 'action:heal' ] as const;

    readonly defaults : HealthState = { health: 0, maxHealth: 0 };

    onEvent(event : GameEvent) : boolean
    {
        if(event.type === 'action:damage')
        {
            const wasAlive = this.state.health > 0;

            this.state.health = Math.max(0, this.state.health - event.payload.amount);

            if(wasAlive && this.state.health <= 0)
            {
                this.entity.emit('entity:died', { entityId: this.entity.id });
            }

            return true;
        }

        if(event.type === 'action:heal')
        {
            this.state.health = Math.min(this.state.maxHealth, this.state.health + event.payload.amount);
            return true;
        }

        return false;
    }
}

The mechanical rewrite for each 0.9 behavior:

0.90.10
name = '...'Removed. The class is its own identifier.
eventSubscriptions = [ ... ]static readonly events = [ ... ] as const
processEvent(event, state)onEvent(event), reading and writing this.state
update(dt, state)onUpdate(dt), once per fixed step
this.$emit({ type, payload })this.entity.emit(type, payload, targetID?)
this.$emitStateChanged()Removed. Writing a state field notifies nobody.
onNodeAttached(node, engine)onMeshLoaded(), on a NodeEntity. Read this.entity.node and this.entity.mesh.
onNodeDetached(), destroy()onDestroy(), on a NodeEntity
onReset(state)Removed. Entities are never recycled.
Public methods called from outsidestatic readonly ops = [ 'name' ] as const, called through entity.ops.name()
this.entity.state (whole bag)this.state (own slice only)

Defaults are required and typed against the slice. Two behaviors may declare the same field, and that is how they share a fact: the later behavior's default wins, in attachment order. A behavior that only reads a sibling's field still declares it.

Behavior instance fields are transient. Only state persists, so anything worth saving goes in the state bag. onCreate runs once on a fresh entity and never on a restored one, and it does not call getEntity.

onStateReady is new beside onCreate: it runs on every entity, created or restored, the moment the behaviors are wired and before anything has read the state. A behavior that has to reach its own state first does it there.

A NodeEntity acquires its node before any hook runs, so this.entity.node is there from onStateReady onwards. The mesh is what waits. this.entity.mesh before onMeshLoaded is whatever the entity's node set carries: undefined for a fresh load still in flight, undefined for an adopted node, and the pooled set's own mesh for a claim that arrived loaded. onMeshLoaded is the hook that says the loading has settled, whichever of those it was.

Declare the event map ​

The event vocabulary is closed. Every event a game emits or subscribes to, action events included, is declared once by augmenting sage-core's EventMap:

typescript
// events.ts
declare module '@skewedaspect/sage-core'
{
    interface EventMap
    {
        'action:damage' : { amount : number };
        'action:heal' : { amount : number };
        'entity:died' : object;
        'action:moveForward' : number;
        'action:jump' : boolean;
        'action:open' : { speed : number };
    }
}

export {};

An event name outside the map, or a payload of the wrong type, does not compile.

One bus per engine ​

createGameEngine builds one EventBus, held as GameEngine.bus. Every manager, handler, level, and UI registers on it once, under its own name, and the bound simulation attaches to it: an attached simulation's entities register and emit through the bus directly, and a preloaded, not-yet-bound simulation holds its entities' subscriptions and emissions until it is bound. The library's own events -- level:*, asset:*, trigger:*, game:*, input:deviceChanged -- are declared into the event map, so they are typed like a game's own.

Anything that is not an entity subscribes with bus.on(subscription, callback). The bus mints the subscriber ID, the callback answers nothing, and the call answers with the teardown:

In 0.9:

typescript
const unsubscribe = engine.eventBus.subscribe('level:*', (event) => { console.log(event); });

In 0.10:

typescript
const unsubscribe = engine.bus.on('level:*', (event) => { console.log(event); });

An event is { type, senderID, payload }, plus an optional targetID for one addressed to a single subscriber. A subscription is an exact event type or a prefix:* pattern; a regular expression is not one.

A handler is synchronous. A throwing handler's error propagates out of whichever drain ran it -- the fixed step's or the frame's -- instead of being logged. A game that wants a handler's failure to stay contained catches inside the handler itself.

GameEventBus, GameEngine.eventBus, LibraryEventPayloadMap, WildcardPattern, LevelEvent, and the GameEntityEvent alias are gone. GameEvent is sage-core's own type, and EventBus and EventMap are the one bus and the one map.

send and request become targeted emit and operations ​

0.9's entity.send(id, type, payload) and entity.request(id, type) are gone, along with processRequest. Two mechanisms replace them.

A targeted emit delivers an event to one entity alone. It queues on the bus and lands at the next drain, with no return value:

In 0.9:

typescript
await this.entity.send(doorID, 'action:open', { speed: 2.0 });

In 0.10:

typescript
class DoorSwitchBehavior extends Behavior<object>
{
    readonly defaults = {};

    open() : void
    {
        this.entity.emit('action:open', { speed: 2.0 }, doorID);
    }
}

The payload no longer carries a targetId, and the receiving behavior no longer filters on one. A targeted event reaches its addressee and nobody else.

A request becomes an operation call. Resolve the entity by ID, narrow it with hasBehavior, and call the operation through ops. The call is synchronous and answered immediately:

In 0.9:

typescript
const result = await this.entity.request<boolean>(chestID, 'query:is-locked');
if(result.success && result.value) { /* locked */ }

In 0.10:

typescript
class LeverBehavior extends Behavior<object>
{
    readonly defaults = {};

    tryOpen(chestID : string) : void
    {
        const chest = this.entity.getEntity(chestID);

        if(chest !== undefined && chest.hasBehavior(LockBehavior))
        {
            if(chest.ops.isLocked()) { /* locked */ }
        }
    }
}

The behavior on the other side declares the operation, as LockBehavior does above.

A 0.9 request could fail at runtime for three reasons: no entity, no handler, or a throw inside one. In 0.10, no entity is getEntity answering undefined; no handler is hasBehavior answering false; and a throw is a throw. Between behaviors on one entity, a missing operation is a compile error through Requires. Cross-entity access is read-only and read state and call operations only: an un-narrowed state is object, and hasBehavior narrows it to that behavior's state, deep read-only, so a cross-entity write fails to compile. An entity's own state, the one spawn, getEntity, and getEntitiesOfClass all hand back, is deep read-only too, for the same reason: the one writable view of a slice is the behavior's own this.state.

Relationships are IDs in state. Resolve the live entity at the moment of use and never hold it across fixed steps.

Input ​

BindingManager and engine.subscribeAction are gone. engine.managers.inputManager owns the action registry, the action context registry, device state capture, and the routing loop. A game declares its actions, contexts, and bindings onto it, and every fired action arrives on the simulation bus as a broadcast action:<name> event.

In 0.9:

typescript
const bindingManager = engine.managers.bindingManager;
bindingManager.registerAction({ type: 'digital', name: 'jump' });
bindingManager.registerContext('gameplay', true);
bindingManager.registerBinding({
    type: 'trigger',
    action: 'jump',
    context: 'gameplay',
    input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'Space' },
    options: { edgeMode: 'rising' },
});
bindingManager.activateContext('gameplay');
engine.subscribeAction('jump', (event) => { /* event.payload.value, event.payload.deviceId */ });

In 0.10:

typescript
const inputManager = engine.managers.inputManager;

inputManager.actions.register({ type: 'digital', name: 'jump' });
inputManager.contexts.registerContext('gameplay', true);

const definition : BindingDefinition = {
    kind: 'trigger',
    action: 'jump',
    context: 'gameplay',
    input: { device: { type: 'keyboard', index: 0 }, sourceType: 'key', sourceKey: 'Space' },
    options: { edge: 'rising' },
};

inputManager.bind(definition);

inputManager.contexts.activateContext('gameplay', (binding) =>
{
    return inputManager.deviceState(binding.device.type, binding.device.index);
});

bind(definition) resolves the definition's reader through readerFor and adds the binding to its context in one call, which is how a game binds every definition an imported configuration hands back with no reader code of its own. resolveBinding(definition, action, reader) and readerFor(input) are the two pieces underneath it, for a game that reads a device state shape of its own and hands resolveBinding a reader directly.

The action is answered by a behavior, not a callback:

typescript
interface JumpState
{
    jumpRequested : number;
}

export class JumpBehavior extends Behavior<JumpState>
{
    static readonly events = [ 'action:jump' ] as const;

    readonly defaults : JumpState = { jumpRequested: 0 };

    onEvent(event : GameEvent) : boolean
    {
        if(event.type === 'action:jump')
        {
            this.state.jumpRequested += 1;
            return true;
        }

        return false;
    }
}

What changed in bindings:

0.90.10
BindingDefinition.typeBindingDefinition.kind
options.edgeModeoptions.edge
options.passthroughRemoved. A trigger bound to an analog action passes the raw value.
input.deviceID: 'keyboard-0'input.device: { type: 'keyboard', index: 0 }
input.type: 'keyboard', no sourceTypesourceType: 'key'
sourceKey: 'button-0', 'axis-0'sourceKey: '0'
Mouse position absolute:x, relative:xsourceType: 'position', sourceKey: 'x'. Raw movement is its own source type, movement.
Binding kinds trigger, toggle, valuePlus delta, for one-shot samples such as a wheel notch
bindingManager.registerBinding(definition)inputManager.bind(definition), or resolveBinding(definition, action, reader) then contexts.addBinding(binding) for a custom reader
bindingManager.unregisterBindings(action, context)inputManager.getBindingsForAction(action, deviceType?, context?) then contexts.removeBinding(binding) for each
bindingManager.activateContext(name)contexts.activateContext(name, currentState), where currentState reads inputManager.deviceState for a binding's device
bindingManager.captureInput(options)new InputCapture(options), inputManager.registerCapture(capture), await capture.wait()
bindingManager.exportConfiguration()exportConfiguration(actions.list(), contexts.list(), contextDefinitions)
bindingManager.importConfiguration(config)importConfiguration(config, actions, contexts), which registers actions and contexts and hands back the binding definitions for the game to resolve
options.emitOnChange, options.onValue, options.offValue on a valueRemoved. A value binding's options are scale, offset, invert, deadzone, min, max, and preventDefault; a saved file carrying the old three drops them.
min: -.inf / max: .infDrop them. A clamp that never clamps is not configuration, and 0.10 writes a bound back only when one was declared.
input.optionsRemoved. BindingInput is the device, the source type, and the source key.
A value binding on relative mouse movementA delta binding on sourceType: 'movement'. Raw movement is a per-frame sample, which is what a delta fires on; a value binding holds a level.

A game that persists bindings per player converts the saved 0.9 file on load rather than refusing it: detect the old shape, convert each entry by this table, write the file back once with a backup beside it, and load the result. Every converted definition builds a reader through readerFor, which is the check that the conversion is complete. | inputManager.pollGamepads() | Removed. InputManager polls every connected gamepad once a frame. | | ActionPayload.value, ActionPayload.deviceId | The event payload is the value alone. Device attribution is not carried on the event. | | input:changed, input:device:connected events | Removed. Read inputManager.deviceState(type, index) and inputManager.lastActiveDeviceType. |

The routing loop runs once a rendered frame, before that frame's fixed steps. An action captured this frame reaches onEvent at this frame's drain, and onUpdate acts on what onEvent wrote at the next fixed step.

InputCapture no longer suppresses gameplay bindings while it waits. A rebind retires the old binding with removeBinding before adding the replacement. See the Input System guide.

Saves ​

SaveManager is now SimulationSaveManager, still at engine.managers.saveManager. A save is a versioned envelope around a core snapshot.

In 0.9:

typescript
const data = saveManager.serialize();
await saveManager.deserialize(data);
saveManager.onBeforeSerialize(() => ({ score }));
saveManager.onAfterDeserialize((custom) => { score = custom.score as number; });

In 0.10:

typescript
const envelope = saveManager.save();
const result = await saveManager.load(envelope);
const unsubscribe = saveManager.onBeforeSave(() => ({ score }));
score = result.envelope.custom.score as number;

save() takes no argument: it saves the simulation the engine currently holds, the one a load restores into. onBeforeSave answers with the teardown that removes that one hook, the same pattern onBeforeStart, onStart, and onTeardown follow; two hooks answering with the same field are refused, naming it.

load takes the envelope alone. It transitions to the saved level, restores the snapshot into the simulation that transition built, binds the restored simulation to the level and to engine.simulation, and answers with both the replacement and the envelope it read. There is no onAfterDeserialize hook. Read custom off the envelope yourself.

Entity IDs are preserved across save and load. Nothing remaps them, so an ID a game stored elsewhere still names the same entity afterwards.

A save written under 0.9 does not load under 0.10. The snapshot shape changed, and the format version field is saveFormatVersion rather than version. There is no migration mechanism. See Saves.

Rebuilding a simulation from a snapshot ​

sage-core's own GameSimulation answers buildFromSnapshot, not fromSnapshot. A game that builds its own simulation directly from a snapshot, rather than going through SimulationSaveManager, calls the current name; SimulationSaveManager.load already does.

The rename is the smaller half of the change. buildFromSnapshot answers with a new simulation and leaves the one it was called on running and untouched. Three things follow from that, and the caller handles all three.

The replacement is built detached. Its entities register and emit on its own bus, and nothing outside it hears an event, until attach(). Call it once the replacement is the simulation the game is using:

typescript
const replacement = simulation.buildFromSnapshot(simulation.snapshot());

simulation.detach();
replacement.attach();

The replacement carries the engine bus the original was constructed with, and nothing else. A simulation built with no bus option rebuilds with no bus: attach() on the replacement throws, naming the simulation, and every subscriber a game registered on the old simulation's bus stays registered there, hearing the old simulation and nothing from the new one. Nothing warns about it, since a simulation with no engine bus is a working configuration on its own. Pass the engine bus at construction if the rebuilt simulation has to reach the same subscribers.

Under sage, engine.bindSimulation(replacement) does the swap: it detaches the simulation the engine held and attaches the new one. NodeSimulation's buildFromSnapshot is typed as answering with a NodeSimulation, so the replacement binds without narrowing:

typescript
const replacement = simulation.buildFromSnapshot(simulation.snapshot());

engine.bindSimulation(replacement);

SimulationSaveManager.load does all three already and answers with a NodeSimulation. Call buildFromSnapshot directly only for a rebuild the save manager does not cover, such as freeing a retired ID.

Names ​

0.9 gave an entity a type (the definition it came from) and an optional per-instance name. 0.10 has one name: the registry key the entity was spawned or restored under, stamped once, readonly, and the same for every instance of the class.

A per-instance label is a state field. NamedBehavior, provided by sage-core, declares one called displayName:

typescript
const behaviors = [ NamedBehavior ] as const;

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

const enemy = simulation.spawn('EnemyEntity', { displayName: 'Grunt 7' });

A level's spawn and entity definitions write displayName directly; there is no name field on either to write through. See Scene-node adoption and markers.

The 0.9 lookups map as follows:

0.90.10
entityManager.getEntity(id)simulation.getEntity(id)
entityManager.getByName(name), getEntitiesByName(name)Filter simulation.entities on state.displayName
entityManager.getByType(type)Filter simulation.entities with instanceof, or on entity.name
entityManager.getByTag(tag)simulation.getEntitiesByTag(tag)
entityManager.getByTags(tags, mode)Filter simulation.entities on state.tags
entityManager.getByNode(node)Entity picking. See Picking.
entityManager.getAllEntities(), entityCountsimulation.entities
entityManager.addTag, removeTag, entity.hasTagtags is a state field. Write it through the entity's own behaviors.

Meshes ​

0.9 built a mesh at the spawn call site and bound it with attachToNode. A 0.10 NodeEntity always has a TransformNode, created with it in the level's scene, and loads its mesh through one mesh source keyed by the entity's name and read from its state.

Either write a mesh source and hand it to rebuildSimulation:

typescript
import { MeshBuilder } from '@babylonjs/core';

export const loadMesh : MeshLoader = async (scene, name, state) =>
{
    if(name === 'PlayerEntity')
    {
        return MeshBuilder.CreateBox('player', { size: 1 }, scene);
    }

    return undefined;
};

Or declare meshConfig on the class and use the standard mesh source:

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

export class CrateEntity extends NodeEntity
{
    static override readonly meshConfig = { source: 'box', params: { size: 1 } };
}

const meshSource = buildStandardMeshSource(entityClasses, engine.managers.assetManager);

EntityMeshLoader is gone, merged into MeshLoader, which now takes the scene, the entity's name, and its state, in that order: (scene, name, state). NodeSimulationContext.loadMesh has that signature, and createGameEngine's registry is a NodeEntityRegistry.

A mesh loads asynchronously. entity.mesh is undefined until it arrives. state.position and state.rotation are computed from the node rather than written onto it, so the game loop no longer syncs mesh positions from state.

A level config carries the mesh source, or its entities have no mesh ​

A LevelConfig carries meshSource, a MeshLoader. The level hands it to rebuildSimulation, and every NodeEntity that level spawns loads its mesh through it. A config carrying no meshSource builds its simulation without one, and then every entity that level spawns gets a TransformNode and no mesh. Nothing warns: the level loads, simulation.entities counts every spawn, and nothing is drawn.

A config is data and a mesh source is a function, so a game whose levels are YAML attaches the function in code, after the YAML is parsed and before registerLevelConfig:

typescript
const meshSource = buildStandardMeshSource(entityClasses, assetManager);

for(const config of parseLevelYaml())
{
    levelManager.registerLevelConfig({ ...config, meshSource });
}

Level Loading shows a loader that does this for a directory of YAML files.

A glb path in source is no longer a whole file to import ​

0.9 read an entity definition's mesh.source as one of four primitive names, box, sphere, capsule, or cylinder, and took every other string as a path to a file it imported whole, parenting each mesh it found under the entity's node. That rule is gone.

buildStandardMeshSource reads meshConfig.source two ways and no others:

sourceWhat the standard source builds
A name Babylon's MeshBuilder has a Create* for: box, sphere, capsule, cylinder, ground, torusKnot, and the restThat primitive, with params passed through as its options
A glb path with a fragment naming one mesh inside it, models/props.glb#crateThat one mesh, copied per spawn into the level's scene out of the file's reference-counted container

A source with no # in it that is not a builder name throws out of the mesh source:

No Babylon mesh builder provides the primitive shape "models/crate.glb".

A mesh load that fails does not fail the spawn. The error is logged, naming the entity and carrying what the source raised, the entity keeps its node and carries no mesh, and onMeshLoaded runs with the mesh unset, the same as it does for a source that answers with nothing. See NodeEntity hooks.

A 0.9 definition that named a file ports one of two ways.

One mesh out of the file. Name it after a #, and the standard source handles the rest. The copy is built into the scene the source was handed, which is the level's, so a spawned entity's mesh draws:

typescript
export class CrateEntity extends NodeEntity
{
    static override readonly meshConfig : MeshConfig = { source: 'models/props.glb#crate' };
}

The copy is that one mesh by itself, carrying none of the glb's authored properties, which the level walk reads off the level's own scene and never off a spawned mesh. A mesh parented under it inside the glb does not come with it, because Babylon cannot copy a hierarchy between scenes, and a NodeEntity holds one mesh either way. A model made of several meshes needs the other port.

The whole file. Write a mesh source of your own that imports into the scene it is handed. Naming the root as a fragment, models/room.glb#__root__, is not a shortcut for this: the node the glTF loader parents an import under has no geometry of its own, so the copy is an empty node rather than the model.

typescript
export function buildFileMeshSource(sceneEngine : SceneEngine, files : Record<string, string>) : MeshLoader
{
    return async (scene, name) =>
    {
        const path = files[name];

        if(path === undefined)
        {
            return undefined;
        }

        const result = await sceneEngine.importMeshes([], path, scene);

        // The glTF loader parents everything it imports under one `__root__` mesh.
        return result.meshes.find((mesh) => mesh.name === '__root__');
    };
}

importMeshes imports into the scene it is given, children and all, so what this source answers with is a root that already has the model under it in the level's scene. Naming meshes in the first argument imports only those, under the same root.

The standard mesh source shows how to compose a source like this with the standard one, so primitives and files are covered by a single source.

Pooling ​

0.9 pooled entities and reset them through onReset. 0.10 pools only the Babylon node and mesh set. Every spawn constructs a fresh entity and fresh behaviors.

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

export class BulletEntity extends NodeEntity
{
    static override readonly poolable = true;
    static override readonly poolSize = 32;
}

poolSize is a cap on how many sets the pool holds for this class, not a prewarm count. The default cap is poolSizeLimit.

0.90.10
poolable: true in the definitionstatic poolable = true on the class
poolSize: 50 (prewarm count)static poolSize = 50 (cap). Prewarm with simulation.prewarm(name, count, state).
entityManager.prewarm(type, count)simulation.prewarm(name, count)
entityManager.drainPool(type)simulation.pool.clear(name) or simulation.pool.clearAll()
onReset(state)Removed

A level transition builds a fresh pool for the new scene. Clearing by hand is for memory pressure inside one level.

Picking ​

engine.raycast is gone. Picking is NodeSimulation's:

In 0.9:

typescript
const result = engine.raycast.pickEntityForward(scene, camera, 50, { type: 'enemy' });

In 0.10:

typescript
const result = simulation.pickEntityForward(camera, 50, { tags: [ 'enemy' ] });

if(result !== undefined)
{
    console.log(result.entity.name, result.point, result.distance);
}

pickEntity(x, y, filter), pickEntityWithRay(ray, filter), and pickEntityForward(camera, range, filter) each answer with the nearest entity or undefined. The filter carries tags only; type and predicate are gone. The result is entity, mesh, point, distance, and normal. There is no multi-result pick.

Scene-node adoption and markers ​

An entity marker in a Blender scene now adopts the marked node. The entity takes the authored TransformNode as its own instead of building one, and its state records the node's name in nodeName. The level writes nodeName; a game never does.

The level config's entities section names the class to spawn:

yaml
# Before (0.9)
entities:
    door:
        config:
            locked: true

# After (0.10)
entities:
    door:
        entity: DoorEntity
        displayName: Vault Door
        tags: [ interactive ]
        config:
            locked: true

SpawnDefinition and EntityDefinition are one type now, carrying entity (the registry name), an optional displayName, tags, config, and debugCollider. A spawn point's scale is not carried into state.

An adopted entity's mesh stays undefined. Reach what the node carries through node. Its position and rotation are computed from the node, which is what they are for every entity, and construction writes no transform over the one authoring gave.

The hierarchy ​

addChild, removeChild, entity.parent, and entity.children are gone. A child names its parent in its own state:

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

Node parenting follows parentID, so Babylon composes world transforms. There is no update cascade. The simulation updates every entity in insertion order, and a parent that needs its children updated in a particular order overrides update().

StateMachineBehavior ​

StateMachineBehavior.create(config) is gone. StateMachineBehavior is an abstract class in sage-core. A subclass declares its states through defineStates and its one state field, currentState:

typescript
import { StateMachineBehavior, defineStates } from '@skewedaspect/sage-core';

interface EnemyAIState
{
    currentState : 'idle' | 'chase' | 'attack';
}

export class EnemyAIBehavior extends StateMachineBehavior<EnemyAIState>
{
    readonly defaults : EnemyAIState = { currentState: 'idle' };

    readonly states = defineStates(this, {
        idle: {},
        chase: {
            enter() { /* this.state, this.ops, this.entity are all typed here */ },
            update(dt) { /* runs once per fixed step while chasing; dt is typed here too */ },
        },
        attack: {
            exit(to) { /* to is the state being transitioned to */ },
        },
    });
}

transitionTo(name) is its one operation, reachable as entity.ops.transitionTo('chase'). There is no transition table, no guards, and no emitted event. A state's enter never runs for the state an entity is created or restored into.

SoundBehavior ​

SoundBehavior moved onto the new behavior model. Its state is a sounds map, it builds every sound in onMeshLoaded, and it exposes two operations, play(name) and stop(name):

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

const torch = simulation.spawn('TorchEntity', {
    sounds: {
        crackle: { url: 'audio/fire.ogg', loop: true, spatial: true, channel: 'ambient' },
    },
});

if(torch.hasBehavior(SoundBehavior))
{
    torch.ops.stop('crackle');
}

A loop sound starts as soon as it is built. autoplay, pause, isPlaying, setVolume, registerSound, unregisterSound, getSoundNames, and hasSound are gone. play and stop both throw, naming the entity, on an entity carrying no AudioManager.

AudioManager volume and mute ​

masterVolume, masterMuted, and channels are accessors now, in place of getter and setter methods:

typescript
audioManager.masterVolume = 0.5;
audioManager.masterMuted = true;
console.log(audioManager.channels);
0.90.10
getMasterVolume() / setMasterVolume(v)masterVolume
isMasterMuted() / setMasterMuted(v)masterMuted
getChannels()channels

The four channel-keyed methods -- getChannelVolume, setChannelVolume, isChannelMuted, setChannelMuted -- keep their names. ChannelInfo is gone; nothing ever returned it.

A channel name that is not one of the game's declared channels used to warn and fall back: createSound took the default bus, and the volume and mute methods answered 1 or false. createSound, getChannelVolume, setChannelVolume, isChannelMuted, and setChannelMuted all throw now, naming the channel:

typescript
// Throws: AudioManager: no channel named "voice" is declared.
audioManager.setChannelVolume('voice', 0.5);

The scene engine no longer touches the canvas ​

SceneEngine's constructor no longer takes a canvas, and createFreeCamera is gone. The scene engine builds lights and meshes; a camera is the game's to construct and aim, and which camera takes control of the canvas is the level's decision.

In 0.9, createFreeCamera built the camera and attached control to the engine's own canvas automatically:

typescript
const camera = sceneEngine.createFreeCamera('main', new Vector3(0, 2, -10), scene);

In 0.10, the level builds the camera and attaches control itself:

typescript
import { FreeCamera, Vector3 } from '@babylonjs/core';

const camera = new FreeCamera('main', new Vector3(0, 2, -10), scene);
camera.setTarget(Vector3.Zero());
camera.attachControl(true);

Physics ​

0.9 had no provided physics behavior. A game either wrote its own body-building code against Babylon's PhysicsAggregate, or called SceneEngine.addPhysics for a quick one. addPhysics is gone:

In 0.9:

typescript
await sceneEngine.enablePhysics(scene);
sceneEngine.addPhysics(ground, PhysicsShapeType.BOX, { mass: 0 }, scene);

In 0.10, call Babylon's own constructor directly, once enablePhysics has resolved:

typescript
import { PhysicsAggregate, PhysicsShapeType } from '@babylonjs/core';

await sceneEngine.enablePhysics(scene);
new PhysicsAggregate(ground, PhysicsShapeType.BOX, { mass: 0 }, scene);

Or, for an entity rather than level geometry, HavokPhysicsBodyBehavior builds and owns the body: six shapes, three motion types, and applyImpulse as an operation.

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

const crate = simulation.spawn('CrateEntity', { shape: 'box', mass: 4 });

if(crate.hasBehavior(HavokPhysicsBodyBehavior))
{
    crate.ops.applyImpulse({ x: 0, y: 5, z: 0 });
}

Every field but shape and parts is a getter and a setter over the body itself once there is one: reading state.velocity asks the body, and writing it tells the body to move. restitution defaults to 0, matching Havok's own material default; addPhysics defaulted to 0.75, and a body ported to PhysicsAggregate directly, or onto the behavior, keeps its old bounce only by passing restitution explicitly.

An entity model's authored collider builds nothing ​

0.9 ran the property handlers over an entity's imported model, so a collider: box authored on a crate's mesh gave every spawned crate a body. 0.10 never reads a spawned mesh's authored properties: they are the level walk's to read off the level's own scene, and a mesh source's copy carries none of them. A model that relied on that has no body at all in 0.10, and nothing warns, because the mesh loads and draws as before. The symptom is a prop that nothing collides with and that a placement ray passes through.

Give the entity HavokPhysicsBodyBehavior instead. Its box is measured from everything under the node, so a model with a lid and a body under one root gets one box around both:

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

export const CrateEntity = defineEntity({
    type: 'CrateEntity',
    mesh: { source: 'models/crate.glb#Crate' },
    defaultState: { mass: 20 },
    behaviors: [ HavokPhysicsBodyBehavior ] as const,
});

The collider extras on the model can stay or go; nothing reads them once the model is an entity's. A node the level authored with an entity marker is different: its body is the level's, built in the walk, and the adopting entity keeps it.

The authored collider property also changed:

0.90.10
collider_kinematic : booleanRemoved. collider_motion : 'static' | 'dynamic' | 'kinematic' names the motion directly, the same word HavokPhysicsBodyBehavior's own motion field uses.
collider_mass decided both mass and whether the body was static or dynamiccollider_mass sets the mass alone, defaulting to 1. A node with no collider_motion is static regardless of its mass.
collider : box | sphere | meshbox, sphere, and mesh keep their meanings, and none joins them as an explicit way to say a node has no collider. Anything else warns, naming the node and the four words the property takes, and builds nothing. mesh is stricter than it was; see below.

A mesh collider is built from triangles, so 0.10 needs a node that has some. 0.9 checked whether the node was a Babylon Mesh, logged Skipping mesh collider for Door: not a Mesh instance, and carried on. 0.10 throws instead, for a node that has no vertices of its own and no child marked collider_mesh:

Cannot build a "mesh" collider on "Door": a triangle mesh reads vertices, and this node has none of its own.

The level catches what a property handler throws, logs it as an error against that handler and node, and finishes loading. The node still gets no body, the same as in 0.9. What changed is that a level carrying such a node now logs an error every time it loads, where 0.9 logged a warning. Each one is an authoring mistake to fix at the node:

  • Give the node geometry of its own, so its own triangles are the shape.
  • Give it a child carrying collider_mesh: true. The body is built from that child's triangles and the child is hidden, which is how a detailed model ships a low-triangle stand-in to collide against.
  • Set collider: none on the node if it was never meant to collide, or drop the property.

Collider debug visualization ​

ColliderDebugConfig, the type showCollider, hideCollider, and a spawned entity's debugCollider field all take, is now a union: boolean | string | { color } | { nodes }. A string is always a node name.

In 0.9:

typescript
entity.showCollider('#ff0000');

In 0.10:

typescript
entity.showCollider({ color: '#ff0000' });

Color every collider under the node with { color }, or color select ones by name with { nodes: { head: '#ff0000', shield: false } }. See Collider Debugging.

Levels and property handlers ​

LevelContext names every dependency a level has, by field: the bus, the scene engine, the asset manager, the audio manager where there is one, rebuildSimulation for the level's own scene, the canvas, whether large world rendering is on, the property handler map, and the logging utility. There is no engine in it, and a Level subclass reads it through the protected this.context rather than this.gameEngine. See the DemoLevel example under The entity registry.

A property handler's signature changed to match: it took the whole GameEngine as its fourth argument, and now takes a HandlerContext naming the three things a handler is handed -- the bus, the audio manager where there is one, and the logger it warns through.

In 0.9:

typescript
levelManager.registerPropertyHandler('sound_ambient', (node, value, level, gameEngine) =>
{
    gameEngine.managers.audioManager?.createSound(node.name, value as string);
});

In 0.10:

typescript
levelManager.registerPropertyHandler('sound_ambient', (node, value, level, context) =>
{
    context.audioManager?.createSound(node.name, value as string);
});

registerAllPropertyHandlers and the six functions it calls each take what registers a handler and nothing more, Pick<LevelManager, 'registerPropertyHandler'>, so a level manager carrying any entity registry satisfies them. A game writing its own handler-registering function does the same:

typescript
function registerGlowHandler(levelManager : Pick<LevelManager, 'registerPropertyHandler'>) : void
{
    levelManager.registerPropertyHandler('glow', (node, value) =>
    {
        console.log(node.name, value);
    });
}

A failed transition leaves a definite current level ​

Two failure points come after the level being left is already gone: a restart's reload, which throws after the rebuild has disposed the instance it is replacing, and a transition without keepAlive whose new level throws from onActivate, after the old level has already been unloaded. Past either point, the catch used to put the disposed level back as currentLevel anyway. It now leaves currentLevel null and the engine unbound instead, restoring the outgoing level as current only while it is still loaded.

Code that reads levelManager.currentLevel right after a failed transition() call handles null, not a level that looks current but is already disposed:

typescript
try
{
    await engine.managers.levelManager.transition('nextLevel');
}
catch (error)
{
    const current = engine.managers.levelManager.currentLevel;

    if(current === null)
    {
        console.error('No level is current after the failed transition.', error);
    }
}

occlusion and visible ​

0.9's occluder property took a boolean: true hid the mesh and flagged it for a Babylon occluder feature this version of Babylon does not have. occlusion replaces it with Babylon's own occlusion query types, optimistic or strict, set through the mesh's occlusionType. Neither value hides the mesh. Occlusion decides whether a later frame's query skips drawing it, not whether it draws now.

yaml
# Before (0.9)
occluder: true

# After (0.10)
occlusion: optimistic
0.90.10
occluder : booleanocclusion : 'optimistic' | 'strict'
true hid the mesh and marked it an occluderNeither value hides the mesh
registerOccluderHandlerregisterOcclusionHandler

A value that is neither optimistic nor strict warns and leaves the mesh's occlusion type alone.

visible still takes a boolean, the strings true/false, the string 1, or a number that is false only at zero, but a string outside that list now leaves the mesh visible instead of hiding it. 'yes' and '0' used to hide the mesh; neither does now.

Teardown ​

Every manager, engine, level, and the debug console tear down through dispose(). $teardown and $dispose are gone.

In 0.9:

typescript
await audioManager.$teardown();
await level.$dispose();

In 0.10:

typescript
await audioManager.dispose();
await level.dispose();

GameEngine.stop() tears the engine down in one order: it stops the frame loop first, runs the onTeardown hooks, disposes the debug console and the collider debug manager, then the managers, then the engines. A failure in one disposal does not stop the rest; the first one raised is raised again once everything else has disposed. An engine that has stopped does not start again.

GameManager.timeScale is a multiplier on the elapsed time a frame hands the simulation, and every frame callback that answers to pause, 1 by default; a scale that is not a finite number of zero or more throws. The debug console's slow(factor) sets it -- in 0.9 it wrote a timeStep Babylon never read, so slowing the game from the console did nothing; in 0.10 it works.

The debug console's logEvents(pattern) now requires a pattern. Calling it with none, which used to log every event on the bus, throws instead, naming that it takes an event type or a pattern such as "level:*".

The Vue integration ​

SageCanvas takes :entity-classes in place of :entity-definitions. useSageState is gone: a component reads an entity through useSageEntity(entityClass, id), which answers with a Ref holding a GameEntityView -- the entity's ID, whether it is present, its state read-only and reactive, and its operations -- and useSageEntities(tag, behavior?) does the same for every entity carrying a tag, whatever its class, narrowed to one behavior's state and operations when a behavior is named.

SageCanvas is generic in the entity registry the same way createGameEngine is: the engine it announces through engine-ready, and an engine prop a game built for itself and handed in, are both typed by the entityClasses it was given. useSageEngine() answers with a Ref typed as ObservedEngine, not GameEngine: the bus, the timer, the bound simulation's entity lookups, and the game manager's frame callbacks, the fields a UI reads, none of which needs Babylon. An engine typed by any entity registry is one of those, so a canvas handed a game's own typed engine still accepts it; a component that wants that engine's typed spawn holds the engine the game built rather than asking the canvas for it.

useSageEvent(subscription, handler) subscribes on the engine bus with an event type or a prefix:* pattern, for as long as the component is mounted, and answers with the teardown. useSageAction(name) is built on it: it reads the action's value straight off the payload of the action:<name> event, and answers with a Ref holding the last value delivered, 0 until the first one arrives. useSageEngine() answers with the engine alone, in the Ref the canvas holds it in; the bus a UI reaches through useSageEvent is that engine's own, since an engine has one bus. See Vue 3 Integration.

Removed ​

  • GameEntityBehavior, GameEntityDefinition in its 0.9 shape, EntityManager and every method on it.
  • entity.send, entity.request, processRequest, RequestResult.
  • $emit, $emitStateChanged, and the entity:state-changed event. Nothing in 0.10 emits it.
  • attachToNode, detachFromNode, getByNode, getChildNode, findNode.
  • attachBehavior, detachBehavior, getBehavior. A composition is fixed at the class.
  • Definition inheritance (extends) and declared children.
  • BindingManager, engine.subscribeAction, ActionPayload, inputManager.pollGamepads, and the input:* library events.
  • SaveManager in its 0.9 shape, SaveData, SerializedEntity, onAfterDeserialize, and ID remapping on load.
  • createEntityProxy, and the debug console's name-lookup sage.entities proxy, showColliders, and hideColliders. sage.entities now indexes by ID, list takes a registry name and a tag, and showCollider and hideCollider are methods on NodeEntity. See Debug Console.
  • engine.raycast and its multi-result picks.
  • SceneEngine.createFreeCamera. A game constructs and aims its own camera.
  • StateMachineBehavior.create and its transition tables.
  • The 0.9 SoundBehavior API beyond play and stop.
  • 0.9 entity pooling and onReset.
  • entity.highlight and entity.unhighlight. OutlineManager.highlightEntity(layer, entity) still takes a NodeEntity.
  • GameEventBus, GameEngine.eventBus, LibraryEventPayloadMap, WildcardPattern, LevelEvent, and the GameEntityEvent alias. A subscription is an event type or a prefix:* pattern; a regular expression is not one.
  • SceneEngine.addPhysics. Call Babylon's PhysicsAggregate constructor directly, once enablePhysics has resolved.
  • collider_kinematic. collider_motion: 'kinematic' replaces it.
  • $teardown and $dispose. dispose() is the one teardown word, everywhere.
  • EntityMeshLoader, merged into MeshLoader.
  • AudioManager's getMasterVolume/setMasterVolume, isMasterMuted/setMasterMuted, and getChannels(), replaced by the masterVolume, masterMuted, and channels accessors. ChannelInfo, which nothing ever returned.
  • GameSimulation.fromSnapshot, renamed buildFromSnapshot.

The removed names not called out above ​

The bullets above cover the APIs a 0.9 game called. The table below covers the rest of the removed exports, mostly types, for checking an import list that no longer resolves. The rows run in four groups: input, entities and behaviors, events, and the rest.

RemovedReplaced by
BaseInputStateNothing. KeyboardState, MouseState, and GamepadState share no base type.
KeyboardInputStateKeyboardState
MouseInputStateMouseState
GamepadInputStateGamepadState
InputStateThe three device state types. InputManager.deviceState(type, index) answers their union.
ButtonStateNothing. A button reads as a number, like every other gamepad source.
InputDeviceDeviceID, a DeviceType and an index.
KeyboardDevice, MouseDevice, GamepadDeviceNothing. A device is a DeviceID.
validDeviceTypesNothing. DeviceType names the three.
isKeyboardState, isMouseState, isGamepadStateNothing. deviceState answers the union, so nothing branches on the device type to ask.
DeviceValueReaderDeviceReader<State>
DeviceValueReaderDefinition, DeviceValueReaderDefinitionBaseBindingInput. readerFor(input) builds the reader from it.
KeyboardValueReaderDefinition, MouseValueReaderDefinition, GamepadValueReaderDefinitionBindingInput, whose device, sourceType, and sourceKey say the same thing for every device.
InputDefinitionBindingInput
BindingTypeBindingKind
bindingTypesNothing. BindingKind names the four.
BindingDefinitionBaseBindingDefinition
ToggleBindingDefinition, TriggerBindingDefinition, ValueBindingDefinitionBindingDefinition, whose kind says which.
CaptureInputOptionsCaptureOptions
ContextContextDefinition
InputChangedPayloadNothing.
DeviceConnectedPayload, DeviceDisconnectedPayload, DeviceTypeChangedPayloadDeviceChangedPayload, carried by input:deviceChanged.
ActionEvent, LibraryActionEvent, InputEvent, LibraryInputEventNothing. An action arrives as an action:<name> event a game declares in EventMap. See Input.
GameEntityBehaviorConstructorBehaviorClass
AttachBehaviorOptionsNothing. A composition is fixed at the class.
CreateEntityOptionsNothing. spawn(name, state?, id?) takes the state.
CreateEntityMeshOptionsMeshConfig. See Authoring.
EntityStateChangedPayloadNothing. Nothing publishes entity:state-changed.
SoundEntityStateSoundState
PositionVector3State
generateIdNothing. A simulation mints <name>-<n> from a counter it owns.
StateMachineNothing. StateMachineBehavior is the state machine.
StateMachineBehaviorConfig, TransitionConfigStateDefinition, from @skewedaspect/sage-core, written through defineStates.
WildcardTransitionConfigNothing. A state table has no wildcard entry.
GameEventCallback, WildcardEventCallbackEventCallback
LibraryEventTypekeyof EventMap
LibraryLevelEventNothing. The level:* entries are in EventMap like a game's own.
DestroyableNothing. GameEntity.destroy() is the base class's own method; Disposable covers component teardown, and its one member is dispose().
RaycastHelperNodeSimulation.pickEntity, pickEntityWithRay, and pickEntityForward. See Picking.
collectPrefixedPropertiesNothing. It is the package's own.

Still here ​

GameEngine, createGameEngine, LevelManager, GameLevel, Level, the YAML level config, property handlers, AssetManager, AudioManager, GameTimer, DebugConsole, ColliderDebugManager, and OutlineManager are unchanged apart from the fields called out above. Level, asset, trigger, and game events travel on the one engine bus now, not a separate GameEventBus.

Migrating from 0.8 to 0.9 ​

SAGE 0.9.0 includes several breaking changes from the 0.8.x series. This guide walks through each change with before/after code examples.

1. Update Dependencies ​

SAGE now requires BabylonJS 9.0 and @babylonjs/loaders as a peer dependency:

bash
npm install @babylonjs/core@^9.0.0 @babylonjs/havok@^1.3.10 @babylonjs/loaders@^9.0.0

If you had import '@babylonjs/loaders/glTF' in your own code, remove it. SAGE now registers all loaders automatically via registerBuiltInLoaders().

2. Rename SkewedAspectGameEngine to GameEngine ​

typescript
// Before
import { SkewedAspectGameEngine } from '@skewedaspect/sage';
typescript
// After
import { GameEngine } from '@skewedaspect/sage';

3. Update Event Bus Usage ​

TypedEventBus was merged into GameEventBus:

typescript
// Before
import { TypedEventBus } from '@skewedaspect/sage';
const bus = new TypedEventBus<MyEvents>();

// After (0.9.x)
import { GameEventBus } from '@skewedaspect/sage';
const bus = new GameEventBus<MyEvents>();

4. Update Behavior Definitions ​

Behaviors switched to array-based registration with constructor identification:

typescript
// Before (0.8.x)
const entity = entityManager.createEntity('player', {
    behaviors: {
        movement: new MovementBehavior(),
        combat: new CombatBehavior(),
    },
});

// After (0.9.x)
const entity = entityManager.createEntity('player', {
    behaviors: [ MovementBehavior, CombatBehavior ],
});

5. Update LevelContext Access ​

Level subclasses now access services through this.gameEngine instead of individual injected references:

typescript
// Before
class MyLevel extends Level
{
    async buildScene()
    {
        const scene = this.sceneEngine.createScene();
        await this.entityManager.createEntity('player', {});
    }
}

// After
class MyLevel extends Level
{
    async buildScene()
    {
        const scene = this.gameEngine.engines.sceneEngine.createScene();
        await this.gameEngine.managers.entityManager.createEntity('player', {});
    }
}

6. Update onNodeAttached Signature ​

The behavior lifecycle hook now receives gameEngine as a second parameter:

typescript
// Before
class MyBehavior
{
    onNodeAttached(node : TransformNode) : void
    {
        const scene = node.getScene();
    }
}

// After
class MyBehavior
{
    onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void
    {
        const scene = node.getScene();
        const physics = gameEngine.physics;
    }
}

7. Rename ChannelState to ChannelInfo ​

typescript
// Before
import type { ChannelState } from '@skewedaspect/sage';

// After
import type { ChannelInfo } from '@skewedaspect/sage';

8. Update getBindingsForAction Calls ​

The parameter order changed to accommodate device type filtering:

typescript
// Before
const bindings = bindingManager.getBindingsForAction('jump', 'gameplay');

// After — insert undefined for deviceType
const bindings = bindingManager.getBindingsForAction('jump', undefined, 'gameplay');

// Or filter by device type
const kbBindings = bindingManager.getBindingsForAction('jump', 'keyboard', 'gameplay');

9. Replace SceneLoader Usage ​

If you used SceneLoader.ImportMeshAsync directly, switch to the module-level function:

typescript
// Before
import { SceneLoader } from '@babylonjs/core';
const result = await SceneLoader.ImportMeshAsync('', '/assets/', 'model.glb', scene);
typescript
// After
import { ImportMeshAsync } from '@babylonjs/core';
const result = await ImportMeshAsync('/assets/model.glb', scene);

Released under the MIT License.