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
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.
// 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:
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.
// 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 field | 0.10 |
|---|---|
type | type, still the class-table key |
name | Removed. A per-instance label is NamedBehavior's displayName state field. See Names. |
tags | tags |
defaultState | defaultState, now optional. Each behavior declares its own defaults. |
behaviors | behaviors, an as const tuple. The compiler checks the composition. |
actions | actions, carried as a static on the class |
mesh | mesh, a MeshConfig. See Meshes. |
poolable, poolSize | Statics on the class. See Pooling. |
extends | Removed. Compose behaviors instead. |
children | Removed. A child sets parentID in its own state. See The hierarchy. |
onBeforeCreate, onCreate, onBeforeDestroy, onDestroy | Removed. Behaviors declare onCreate and, on a NodeEntity, onDestroy. |
debugCollider | A 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.
// 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.9 | 0.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 outside | static 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:
// 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:
// 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:
// 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:
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.
// 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:
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.9 | 0.10 |
|---|---|
BindingDefinition.type | BindingDefinition.kind |
options.edgeMode | options.edge |
options.passthrough | Removed. 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 sourceType | sourceType: 'key' |
sourceKey: 'button-0', 'axis-0' | sourceKey: '0' |
Mouse position absolute:x, relative:x | sourceType: 'position', sourceKey: 'x'. There is no relative-movement source. |
Binding kinds trigger, toggle, value | Plus 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.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.
// 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:
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.9 | 0.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(), entityCount | simulation.entities |
entityManager.addTag, removeTag, entity.hasTag | tags 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:
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:
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.
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.9 | 0.10 |
|---|---|
poolable: true in the definition | static 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:
// 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:
# Before (0.9)
entities:
door:
config:
locked: true
# After (0.10)
entities:
door:
entity: DoorEntity
name: Vault Door
tags: [ interactive ]
config:
locked: trueSpawnDefinition 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:
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:
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):
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,GameEntityDefinitionin its 0.9 shape,EntityManagerand every method on it.entity.send,entity.request,processRequest,RequestResult.$emit,$emitStateChanged, and theentity:state-changedevent. 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 declaredchildren. BindingManager,engine.subscribeAction,ActionPayload,inputManager.pollGamepads, and theinput:*library events.SaveManagerin its 0.9 shape,SaveData,SerializedEntity,onAfterDeserialize, and ID remapping on load.createEntityProxy, and the debug console's name-lookupsage.entitiesproxy,showColliders, andhideColliders.sage.entitiesnow indexes by ID,listtakes a class-table name and a tag, andshowColliderandhideColliderare methods onNodeEntity. See Debug Console.engine.raycastand its multi-result picks.StateMachineBehavior.createand its transition tables.- The 0.9
SoundBehaviorsurface beyondplayandstop. - 0.9 entity pooling and
onReset. entity.highlightandentity.unhighlight.OutlineManager.highlightEntity(layer, entity)still takes aNodeEntity.
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:
npm install @babylonjs/core@^9.0.0 @babylonjs/havok@^1.3.10 @babylonjs/loaders@^9.0.0If 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
// Before
import { SkewedAspectGameEngine } from '@skewedaspect/sage';
// After
import { GameEngine } from '@skewedaspect/sage';3. Update Event Bus Usage
TypedEventBus was merged into GameEventBus:
// 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:
// 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:
// 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:
// 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
// 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:
// 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:
// 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);