Skip to content

Migration Guide

Migrating from 0.9.x to 0.10.x

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 surface onto its 0.10 replacement.

Install both packages

bash
npm install @skewedaspect/sage @skewedaspect/sage-core @babylonjs/core @babylonjs/havok @babylonjs/loaders

@skewedaspect/sage re-exports Behavior, GameEntity, NodeEntity, and the composition types, so most game code imports from @skewedaspect/sage alone. Two things need @skewedaspect/sage-core directly: the event map augmentation (a declare module merges only into the module that declares the interface) and the provided behaviors NamedBehavior and StateMachineBehavior.

The entity class table replaces entity definitions

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

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

// After (0.10)
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', undefined, { displayName: 'Hero' });

spawn is synchronous. It 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 engine.rebuildSimulation(scene, meshSource) for you. A custom Level subclass calls it itself, once its scene exists:

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

Entity classes

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

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

// After (0.10), hand-written
import { NodeEntity } from '@skewedaspect/sage';
import type { OpsFor, StateFor } from '@skewedaspect/sage';

const behaviors = [ ShieldBehavior, HealthBehavior ] as const;

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

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

// After (0.10), from a definition
import { defineEntity } from '@skewedaspect/sage';

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

The two declare readonly lines are the narrowing pattern. NodeEntity itself types state and ops as object. Re-declaring them with StateFor and OpsFor over the class's own tuple means instanceof PlayerEntity narrows state and ops everywhere downstream, and a call site never needs a ComposedOf<typeof PlayerEntity> cast.

The definition's shape changed:

0.9 field0.10
typetype, still the class-table 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 Debugging.

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.

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.

typescript
// Before (0.9)
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;
    }
}

// After (0.10)
import { Behavior } from '@skewedaspect/sage';
import type { GameEntityEvent as GameEvent } from '@skewedaspect/sage';

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.

A NodeEntity's node is not assigned until after onCreate returns. Reading this.entity.node or this.entity.mesh from onCreate throws. onMeshLoaded is the first hook where both are safe to read.

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' : { entityId : string };
        'action:moveForward' : number;
        'action:jump' : boolean;
    }
}

export {};

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

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:

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

// After (0.10)
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:

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

// After (0.10)
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:

typescript
export class LockBehavior extends Behavior<LockState>
{
    static readonly ops = [ 'isLocked', 'unlock' ] as const;

    readonly defaults : LockState = { locked: true, codes: [ '1234' ] };

    isLocked() : boolean
    {
        return this.state.locked;
    }

    unlock(code : string) : boolean
    {
        if(!this.state.codes.includes(code)) { return false; }

        this.state.locked = false;
        return true;
    }
}

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. State reached through hasBehavior is deep read-only, so a cross-entity write fails to compile.

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.

typescript
// Before (0.9)
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 */ });

// After (0.10)
import { KeyboardReader, resolveBinding } from '@skewedaspect/sage';
import type { BindingDefinition } from '@skewedaspect/sage';

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' },
};

const action = inputManager.actions.get('jump');

if(action !== undefined)
{
    inputManager.contexts.addBinding(resolveBinding(definition, action, new KeyboardReader('Space')));
}

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

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 the binding surface:

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'. There is no relative-movement source.
Binding kinds trigger, toggle, valuePlus delta, for one-shot samples such as a wheel notch
bindingManager.registerBinding(definition)resolveBinding(definition, action, reader) then contexts.addBinding(binding)
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
inputManager.pollGamepads()Removed. InputManager polls every connected gamepad once a frame.
ActionPayload.value, ActionPayload.deviceIdThe event payload is the value alone. Device attribution is not carried on the event.
input:changed, input:device:connected eventsRemoved. 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.

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

// After (0.10)
const envelope = saveManager.save(simulation);
const result = await saveManager.load(envelope, simulation);
saveManager.onBeforeSave(() => ({ score }));
score = result.envelope.custom.score as number;

load transitions to the saved level, builds a replacement simulation from the envelope's snapshot, 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.

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 class-table 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
import { NamedBehavior } from '@skewedaspect/sage-core';

const behaviors = [ HealthBehavior, NamedBehavior ] as const;

export class EnemyEntity extends NodeEntity
{
    static override readonly behaviors = behaviors;

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

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

A level's SpawnDefinition.name and EntityDefinition.name write displayName, never name.

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.

Either write a mesh source and hand it to rebuildSimulation:

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

export const loadMesh : EntityMeshLoader = async (scene, name) =>
{
    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 { buildStandardMeshSource } from '@skewedaspect/sage';

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

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

A mesh loads asynchronously. entity.mesh is undefined until it arrives. NodeEntity.update writes state.position and state.rotation onto the node every fixed step, so the game loop no longer syncs mesh positions from state.

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
export class BulletEntity extends NodeEntity
{
    static override readonly poolable = true;
    static override readonly poolSize = 32;
    static override readonly behaviors = behaviors;
}

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:

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

// After (0.10)
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
        name: Vault Door
        tags: [ interactive ]
        config:
            locked: true

SpawnDefinition and EntityDefinition both carry entity (the class-table name), an optional name (written to displayName), tags, and config. 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 update does not write state.position or state.rotation onto the node, so a behavior that moves an adopted prop moves the node directly.

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', undefined, { 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 */ },
        },
        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', undefined, {
    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.

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 the entity's state read-only and reactive alongside its operations, and useSageEntities(entityClass, tag) does the same for a tag. useSageAction listens on the engine's library bus, which actions no longer travel on, and has not been rebuilt. 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 class-table name and a tag, and showCollider and hideCollider are methods on NodeEntity. See Debug Console.
  • engine.raycast and its multi-result picks.
  • StateMachineBehavior.create and its transition tables.
  • The 0.9 SoundBehavior surface beyond play and stop.
  • 0.9 entity pooling and onReset.
  • entity.highlight and entity.unhighlight. OutlineManager.highlightEntity(layer, entity) still takes a NodeEntity.

Still here

GameEngine, createGameEngine, LevelManager, GameLevel, Level, the YAML level config, property handlers, AssetManager, AudioManager, GameTimer, DebugConsole, ColliderDebugManager, OutlineManager, and the engine's own GameEventBus at engine.eventBus for level, asset, trigger, and game events are unchanged apart from the entity-related fields called out above.

Migrating from 0.8.x to 0.9.x

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';

// 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
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
onNodeAttached(node : TransformNode) : void
{
    const scene = node.getScene();
}

// After
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);

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

Released under the MIT License.