--- url: /concepts/architecture.md description: >- How SAGE is structured -- layered architecture, event-driven communication, core managers, data flow, and resource lifecycle. --- # Architecture SAGE follows a layered, event-driven architecture inspired by iDesign principles. Each layer has a clear responsibility, and communication flows through well-defined channels -- primarily the event bus. The result is a codebase where components are loosely coupled, independently testable, and composable. ## Layers at a Glance ``` +-----------------------------------------------------------+ | Game Engine (sage.ts) | | Orchestrator / public API | +-------------+-----------------+---------------------------+ | Event Bus | Entity System | Scene Engine | | (pub/sub) | (composition) | (BabylonJS + Havok) | +-------------+-----------------+---------------------------+ | Entity | Game Manager | Binding / Input | | Manager | (game loop) | Manager | +-------------+-----------------+---------------------------+ | Utilities & ID Generation | +-----------------------------------------------------------+ ``` | Layer | Responsibility | |---|---| | **Engine** | Top-level orchestrator. `createGameEngine()` wires everything together and exposes the public API. | | **Managers** | Coordinate subsystems: game loop, entities, levels, input bindings. Each manager owns one domain. | | **Classes / Behaviors** | The building blocks. `GameEntity`, `GameEntityBehavior`, `GameEventBus`, etc. Pure logic lives here. | | **Utils** | Cross-cutting helpers: ID generation, deep merge, type guards. No I/O, no state. | Each layer only talks to the layer directly below it. Managers never import other managers' internals; they communicate through the event bus or through the engine facade. ## Event-Driven Communication Direct coupling between subsystems is the enemy of maintainability. SAGE avoids it by routing nearly all inter-component communication through a central `GameEventBus`. ```typescript // A behavior emits an event... this.$emit({ type: 'weapon:fired', payload: { damage: 50 } }); // ...and any subscriber anywhere in the engine picks it up eventBus.subscribe('weapon:fired', (event) => { audioSystem.playSound('gunshot.wav'); }); ``` The event bus supports exact subscriptions, wildcard patterns (`input:*`), and regex matching. Events are delivered asynchronously via microtasks, so publishers never block on subscriber execution. See the [Events](./events.md) page for the full API. ## Data Flow: Input to Rendering Here is what happens when a player presses a key, end to end: 1. The **Input Manager** detects the raw keypress and publishes an `input:changed` event. 2. The **Binding Manager** maps the input to a named action and publishes an `action:` event. 3. **Behaviors** subscribed to that action event receive it and update entity state (e.g. set `jumping = true`). 4. Those behaviors may emit additional events (e.g. `player:jumped`), which other behaviors can react to. 5. The **Game Manager**'s frame loop calls `$update(deltaTime)` on every root entity, cascading to children. 6. Each behavior's `update()` applies continuous logic (movement, physics forces, animations). 7. The **Scene Engine** and **Havok Physics** apply the resulting transforms to the BabylonJS scene graph. 8. BabylonJS renders the frame. ``` Keypress -> Input Manager -> Binding Manager -> action event -> Behavior.processEvent() -> state change -> Behavior.update() -> Scene Engine render ``` This pipeline keeps each step isolated. The input system knows nothing about rendering; behaviors know nothing about raw key codes. ## Core Managers ### GameManager Controls the overall game lifecycle and the frame loop. * Starts and stops the game * Drives the update-render loop * Provides `registerFrameCallback()` for custom per-frame logic outside the entity system ```typescript const unsubscribe = sage.managers.gameManager.registerFrameCallback((deltaTime) => { // Custom per-frame logic -- useful for debug overlays, profiling, etc. fpsCounter.update(deltaTime); }); // Stop receiving callbacks unsubscribe(); ``` ### EntityManager Central hub for entity creation, tracking, and querying. * Creates entities from registered definitions (blueprints) * Maintains indexed collections for O(1) lookup by ID, name, type, tag, or scene node * Manages entity tags at runtime * Handles node attachment / detachment for scene graph integration * Supports object pooling for high-frequency create/destroy patterns ### LevelManager Organizes the game into discrete sections. * Registers and tracks `GameLevel` instances * Handles level loading, activation, and transitions * Manages cleanup between levels * Registers property handlers that process scene node metadata from loaded GLB files * Supports custom `GameLevel` subclasses for level-specific logic ### BindingManager Connects raw input to game actions. * Registers actions (digital and analog) * Creates bindings between physical inputs and named actions * Manages input contexts so different game states (menu, gameplay, cutscene) use different bindings ### SceneEngine Thin wrapper around BabylonJS scene and rendering. * Creates scenes with optional Havok physics * Provides helpers for cameras, lights, and primitive meshes * Loads external 3D models (GLB/GLTF) ## Resource Lifecycle and Cleanup Every manager and engine in SAGE implements the `Disposable` interface: ```typescript interface Disposable { $teardown() : Promise; } ``` When you call `gameEngine.$teardown()`, cleanup cascades through every subsystem in the correct order: entities are destroyed (pools drained), event subscriptions are cleared, the physics engine is disposed, and the BabylonJS engine is shut down. No manual cleanup is needed for anything the engine created. ::: tip Level transitions also trigger partial teardown -- the `LevelManager` cleans up the current level's entities, scene, and physics before loading the next one. Entity pools are drained automatically during this process. ::: ## When to Use Which Layer | You want to... | Use this | |---|---| | Create a game object with custom logic | Define an entity with behaviors | | React to something happening in the game | Subscribe to an event on the event bus | | Run logic every frame | `Behavior.update()` or `registerFrameCallback()` | | Organize the game into sections | Create `GameLevel` subclasses | | Map player input to game actions | Register actions and bindings with the `BindingManager` | | Access BabylonJS directly | `sage.sceneEngine.scene`, `sage.sceneEngine.engine` | | Add cross-cutting debug tools | `registerFrameCallback()` + event subscriptions | ## Comparison with Other Engines SAGE takes a code-first approach rather than an editor-first one: | Aspect | Unity / Unreal | SAGE | |---|---|---| | Object model | Inheritance-heavy GameObjects / Actors | Composition via entity + behavior | | Communication | Direct references, singletons | Event bus (pub/sub) | | Workflow | Visual editor with scripts attached | TypeScript-first, data-driven levels | | Physics | Built-in engine | Havok (same physics, explicit integration) | The tradeoff is intentional: SAGE optimizes for programmers who want full control over their game's architecture without fighting an editor. --- --- url: /api/audio.md description: >- API reference for SAGE's audio system -- AudioEngine, AudioManager, channel-based mixing, volume control, and integration with SoundBehavior and the Blender sound handler. --- # Audio SAGE provides a channel-based audio system built on BabylonJS AudioV2. You define named channels (like `music`, `sfx`, `ambient`) and the system creates an audio bus graph with independent volume and mute controls per channel. This gives your players the standard per-category volume sliders they expect from a settings menu. The audio system is **opt-in**. If you don't pass `audioChannels` to `createGameEngine()`, no audio infrastructure is created and `SoundBehavior` / the sound handler will not create sounds. ## Architecture The audio system follows SAGE's iDesign layering: ``` ┌────────────────────────────────────────────┐ │ AudioManager │ ← Orchestration layer │ - Channel volume/mute │ │ - Master volume/mute │ │ - Sound creation (routed to channels) │ ├────────────────────────────────────────────┤ │ AudioEngine │ ← Engine layer (wraps BabylonJS AudioV2) │ - WebAudio engine lifecycle │ │ - Bus creation & management │ │ - Raw sound creation │ ├────────────────────────────────────────────┤ │ BabylonJS AudioV2 │ ← Underlying implementation │ - WebAudio API │ │ - AudioBus, StaticSound │ └────────────────────────────────────────────┘ ``` **Audio graph:** ``` MainBus (master volume) ├── music bus ├── sfx bus ├── ambient bus └── ... (one bus per channel) ``` Each sound is routed to a channel bus. Adjusting a channel's volume affects all sounds on that bus. The master volume sits at the top of the graph and scales everything. ## Setup Pass `audioChannels` to `createGameEngine()`: ```typescript import { createGameEngine } from '@skewedaspect/sage'; const engine = await createGameEngine(canvas, entityDefs, { audioChannels: [ 'music', 'sfx', 'ambient', 'voice' ], }); ``` This creates an `AudioEngine` and `AudioManager`, both accessible on the engine instance: ```typescript engine.engines.audioEngine // AudioEngine (low-level) engine.managers.audioManager // AudioManager (use this one) ``` ### Scene integration `SoundBehavior` and the Blender sound handler receive the `AudioManager` automatically via `gameEngine`. No manual wiring is needed per-level -- as long as you passed `audioChannels` to `createGameEngine()`, behaviors and handlers will have access to the audio system. ## AudioManager The `AudioManager` is the primary interface for game code. It handles channel routing, volume, muting, and sound creation. ### Creating sounds ```typescript const audioManager = engine.managers.audioManager; // Create a sound on the 'sfx' channel const jumpSound = await audioManager.createSound('jump', 'audio/jump.ogg', 'sfx'); // Create a sound on the 'music' channel with options const bgMusic = await audioManager.createSound('bgm', 'audio/theme.ogg', 'music', { loop: true, autoplay: true, volume: 0.7, }); // No channel -- routes to the main bus directly const uiClick = await audioManager.createSound('click', 'audio/click.ogg'); ``` **Signature:** ```typescript async createSound( name : string, url : string, channel ?: string, options ?: Partial ) : Promise ``` | Parameter | Type | Description | |-----------|------|-------------| | `name` | `string` | Unique identifier for the sound | | `url` | `string` | Path to the audio file | | `channel` | `string` | Channel name to route to (optional) | | `options` | `Partial` | BabylonJS AudioV2 sound options (optional) | If `channel` doesn't match a registered channel, a warning is logged and the sound routes to the main bus. ### Master volume ```typescript // Set master volume (0-1) audioManager.setMasterVolume(0.8); // Get current master volume const vol = audioManager.getMasterVolume(); // Mute/unmute everything audioManager.setMasterMuted(true); audioManager.setMasterMuted(false); // restores previous volume // Check mute state if(audioManager.isMasterMuted()) { /* ... */ } ``` Master mute stores the current volume and sets it to 0. Unmuting restores the stored volume, so `setMasterVolume()` and `setMasterMuted()` work independently without stepping on each other. ### Channel volume ```typescript // Set channel volume (0-1) audioManager.setChannelVolume('music', 0.5); audioManager.setChannelVolume('sfx', 1.0); // Get channel volume const musicVol = audioManager.getChannelVolume('music'); // Mute/unmute a channel audioManager.setChannelMuted('music', true); audioManager.setChannelMuted('music', false); // restores previous volume // Check mute state if(audioManager.isChannelMuted('music')) { /* ... */ } ``` Channel mute works the same way as master mute -- volume is stored and restored on unmute. ### Querying channels ```typescript // Get all registered channel names const channels = audioManager.getChannels(); // => ['music', 'sfx', 'ambient', 'voice'] ``` This is useful for building settings UIs dynamically. ## AudioEngine The `AudioEngine` is the lower-level wrapper around BabylonJS AudioV2. You typically don't need to use it directly -- `AudioManager` covers the common cases. It's exposed for advanced scenarios like creating custom bus topologies. ### API | Method | Signature | Description | |--------|-----------|-------------| | `initialize()` | `() => Promise` | Creates the WebAudio engine and main bus | | `createBus(name)` | `(string) => Promise` | Create a named bus routed to the main bus | | `getBus(name)` | `(string) => AudioBus \| undefined` | Look up a bus by name | | `createSound(name, url, bus?, options?)` | `(...) => Promise` | Create a sound, optionally routed to a bus | | `setMasterVolume(volume)` | `(number) => void` | Set the engine-level master volume | | `getMasterVolume()` | `() => number` | Get the engine-level master volume | | `setBusVolume(bus, volume)` | `(AudioBus, number) => void` | Set volume on a specific bus | ## Settings UI example A typical audio settings implementation using `AudioManager`: ```typescript import type { AudioManager } from '@skewedaspect/sage'; function buildAudioSettings(audioManager : AudioManager) { // Build sliders for each channel for(const channel of audioManager.getChannels()) { const volume = audioManager.getChannelVolume(channel); const muted = audioManager.isChannelMuted(channel); // Create your UI slider with these values... // On slider change: // audioManager.setChannelVolume(channel, newValue); // On mute toggle: // audioManager.setChannelMuted(channel, !muted); } // Master volume const masterVol = audioManager.getMasterVolume(); // audioManager.setMasterVolume(newValue); // audioManager.setMasterMuted(true/false); } ``` ## Without AudioManager When `audioChannels` is not provided (or is empty), the audio system is not initialized: * `engine.engines.audioEngine` is `undefined` * `engine.managers.audioManager` is `undefined` * `SoundBehavior` logs a warning and creates no sounds * The Blender sound handler logs a warning and creates no sounds To enable audio, pass `audioChannels` to `createGameEngine()`. --- --- url: /api/behaviors.md description: >- API reference for SAGE's behavior system -- the GameEntityBehavior base class, lifecycle hooks, event handling, state machines, and composition patterns. --- # Behaviors Behaviors are the building blocks that give entities their abilities. Each behavior is a reusable component that listens for events, processes state changes, and optionally runs per-frame updates. You compose entities by attaching multiple behaviors, each handling a single responsibility. ## GameEntityBehavior The abstract base class for all behaviors. Subclass it to define how an entity reacts to events and updates each frame. ```typescript import { GameEntityBehavior } from '@skewedaspect/sage'; import type { GameEvent } from '@skewedaspect/sage'; ``` ### Abstract Members | Member | Type | Description | |--------|------|-------------| | `name` | `string` | Unique identifier for this behavior type | | `eventSubscriptions` | `string[]` | Event types this behavior listens for | | `processEvent(event, state)` | `(GameEvent, S) => boolean \| Promise` | Handle an incoming event. Return `true` to stop propagation. | ### Optional Members | Member | Type | Description | |--------|------|-------------| | `update(dt, state)` | `(number, S) => void` | Called every frame. `dt` is seconds since last frame. | | `destroy()` | `() => Promise` | Cleanup when the entity is destroyed | | `onNodeAttached(node, gameEngine)` | `(TransformNode, GameEngine) => void` | Called when entity is attached to a scene node | | `onNodeDetached()` | `() => void` | Called when entity is detached from its scene node | | `onReset(state)` | `(S) => void` | Called when a poolable entity is recycled | ### Protected Members | Member | Type | Description | |--------|------|-------------| | `entity` | `SimpleGameEntity \| null` | Reference to the owning entity (set automatically) | ### Internal API | Method | Signature | Description | |--------|-----------|-------------| | `$emit(event)` | `(GameEvent) => void` | Publish an event on the entity's event bus | | `$emitStateChanged(state?, changes?)` | `(object?, Record?) => void` | Emit `entity:state-changed` for UI reactivity | | `$setEntity(entity)` | `(SimpleGameEntity \| null) => void` | Set the owning entity (called by the framework) | ## Defining a Behavior Every behavior needs a `name`, an `eventSubscriptions` array, and a `processEvent` implementation. The generic parameter `RequiredState` documents what entity state properties the behavior depends on. ```typescript import { GameEntityBehavior } from '@skewedaspect/sage'; import type { GameEvent } from '@skewedaspect/sage'; interface HealthState { currentHealth : number; maxHealth : number; isAlive : boolean; } export class HealthBehavior extends GameEntityBehavior { name = 'HealthBehavior'; eventSubscriptions = [ 'entity:damage', 'entity:heal' ]; processEvent(event : GameEvent, state : HealthState) : boolean { if(event.type === 'entity:damage') { const damage = event.payload?.amount || 0; state.currentHealth = Math.max(0, state.currentHealth - damage); if(state.currentHealth === 0 && state.isAlive) { state.isAlive = false; this.$emit({ type: 'entity:died', payload: { causeOfDeath: 'damage', finalBlow: event.payload }, }); } return true; } if(event.type === 'entity:heal' && state.isAlive) { const amount = event.payload?.amount || 0; state.currentHealth = Math.min(state.maxHealth, state.currentHealth + amount); this.$emit({ type: 'entity:healed', payload: { amount } }); return true; } return false; } } ``` ## Lifecycle When a behavior is attached to an entity, it follows this lifecycle: 1. **Instantiation** -- The behavior is created via its zero-argument constructor 2. **Attachment** -- `$setEntity()` is called, linking the behavior to its entity 3. **Subscription** -- The framework subscribes to every event type in `eventSubscriptions` 4. **Active phase** -- `processEvent()` is called for matching events; `update()` runs each frame 5. **Detachment / Destruction** -- Event subscriptions are removed; `destroy()` is called ### Lifecycle Hooks | Hook | When Called | Use Case | |------|------------|----------| | `onNodeAttached(node, gameEngine)` | Entity is attached to a `TransformNode` | Initialize physics, particles, spatial audio | | `onNodeDetached()` | Entity is detached from its node | Dispose scene-dependent resources | | `onReset(state)` | Entity is recycled by the object pool | Clear internal timers, counters, cached refs | | `destroy()` | Entity is destroyed | Final cleanup of all resources | ::: tip If your behavior creates resources in `onNodeAttached`, implement both `onNodeDetached` (for detachment) and `destroy` (for entity destruction while still attached) to cover all cleanup paths. ::: ### onNodeAttached Called when the entity receives a scene node, either at creation time or later via `entityManager.attachToNode()`. Access the scene via `node.getScene()` and engine subsystems via `gameEngine`. ```typescript import type { TransformNode } from '@babylonjs/core'; import type { GameEngine } from '@skewedaspect/sage'; import { PhysicsAggregate, PhysicsShapeType } from '@babylonjs/core'; class PhysicsBodyBehavior extends GameEntityBehavior<{ mass : number }> { name = 'PhysicsBodyBehavior'; eventSubscriptions = []; private _aggregate : PhysicsAggregate | null = null; onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void { const scene = node.getScene(); this._aggregate = new PhysicsAggregate( node, PhysicsShapeType.BOX, { mass: this.entity!.state.mass ?? 1 }, scene ); } onNodeDetached() : void { this._aggregate?.dispose(); this._aggregate = null; } processEvent() : boolean { return false; } async destroy() : Promise { this._aggregate?.dispose(); this._aggregate = null; } } ``` ### onReset Called when a poolable entity is returned to the pool and reused. The entity's state has already been restored to its defaults, so the `state` argument contains the freshly reset values. Use this to clear behavior-internal state that should not carry over between lives. ```typescript class CooldownBehavior extends GameEntityBehavior<{ attackSpeed : number }> { name = 'CooldownBehavior'; eventSubscriptions = [ 'combat:attack' ]; private cooldownTimer = 0; private comboCount = 0; update(dt : number, _state : { attackSpeed : number }) : void { if(this.cooldownTimer > 0) { this.cooldownTimer -= dt; } } processEvent(event : GameEvent, state : { attackSpeed : number }) : boolean { if(event.type === 'combat:attack' && this.cooldownTimer <= 0) { this.comboCount++; this.cooldownTimer = 1 / state.attackSpeed; return true; } return false; } onReset(_state : { attackSpeed : number }) : void { this.cooldownTimer = 0; this.comboCount = 0; } } ``` ## Event Processing ### processEvent The core method where your behavior responds to events. It receives the event object and the entity's shared state, and returns `true` to stop propagation or `false` to let subsequent behaviors handle the event. ```typescript processEvent(event : GameEvent, state : RequiredState) : boolean | Promise ``` Behaviors are processed in attachment order. When a behavior returns `true`, no further behaviors see that event. This enables patterns like damage reduction chains: ```typescript const entityDef = { type: 'character:tank', defaultState: { /* ... */ }, behaviors: [ ShieldBehavior, // First chance to block damage ArmorBehavior, // Second chance to reduce damage HealthBehavior, // Finally, apply remaining damage ], }; ``` ### Emitting Events Use `$emit()` to publish events from within a behavior: ```typescript this.$emit({ type: 'entity:healed', payload: { amount: 25 }, }); ``` The `senderID` is set automatically to the entity's ID. ### State Change Notifications Call `$emitStateChanged()` after significant mutations to notify UI layers and other observers: ```typescript this.$emitStateChanged(); // Or with specific changes this.$emitStateChanged(undefined, { health: state.health }); ``` ## The update Method The optional `update` method runs every frame. The `dt` parameter is the elapsed time in **seconds** since the last frame, enabling frame-rate independent logic. ```typescript update(dt : number, state : MovementState) : void { state.position.x += state.velocity.x * dt; state.position.y += state.velocity.y * dt; state.velocity.y -= 9.81 * dt; // gravity } ``` ## Dynamic Attachment Behaviors can be attached and detached at runtime: ```typescript // Attach a new behavior const invincibility = new InvincibilityBehavior(); entity.attachBehavior(invincibility); // Detach by class (not instance) entity.detachBehavior(InvincibilityBehavior); ``` ### AttachBehaviorOptions Control insertion position when attaching: ```typescript interface AttachBehaviorOptions { before ?: GameEntityBehaviorConstructor; after ?: GameEntityBehaviorConstructor; at ?: number; } ``` | Option | Description | |--------|-------------| | `before` | Insert before the behavior of this class | | `after` | Insert after the behavior of this class | | `at` | Insert at a specific 0-based index (clamped to valid range) | Only one option may be specified per call. ```typescript entity.attachBehavior(new ArmorBehavior(), { before: ShieldBehavior }); entity.attachBehavior(new DebugBehavior(), { at: 0 }); ``` ### Querying Behaviors ```typescript // Check if a behavior class is attached entity.hasBehavior(HealthBehavior); // boolean // Get a behavior instance by class const health = entity.getBehavior(HealthBehavior); ``` ## Registering with Entity Definitions Behaviors are listed in entity definitions and instantiated automatically when entities are created: ```typescript gameEngine.managers.entityManager.registerEntityDefinition({ type: 'character:hero', defaultState: { currentHealth: 100, maxHealth: 100, isAlive: true, }, behaviors: [ HealthBehavior, MovementBehavior, ], }); const hero = await gameEngine.managers.entityManager.createEntity( 'character:hero', { initialState: { currentHealth: 80 }, name: 'Frodo', tags: [ 'player', 'controllable' ], } ); ``` ## SoundBehavior SAGE ships with a built-in `SoundBehavior` for managing sounds on entities. Sounds are configured in the entity's state and controlled at runtime via play/stop/pause methods. ```typescript import { SoundBehavior } from '@skewedaspect/sage'; import type { SoundConfig, SoundEntityState } from '@skewedaspect/sage'; ``` ### SoundConfig | Property | Type | Default | Description | |----------|------|---------|-------------| | `url` | `string` | -- | Path to the audio file (required) | | `volume` | `number` | `1` | Volume level (0-1) | | `loop` | `boolean` | `false` | Whether to loop the sound | | `spatial` | `boolean` | `false` | Use 3D positional audio (auto-attaches to entity node) | | `maxDistance` | `number` | `100` | Maximum distance for spatial audio | | `autoplay` | `boolean` | `false` | Start playing when registered | | `channel` | `string` | -- | Audio channel name (e.g., `'sfx'`, `'music'`). Used with AudioV2. | ### Entity definition Configure sounds in your entity's `defaultState.sounds` map: ```typescript gameEngine.managers.entityManager.registerEntityDefinition({ type: 'character:player', defaultState: { sounds: { jump: { url: 'audio/jump.ogg', volume: 0.8, channel: 'sfx' }, footstep: { url: 'audio/footstep.ogg', loop: true, channel: 'sfx' }, theme: { url: 'audio/player-theme.ogg', loop: true, channel: 'music', autoplay: true }, }, // ... other state }, behaviors: [ SoundBehavior, /* ... */ ], }); ``` ### Runtime control Get the behavior instance from an entity and call its methods: ```typescript const soundBehavior = entity.getBehavior(SoundBehavior); // Play a specific sound soundBehavior?.play('jump'); // Stop a specific sound, or stop all soundBehavior?.stop('footstep'); soundBehavior?.stop(); // Pause/check state soundBehavior?.pause('theme'); soundBehavior?.isPlaying('theme'); // => false // Adjust volume at runtime soundBehavior?.setVolume(0.5, 'footstep'); soundBehavior?.setVolume(0.3); // all sounds ``` If the entity only has one sound, you can omit the name parameter from `play()`, `pause()`, and `isPlaying()`. ### Registering sounds at runtime ```typescript soundBehavior?.registerSound('alert', { url: 'audio/alert.ogg', volume: 1.0, channel: 'sfx', }); soundBehavior?.unregisterSound('alert'); ``` ### Querying sounds ```typescript soundBehavior?.getSoundNames(); // => ['jump', 'footstep', 'theme'] soundBehavior?.hasSound('jump'); // => true ``` ### AudioV2 integration `SoundBehavior` requires AudioV2. It gets the `AudioManager` from `gameEngine.managers.audioManager` (passed automatically via `onNodeAttached`) and creates sounds via `AudioManager.createSound()`, routed to the specified `channel`. If no AudioManager is found, a warning is logged and no sounds are created. When `spatial` is `true`, the sound is created with `spatialEnabled` and automatically attached to the entity's scene node, so the audio position tracks the entity in 3D space. See [Audio](./audio.md) for how to set up AudioV2. ## Composition Patterns ### Filter Chain Behaviors process events in sequence, with each potentially modifying or blocking the event: ``` Input -> ActionValidation -> ActionExecution ``` ### Observer Pattern Multiple independent behaviors respond to the same events: ``` enemy:defeated -> AchievementBehavior SoundBehavior AnimationBehavior ``` ### Event Relay One behavior emits events that another consumes, without direct coupling: ```typescript // InventoryBehavior emits item:effect // PotionEffectBehavior listens for item:effect, emits entity:heal // HealthBehavior listens for entity:heal ``` ### Communication Approaches | Approach | Coupling | Best For | |----------|----------|----------| | Direct state mutation | Tight | Closely related behaviors (movement + animation) | | Event broadcasting | Loose | Cross-cutting concerns (sound, achievements) | | Entity references in state | Medium | Entity-to-entity interactions (AI targeting) | ## StateMachineBehavior SAGE ships with a built-in `StateMachineBehavior` for finite state machines synced to entity state. ### Creating with the Factory Since behaviors use zero-argument constructors, `StateMachineBehavior` uses a static `create()` factory: ```typescript import { StateMachineBehavior } from '@skewedaspect/sage'; type EnemyStates = 'idle' | 'patrol' | 'chase' | 'attack' | 'dead'; interface EnemyState { aiState : EnemyStates; health : number; } const EnemyAIBehavior = StateMachineBehavior.create({ initialState: 'idle', stateKey: 'aiState', transitions: [ { from: 'idle', to: 'patrol' }, { from: 'patrol', to: 'chase' }, { from: 'chase', to: 'attack' }, { from: 'attack', to: 'chase' }, { from: 'attack', to: 'idle' }, ], wildcardTransitions: [ { to: 'dead' }, ], }); ``` ### StateMachineBehaviorConfig | Property | Type | Description | |----------|------|-------------| | `initialState` | `States` | The state the machine starts in | | `stateKey` | `keyof EntityState` | Entity state property updated on transitions | | `transitions` | `{ from, to, guard? }[]` | Allowed transitions between specific states | | `wildcardTransitions` | `{ to, guard? }[]` | Transitions allowed from any state | Guards are optional functions returning `boolean`. A `false` return rejects the transition. ### Triggering Transitions ```typescript const sm = entity.getBehavior(StateMachineBehavior); sm?.transition('chase'); // Check current state console.log(sm?.currentState); // 'chase' ``` ## Standalone StateMachine The `StateMachine` class can be used independently for game-level flow (screens, match phases) outside the entity system. ```typescript import { StateMachine } from '@skewedaspect/sage'; type GamePhase = 'title' | 'playing' | 'paused' | 'gameOver'; const gameFlow = new StateMachine('title'); gameFlow.addTransition('title', 'playing'); gameFlow.addTransition('playing', 'paused'); gameFlow.addTransition('paused', 'playing'); gameFlow.addTransition('playing', 'gameOver'); gameFlow.addTransition('gameOver', 'title'); gameFlow.addTransitionFromAny('title'); ``` ### Lifecycle Callbacks ```typescript gameFlow.onEnter('playing', () => startGameLoop()); gameFlow.onExit('playing', () => stopGameLoop()); ``` ### Event Bus Integration Pass a `GameEventBus` to emit events on transitions: ```typescript const gameFlow = new StateMachine('title', eventBus); // Events follow: state:enter:, state:exit: // Payload: { from, to } eventBus.subscribe('state:enter:playing', (event) => { console.log(`Entered playing from ${ event.payload.from }`); }); ``` ### Querying Transitions ```typescript if(gameFlow.canTransition('paused')) { gameFlow.transition('paused'); } // Invalid transitions throw gameFlow.transition('gameOver'); // throws if not defined from current state ``` ## Testing Behaviors Behaviors are designed for isolated unit testing. Mock `$emit` and feed events directly: ```typescript import { describe, it, expect, vi } from 'vitest'; describe('HealthBehavior', () => { it('should reduce health when damaged', () => { const behavior = new HealthBehavior(); behavior.$emit = vi.fn(); const state = { currentHealth: 100, maxHealth: 100, isAlive: true }; behavior.processEvent( { type: 'entity:damage', payload: { amount: 30 } }, state ); expect(state.currentHealth).toBe(70); expect(state.isAlive).toBe(true); }); it('should emit death event at zero health', () => { const behavior = new HealthBehavior(); const emitted : unknown[] = []; behavior.$emit = (event) => { emitted.push(event); }; const state = { currentHealth: 10, maxHealth: 100, isAlive: true }; behavior.processEvent( { type: 'entity:damage', payload: { amount: 50 } }, state ); expect(state.currentHealth).toBe(0); expect(state.isAlive).toBe(false); expect(emitted[0]).toMatchObject({ type: 'entity:died' }); }); }); ``` --- --- url: /guides/blender-workflow.md description: >- Author SAGE levels in Blender using GLB export, custom properties for spawn points, entity markers, sounds, colliders, triggers, and performance optimization. --- # Blender Workflow This guide covers how to create SAGE levels in Blender. You will learn the GLB export settings, the custom properties system that maps Blender objects to SAGE game functionality, and the recommended workflow for building levels that load cleanly into the engine. ## Overview SAGE uses glTF/GLB files exported from Blender as level scene files. By adding custom properties to objects in Blender, you control game behavior without writing code: * Define spawn points for players, enemies, and items * Mark meshes as interactive entities * Attach spatial sounds to objects * Configure collision shapes * Create trigger zones for gameplay events * Optimize performance with occluders and LOD When a level loads, SAGE walks the scene graph, reads these properties, and automatically configures everything. The programmer defines entity types and behaviors in code; the artist places and configures instances in Blender. ## GLB export format and settings Export your levels as **GLB (glTF Binary)** files. This format bundles geometry, materials, textures, and metadata into a single file. ### Export procedure 1. Go to **File > Export > glTF 2.0 (.glb/.gltf)** 2. Set **Format** to **glTF Binary (.glb)** 3. Configure the remaining settings: **Include:** | Setting | Value | Why | |---------|-------|-----| | Selected Objects | Off | Export the entire scene | | Visible Objects | On | Skip hidden helper objects | | Renderable Objects | On | | | Active Collection | Off | | | Include Nested Collections | On | | | Custom Properties | **On** | Required for SAGE metadata | **Transform:** | Setting | Value | |---------|-------| | +Y Up | On | **Data > Lighting:** | Setting | Value | Why | |---------|-------|-----| | Lighting Mode | **Unitless** | Standard mode produces values that are massively overexposed in BabylonJS. See [Blender lights](#blender-lights). | **Data > Mesh:** | Setting | Value | |---------|-------| | Apply Modifiers | On | | UVs | On | | Normals | On | | Vertex Colors | On (if used) | **Data > Materials:** | Setting | Value | |---------|-------| | Export | On | | Images Format | Automatic | **Animation (if needed):** | Setting | Value | |---------|-------| | Use Current Frame | Off | | Export Animations | On | ::: warning Custom Properties must be enabled If "Custom Properties" is not checked, none of the SAGE metadata will be included in the export. This is the single most common cause of "it works in Blender but not in the game." ::: ## Custom properties in Blender Custom properties are the bridge between Blender and SAGE. You add them in the Properties panel: 1. Select an object in the 3D viewport 2. Open the **Properties panel** (right side) 3. Click the **Object Properties** tab (orange square icon) 4. Scroll down to **Custom Properties** 5. Click **New** to add a property ### Property types | Type | Example | Use case | |------|---------|----------| | String | `"assets/audio/music.mp3"` | File paths, names, JSON context | | Integer | `100` | Distances, counts, floor numbers | | Float | `0.75` | Volume, scale factors | | Boolean | Checkbox | Enable/disable features | ### Naming convention SAGE properties use a flat naming pattern: * Main property: `property_name` (e.g., `sound`, `collider`, `trigger`) * Sub-properties: `property_subname` (e.g., `sound_volume`, `sound_loop`) Property names are case-sensitive. ## Spawn points Spawn points mark locations where game entities appear when the level loads. They are the primary way to place players, enemies, and items. ### Creating a spawn point 1. Add an **Empty** object (**Add > Empty > Plain Axes**) 2. Position and rotate it where you want the entity to appear 3. Add a custom property: * Name: `spawn` * Type: String * Value: A unique name (e.g., `player_start`, `goblin_patrol_1`) The empty's transform is passed to the spawned entity: * **Position** -- where the entity appears * **Rotation** -- which direction it faces * **Scale** -- passed through (useful for size variations) ### How spawns connect to code The spawn name maps to the YAML level config. In Blender you place `spawn = "player_start"`. In the YAML: ```yaml spawns: player_start: entity: player config: health: 100 ``` When the level loads, SAGE finds the Blender empty named `player_start`, creates a `player` entity at its position, and merges the `config` values into the entity's initial state. ### Example spawn points ``` Empty: "PlayerSpawn" spawn = "player_start" Empty: "Enemy_Patrol_01" spawn = "goblin_patrol_1" Empty: "Treasure_Location" spawn = "chest_01" ``` ## Entity markers Entity markers turn existing meshes into interactive game objects. Unlike spawn points (which replace an empty with a new entity), entity markers wrap the mesh itself as the entity's visual. ### Creating an entity marker 1. Select a mesh in your scene (a door, a chest, a button) 2. Add a custom property: * Name: `entity` * Type: String * Value: The entity type (e.g., `door`, `button`, `elevator`) The Blender object name becomes the entity's name at runtime. You can look up entities by name with `entityManager.getByName('Door_Main')`, which is how interaction targets are resolved. ### Additional metadata Entity markers can carry extra metadata that behaviors read in `onNodeAttached()`: ``` Mesh: "ElevatorButton_F2" entity = "elevator_button" target = "Elevator" prompt = "to call elevator" locked = true context = '{"floor": 2}' ``` The behavior reads these from `node.metadata`: ```typescript onNodeAttached(node : TransformNode) : void { this.target = (node.metadata?.target as string) ?? null; this.locked = !!(node.metadata?.locked); } ``` This pattern keeps per-instance configuration in Blender and generic logic in code. ### When to use entity vs spawn | Use case | Property | What happens | |----------|----------|-------------| | Place a new object (player, enemy, item) | `spawn` | Empty replaced with spawned entity | | Make an existing mesh interactive (door, button) | `entity` | Mesh wrapped with entity behaviors | ## Sounds Attach ambient or spatial sounds to objects in your scene. ### Basic sound ``` Mesh: "Waterfall" sound = "assets/audio/waterfall.mp3" ``` ### Sound options | Property | Type | Default | Description | |----------|------|---------|-------------| | `sound` | String | -- | Path to audio file (required) | | `sound_volume` | Float | 1.0 | Volume (0.0 to 1.0) | | `sound_loop` | Boolean | true | Loop the audio | | `sound_spatial` | Boolean | true | Use 3D positional audio | | `sound_distance` | Integer | 100 | Maximum audible distance for spatial audio | | `sound_autoplay` | Boolean | true | Start playing on load | | `sound_channel` | String | `"ambient"` | Audio channel name | ### Example: Ambient campfire ``` Mesh: "Campfire" sound = "assets/audio/fire_crackling.mp3" sound_volume = 0.6 sound_loop = true sound_spatial = true sound_distance = 20 sound_channel = "ambient" ``` ### Example: One-shot announcement ``` Mesh: "Announcement_Speaker" sound = "assets/audio/welcome.mp3" sound_loop = false sound_autoplay = true sound_channel = "voice" ``` ### Tips * Use `.mp3` or `.ogg` for best compatibility * Set `sound_spatial = true` for environmental sounds, `false` for music or UI * Set `sound_distance` based on the object's size and importance in the scene ## Colliders Colliders define physical boundaries for collision detection. ### Collider types | Type | Performance | Best for | |------|-------------|----------| | `box` | Fast | Crates, walls, rectangular objects | | `sphere` | Fast | Balls, barrels, rounded objects | | `mesh` | Slow | Complex shapes, terrain | | `none` | N/A | Visual only (disable collision) | ### Basic usage ``` Mesh: "Crate" collider = "box" Mesh: "Boulder" collider = "sphere" Mesh: "Decoration_Flowers" collider = "none" ``` ### Simplified collision meshes For detailed visual models, create a simpler child mesh for collision: ``` Mesh: "Statue_Detailed" (parent) collider = "mesh" Mesh: "Statue_Collision" (child) collider_mesh = true ``` Setup: 1. Create your detailed visual mesh 2. Create a simplified version for collision 3. Parent the simplified mesh to the detailed mesh 4. On the parent: set `collider = "mesh"` 5. On the child: set `collider_mesh = true` (Boolean, checked) The collision child is automatically hidden in-game. Only its geometry is used for physics. ::: tip Performance Use `box` or `sphere` whenever possible. Mesh colliders are significantly slower and should be reserved for terrain or complex static geometry. ::: ## Trigger zones Triggers are invisible areas that fire events when objects enter or exit them. ### Creating a trigger 1. Create a mesh that defines the trigger area (usually a box) 2. Add a custom property: * Name: `trigger` * Type: String * Value: A unique trigger name ``` Mesh: "TriggerZone_BossRoom" trigger = "boss_arena" ``` The mesh becomes invisible automatically. When any object enters, a `trigger:enter` event fires with the trigger name and entering entity. When it exits, `trigger:exit` fires. ### Tips * Make trigger zones slightly larger than the area you want to detect (accounts for player size) * Use descriptive names: `door_1_entrance` is better than `trigger_3` * Simple box shapes are the most efficient ## Performance optimization ### Occluders Occluders are invisible meshes that help the engine skip rendering objects hidden behind them. ``` Mesh: "Wall_Large" occluder = true ``` Use occluders for large, solid objects like walls and pillars. Keep the occluder shapes simple. ### Level of detail (LOD) LOD switches between mesh detail levels based on camera distance. ``` Mesh: "Tree" (parent) lod_distances = "15,30,60" Mesh: "Tree_LOD0" (child) -- full detail Mesh: "Tree_LOD1" (child) -- medium detail Mesh: "Tree_LOD2" (child) -- low detail ``` The distances define the transition points: * 0 to 15 units: LOD0 (full detail) * 15 to 30 units: LOD1 * 30 to 60 units: LOD2 * Beyond 60: not rendered To create LOD meshes: 1. Model the full-detail mesh 2. Duplicate and reduce polygons (Decimate modifier or manual retopo) 3. Repeat for each LOD level 4. Parent all LOD meshes to the main mesh 5. Name them clearly: `Tree_LOD0`, `Tree_LOD1`, `Tree_LOD2` 6. Add `lod_distances` to the parent ## Lighting and materials Blender's glTF exporter produces PBR (physically based rendering) materials exclusively. This has implications for how you set up lights and create materials in code. Getting this wrong is the single most common reason a scene looks washed out, pitch black, or inconsistent between Blender-authored and code-created objects. ### The core rule **Any scene that contains Blender-exported content is a PBR scene.** All code-created meshes in that scene must use `PBRMaterial`, not `StandardMaterial`. Mixing the two produces inconsistent lighting because they respond to lights differently. ```typescript // Wrong -- StandardMaterial in a scene with Blender PBR content const mat = new StandardMaterial('crate-mat', scene); mat.diffuseColor = new Color3(0.6, 0.4, 0.2); // Correct -- PBRMaterial matches the Blender-exported materials const mat = new PBRMaterial('crate-mat', scene); mat.albedoColor = new Color3(0.6, 0.4, 0.2); mat.metallic = 0.1; mat.roughness = 0.8; ``` ### Light intensity values BabylonJS PBR uses physical light units. The key thing to know: **PBR rendering divides incoming light by PI** for energy conservation. This affects how you think about intensity values. | Light type | Unit | Notes | |------------|------|-------| | `HemisphericLight` | lux | Ambient fill. `0.7` -- `1.0` typical. | | `DirectionalLight` | lux | Sun/key light. Set to `Math.PI` (~3.14) for "full" brightness, since PBR divides by PI. | | `PointLight` | candela | Uses inverse square falloff. Values like `100` -- `500` are typical. `0.6` will be invisible. | | `SpotLight` | candela | Same as PointLight. `150` is a reasonable spotlight. | The trap: tutorials and AI-generated code often use `PointLight` with `intensity = 0.6`, which works fine with `StandardMaterial` but produces no visible light with PBR materials. Similarly, `DirectionalLight` at `1.0` looks like a dim overcast day with PBR, not bright sun. ```typescript // PBR-correct lighting setup: const hemi = new HemisphericLight('ambient', new Vector3(0, 1, 0), scene); hemi.intensity = 0.7; hemi.groundColor = new Color3(0.15, 0.15, 0.2); // Math.PI compensates for the PBR 1/PI energy conservation factor const sun = new DirectionalLight('sun', new Vector3(-1, -2, 1).normalize(), scene); sun.intensity = Math.PI; // Point/Spot lights use candela -- much higher numbers const spot = new SpotLight('spotlight', position, direction, angle, exponent, scene); spot.intensity = 150; ``` ### PBRMaterial essentials When creating PBR materials in code, you must set `metallic` and `roughness` explicitly. The defaults (`metallic = 0`, `roughness = 1`) produce flat matte plastic that looks wrong in most contexts. | Property | Type | Range | Description | |----------|------|-------|-------------| | `albedoColor` | `Color3` | 0--1 per channel | Base color (equivalent to diffuse) | | `metallic` | `number` | 0--1 | 0 = dielectric (plastic, wood), 1 = metal | | `roughness` | `number` | 0--1 | 0 = mirror-smooth, 1 = matte | | `emissiveColor` | `Color3` | 0--1 per channel | Self-illumination color | | `emissiveIntensity` | `number` | 0+ | Emissive brightness multiplier | **Common material recipes:** ```typescript // Shiny metal mat.metallic = 0.8; mat.roughness = 0.2; // Wood / plastic mat.metallic = 0.1; mat.roughness = 0.7; // Glowing collectible (visible regardless of scene lighting) mat.albedoColor = new Color3(1, 0.85, 0); mat.emissiveColor = new Color3(0.3, 0.25, 0); mat.emissiveIntensity = 1.0; mat.metallic = 0.8; mat.roughness = 0.2; ``` ### Emissive as a visibility tool In PBR scenes with low ambient light, objects can be hard to see. Adding `emissiveColor` makes objects glow independently of scene lighting. This is how the level-loading example makes keys and magic platforms visible in darker areas. It is not a hack -- real-time games use emissive materials constantly for UI elements, collectibles, and interactive objects. ### Blender lights Blender lights export via the `KHR_lights_punctual` glTF extension. By default, Blender's exporter uses **"Standard" lighting mode**, which converts watts to physical photometric units using a `683 lm/W` factor. This produces values like 683,000 lux for a 1000W sun — technically correct per the glTF spec, but BabylonJS (and most glTF viewers) don't apply the exposure compensation needed to display those values, so the scene is massively overexposed. **The fix:** In Blender's GLB export dialog, change the lighting mode to **Unitless**: 1. **File > Export > glTF 2.0** 2. Under **Data > Lighting**, set **Lighting Mode** to **Unitless** Unitless mode skips the 683 lm/W conversion and produces intensity values that look correct in BabylonJS at default exposure. This is the recommended setting for SAGE projects. ::: warning Area lights Blender area lights are not part of the glTF spec and are silently dropped on export. Use point or spot lights instead. ::: #### Working with GLBs exported in Standard mode If you receive a GLB that was exported with Standard lighting mode and can't re-export it, you can correct the intensities in a `GameLevel` subclass. The conversion factor is `683` — divide by it to undo the Standard mode conversion: ```typescript import { DirectionalLight, PointLight, SpotLight } from '@babylonjs/core'; protected async buildScene() : Promise { const scene = await super.buildScene(); const LUMENS_PER_WATT = 683; for(const light of scene.lights) { if(light instanceof DirectionalLight) { // Standard mode: watts * 683 = lux. Undo it. light.intensity /= LUMENS_PER_WATT; } else if(light instanceof PointLight || light instanceof SpotLight) { // Standard mode: watts * 683 / (4 * PI) = candela. Undo it. light.intensity /= LUMENS_PER_WATT / (4 * Math.PI); } } return scene; } ``` ### Code-only scenes (no Blender) If your scene has no Blender content at all (everything is created in code), you can use either `StandardMaterial` or `PBRMaterial`. `StandardMaterial` with traditional intensity values (0--1) works fine when there are no PBR materials to conflict with. The entity-behaviors example demonstrates this approach. However, if you plan to add Blender content later, start with PBR from the beginning. Migrating from StandardMaterial to PBRMaterial requires adjusting every material and light in the scene. ## Recommended Blender workflow ### Scene organization Organize your Blender scene with collections: ``` Collection: "Environment" Ground, walls, terrain, static props Collection: "Audio" Empties or meshes with sound properties Collection: "Gameplay" Spawn points, entity markers, trigger zones Collection: "Interactive" Doors, buttons, elevators, chests ``` This keeps the outliner manageable and makes it easy to hide/show groups while working. ### Naming conventions * Use descriptive names: `Door_Main`, `ElevatorButton_F2`, `Crate_Storage_01` * Entity names in Blender become entity names at runtime -- keep them meaningful * Spawn point empty names should match the YAML config keys ### Transform hygiene Before export: 1. **Apply all transforms** (Ctrl+A > All Transforms) on meshes that use collision 2. Check for non-manifold edges if using mesh colliders 3. Ensure scale is (1, 1, 1) on objects where precise collision matters ### Iteration workflow 1. Author the scene in Blender with custom properties 2. Export as GLB to the project's public assets directory 3. Run the dev server (`npm run dev`) 4. Test in-browser -- check spawn positions, collision, sounds, and triggers 5. Adjust in Blender and re-export 6. The dev server hot-reloads the GLB on the next level load ::: tip Physics debugging The sandbox example includes an F3 toggle for the Havok physics viewer. Use it to visualize collision shapes and verify that your colliders match the visual meshes. ::: ## File organization Organize your project assets alongside the source: ``` public/ assets/ sandbox/ SAGE_dev-box.glb # Level scene door-open.mp3 # Sound effects door-close.mp3 ambient-factory.mp3 # Background audio unlock.mp3 level-loading/ simple-arena.glb physics-playground/ bulldozer.glb examples/src/examples/sandbox/ levels/ source/ SAGE_dev-box.blend # Blender source (not exported) sandbox.yaml # Level config ``` Keep Blender source files in a `source/` directory adjacent to the YAML configs. These are not exported or served -- they are your editable originals. ## Quick reference ### Essential properties | Property | Type | Example | Purpose | |----------|------|---------|---------| | `spawn` | String | `"player_start"` | Mark spawn location | | `entity` | String | `"door"` | Mark mesh as entity | | `sound` | String | `"audio/fire.mp3"` | Attach sound | | `collider` | String | `"box"` | Add collision | | `trigger` | String | `"boss_room"` | Create trigger zone | | `occluder` | Boolean | true | Occlusion optimization | | `lod_distances` | String | `"15,30,60"` | Level of detail | ### Entity metadata properties | Property | Type | Example | Purpose | |----------|------|---------|---------| | `target` | String | `"Door_Main"` | Interaction target entity name | | `prompt` | String | `"to open"` | Interaction prompt text | | `locked` | Boolean | true | Lock the interaction | | `context` | String | `'{"floor": 2}'` | Extra payload (JSON) | | `mass` | Float | `20` | Physics mass for props | | `floor_count` | Integer | `4` | Elevator floor count | | `floor_spacing` | Float | `3.0` | Elevator floor height | ### Sound sub-properties | Property | Type | Default | |----------|------|---------| | `sound_volume` | Float | 1.0 | | `sound_loop` | Boolean | true | | `sound_spatial` | Boolean | true | | `sound_distance` | Integer | 100 | | `sound_autoplay` | Boolean | true | | `sound_channel` | String | `"ambient"` | ## Troubleshooting ### Properties not recognized * Verify "Custom Properties" is enabled in export settings * Check property names for typos (case-sensitive) * Re-export the GLB after changes ### Collision issues * Check that `collider` is set on the correct object * For mesh colliders, ensure geometry is manifold (no holes) * Try a simpler collider type (box instead of mesh) * Apply transforms before export ### Sound not playing * Verify the audio file path is correct and the file exists in `public/` * Check `sound_volume` is not 0 * For spatial sounds, check `sound_distance` is large enough * Ensure `sound_autoplay` is true if expecting immediate playback ### Trigger not firing * Verify the trigger mesh has actual geometry (not an Empty) * Check the mesh is large enough to intersect with moving objects * Verify the trigger name matches what the code expects ### Entity not spawning at correct position * Check that the Blender empty/mesh transform is applied * The GLB root node applies a coordinate system conversion -- use `Vector3.TransformCoordinates(localPos, root.getWorldMatrix())` to convert to world space ## Next steps * [Level Loading](./level-loading.md) -- the YAML config format and level transitions in detail * [Sandbox](./sandbox.md) -- see these Blender workflows in action with a complete first-person environment --- --- url: /guides/collider-debugging.md description: >- Visualize physics colliders in your scene using SAGE's debug tools — per-entity, per-node, or config-driven through entity definitions and level YAML. --- # Collider Debug Visualization When physics colliders don't line up with your meshes, you get invisible walls, objects falling through floors, and a lot of confused swearing at your monitor. SAGE wraps BabylonJS's `PhysicsViewer` into a three-level API that lets you visualize colliders with a single line of code or a single line of YAML. ## Three ways to use it | Approach | Best for | |----------|----------| | **Config-driven** (`debugCollider` in entity defs / YAML) | Always-on during development | | **Entity methods** (`entity.showCollider()`) | Runtime toggling, inspector tools | | **Low-level manager** (`gameEngine.debug.colliders`) | Custom debug UIs, fine-grained control | Most of the time you want the config-driven approach. Drop `debugCollider: true` into your entity definition and forget about it until you ship. ## Config-driven: `debugCollider` Add `debugCollider` to an entity definition, a level spawn, or an entity node config. SAGE applies it automatically when the entity is created during level loading. ### In entity definitions (TypeScript) ```typescript const soldier : GameEntityDefinition = { type: 'soldier', defaultState: { /* ... */ }, behaviors: [ SoldierBehavior ], debugCollider: true, // all colliders, default color }; ``` Other forms: ```typescript debugCollider: '#ff0000' // all colliders in red debugCollider: { head: '#ff0000', body: '#00ff00' } // per-node colors debugCollider: { head: '#ff0000', shield: false } // show head, explicitly hide shield ``` ### In level config (YAML) Level config overrides the entity definition. This means you can leave `debugCollider` off in your entity definitions and turn it on for specific levels or spawns without touching TypeScript. ```yaml name: battlefield scene: /assets/levels/battlefield.glb physics: true # Override for all soldiers in this level entities: soldier: debugCollider: '#00ff00' # Override for a specific spawn point spawns: player_spawn: entity: player debugCollider: true ``` The priority chain: **spawn/entity config in YAML** > **entity definition `debugCollider`** > **nothing**. ## Entity methods: `showCollider()` / `hideCollider()` These convenience methods on `GameEntity` find all physics nodes in the entity's node tree and delegate to the debug manager. Useful when you want runtime control — a debug key binding, an inspector panel, etc. ### `showCollider()` ```typescript entity.showCollider(); // all colliders, default color entity.showCollider(true); // same (useful for config-driven patterns) entity.showCollider('#ff0000'); // all colliders in red entity.showCollider('head'); // single node by name entity.showCollider('head', '#ff0000'); // single node by name, in red entity.showCollider({ // per-node control head: '#ff0000', body: '#00ff00', shield: false, }); ``` The node names (like `'head'`) correspond to the node names in your scene file. In Blender, that's the object name in the outliner. ### `hideCollider()` ```typescript entity.hideCollider(); // hide all entity.hideCollider('head'); // hide one by name ``` ### Example: Toggle colliders with a key binding ```typescript let collidersVisible = false; gameEngine.managers.inputManager.on('action:toggleColliders', () => { collidersVisible = !collidersVisible; for(const entity of gameEngine.managers.entityManager.getAllEntities()) { if(collidersVisible) { entity.showCollider('#00ff00'); } else { entity.hideCollider(); } } }); ``` ## Low-level: `ColliderDebugManager` The manager lives at `gameEngine.debug.colliders` and operates on individual `TransformNode` references. This is what the entity methods delegate to under the hood. ```typescript const debugColliders = gameEngine.debug.colliders; // Show a node's collider debugColliders.showColliderForNode(node); debugColliders.showColliderForNode(node, '#ff0000'); // Hide it debugColliders.hideColliderForNode(node); // Hide everything debugColliders.hideAll(); // Check if a node's collider is currently visible debugColliders.isShown(node); // Clean up all debug visualization debugColliders.dispose(); ``` ### Imports ```typescript import { ColliderDebugManager } from '@skewedaspect/sage'; import type { ColliderDebugConfig } from '@skewedaspect/sage'; ``` `ColliderDebugConfig` is the union type accepted by `debugCollider` and `showCollider()`: ```typescript type ColliderDebugConfig = boolean | string | Record; ``` ## How it works A few details worth knowing: * **Lazy initialization.** The underlying `PhysicsViewer` is not created until the first call to `showColliderForNode()`. If you never use debug visualization, there is zero overhead. * **Utility layer rendering.** Debug meshes render on a separate `UtilityLayerRenderer`, so they don't interfere with raycasts, picking, or your scene's render pipeline. * **Material caching.** Color materials are created once per hex string and reused. Showing 50 colliders in `#ff0000` creates one material, not 50. * **Colors are hex strings.** Use `#RRGGBB` format. Named CSS colors (`red`, `green`) are not supported — they will be passed directly to `Color3.FromHexString()` and throw. * **Node tree walking.** `entity.showCollider()` recursively finds all nodes with a `physicsBody` in the entity's node hierarchy. This covers compound physics setups where colliders are on child nodes, not the root mesh. ## Related pages * [Physics API Reference](/api/physics) — Havok integration, `PhysicsAggregate`, forces, collisions * [Physics Playground guide](/guides/physics-playground) — hands-on physics example * [Level Loading guide](/guides/level-loading) — YAML level config, spawn points, entity definitions --- --- url: /guides/coordinate-systems.md --- # Coordinate Systems SAGE uses BabylonJS as its rendering engine, which operates in a **left-handed** coordinate system. Most 3D content is authored in **right-handed** tools (Blender, glTF standard). This page explains how SAGE handles the conversion transparently. ## How BabylonJS Handles glTF Imports When BabylonJS imports a `.glb` or `.gltf` file into a left-handed scene, it creates a hidden `__root__` transform node at the top of the imported hierarchy. This node has `scaling = (-1, 1, 1)`, which mirrors the X axis to convert from right-handed to left-handed coordinates. You generally don't need to think about `__root__`. It's an implementation detail of BabylonJS's loader. All nodes in the imported scene become children of `__root__`, so their local coordinates are in the mirrored (right-handed) space, while their world-space positions are correct left-handed values. ## How SAGE Handles Spawn Points Spawn points are nodes in your scene file tagged with a `spawn` custom property. They live inside the scene's `__root__` hierarchy. When SAGE reads spawn points, it extracts **world-space** (absolute) position and rotation. Scaling uses the spawn point's own local value since it represents artist intent, not the coordinate conversion. Entity nodes are created at the **scene root** — outside `__root__`. Since they have no parent, local equals world, so the absolute values apply directly. This ensures entities appear exactly where the spawn point is, facing the correct direction. ## How Entity Meshes Work When an entity has a GLB mesh, SAGE imports it and the loader creates its own `__root__` node for that import. This is parented under the entity node at the scene root: ``` scene root ├── __root__ (scene GLB — handedness conversion) │ ├── level geometry │ ├── lights │ └── spawn points │ ├── entity-1 (world-space position from spawn point) │ └── __root__ (entity GLB — handedness conversion) │ └── meshes │ ├── entity-2 (primitive — no GLB, no __root__) │ └── box mesh ``` Each GLB import gets exactly one `__root__` handedness conversion. Primitive meshes (box, sphere, capsule, cylinder) don't need conversion since they're created natively in BabylonJS's left-handed space. ## The `rightHanded` Option If your scene file was exported for a right-handed coordinate system, you can set `rightHanded: true` in your level config: ```yaml scene: path: models/level.glb rightHanded: true ``` This sets `scene.useRightHandedSystem = true`, which tells BabylonJS to interpret all coordinates as right-handed. When enabled, the glTF loader does **not** create `__root__` — no conversion is needed since both the scene and the format are right-handed. ## Troubleshooting **Entity appears at mirrored X position:** The spawn point's position is being read in local space instead of world space. This is a SAGE bug — spawn points should always use `getAbsolutePosition()`. Check that you're using an up-to-date version. **Entity mesh appears mirrored or inside-out:** The entity's GLB `__root__` may be missing or stripped. Each GLB import needs its own `__root__` for correct handedness conversion. **Entity mesh appears correct but at the wrong rotation:** Same root cause as the position issue. Rotations under a mirrored parent behave differently — SAGE extracts `absoluteRotationQuaternion` to handle this correctly. --- --- url: /guides/debug-console.md description: >- Turn your browser's dev console into a game debug console with window.sage -- inspect entities, teleport objects, toggle colliders, log events, and control the game loop in real time. --- # Debug Console Debugging a game engine from the outside is painful. You end up littering your code with `console.log` calls, rebuilding, checking the output, and repeating. SAGE's debug console takes a different approach: it exposes your running game on `window.sage` so you can poke at it directly from the browser's developer console. Open DevTools, type `sage.entities.player`, and you get the live entity. Call `sage.tp('player', 0, 10, 0)` and your player teleports. No rebuilds, no throwaway logging code, no friction. ## Quick start The debug console is **enabled by default**. Start your game normally: ```typescript const engine = await createGameEngine(canvas, entities); await engine.start(); ``` Open your browser's developer console and type: ```javascript sage.engine // the GameEngine instance sage.entities.player // look up any entity by name sage.list() // table of all entities sage.inspect('player') // dump full entity state ``` That is it. Everything below is details. ## Configuration The debug console is configured through `SageOptions` when creating the engine. ### Default (enabled) ```typescript // Debug console is on, accessible as window.sage const engine = await createGameEngine(canvas, entities); ``` ### Custom namespace ```typescript // Accessible as window.myGame instead of window.sage const engine = await createGameEngine(canvas, entities, { debug: { namespace: 'myGame' } }); ``` Now you type `myGame.entities.player` in the console instead of `sage.entities.player`. ### Disabled (production) ```typescript // No debug namespace on window at all const engine = await createGameEngine(canvas, entities, { debug: false }); ``` For production builds, disable the debug console entirely. There is no runtime cost when disabled -- the `expose()` calls become no-ops. ## Built-in references These are registered automatically when the engine starts. They give you direct access to the major subsystems without digging through manager hierarchies. | Property | What it is | |----------|-----------| | `sage.engine` | The `GameEngine` instance | | `sage.entities` | Proxy-wrapped `EntityManager` (see below) | | `sage.level` | Current level (dynamic -- updates on level transitions) | | `sage.scene` | Current BabylonJS `Scene` (dynamic) | | `sage.physics` | `HavokPlugin` instance | | `sage.colliders` | `ColliderDebugManager` | | `sage.events` | `EventBus` | ### The entity proxy `sage.entities` is special. It is a `Proxy` around the `EntityManager` that intercepts property access and resolves entities by name. This means: ```javascript // These are equivalent: sage.entities.player sage.entities.getByName('player') // But the real EntityManager methods still work: sage.entities.getByType('soldier') sage.entities.getAllEntities() ``` Property access resolves via `getByName()` first. If there is no entity with that name, it falls through to the actual `EntityManager` method or property. ## Built-in commands These are registered as functions on the debug namespace. They are designed for the interactive console -- formatted output, sensible defaults, minimal typing. ### Entity manipulation | Command | Description | |---------|-------------| | `sage.tp('player', x, y, z)` | Teleport an entity to a world position | | `sage.spawn('soldier', x, y, z)` | Spawn an entity at a position | | `sage.list()` | List all entities using `console.table` | | `sage.list('soldier')` | List entities of a specific type | | `sage.inspect('player')` | Dump entity state, behaviors, and node info | ### Collider visualization | Command | Description | |---------|-------------| | `sage.showColliders()` | Show collider debug vis for all entities | | `sage.showColliders('player')` | Show by entity name or type | | `sage.hideColliders()` | Hide all collider debug vis | | `sage.hideColliders('player')` | Hide by entity name or type | These commands wrap the same collider debug system described in the [Collider Debugging guide](/guides/collider-debugging). The difference is convenience -- you do not need to look up entity references or call methods on them. Just type a name. ### Event debugging | Command | Description | |---------|-------------| | `sage.logEvents('input:*')` | Log matching events to the console; returns an unsubscribe function | | `sage.stopLogging()` | Stop all active event loggers | `logEvents` accepts the same wildcard patterns as the event bus. A few useful patterns: ```javascript // Log all input events sage.logEvents('input:*') // Log everything (noisy, but sometimes that's what you need) sage.logEvents('*') // Log entity lifecycle events sage.logEvents('entity:*') // Save the unsubscribe function if you want fine-grained control const unsub = sage.logEvents('physics:*') unsub() // stop just this one ``` ### Game loop control | Command | Description | |---------|-------------| | `sage.pause()` | Pause the game loop | | `sage.resume()` | Resume the game loop | | `sage.slow(0.5)` | Set time scale (0.5 = half speed, 2.0 = double speed) | `sage.slow()` is invaluable for debugging physics and animation issues. Slow the game down to quarter speed with `sage.slow(0.25)` and watch what happens frame by frame. ## Registering game-specific commands The built-in commands cover engine-level debugging, but your game has its own state and logic. Use `gameEngine.debug.expose()` to register your own entries on the debug namespace. ### Direct values ```typescript // Expose a reference to your game state gameEngine.debug.expose('gameState', myGameState); ``` Now `sage.gameState` returns your object in the console. ### Callable commands ```typescript gameEngine.debug.expose('godmode', () => { const player = gameEngine.managers.entityManager.getByName('player'); if(player) { player.state.invincible = true; console.log('[game] God mode enabled'); } }); gameEngine.debug.expose('giveItem', (itemType : string) => { const player = gameEngine.managers.entityManager.getByName('player'); if(player) { player.state.inventory.push(itemType); console.log(`[game] Added ${ itemType } to inventory`); } }); ``` Then in the console: ```javascript sage.godmode() sage.giveItem('railgun') ``` ### Dynamic getters Sometimes you want a property that resolves fresh on every access, not a snapshot. Pass an object with a `get` function: ```typescript gameEngine.debug.expose('player', { get: () => gameEngine.managers.entityManager.getByName('player') }); ``` This way `sage.player` always returns the current player entity, even if it was destroyed and respawned since the last time you checked. ### Where to register The natural place to register game-specific commands is in the engine's `onStart` hook, after entities and levels are loaded: ```typescript engine.onStart(async (ge) => { // Entity references are available now ge.debug.expose('player', { get: () => ge.managers.entityManager.getByName('player') }); ge.debug.expose('godmode', () => { const player = ge.managers.entityManager.getByName('player'); if(player) { player.state.invincible = true; console.log('[game] God mode enabled'); } }); ge.debug.expose('resetLevel', async () => { await ge.managers.levelManager.reload(); console.log('[game] Level reloaded'); }); }); ``` ## Cleanup The debug namespace is automatically removed from `window` when the engine stops via `gameEngine.stop()`. You do not need to clean up manually. ## Practical workflow Here is a typical debugging session using the console: ```javascript // 1. See what's in the scene sage.list() // 2. Find the entity that's misbehaving sage.inspect('broken-door') // 3. Show its colliders to see if physics shapes are off sage.showColliders('broken-door') // 4. Slow down time to watch the interaction sage.slow(0.25) // 5. Teleport the player near it to trigger the issue sage.tp('player', 5, 0, 3) // 6. Log events to see what's firing sage.logEvents('entity:broken-door:*') // 7. Found it -- resume normal speed sage.slow(1) sage.stopLogging() sage.hideColliders('broken-door') ``` ## API summary ### `gameEngine.debug.expose(name, value)` Register a value, function, or dynamic getter on the debug namespace. | Argument | Type | Description | |----------|------|-------------| | `name` | `string` | Property name on the debug namespace | | `value` | `unknown \| Function \| { get: () => unknown }` | Value, callable command, or dynamic getter | ### `SageOptions.debug` | Value | Effect | |-------|--------| | `undefined` (default) | Enabled as `window.sage` | | `{ namespace: 'name' }` | Enabled as `window.name` | | `false` | Disabled entirely | ### Exports ```typescript import { DebugConsole } from '@skewedaspect/sage'; import { createEntityProxy } from '@skewedaspect/sage'; ``` ## Related pages * [Collider Debugging](/guides/collider-debugging) -- visual collider debug tools, config-driven and programmatic * [Events](/concepts/events) -- event bus patterns and wildcards used by `logEvents` * [Entity Behaviors](/guides/entity-behaviors) -- entity composition, state, and the behavior system --- --- url: /concepts/entities.md description: >- The SAGE entity system -- composition over inheritance, entity definitions, behaviors, parent-child hierarchies, node attachment, querying, and object pooling. --- # Entities SAGE uses a composition-based entity system. Instead of building game objects through deep inheritance chains, you assemble them from small, reusable **behaviors** that each handle one concern. A player character is just an entity with movement, health, and input behaviors attached. Swap the input behavior for an AI behavior and you have an enemy. ## What is an Entity? An entity is any object in your game world: a player, an enemy, a door, a projectile, a trigger volume. Each entity is an instance of `GameEntity` with the following structure: | Property | Type | Description | |---|---|---| | `id` | `string` | 16-character hex string, unique within the session. Generated automatically. | | `type` | `string` | The definition type this entity was created from (e.g. `'enemy:goblin'`). | | `name` | `string \| undefined` | Optional human-readable name. Not required to be unique. | | `tags` | `Set` | Strings for categorization and querying (e.g. `'hostile'`, `'interactive'`). | | `state` | `object` | Arbitrary data: health, speed, inventory -- whatever the entity needs. | | `behaviors` | `GameEntityBehavior[]` | Attached behavior instances that define what the entity can do. | | `node` | `TransformNode \| undefined` | Optional BabylonJS scene node for visual representation. | | `parent` | `GameEntity \| null` | Parent entity in a hierarchy, or `null` for root entities. | | `children` | `GameEntity[]` | Child entities. | Behaviors are the building blocks. Each behavior handles a single aspect of functionality -- movement, health, combat, audio -- and can subscribe to events, update every frame, and read/write entity state. See the [Behaviors guide](/api/behaviors) for the full story on writing behaviors. ## Entity Definitions Before you can create entities, you register a **definition** (blueprint) with the `EntityManager`. Definitions describe the type, default state, and behaviors for a class of entities. ```typescript interface GameEntityDefinition { type : string; name? : string; tags? : string[]; defaultState : State; behaviors : GameEntityBehaviorConstructor[]; actions? : Action[]; // Lifecycle hooks onBeforeCreate? : (state : State) => Promise | State | undefined; onCreate? : (state : State) => Promise | State | undefined; onBeforeDestroy? : (state : State) => Promise | State | undefined; onDestroy? : (state : State) => Promise | void; } ``` ### Registering and Creating ```typescript // Register a definition entityManager.registerEntityDefinition({ type: 'weapon:energySword', name: 'Energy Sword', tags: [ 'weapon', 'melee' ], defaultState: { color: 'blue', isActive: false, damage: 50, owner: null, }, behaviors: [ GlowingBehavior, SoundEffectBehavior, DamageBehavior ], }); // Create an instance with defaults const sword = await entityManager.createEntity('weapon:energySword'); console.log(sword.name); // 'Energy Sword' console.log(sword.state.color); // 'blue' console.log([ ...sword.tags ]); // [ 'weapon', 'melee' ] // Create a customized instance const playerSword = await entityManager.createEntity('weapon:energySword', { initialState: { color: 'green', damage: 75 }, name: "Player's Blade", tags: [ 'equipped', 'legendary' ], }); console.log(playerSword.state.damage); // 75 console.log([ ...playerSword.tags ]); // [ 'weapon', 'melee', 'equipped', 'legendary' ] ``` ### CreateEntityOptions When creating an entity, you can pass options to customize the instance: ```typescript interface CreateEntityOptions { initialState? : Partial; // Merged with defaultState name? : string; // Overrides definition name tags? : string[]; // Added to definition tags node? : TransformNode; // Scene node to attach } ``` Tags from the definition and from creation options are merged together. State is shallow-merged, with creation values overriding definition defaults. ## Definition Inheritance Entity definitions can extend other definitions using the `extends` field. This lets you build specialized types from a common base without duplicating configuration. The parent must be registered first. ```typescript // Base definition entityManager.registerEntityDefinition({ type: 'enemy:base', name: 'Enemy', tags: [ 'enemy', 'hostile' ], defaultState: { health: 100, speed: 5, damage: 10 }, behaviors: [ AiBehavior, CombatBehavior ], }); // Child overrides specific fields entityManager.registerEntityDefinition({ type: 'enemy:goblin', extends: 'enemy:base', name: 'Goblin', defaultState: { health: 50, speed: 8 }, behaviors: [ AiBehavior, CombatBehavior, StealBehavior ], tags: [ 'small' ], }); ``` ### Merge Rules | Field | Strategy | Details | |---|---|---| | `defaultState` | **Shallow merge** | Child properties override base; base-only properties preserved. | | `tags` | **Concatenate and deduplicate** | Both arrays combined into a unique set. | | `behaviors` | **Replace** | Child list replaces base entirely. Omit to inherit. | | `actions` | **Replace** | Same as behaviors. | | `children` | **Replace** | Same as behaviors. | | `name`, `mesh`, `poolable`, `poolSize` | **Child wins** | Child value if provided, otherwise base. | | Lifecycle hooks | **Child wins** | Child overrides if provided. | The resolved `enemy:goblin` from the example above: ```typescript { type: 'enemy:goblin', name: 'Goblin', // child overrides tags: [ 'enemy', 'hostile', 'small' ], // concatenated, deduplicated defaultState: { health: 50, speed: 8, damage: 10 }, // shallow merge behaviors: [ AiBehavior, CombatBehavior, StealBehavior ], // child replaces entirely } ``` ### Recursive Chains Inheritance chains work to any depth. If `A` extends `B` and `B` extends `C`, register them in order: `C`, then `B`, then `A`. Each registration resolves against its already-resolved parent. ```typescript entityManager.registerEntityDefinition({ type: 'unit:base', defaultState: { health: 100, speed: 5 }, behaviors: [ MoveBehavior ], }); entityManager.registerEntityDefinition({ type: 'unit:soldier', extends: 'unit:base', defaultState: { armor: 20 }, behaviors: [ MoveBehavior, CombatBehavior ], }); entityManager.registerEntityDefinition({ type: 'unit:elite-soldier', extends: 'unit:soldier', defaultState: { health: 200 }, }); // Resolved elite-soldier: // state: { health: 200, speed: 5, armor: 20 } // behaviors: [ MoveBehavior, CombatBehavior ] (inherited from soldier) ``` If the base type is not registered when a child is registered, SAGE throws immediately -- no silent failures at entity creation time. ## Parent-Child Hierarchy Entities can be organized into parent-child trees. When both parent and child have scene nodes, the child's node is automatically parented under the parent's node in the BabylonJS scene graph. ### Declaring Children in Definitions ```typescript entityManager.registerEntityDefinition({ type: 'vehicle:tank', defaultState: { health: 500, speed: 3 }, behaviors: [ VehicleBehavior ], children: [ { type: 'weapon:turret', name: 'main_turret', position: { x: 0, y: 1.5, z: 0 }, }, { type: 'weapon:machineGun', name: 'coax_mg', initialState: { ammo: 500 }, position: { x: 0.3, y: 1.6, z: 0.5 }, }, ], }); // Creating the tank also creates both children const tank = await entityManager.createEntity('vehicle:tank'); console.log(tank.children.length); // 2 console.log(tank.children[0].name); // 'main_turret' ``` Each child entry accepts: | Field | Description | |---|---| | `type` | Required. The registered entity type to create. | | `name` | Optional name override for the child instance. | | `initialState` | Optional state merged with the child's `defaultState`. | | `position` | Optional `{ x, y, z }` stored as `_position` in initial state. | | `rotation` | Optional `{ x, y, z }` stored as `_rotation` in initial state. | ### Runtime Hierarchy Management ```typescript const ship = await entityManager.createEntity('vehicle:ship'); const cannon = await entityManager.createEntity('weapon:cannon'); // Attach as child entityManager.addChild(ship.id, cannon.id); console.log(cannon.parent === ship); // true console.log(ship.children.includes(cannon)); // true // Detach later entityManager.removeChild(ship.id, cannon.id); console.log(cannon.parent); // null ``` ### Lifecycle Cascade Parent-child relationships cascade through several operations: * **Update:** The frame loop only calls `$update()` on root entities. Each root cascades to its children automatically. * **Destroy:** Destroying a parent destroys all children first, depth-first. * **Node attachment:** When a parent is attached to a node, children's nodes are re-parented under it. Detaching the parent detaches children from the scene graph hierarchy. * **Reset:** When a poolable entity is reset, all children reset recursively. ## Entity-Node Attachment Entities can be attached to BabylonJS `TransformNode` objects, linking game logic to visual representation. ### Attaching at Creation ```typescript const doorMesh = await SceneLoader.ImportMeshAsync('door', 'models/', 'door.glb'); const doorNode = doorMesh.meshes[0] as TransformNode; const doorEntity = await entityManager.createEntity('object:door', { node: doorNode, name: 'main_door', tags: [ 'interactive', 'locked' ], }); ``` ### Runtime Attachment ```typescript const entity = await entityManager.createEntity('object:pickup'); // Attach to a mesh const meshNode = scene.getMeshByName('health_pack'); entityManager.attachToNode(entity, meshNode); // Detach entityManager.detachFromNode(entity); ``` ### Behavior Lifecycle Hooks When entities are attached to or detached from nodes, all behaviors receive lifecycle hooks: * **`onNodeAttached(node : TransformNode, gameEngine : GameEngine)`** -- called when a node is attached (at creation, via `attachToNode()`, or when a behavior is added to an entity that already has a node). * **`onNodeDetached()`** -- called when the node is removed. ```typescript class ParticleEffectBehavior extends GameEntityBehavior { name = 'ParticleEffectBehavior'; private particleSystem : ParticleSystem | null = null; onNodeAttached(node : TransformNode, _gameEngine : GameEngine) : void { const scene = node.getScene(); this.particleSystem = new ParticleSystem('particles', 2000, scene); this.particleSystem.emitter = node; } onNodeDetached() : void { this.particleSystem?.dispose(); this.particleSystem = null; } } ``` ### Looking Up Entities by Node After raycasting or scene interaction, find the entity attached to a picked mesh: ```typescript scene.onPointerDown = (evt, pickResult) => { if(pickResult.hit && pickResult.pickedMesh) { let node = pickResult.pickedMesh as TransformNode; let entity = entityManager.getByNode(node); // Walk up the hierarchy if needed while(!entity && node.parent) { node = node.parent as TransformNode; entity = entityManager.getByNode(node); } if(entity) { console.log(`Clicked on entity: ${ entity.name ?? entity.id }`); } } }; ``` ### Node Hierarchy Navigation Entities provide helpers for navigating their attached node's children: ```typescript // Get a direct child node by name const weaponSlot = character.getChildNode('weapon_slot'); // Find a descendant by slash-separated path const fingerBone = character.findNode('armature/hand_R/finger_index'); ``` Both return `undefined` if the entity has no attached node or the path is not found. ## Querying Entities The `EntityManager` provides indexed lookups for finding entities. All primary lookups are **O(1)**. ### By ID ```typescript const entity = entityManager.getEntity('8cd88e1a9b13aac0'); ``` ### By Name ```typescript // First match const mainDoor = entityManager.getByName('main_entrance'); // All matches (names are not required to be unique) const guardDoors = entityManager.getEntitiesByName('guard_room_door'); ``` ### By Type ```typescript const allGoblins = entityManager.getByType('character:goblin'); ``` ### By Tag ```typescript // Single tag const enemies = entityManager.getByTag('enemy'); // Multiple tags -- 'all' mode (default): must have ALL tags const hostileElites = entityManager.getByTags([ 'enemy', 'elite' ], 'all'); // 'any' mode: must have AT LEAST ONE tag const threats = entityManager.getByTags([ 'enemy', 'trap', 'hazard' ], 'any'); ``` ### By Node ```typescript const entity = entityManager.getByNode(pickedMesh); ``` ### Iterating All Entities ```typescript for(const entity of entityManager.getAllEntities()) { console.log(`${ entity.id } (${ entity.type })`); } console.log(`Total: ${ entityManager.entityCount }`); ``` ### Performance Table | Method | Performance | Notes | |---|---|---| | `getEntity(id)` | O(1) | Direct Map lookup | | `getByName(name)` | O(1) | Indexed by name | | `getEntitiesByName(name)` | O(1) + O(n) | Index lookup + array copy | | `getByType(type)` | O(1) + O(n) | Index lookup + array copy | | `getByTag(tag)` | O(1) + O(n) | Index lookup + array copy | | `getByNode(node)` | O(1) | Direct Map lookup | | `getByTags(tags, 'all')` | O(n*m) | Intersection of tag sets | | `getByTags(tags, 'any')` | O(n*m) | Union of tag sets | Where *n* is the number of matching entities and *m* is the number of tags. ## Naming and Tags ### Names Names are optional, human-readable identifiers. Multiple entities can share the same name. ```typescript const door1 = await entityManager.createEntity('object:door', { name: 'guard_room_door' }); const door2 = await entityManager.createEntity('object:door', { name: 'guard_room_door' }); // Both share the same name -- use getEntitiesByName() to find all of them ``` ### Tags Tags are strings for categorization. Definition tags and creation tags are merged. ```typescript // Definition has tags: [ 'enemy', 'hostile' ] const elite = await entityManager.createEntity('character:goblin', { tags: [ 'elite', 'boss' ], }); // Merged result: [ 'enemy', 'hostile', 'elite', 'boss' ] console.log(elite.hasTag('enemy')); // true console.log(elite.hasTag('elite')); // true ``` #### Runtime Tag Manipulation ```typescript // Add (returns true if added, false if already present) entityManager.addTag(entity, 'invisible'); entityManager.addTag(entity.id, 'invulnerable'); // also accepts entity ID // Check entity.hasTag('invisible'); // true // Remove (returns true if removed, false if not present) entityManager.removeTag(entity, 'invisible'); ``` ## Object Pooling For entities that are created and destroyed frequently -- bullets, particles, pickups -- object pooling avoids repeated allocation and garbage collection. SAGE provides built-in pooling that is transparent to the rest of your code. ### Enabling Pooling ```typescript entityManager.registerEntityDefinition({ type: 'projectile:bullet', poolable: true, defaultState: { speed: 50, damage: 10, lifetime: 2.0 }, behaviors: [ ProjectileBehavior ], }); ``` With `poolable: true`, calling `destroyEntity()` resets the entity and stores it in an internal pool. The next `createEntity('projectile:bullet')` pulls from the pool instead of allocating. Your game code does not change -- `createEntity` and `destroyEntity` work exactly the same way. ### Prewarming Avoid allocation hitches during gameplay by pre-creating pooled entities at registration time: ```typescript entityManager.registerEntityDefinition({ type: 'projectile:bullet', poolable: true, poolSize: 50, // Pre-creates 50 bullets in the pool defaultState: { speed: 50, damage: 10, lifetime: 2.0 }, behaviors: [ ProjectileBehavior ], }); // Or prewarm manually at any time await entityManager.prewarm('projectile:bullet', 100); ``` ### The `onReset` Hook When a poolable entity is recycled, SAGE resets its state to `defaultState`, clears and restores default tags, detaches from any node, and calls `onReset()` on every attached behavior. Use this hook to clean up internal behavior state: ```typescript class ProjectileBehavior extends GameEntityBehavior { name = 'ProjectileBehavior'; eventSubscriptions = []; private trailParticles : ParticleSystem | null = null; onReset(state : { lifetime : number }) : void { this.trailParticles?.dispose(); this.trailParticles = null; } // ... } ``` ### Draining Pools When transitioning levels or cleaning up, drain a pool to actually destroy the entities and free memory: ```typescript await entityManager.drainPool('projectile:bullet'); ``` SAGE's `$teardown()` drains all pools automatically, so you typically don't need to do this manually. ### Parent-Child Pooling When a poolable parent is destroyed (pooled), its children are also destroyed -- and if the children's types are poolable, they go into their own pools. When the parent is recycled, children declared in the definition are re-created (potentially also recycled). Define both parent and child types as poolable and the system handles it. ## ID Generation SAGE uses `hexoid` for ID generation, producing 16-character hex strings. This is roughly 200x faster than `crypto.randomUUID()`, so entity creation is never a bottleneck. ```typescript import { generateId } from '@skewedaspect/sage'; const id = generateId(); // "8cd88e1a9b13aac0" ``` The `generateId` utility is exported for use in your own code when you need fast, unique identifiers. ## Entity Messaging The [event bus](/concepts/events) is great for broadcasting, but sometimes you need to talk directly to a specific entity. Entity messaging provides two patterns for this: **fire-and-forget** and **request-response**. Both bypass the event bus entirely -- they dispatch directly to the target entity's behaviors. ### Fire-and-Forget: `send()` `send()` delivers a one-way message to a target entity. The target's behaviors process it through `processEvent()`, just like a bus event, but only the target entity sees it. ```typescript // From game code: tell a specific door to open const door = entityManager.getByName('vault_door'); await door.send(door.id, 'action:open', { speed: 2.0 }); // From inside a behavior: message another entity by name await this.entity.send('vault_door', 'action:open', { speed: 2.0 }); // Or by ID, if you have it await this.entity.send(targetId, 'action:open', { speed: 2.0 }); ``` ### Request-Response: `request()` `request()` sends a message and waits for a response. The target's behaviors are checked in order -- the first behavior that implements `processRequest()` and returns a non-`undefined` value provides the response. ```typescript const result = await this.entity.request('chest_01', 'query:is-locked'); if(result.success) { console.log(`Locked: ${ result.value }`); } ``` The return type is `RequestResult`: ```typescript type RequestResult = | { success : true; value : T } | { success : false; error : string }; ``` A request fails (`success: false`) when no behavior handles it or when a behavior throws an error. ### Handling Requests in Behaviors Behaviors respond to requests by implementing `processRequest()`. Return a value to respond, or `undefined` to pass the request to the next behavior in the chain. ```typescript class LockBehavior extends GameEntityBehavior<{ locked : boolean }> { name = 'lock'; eventSubscriptions = [ 'action:unlock' ]; processEvent(event : GameEvent, state : { locked : boolean }) : boolean { if(event.type === 'action:unlock') { state.locked = false; return true; } return false; } processRequest(event : GameEvent, state : { locked : boolean }) : unknown | undefined { if(event.type === 'query:is-locked') { return state.locked; } // Not our request -- pass to next behavior return undefined; } } ``` ### Address Resolution Both `send()` and `request()` accept a target string that is resolved in two steps: 1. **ID lookup** -- checked against the entity ID map (O(1)) 2. **Name fallback** -- if no ID matches, checked against the name index (O(1)) This means you can address entities by either their generated ID or their human-readable name. When targeting by name, the first entity with that name is used. ### Messaging vs. the Event Bus | | Event Bus | Entity Messaging | |---|---|---| | **Delivery** | Broadcast to all subscribers | Direct to one entity | | **Response** | No built-in response mechanism | `request()` returns a value | | **Coupling** | Loose -- publishers and subscribers are anonymous | Tighter -- caller must know the target | | **Best for** | System-wide notifications, cross-cutting concerns | Targeted commands, queries, inter-entity dialogue | Use the event bus for things like "the player died" or "a level was loaded" -- events that many systems care about. Use messaging when you need to tell a specific entity to do something, or ask it a question. ## Best Practices 1. **Single-responsibility behaviors.** Each behavior handles one aspect of functionality. 2. **Minimize behavior interdependencies.** Behaviors should work independently when possible. 3. **Use events for communication.** Prefer the event bus for broadcasts. Use entity messaging for targeted commands and queries. 4. **Test behaviors individually.** Validate in isolation before integration. 5. **Document state requirements.** Each behavior should be clear about which state properties it reads/writes. 6. **Consider behavior order.** The order behaviors are attached affects event processing priority. 7. **Prefer tags over type checks.** For cross-cutting queries (`'enemy'`, `'interactive'`), tags are more flexible than type strings. 8. **Use pooling for high-frequency entities.** Bullets, particles, effects -- anything created and destroyed rapidly. --- --- url: /guides/entity-behaviors.md description: >- Build a survival game using SAGE's entity-behavior composition system with health, mana, shields, enemy AI, and collectibles. --- # Entity Behaviors This guide walks you through SAGE's entity-behavior composition system by building a survival game. You will create a player with health, mana, and a shield; enemies with state-machine AI; collectible pickups with bobbing animations; and a game loop that ties it all together through events. The full source is in `examples/src/examples/entity-behaviors/`. ::: tip Try it live Run this example at [Examples > Entity Behaviors](/examples/entity-behaviors). ::: ## What entity-behavior composition is Traditional game engines use inheritance: `Enemy extends Character extends GameObject`. SAGE uses composition instead. An entity is a container that holds **state** (data) and **behaviors** (logic). Different entities compose different behaviors: | Entity | Behaviors | |-------------|-------------------------------------------------| | Player | ShieldBehavior, HealthBehavior, MovementBehavior, ManaBehavior | | Enemy | HealthBehavior, EnemyAIBehavior | | Bomber | HealthBehavior, BomberAIBehavior | | Collectible | CollectibleBehavior | `HealthBehavior` is the same class used by the player, regular enemies, and bombers. Write it once, attach it to anything that has health. ## Project structure ``` entity-behaviors/ ├── index.vue # Vue component — UI and initialization ├── playerStats.vue # Player stats panel (uses useSageState) ├── types.ts # State interfaces and type guards ├── constants.ts # Game balance values ├── behaviors/ │ ├── HealthBehavior.ts # Damage, healing, death events │ ├── MovementBehavior.ts# Velocity-based movement with friction │ ├── ManaBehavior.ts # Mana pool, regen, shield drain │ ├── ShieldBehavior.ts # Intercepts damage events │ ├── EnemyAIBehavior.ts # Chase AI via StateMachineBehavior │ ├── BomberAIBehavior.ts# Fast chase variant │ └── CollectibleBehavior.ts # Bobbing animation ├── entities/ │ └── definitions.ts # Entity templates (type, state, behaviors) ├── input/ │ ├── bindings.ts # Input binding configuration │ └── helpers.ts # Input descriptor factories └── game/ ├── GameController.ts # Main orchestration ├── BehaviorDemoLevel.ts # Custom Level subclass ├── scene.ts # BabylonJS scene setup ├── entities.ts # Entity creation and mesh management └── loop.ts # Per-frame game logic ``` ## Step 1: Define state interfaces State is just data. Behaviors read and modify state, but the state itself is a plain object — no methods, no inheritance chains. SAGE entities use interface composition to build complex state from simple pieces: ```typescript // Every entity has a position export interface BaseEntityState { position : { x : number; y : number; z : number }; } // Entities that can take damage export interface HealthState { health : number; maxHealth : number; } // Entities that can move export interface MovementState { velocity : { x : number; y : number; z : number }; speed : number; } ``` The player composes all of these plus its own unique properties: ```typescript export interface PlayerState extends BaseEntityState, HealthState, MovementState { score : number; mana : number; maxMana : number; shieldActive : boolean; } ``` Enemies share `BaseEntityState`, `HealthState`, and `MovementState` but add AI-specific fields: ```typescript export interface EnemyState extends BaseEntityState, HealthState, MovementState { targetId : string | null; targetPosition : { x : number; y : number; z : number } | null; aiState : 'idle' | 'chase' | 'attack'; } ``` Collectibles are simple — just a position and pickup metadata: ```typescript export interface CollectibleState extends BaseEntityState { kind : 'health' | 'mana'; value : number; bobOffset : number; spawnTime : number; } ``` ## Step 2: Write the HealthBehavior Behaviors extend `GameEntityBehavior` where `T` is the state shape they require. Here is the complete HealthBehavior: ```typescript import { GameEntityBehavior, type GameEvent } from '@skewedaspect/sage'; import type { HealthState } from '../types.ts'; export class HealthBehavior extends GameEntityBehavior { name = 'health'; eventSubscriptions = [ 'action:damage', 'action:heal' ]; processEvent(event : GameEvent, state : HealthState) : boolean { if(!this.entity) { return false; } const payload = event.payload as { targetId ?: string; amount ?: number } | undefined; if(event.type === 'action:damage') { // Event targeting — ignore damage meant for other entities if(payload?.targetId && payload.targetId !== this.entity.id) { return false; } const wasAlive = state.health > 0; const damage = payload?.amount ?? 10; state.health = Math.max(0, state.health - damage); // Notify listeners that state changed this.$emitStateChanged(state, { health: state.health }); // Emit death event on the alive -> dead transition if(wasAlive && state.health <= 0) { this.$emit({ type: 'entity:died', payload: { entityId: this.entity.id }, }); } return true; // Event consumed } if(event.type === 'action:heal') { if(payload?.targetId && payload.targetId !== this.entity.id) { return false; } const amount = payload?.amount ?? 10; state.health = Math.min(state.maxHealth, state.health + amount); this.$emitStateChanged(state, { health: state.health }); return true; } return false; } } ``` Key concepts demonstrated here: * **`eventSubscriptions`** — declares which event types this behavior cares about. SAGE only delivers matching events to `processEvent()`. * **`processEvent()` return value** — returning `true` means "I handled this, stop propagating." Later behaviors in the chain will not see the event. Returning `false` means "pass it along." * **Event targeting** — since all entities share an event bus, events include a `targetId` in their payload. Behaviors check this to ignore events meant for other entities. * **`$emitStateChanged()`** — notifies the system (and any UI bindings) that state has changed. * **`$emit()`** — publishes a new event to the entity's event bus. * **No `update()` method** — health does not change over time on its own. It only changes in response to events. Compare this with ManaBehavior, which implements `update()` for regeneration. ## Step 3: Write the MovementBehavior MovementBehavior demonstrates the difference between event-driven logic (`processEvent`) and per-frame logic (`update`): ```typescript export class MovementBehavior extends GameEntityBehavior { name = 'movement'; eventSubscriptions = [ 'action:move' ]; processEvent(event : GameEvent, state : BaseEntityState & MovementState) : boolean { if(event.type === 'action:move') { const payload = (event.payload ?? {}) as { x ?: number; z ?: number }; const { x = 0, z = 0 } = payload; // Convert direction to velocity using the entity's speed state.velocity.x = x * state.speed; state.velocity.z = z * state.speed; return true; } return false; } update(deltaTime : number, state : BaseEntityState & MovementState) : void { // Apply velocity to position (frame-rate independent) state.position.x += state.velocity.x * deltaTime; state.position.z += state.velocity.z * deltaTime; // Clamp to arena bounds state.position.x = Math.max(-ARENA_HALF_SIZE, Math.min(ARENA_HALF_SIZE, state.position.x)); state.position.z = Math.max(-ARENA_HALF_SIZE, Math.min(ARENA_HALF_SIZE, state.position.z)); // Apply friction for smooth deceleration state.velocity.x *= MOVEMENT_FRICTION; state.velocity.z *= MOVEMENT_FRICTION; } } ``` The `update()` method is called every frame by the game loop. Use it for continuous logic: physics, animations, timers, cooldowns. Use `processEvent()` for reactive logic that only runs in response to something happening. Note the generic type `BaseEntityState & MovementState`. This behavior works with any entity that has both position and velocity/speed in its state — the player, enemies, anything you want to move. ## Step 4: Write the ShieldBehavior (event interception) This is where behavior ordering gets interesting. The ShieldBehavior intercepts damage events before HealthBehavior sees them: ```typescript export class ShieldBehavior extends GameEntityBehavior { name = 'shield'; eventSubscriptions = [ 'action:damage' ]; processEvent(event : GameEvent, state : PlayerState) : boolean { if(!this.entity) { return false; } if(event.type === 'action:damage') { const payload = event.payload as { targetId ?: string; amount ?: number } | undefined; if(payload?.targetId && payload.targetId !== this.entity.id) { return false; } // Shield not active — let damage pass through to HealthBehavior if(!state.shieldActive) { return false; } // Shield active — convert damage to mana drain const amount = payload?.amount ?? 10; state.mana -= amount; if(state.mana <= 0) { state.mana = 0; state.shieldActive = false; this.$emitStateChanged(state, { mana: state.mana, shieldActive: false }); } else { this.$emitStateChanged(state, { mana: state.mana }); } // CONSUME the event — HealthBehavior never sees this damage return true; } return false; } } ``` ::: tip Behavior order matters In the player's entity definition, behaviors are ordered `[ ShieldBehavior, HealthBehavior, ... ]`. SAGE delivers events to behaviors in this order. When ShieldBehavior returns `true`, the event is consumed and HealthBehavior never sees it. If the shield is down, ShieldBehavior returns `false` and the event passes through to HealthBehavior normally. Swap the order and the shield does nothing — HealthBehavior would consume the damage first. ::: ## Step 5: Write the ManaBehavior ManaBehavior handles the mana resource pool. It uses both `processEvent()` for instant mana costs and `update()` for continuous regeneration and shield drain: ```typescript export class ManaBehavior extends GameEntityBehavior { name = 'mana'; eventSubscriptions = [ 'action:drain-mana' ]; processEvent(event : GameEvent, state : PlayerState) : boolean { if(!this.entity) { return false; } if(event.type === 'action:drain-mana') { const payload = event.payload as { targetId ?: string; amount ?: number } | undefined; if(payload?.targetId && payload.targetId !== this.entity.id) { return false; } const amount = payload?.amount ?? 10; state.mana = Math.max(0, state.mana - amount); this.$emitStateChanged(state, { mana: state.mana }); return true; } return false; } update(deltaTime : number, state : PlayerState) : void { if(state.health <= 0) { return; } if(state.shieldActive) { // Shield drains mana continuously state.mana = Math.max(0, state.mana - (SHIELD_MANA_DRAIN_RATE * deltaTime)); if(state.mana <= 0) { state.shieldActive = false; this.$emitStateChanged(state, { mana: state.mana, shieldActive: false }); } else { this.$emitStateChanged(state, { mana: state.mana }); } } else if(state.mana < state.maxMana) { // Regen when shield is off state.mana = Math.min(state.maxMana, state.mana + (MANA_REGEN_RATE * deltaTime)); this.$emitStateChanged(state, { mana: state.mana }); } } } ``` ManaBehavior and ShieldBehavior both read and write `mana` and `shieldActive`. This is intentional — they represent different aspects of the same resource system. ShieldBehavior handles the damage-to-mana conversion; ManaBehavior handles the passive costs and regeneration. ## Step 6: Write enemy AI with StateMachineBehavior SAGE provides `StateMachineBehavior` for building state-driven AI. You define states and allowed transitions, and the base class handles validation and entity state synchronization: ```typescript import { StateMachineBehavior } from '@skewedaspect/sage'; import type { EnemyState } from '../types.ts'; type EnemyAIState = 'idle' | 'chase' | 'attack'; const EnemyAIBase = StateMachineBehavior.create({ initialState: 'idle', stateKey: 'aiState', // Syncs to state.aiState automatically transitions: [ { from: 'idle', to: 'chase' }, { from: 'chase', to: 'attack' }, { from: 'chase', to: 'idle' }, { from: 'attack', to: 'chase' }, ], wildcardTransitions: [ { to: 'idle' }, // Any state can return to idle (for pooling) ], }); ``` `StateMachineBehavior.create()` returns a class. Extend it to add per-frame logic: ```typescript export class EnemyAIBehavior extends EnemyAIBase { override name = 'enemy-ai'; update(deltaTime : number, state : EnemyState) : void { if(!state.targetPosition) { if(this.currentState !== 'idle') { this.transition('idle'); } return; } // Calculate distance to target const deltaX = state.targetPosition.x - state.position.x; const deltaZ = state.targetPosition.z - state.position.z; const distance = Math.sqrt((deltaX * deltaX) + (deltaZ * deltaZ)); // State transitions based on distance if(distance <= AI_ATTACK_DISTANCE && this.currentState === 'chase') { this.transition('attack'); } else if(distance > AI_ATTACK_DISTANCE && this.currentState === 'attack') { this.transition('chase'); } else if(this.currentState === 'idle') { this.transition('chase'); } // Move toward target if((this.currentState === 'chase' || this.currentState === 'attack') && distance > AI_STOP_DISTANCE) { const moveX = (deltaX / distance) * state.speed * deltaTime; const moveZ = (deltaZ / distance) * state.speed * deltaTime; state.position.x += moveX; state.position.z += moveZ; } } // Reset to idle when the entity is recycled from the object pool onReset(_state : EnemyState) : void { if(this.currentState !== 'idle') { this.transition('idle'); } } } ``` The `stateKey: 'aiState'` configuration means the state machine automatically syncs its current state to `state.aiState`. You can read that property anywhere to know what AI state an enemy is in. The `onReset()` method supports SAGE's object pooling. When an entity is recycled (created from the pool rather than freshly constructed), `onReset` fires so behaviors can reinitialize. Without it, a recycled enemy would start in whatever state it died in. BomberAIBehavior is nearly identical but has different balance constants — faster speed, lower health, no contact damage (bombers explode on death instead, handled by the game loop). ## Step 7: Write the CollectibleBehavior The simplest behavior in the example. It demonstrates that behaviors do not need to be complex: ```typescript export class CollectibleBehavior extends GameEntityBehavior { name = 'collectible'; eventSubscriptions = [ 'action:collect' ]; processEvent(event : GameEvent, _state : CollectibleState) : boolean { if(event.type === 'action:collect') { return true; // Acknowledged — actual pickup handled by game loop } return false; } update(deltaTime : number, state : CollectibleState) : void { state.bobOffset += deltaTime * COLLECTIBLE_BOB_SPEED; state.position.y = COLLECTIBLE_MESH_HEIGHT + (Math.sin(state.bobOffset) * COLLECTIBLE_BOB_AMPLITUDE); } } ``` A sine wave on the Y position creates smooth up-and-down motion. The `bobOffset` accumulates over time, and its state is part of the entity — meaning it could be serialized and restored. ## Step 8: Define entity templates Entity definitions are templates that specify the type name, default state values, and which behaviors to attach. Here is the player definition: ```typescript import type { GameEntityDefinition } from '@skewedaspect/sage'; export function createPlayerDefinition(logEvent : LogEventFn) : GameEntityDefinition { return { type: 'player', name: 'Player', defaultState: { position: { x: 0, y: 0.5, z: 0 }, velocity: { x: 0, y: 0, z: 0 }, health: 100, maxHealth: 100, speed: 5, score: 0, mana: 100, maxMana: 100, shieldActive: false, }, // ORDER MATTERS — ShieldBehavior must come before HealthBehavior behaviors: [ ShieldBehavior, HealthBehavior, MovementBehavior, ManaBehavior ], tags: [ 'character', 'controllable' ], onCreate: (state) => { logEvent(`Player created with ${ state.health } HP`, 'success'); }, onDestroy: () => { logEvent('Player destroyed!', 'danger'); }, }; } ``` Enemy and bomber definitions follow the same pattern with different behaviors and state values. Enemies use `[ HealthBehavior, EnemyAIBehavior ]`, bombers use `[ HealthBehavior, BomberAIBehavior ]`. Both mark themselves as `poolable: true` for object pooling since they are created and destroyed frequently: ```typescript export function createEnemyDefinition(logEvent : LogEventFn) : GameEntityDefinition { return { type: 'enemy', name: 'Enemy', defaultState: { position: { x: 0, y: 0.4, z: 0 }, velocity: { x: 0, y: 0, z: 0 }, health: 30, maxHealth: 30, speed: 1.5, targetId: null, targetPosition: null, aiState: 'idle' as const, }, poolable: true, behaviors: [ HealthBehavior, EnemyAIBehavior ], tags: [ 'character', 'hostile' ], }; } ``` Register all definitions once during initialization: ```typescript entityManager.registerEntityDefinition(createPlayerDefinition(logEvent)); entityManager.registerEntityDefinition(createEnemyDefinition(logEvent)); entityManager.registerEntityDefinition(createBomberDefinition(logEvent)); entityManager.registerEntityDefinition(createCollectibleDefinition()); ``` ## Step 9: Create entities and attach meshes With definitions registered, creating entities is straightforward. Each entity gets a BabylonJS mesh bound to it via `attachToNode()`: ```typescript // Create the player entity const player = await entityManager.createEntity('player', { name: 'Hero', }); // Create a mesh for it const mesh = MeshBuilder.CreateSphere('player', { diameter: 1.0 }, scene); const mat = new StandardMaterial('playerMat', scene); mat.diffuseColor = new Color3(0.2, 0.5, 1.0); mesh.material = mat; mesh.position.set(0, 0.5, 0); // Bind mesh to entity — accessible later via entity.node entityManager.attachToNode(player, mesh); ``` `attachToNode()` is the proper way to associate meshes with SAGE entities. After binding, the mesh is accessible as `entity.node` throughout the codebase, including inside behaviors. Enemies spawn at random positions on the arena edge: ```typescript const angle = Math.random() * Math.PI * 2; const x = Math.cos(angle) * 4; const z = Math.sin(angle) * 4; const enemy = await entityManager.createEntity('enemy', { name: `Enemy ${ Math.floor(Math.random() * 1000) }`, initialState: { position: { x, y: 0.4, z }, targetId: player.id, }, }); ``` The `initialState` in `createEntity()` is merged with the definition's `defaultState`. You only need to specify properties that differ from the defaults. ## Step 10: The game loop The game controller's `update()` method runs each frame. It handles cross-entity coordination that individual behaviors cannot do on their own: 1. **Send movement events** to the player entity based on input 2. **Update AI targets** — set each enemy's `targetPosition` to the player's position 3. **Sync meshes** — copy entity `state.position` to mesh positions 4. **Detect collisions** — check distances for pickups, attacks, and explosions ```typescript // Send movement to the player via its event bus if(moveX !== 0 || moveZ !== 0) { playerEntity.eventBus.publish({ type: 'action:move', payload: { x: normalizedX, z: normalizedZ }, }); } // Set enemy targets for(const enemy of sageEntityManager.getByType('enemy')) { if(isEnemyState(enemy.state)) { enemy.state.targetPosition = { x: playerPos.x, y: playerPos.y, z: playerPos.z, }; } } // Sync mesh positions for(const entity of sageEntityManager.getAllEntities()) { const mesh = entity.node as Mesh | undefined; if(mesh && hasPosition(entity.state)) { mesh.position.x = entity.state.position.x; mesh.position.y = entity.state.position.y; mesh.position.z = entity.state.position.z; } } ``` Contact damage between enemies and the player is also handled in the game loop, not in behaviors. The game loop publishes `action:damage` events to the appropriate entity's event bus: ```typescript if(distance < CONTACT_DISTANCE) { // Wounded enemies deal more damage const healthRatio = enemy.state.health / enemy.state.maxHealth; const damageRate = ENEMY_BASE_DAMAGE_RATE + (ENEMY_WOUNDED_DAMAGE_BONUS * (1 - healthRatio)); playerEntity.eventBus.publish({ type: 'action:damage', payload: { amount: damageRate * deltaTime, targetId: playerEntity.id }, }); } ``` This is the correct split of responsibilities: behaviors handle what happens **inside** an entity (take damage, update health, check death). The game loop handles what happens **between** entities (who is close enough to hit whom). ## Step 11: Using the Level system The example uses SAGE's `Level` class to manage scene creation. A custom level class extends `Level` and implements `buildScene()`: ```typescript import { Level, type LevelConfig } from '@skewedaspect/sage'; export class BehaviorDemoLevel extends Level { protected async buildScene() : Promise { return setupScene(this.gameEngine, this.config.canvas); } async unload() : Promise { this._scene?.dispose(); this._scene = null; } } ``` Register the level class and activate it before creating entities: ```typescript engine.managers.levelManager.registerLevelClass('behavior-demo', BehaviorDemoLevel); engine.managers.levelManager.registerLevelConfig({ name: 'demo', class: 'behavior-demo', engine, canvas }); await engine.managers.levelManager.activateLevel('demo'); ``` With the level system, SAGE handles entity updates and rendering automatically. You register a frame callback for your custom game logic, then call `engine.start()`: ```typescript engine.managers.gameManager.registerFrameCallback((dt) => { gameController.update(dt); }); await engine.start(); ``` ## Event flow summary Here is the complete event flow when an enemy hits the player: ``` Game Loop detects contact └─> publishes action:damage to player's eventBus └─> ShieldBehavior.processEvent() ├─ Shield active? │ YES: drain mana, return true (consumed) │ └─> HealthBehavior never sees the event │ │ NO: return false (pass through) └─> HealthBehavior.processEvent() ├─ Reduce health ├─ $emitStateChanged() → UI updates └─ If health <= 0: $emit('entity:died') └─> GameController handles death ├─ Award score ├─ Destroy entity ├─ Random loot drop └─ Spawn replacement ``` ## What you learned * Entity state is composed from simple interfaces (`HealthState`, `MovementState`, etc.) * Behaviors extend `GameEntityBehavior` and implement `processEvent()` and/or `update()` * Returning `true` from `processEvent()` consumes the event — later behaviors do not see it * Behavior order in entity definitions controls event processing order * `StateMachineBehavior.create()` builds state-driven AI with validated transitions * `$emitStateChanged()` notifies the UI; `$emit()` publishes new events * `attachToNode()` binds BabylonJS meshes to entities, accessible via `entity.node` * `poolable: true` enables object pooling for frequently created/destroyed entities * The game loop handles cross-entity coordination; behaviors handle intra-entity logic --- --- url: /guides/entity-messaging.md description: >- Targeted entity-to-entity communication in SAGE -- fire-and-forget commands, request-response queries, behavior-level messaging, and when to use messaging vs. the event bus. --- # Entity Messaging SAGE's event bus is a broadcast system. When you publish an event, every subscriber sees it. That works well for system-wide notifications, but it breaks down when you need to talk to a specific entity. You end up adding `targetId` checks to every `processEvent()`, broadcasting messages that 99% of entities ignore, and having no way to get a response back. Entity messaging solves all three problems. It delivers messages directly to a single entity's behaviors, supports request-response, and both lookups are O(1). ## Fire-and-Forget with `send()` The simplest pattern. You send a message to a target entity, and its behaviors process it through `processEvent()` like any other event. The difference: no other entity sees it. ### Example: A button activating a door A button entity needs to open a specific door when the player interacts with it. With the event bus, you would broadcast an `action:open` event and every door in the level would need to check whether it is the intended target. With messaging, you send directly to the door you mean. ```typescript class ButtonBehavior extends GameEntityBehavior<{ targetDoor : string }> { name = 'button'; eventSubscriptions = [ 'action:interact' ]; processEvent(event : GameEvent, state : { targetDoor : string }) : boolean { if(event.type === 'action:interact') { // Send directly to the door -- no broadcast, no targetId filtering this.entity.send(state.targetDoor, 'action:open'); return true; } return false; } } ``` The door's behavior handles the message the same way it would handle a bus event: ```typescript class DoorBehavior extends GameEntityBehavior<{ isOpen : boolean; speed : number }> { name = 'door'; eventSubscriptions = [ 'action:open', 'action:close' ]; processEvent(event : GameEvent, state : { isOpen : boolean; speed : number }) : boolean { if(event.type === 'action:open') { state.isOpen = true; this.$emitStateChanged(state, { isOpen: true }); return true; } if(event.type === 'action:close') { state.isOpen = false; this.$emitStateChanged(state, { isOpen: false }); return true; } return false; } } ``` Set up the entities and wire them together: ```typescript entityManager.registerEntityDefinition({ type: 'object:door', defaultState: { isOpen: false, speed: 1.0 }, behaviors: [ DoorBehavior ], }); entityManager.registerEntityDefinition({ type: 'object:button', defaultState: { targetDoor: '' }, behaviors: [ ButtonBehavior ], }); // Create the door with a name so the button can find it await entityManager.createEntity('object:door', { name: 'vault_door' }); // Create the button, pointing it at the door by name await entityManager.createEntity('object:button', { initialState: { targetDoor: 'vault_door' }, }); ``` The button stores the door's name in its state. When the player interacts with the button, it sends `action:open` directly to `vault_door`. No broadcasts. No filtering. ## Request-Response with `request()` Sometimes you need to ask an entity a question and wait for the answer. The event bus has no response mechanism -- you would need to publish a question event and then subscribe to a separate answer event. Messaging handles this in a single call. ### Example: Querying an entity for its interaction state A player interaction system needs to check whether a chest is locked before attempting to open it: ```typescript class InteractBehavior extends GameEntityBehavior<{ targetEntity : string | null }> { name = 'interact'; eventSubscriptions = [ 'action:interact' ]; async processEvent( event : GameEvent, state : { targetEntity : string | null } ) : Promise { if(event.type === 'action:interact' && state.targetEntity) { // Ask the target if it's locked const result = await this.entity.request( state.targetEntity, 'query:is-locked' ); if(result.success && result.value) { // It's locked -- notify the player this.$emit({ type: 'ui:show-message', payload: { text: 'This chest is locked.' } }); return true; } // Not locked (or entity doesn't support the query) -- proceed with opening await this.entity.send(state.targetEntity, 'action:open'); return true; } return false; } } ``` The chest responds to the query through `processRequest()`: ```typescript class ChestBehavior extends GameEntityBehavior<{ locked : boolean; lootTable : string }> { name = 'chest'; eventSubscriptions = [ 'action:open' ]; processEvent( event : GameEvent, state : { locked : boolean; lootTable : string } ) : boolean { if(event.type === 'action:open' && !state.locked) { this.$emit({ type: 'game:spawn-loot', payload: { table: state.lootTable } }); return true; } return false; } processRequest( event : GameEvent, state : { locked : boolean; lootTable : string } ) : unknown | undefined { if(event.type === 'query:is-locked') { return state.locked; } // Not a request we handle return undefined; } } ``` The `processRequest()` method works like `processEvent()` in ordering: SAGE calls it on each behavior in attachment order and stops at the first one that returns a non-`undefined` value. ## Behavior-to-Behavior Messaging Behaviors can message other entities through `this.entity.send()` and `this.entity.request()`. This is useful when entities in a parent-child hierarchy need to communicate. ### Example: A weapon applying a buff to its holder A weapon entity is a child of a character entity. When the weapon charges up, it applies a speed buff to whoever is holding it: ```typescript class ChargeWeaponBehavior extends GameEntityBehavior<{ chargeLevel : number; holderId : string | null }> { name = 'charge-weapon'; eventSubscriptions = []; update(dt : number, state : { chargeLevel : number; holderId : string | null }) : void { if(!state.holderId) { return; } state.chargeLevel = Math.min(1.0, state.chargeLevel + dt * 0.2); // At full charge, buff the holder's speed if(state.chargeLevel >= 1.0) { this.entity.send(state.holderId, 'action:apply-buff', { stat: 'speed', multiplier: 1.5, duration: 3.0, }); state.chargeLevel = 0; } } } ``` The holder's `BuffBehavior` picks up the message: ```typescript class BuffBehavior extends GameEntityBehavior<{ speed : number }> { name = 'buff'; eventSubscriptions = [ 'action:apply-buff' ]; processEvent(event : GameEvent, state : { speed : number }) : boolean { if(event.type === 'action:apply-buff') { const { stat, multiplier } = event.payload as { stat : string; multiplier : number }; if(stat === 'speed') { state.speed *= multiplier; this.$emitStateChanged(state, { speed: state.speed }); } return true; } return false; } } ``` The weapon doesn't need a reference to the holder's behavior or even know that `BuffBehavior` exists. It sends a message, and whatever behavior on the target handles buff application takes care of it. ## Error Handling with RequestResult Every `request()` call returns a `RequestResult`. Always check `success` before using the value. ```typescript type RequestResult = | { success : true; value : T } | { success : false; error : string }; ``` A request fails in two cases: 1. **No handler** -- no behavior on the target implements `processRequest()` for that event type, or all of them returned `undefined`. 2. **Error thrown** -- a behavior's `processRequest()` threw an exception. SAGE catches it and continues to the next behavior, but if no subsequent behavior handles the request, the error message is returned. ```typescript const result = await this.entity.request('turret_01', 'query:ammo-count'); if(!result.success) { // Could be "Entity not found", "No handler for request...", or an error message console.warn(`Ammo query failed: ${ result.error }`); return; } console.log(`Turret has ${ result.value } rounds remaining`); ``` If the target entity does not exist (invalid ID or name), `send()` logs a warning and returns silently. `request()` returns `{ success: false, error: 'Entity "..." not found' }`. ## Address Resolution Both `send()` and `request()` resolve the target string in two steps: 1. **ID lookup** -- O(1) Map lookup against registered entity IDs 2. **Name fallback** -- O(1) index lookup against entity names You can use either: ```typescript // By ID (guaranteed unique) await this.entity.send('8cd88e1a9b13aac0', 'action:open'); // By name (first match if multiple entities share the name) await this.entity.send('vault_door', 'action:open'); ``` Names are more readable in level definitions and game scripts. IDs are better when you have a stored reference to a specific entity and need to guarantee you hit the right one. ## When to Use Messaging vs. the Event Bus **Use the event bus when:** * Many systems need to react to the same event (`entity:died`, `level:loaded`) * You do not know or care who is listening * The event is a notification, not a command **Use entity messaging when:** * You need to command a specific entity (`action:open` on a particular door) * You need a response (`query:is-locked`, `query:ammo-count`) * Broadcasting would waste cycles (hundreds of entities filtering out messages not meant for them) * Behaviors on one entity need to talk to behaviors on another entity The two systems complement each other. A common pattern is to use messaging for the targeted interaction, then have the receiving behavior publish a broadcast event for any system that cares about the outcome: ```typescript processEvent(event : GameEvent, state : { isOpen : boolean }) : boolean { if(event.type === 'action:open') { state.isOpen = true; // Targeted message was received -- now broadcast the result for UI, audio, etc. this.$emit({ type: 'door:opened', payload: { entityId: this.entity.id } }); return true; } return false; } ``` --- --- url: /concepts/events.md description: >- The SAGE event bus -- pub/sub communication, subscription types, type safety, built-in events, and common patterns. --- # Events SAGE uses an event bus to decouple communication between all parts of the engine. Publishers emit events without knowing who is listening; subscribers react to events without needing references to publishers. The bus routes everything efficiently in between. ## The GameEvent Interface Every event flowing through the bus is a `GameEvent`: ```typescript interface GameEvent

= Record> { type : string; // Event identifier, e.g. "player:move" senderID? : string; // Optional sender entity ID targetID? : string; // Optional target entity ID payload? : P; // Typed payload data } ``` Events are self-contained packets. The `type` field identifies what happened, the optional `senderID`/`targetID` fields enable directed communication, and `payload` carries the data. ## Pub/Sub Basics ### Creating an Event Bus ```typescript import { GameEventBus } from '@skewedaspect/sage'; const eventBus = new GameEventBus(); ``` In practice, you rarely create one manually -- `createGameEngine()` provides one at `sage.eventBus`. ### Publishing ```typescript eventBus.publish({ type: 'player:move', payload: { x: 100, y: 200, speed: 5, }, }); ``` ### Subscribing ```typescript const unsubscribe = eventBus.subscribe('player:move', (event) => { const { x, y } = event.payload; console.log(`Player moved to ${ x }, ${ y }`); }); // Later, when you no longer care: unsubscribe(); ``` Every `subscribe` call returns an unsubscribe function. Always store it and call it when the subscriber is destroyed to avoid leaks. ## Subscription Types ### Exact Match Listen for one specific event type: ```typescript eventBus.subscribeExact('collision:player', (event) => { const { otherEntity } = event.payload; console.log(`Player collided with ${ otherEntity.name }`); }); ``` ### Wildcard Pattern Catch multiple events with a glob-style pattern: ```typescript // All input events eventBus.subscribePattern('input:*', (event) => { console.log(`Input event: ${ event.type }`); }); ``` ### Regex Pattern For more complex matching, pass a `RegExp`: ```typescript eventBus.subscribePattern(/^collision:/, (event) => { console.log(`Collision detected: ${ event.type }`); }); ``` ### Automatic Detection The generic `subscribe()` method detects whether you are using a pattern and routes accordingly: ```typescript // Exact match (no wildcard characters) eventBus.subscribe('game:start', handleGameStart); // Pattern match (contains *) eventBus.subscribe('player:*', handlePlayerEvents); ``` ## Type Safety ### Generic Payloads Pass a type parameter to `subscribe` for typed payloads: ```typescript interface PlayerMovePayload { x : number; y : number; speed : number; } eventBus.subscribe('player:move', (event) => { // event.payload is typed as PlayerMovePayload const { x, y, speed } = event.payload; animatePlayer(x, y, speed); }); ``` ### Event Payload Maps `GameEventBus` is generic. By default, it uses `LibraryEventPayloadMap`, which types all SAGE-defined events. You can extend it with your own events for full compile-time safety: ```typescript import { GameEventBus } from '@skewedaspect/sage'; import type { LibraryEventPayloadMap } from '@skewedaspect/sage'; interface MyGameEvents { 'game:score-updated' : { score : number; player : string }; 'game:paused' : { reason : string }; 'player:respawned' : { position : { x : number; y : number } }; [key : string] : unknown; } // Create a bus that knows about both library and game events const eventBus = new GameEventBus(); // Compiler enforces your custom payloads eventBus.publish({ type: 'game:score-updated', payload: { score: 100, player: 'alice' }, }); // OK eventBus.publish({ type: 'game:score-updated', payload: { score: 100 }, }); // Error: missing 'player' ``` Subscriptions are also type-safe: ```typescript eventBus.subscribe('input:changed', (event) => { // event.payload is typed as InputChangedPayload console.log(`Input from device: ${ event.payload.deviceId }`); }); ``` ::: info For wildcard/pattern subscriptions, payload typing is not available since multiple event types might match. The callback receives a generic event and you'll need to narrow the type yourself. ::: ### Event Type Helpers SAGE provides template literal types for consistent event naming: ```typescript import type { ActionEvent, LevelEvent, InputEvent } from '@skewedaspect/sage'; // These create branded string types for each event category: // ActionEvent<'jump'> -> "action:jump" // LevelEvent<'loaded'> -> "level:loaded" // InputEvent<'changed'> -> "input:changed" // Build union types for your game's actions type MyActions = 'jump' | 'attack' | 'dodge' | 'interact'; type MyActionEvents = ActionEvent; // Result: "action:jump" | "action:attack" | "action:dodge" | "action:interact" ``` ## Built-in Event Types SAGE emits several events internally that you can subscribe to. ### Input Events | Event Type | Payload | Description | |---|---|---| | `input:device:connected` | `DeviceConnectedPayload` | A new input device was detected | | `input:device:disconnected` | `DeviceDisconnectedPayload` | An input device was removed | | `input:changed` | `InputChangedPayload` | Input state changed on a device | ```typescript interface DeviceConnectedPayload { device : InputDevice; } interface DeviceDisconnectedPayload { device : InputDevice; } interface InputChangedPayload { deviceId : string; device : InputDevice; state : InputState; } ``` ### Level Events | Event Type | Payload | Description | |---|---|---| | `level:progress` | `LevelProgressPayload` | Level loading progress update | | `level:complete` | `LevelCompletePayload` | Level finished loading | | `level:error` | `LevelErrorPayload` | Level loading failed | ```typescript interface LevelProgressPayload { levelName : string; progress : number; // 0-100 message? : string; } interface LevelCompletePayload { levelName : string; message? : string; } interface LevelErrorPayload { levelName : string; message : string; error : unknown; } ``` ### Action Events Action events are dynamic, matching the pattern `action:*` where `*` is the action name you registered with the `BindingManager`. ```typescript interface ActionPayload { value : boolean | number; // true/false for digital, 0-1 for analog deviceId : string; // Which device triggered this context? : string; // Active input context } ``` Example action events: `action:jump`, `action:move_forward`, `action:fire_weapon`. ## Custom Events The event bus is not limited to SAGE's built-in events. You can publish and subscribe to any event type you define. ### Publishing Custom Events The engine's event bus accepts custom event types thanks to the index signature on `LibraryEventPayloadMap`. You need to cast the bus to accept arbitrary payloads: ```typescript // The engine's eventBus is typed as GameEventBus. // To publish custom events, cast it to accept your payload. const bus = engine.eventBus as unknown as GameEventBus>; bus.publish({ type: 'quest:completed', payload: { questId: 'fetch_sword', reward: 500 }, }); ``` This cast is safe -- the bus routes events by string type regardless of the generic parameter. The generic only affects compile-time payload checking. ### Subscribing to Custom Events Subscribing is simpler -- the `subscribe` method accepts any string and you narrow the payload yourself: ```typescript engine.eventBus.subscribe('quest:completed', (event) => { const payload = event.payload as { questId : string; reward : number }; console.log(`Quest ${ payload.questId } done, reward: ${ payload.reward }`); }); ``` ### Type-Safe Custom Events For full compile-time safety across your game, define a payload map and cast the bus once at startup: ```typescript // gameEvents.ts -- define your custom event types interface GameEventPayloads { 'quest:completed' : { questId : string; reward : number }; 'quest:started' : { questId : string; name : string }; 'npc:dialogue' : { npcId : string; line : string }; 'score:changed' : { score : number; delta : number }; [key : string] : unknown; // required -- allows SAGE's built-in events to pass through } ``` ```typescript // game.ts -- cast once, use everywhere import type { LibraryEventPayloadMap, GameEventBus } from '@skewedaspect/sage'; import type { GameEventPayloads } from './gameEvents.ts'; type AllEvents = GameEventPayloads & LibraryEventPayloadMap; // After engine creation, cast the bus const bus = engine.eventBus as unknown as GameEventBus; // Now both custom and built-in events are fully typed bus.publish({ type: 'quest:completed', payload: { questId: 'fetch_sword', reward: 500 }, }); // OK -- payload matches GameEventPayloads['quest:completed'] bus.publish({ type: 'quest:completed', payload: { questId: 'fetch_sword' }, }); // Error: missing 'reward' bus.subscribe('quest:completed', (event) => { event.payload.reward; // number -- fully typed, no cast needed }); ``` ::: warning The `[key : string] : unknown` index signature Your custom payload map **must** include the index signature `[key : string] : unknown`. Without it, TypeScript will reject SAGE's built-in events because your map doesn't contain entries like `'input:changed'`. The index signature tells TypeScript "other event types are allowed, with `unknown` payloads." ::: ### From Behaviors Behaviors publish custom events through the `$emit` helper, which automatically stamps `senderID` and handles the bus casting: ```typescript class QuestBehavior extends GameEntityBehavior { name = 'QuestBehavior'; eventSubscriptions = [ 'quest:started' ]; completeQuest(questId : string, reward : number) : void { // $emit handles the cast internally -- just pass the event this.$emit({ type: 'quest:completed', payload: { questId, reward }, }); } processEvent(event : GameEvent, state : QuestState) : boolean { if(event.type === 'quest:started') { const payload = event.payload as { questId : string; name : string }; state.activeQuest = payload.questId; return true; } return false; } } ``` Note that `$emit` casts the bus internally, so behaviors never need the `as unknown as GameEventBus<...>` cast themselves. ### Common Pitfalls **Forgetting the cast when publishing from game code.** The engine's bus is typed as `GameEventBus`. Publishing a custom event type without casting will produce a TypeScript error like `Type '"quest:completed"' is not assignable to type 'keyof LibraryEventPayloadMap & string'`. Use the cast pattern shown above. **Omitting the index signature.** If your custom payload map doesn't have `[key : string] : unknown`, merging it with `LibraryEventPayloadMap` works but subscribing to unlisted event types will fail. **Subscribing in behaviors without adding to `eventSubscriptions`.** A behavior's `processEvent` is only called for event types listed in its `eventSubscriptions` array. If you subscribe to `'quest:completed'` in `eventSubscriptions` but misspell it as `'quest:complete'`, the behavior will silently never receive the event. ## Common Patterns ### Component Communication Use the event bus to wire up independent systems without direct references: ```typescript // Player controller publishes function handlePlayerInput(key : string) : void { if(key === 'Space') { eventBus.publish({ type: 'player:jump', payload: { timestamp: performance.now() }, }); } } // Audio system subscribes eventBus.subscribe('player:jump', () => { audioSystem.playSound('jump.wav'); }); // Animation system subscribes independently eventBus.subscribe('player:jump', () => { playerSprite.playAnimation('jump'); }); ``` ### Game State Changes ```typescript function changeGameState(newState : string) : void { eventBus.publish({ type: 'game:stateChange', payload: { previous: currentState, current: newState, }, }); currentState = newState; } eventBus.subscribe('game:stateChange', (event) => { const { previous, current } = event.payload; console.log(`State: ${ previous } -> ${ current }`); updateUI(current); }); ``` ### Directed Entity Communication For sending events to a specific entity, use [entity messaging](/concepts/entities#entity-messaging) instead of the event bus. Entity messaging delivers events directly to the target entity's behaviors without broadcasting: ```typescript // Fire-and-forget: activate a specific door await entityManager.send('Door.001', 'activate', { source: 'button' }); // Request-response: query an entity's state const result = await entityManager.request('Door.001', 'lock:state', {}); if(result.success) { console.log(`Door is ${ result.value.locked ? 'locked' : 'unlocked' }`); } ``` The event bus is for **broadcast** communication (one-to-many). Entity messaging is for **targeted** communication (one-to-one). See the [Entity Messaging guide](/guides/entity-messaging) for detailed patterns. ## Performance The event bus is optimized for game development workloads: * **Async delivery via microtasks.** Events are dispatched through `Promise.resolve()`, so publishers never block on subscriber execution. * **O(1) exact matching.** Direct subscriptions use a `Map` for constant-time lookup. * **Efficient pattern matching.** Wildcard and regex subscriptions are matched against incoming events without unnecessary iteration. * **Thousands of events per second.** Benchmarks confirm the bus handles high-frequency event traffic without becoming a bottleneck. ::: tip Use exact subscriptions when possible. They are faster than pattern subscriptions because they skip the pattern-matching step entirely. ::: ## Best Practices 1. **Use structured event types.** Namespace with colons: `category:action` (e.g. `player:jump`, `level:complete`). 2. **Keep payloads small.** Avoid passing large objects or deep structures in event payloads. 3. **Always clean up subscriptions.** Store the unsubscribe function and call it when the subscriber is destroyed. 4. **Be specific.** Prefer exact subscriptions over patterns when you know the exact event type. 5. **Document your events.** Maintain an event payload map so consumers know what to expect. --- --- url: /guides/hello-cube.md description: >- Build the simplest possible SAGE application — a rotating cube with pause/resume controls. --- # Hello Cube This guide walks you through the simplest SAGE application: a rotating cube rendered with BabylonJS, controlled by SAGE's game loop. By the end, you will understand the core initialization flow, how SAGE manages frame callbacks, and how to integrate with Vue via `SageCanvas`. The full source is in `examples/src/examples/hello-cube/`. ::: tip Try it live Run this example at [Examples > Hello Cube](/examples/hello-cube). ::: ## Project structure ``` hello-cube/ ├── index.vue # Vue component — UI and engine wiring └── level/ └── setup.ts # BabylonJS scene, camera, light, and cube ``` Two files. That is all you need. ## Step 1: The Vue component and SageCanvas Every SAGE application starts with a canvas. The `@skewedaspect/sage-vue` package provides `SageCanvas`, a Vue component that creates the underlying BabylonJS engine, initializes SAGE, and emits an `engine-ready` event once everything is ready to go. ```vue ``` The `options` prop accepts engine configuration — here we set the log level. The default slot gives you a `loading` flag you can use for a loading screen. Everything inside the slot renders on top of the canvas. In the script section, set up the imports and state: ```typescript import { ref } from 'vue'; import type { GameEngine } from '@skewedaspect/sage'; import type { Scene, Mesh } from '@babylonjs/core'; import { SageCanvas } from '@skewedaspect/sage-vue'; import { setupLevel } from './level/setup.ts'; const ROTATION_SPEED_X = 0.3; const ROTATION_SPEED_Y = 0.6; const paused = ref(false); let gameEngine : GameEngine | null = null; ``` Note that `gameEngine` is a plain variable, not a Vue ref. SAGE engine references are used only in callbacks, never in templates. Keeping them out of Vue's reactivity system avoids unnecessary proxy overhead. ## Step 2: Setting up the level The level setup is isolated in its own file. This keeps all BabylonJS-specific code separate from SAGE patterns, which is a habit worth building early. ```typescript import { ArcRotateCamera, Color3, Color4, HemisphericLight, type Mesh, MeshBuilder, type Scene, StandardMaterial, Vector3, } from '@babylonjs/core'; import type { GameEngine } from '@skewedaspect/sage'; export interface LevelSetupResult { scene : Scene; camera : ArcRotateCamera; cube : Mesh; } ``` The function uses SAGE's scene engine to create a BabylonJS scene, then builds the camera, light, and cube using standard BabylonJS APIs: ```typescript export function setupLevel(engine : GameEngine, canvas : HTMLCanvasElement) : LevelSetupResult { // Create scene through SAGE's scene engine const scene = engine.engines.sceneEngine.createScene(); scene.clearColor = new Color4(0.12, 0.12, 0.14, 1); // Camera — orbiting view centered on origin const camera = new ArcRotateCamera( 'camera', Math.PI / 4, // alpha (horizontal angle) Math.PI / 3, // beta (vertical angle) 5, // radius Vector3.Zero(), scene ); camera.attachControl(canvas, true); // Ambient light new HemisphericLight('light', new Vector3(0, 1, 0), scene).intensity = 0.8; // The cube const cube = MeshBuilder.CreateBox('cube', { size: 1.5 }, scene); const material = new StandardMaterial('cubeMaterial', scene); material.diffuseColor = new Color3(0.506, 0.173, 0.173); material.specularColor = new Color3(0.3, 0.3, 0.3); cube.material = material; return { scene, camera, cube }; } ``` The key detail is `engine.engines.sceneEngine.createScene()`. SAGE wraps BabylonJS scene creation so it can track scenes internally. Always create scenes through this API rather than calling the BabylonJS `Scene` constructor directly. ## Step 3: The frame callback Back in the Vue component, the `onEngineReady` handler ties everything together: ```typescript function onEngineReady(engine : GameEngine) : void { gameEngine = engine; const level = setupLevel(engine, engine.canvas as HTMLCanvasElement); const scene = level.scene; const cube = level.cube; engine.managers.gameManager.registerFrameCallback((dt : number) => { if(cube) { cube.rotation.x += ROTATION_SPEED_X * dt; cube.rotation.y += ROTATION_SPEED_Y * dt; } scene?.render(); }); engine.managers.gameManager.start(); } ``` The flow is: 1. **Set up the level** — create the scene, camera, light, and cube. 2. **Register a frame callback** — this function runs every frame. The `dt` parameter is the time in seconds since the last frame, which you should use to keep animation speed frame-rate independent. 3. **Start the game manager** — this begins the game loop. Without calling `start()`, nothing renders. Inside the frame callback, we rotate the cube and call `scene.render()` to draw the frame. In later examples you will see SAGE handle rendering automatically via its level system, but at this level of simplicity, calling `render()` manually keeps things transparent. ## Step 4: Pause and resume SAGE's game manager has built-in pause/resume support: ```typescript function togglePause() : void { if(!gameEngine) { return; } if(paused.value) { gameEngine.managers.gameManager.resume(); } else { gameEngine.managers.gameManager.pause(); } paused.value = !paused.value; } ``` When paused, the game manager stops calling your frame callback. The BabylonJS render loop itself continues running (so the UI stays responsive), but your game logic and `scene.render()` calls stop executing. ## Resource cleanup In this example, cleanup is handled automatically by Vue and `SageCanvas`. When the component unmounts, `SageCanvas` disposes the underlying BabylonJS engine, which takes the scene and all meshes with it. For more complex applications where you create additional resources (event subscriptions, entity managers, physics bodies), you will want explicit cleanup in `onBeforeUnmount`. The later examples demonstrate this pattern. ## What you learned * `SageCanvas` bootstraps the engine and provides the `engine-ready` event * Scenes are created through `engine.engines.sceneEngine.createScene()` * Frame callbacks registered with `gameManager.registerFrameCallback()` run every frame * `gameManager.start()` kicks off the loop; `pause()` and `resume()` control it * Keep BabylonJS code in separate files to maintain clean separation ## Next steps * [Input System](./input-system.md) — add keyboard, mouse, and gamepad controls * [Entity Behaviors](./entity-behaviors.md) — build game objects from composable behaviors --- --- url: /api/input.md description: >- API reference for SAGE's input and binding system -- actions, bindings, contexts, the BindingManager, and input device readers. --- # Input & Bindings SAGE's binding system creates an abstraction layer between physical input devices (keyboard, mouse, gamepad) and game actions. You define what your game can do (actions), then map hardware inputs to those actions (bindings), organized into switchable groups (contexts). ## Core Concepts | Concept | Role | |---------|------| | **Action** | A named game event (`jump`, `throttle`) -- the "what" | | **Binding** | Connects a physical input to an action -- the "how" | | **Context** | Groups bindings that activate/deactivate together -- the "when" | ## Actions Actions represent game events triggered by user input. Two types: ### DigitalAction Boolean on/off values (jumping, interacting, firing). ```typescript interface DigitalAction { type : 'digital'; name : string; label ?: string; } ``` ### AnalogAction Numeric values for continuous input (throttle, camera rotation). ```typescript interface AnalogAction { type : 'analog'; name : string; label ?: string; minValue ?: number; maxValue ?: number; } ``` Both types support an optional `label` for display in UIs (e.g., settings screens). If omitted, use `name` as a fallback. ### Action (Union Type) ```typescript type Action = DigitalAction | AnalogAction; ``` ### Registering Actions ```typescript const bindingManager = gameEngine.managers.bindingManager; bindingManager.registerAction({ name: 'jump', type: 'digital', label: 'Jump' }); bindingManager.registerAction({ name: 'move_x', type: 'analog', label: 'Strafe', minValue: -1, maxValue: 1, }); ``` ## Bindings Bindings connect physical inputs to actions. Three types are supported: ### Binding Types ```typescript type BindingType = 'trigger' | 'toggle' | 'value'; ``` | Type | Behavior | Use Case | |------|----------|----------| | `trigger` | Fires once on button state change | Jump, shoot, interact | | `toggle` | Alternates between on/off each press | Crouch toggle, flashlight | | `value` | Continuously passes input values | Joystick axes, triggers | ### Binding Interface All bindings implement: ```typescript interface Binding { readonly type : BindingType; readonly action : Action; readonly context ?: string; readonly deviceID : string; readonly reader : DeviceValueReader; process(state : InputState, eventBus : GameEventBus) : void; resetEdgeState() : void; toJSON() : BindingDefinition; } ``` ### Binding Definitions Bindings are typically created from definition objects rather than class instances. Definitions are JSON-serializable and can be loaded from config files. #### TriggerBindingDefinition ```typescript interface TriggerBindingDefinition { type : 'trigger'; action : string; input : InputDefinition; context ?: string; options ?: { edgeMode ?: 'rising' | 'falling' | 'both'; threshold ?: number; passthrough ?: boolean; }; } ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `edgeMode` | `'rising' \| 'falling' \| 'both'` | `'rising'` | When to fire: press, release, or both | | `threshold` | `number` | `0.5` | Activation threshold for analog inputs | | `passthrough` | `boolean` | `false` | Pass raw value instead of boolean | #### ToggleBindingDefinition ```typescript interface ToggleBindingDefinition { type : 'toggle'; action : string; input : InputDefinition; context ?: string; state ?: boolean; options ?: { invert ?: boolean; initialState ?: boolean; threshold ?: number; onValue ?: boolean | number; offValue ?: boolean | number; }; } ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `initialState` | `boolean` | `false` | Starting toggle state | | `onValue` | `boolean \| number` | `true` | Value emitted when toggled on | | `offValue` | `boolean \| number` | `false` | Value emitted when toggled off | | `invert` | `boolean` | `false` | Invert the input signal | | `threshold` | `number` | `0.5` | Activation threshold for analog inputs | #### ValueBindingDefinition ```typescript interface ValueBindingDefinition { type : 'value'; action : string; input : InputDefinition; context ?: string; options ?: { scale ?: number; offset ?: number; min ?: number; max ?: number; invert ?: boolean; emitOnChange ?: boolean; deadzone ?: number; }; } ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `scale` | `number` | `1.0` | Multiplier applied to the raw value | | `offset` | `number` | `0` | Added after scaling | | `min` | `number` | -- | Clamp minimum | | `max` | `number` | -- | Clamp maximum | | `invert` | `boolean` | `false` | Negate the input value | | `emitOnChange` | `boolean` | `false` | Only emit when value actually changes | | `deadzone` | `number` | `0` | Ignore values below this magnitude | ### InputDefinition Combines device ID and reader configuration: ```typescript type InputDefinition = DeviceValueReaderDefinition & { deviceID : string; }; ``` The `type` field determines which reader is created: | `type` | Additional Fields | Description | |--------|-------------------|-------------| | `'keyboard'` | `sourceKey` | Key code (e.g., `'KeyW'`, `'Space'`, `'Escape'`) | | `'mouse'` | `sourceType`, `sourceKey` | `sourceType`: `'button'`, `'position'`, `'wheel'` | | `'gamepad'` | `sourceType`, `sourceKey` | `sourceType`: `'button'`, `'axis'` | ### Device IDs | Device | ID | |--------|----| | Keyboard | `keyboard-0` | | Mouse | `mouse-0` | | Gamepad 1 | `gamepad-0` | | Gamepad 2 | `gamepad-1` | | Gamepad N | `gamepad-N` | ### Keyboard Source Keys Keyboard inputs use DOM `KeyboardEvent.code` values as the `sourceKey`. No `sourceType` field is needed. **Common keys:** | Category | Keys | |----------|------| | Letters | `KeyA` through `KeyZ` | | Digits | `Digit0` through `Digit9` | | Arrows | `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight` | | Modifiers | `ShiftLeft`, `ShiftRight`, `ControlLeft`, `ControlRight`, `AltLeft`, `AltRight` | | Special | `Space`, `Enter`, `Escape`, `Tab`, `Backspace`, `Delete` | | Function | `F1` through `F12` | | Numpad | `Numpad0` through `Numpad9`, `NumpadAdd`, `NumpadSubtract`, etc. | ```typescript { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'KeyW' } { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'Space' } { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'ShiftLeft' } ``` ### Mouse Source Keys Mouse inputs require both `sourceType` and `sourceKey`. #### Buttons (`sourceType: 'button'`) | `sourceKey` | Button | |-------------|--------| | `button-0` | Left mouse button | | `button-1` | Middle mouse button (wheel click) | | `button-2` | Right mouse button | | `button-3` | Back (side button) | | `button-4` | Forward (side button) | ```typescript { deviceID: 'mouse-0', type: 'mouse', sourceType: 'button', sourceKey: 'button-0' } ``` #### Position (`sourceType: 'position'`) | `sourceKey` | Value | |-------------|-------| | `absolute:x` | Cursor X position (clientX) | | `absolute:y` | Cursor Y position (clientY) | | `relative:x` | Cursor X movement delta | | `relative:y` | Cursor Y movement delta | ```typescript { deviceID: 'mouse-0', type: 'mouse', sourceType: 'position', sourceKey: 'relative:x' } ``` #### Wheel (`sourceType: 'wheel'`) | `sourceKey` | Value | |-------------|-------| | `deltaX` | Horizontal scroll amount | | `deltaY` | Vertical scroll amount | | `deltaZ` | Z-axis scroll amount (rare) | ```typescript { deviceID: 'mouse-0', type: 'mouse', sourceType: 'wheel', sourceKey: 'deltaY' } ``` ### Gamepad Source Keys Gamepad inputs require both `sourceType` and `sourceKey`. Values follow the [standard gamepad mapping](https://w3c.github.io/gamepad/#remapping). #### Buttons (`sourceType: 'button'`) | `sourceKey` | Xbox | PlayStation | |-------------|------|-------------| | `button-0` | A | Cross | | `button-1` | B | Circle | | `button-2` | X | Square | | `button-3` | Y | Triangle | | `button-4` | LB | L1 | | `button-5` | RB | R1 | | `button-6` | LT | L2 | | `button-7` | RT | R2 | | `button-8` | Back / Select | Share | | `button-9` | Start | Options | | `button-10` | L3 (left stick click) | L3 | | `button-11` | R3 (right stick click) | R3 | | `button-12` | D-pad Up | D-pad Up | | `button-13` | D-pad Down | D-pad Down | | `button-14` | D-pad Left | D-pad Left | | `button-15` | D-pad Right | D-pad Right | ```typescript { deviceID: 'gamepad-0', type: 'gamepad', sourceType: 'button', sourceKey: 'button-0' } ``` #### Axes (`sourceType: 'axis'`) Axes return continuous values from -1 to +1. | `sourceKey` | Axis | |-------------|------| | `axis-0` | Left stick horizontal (-1 left, +1 right) | | `axis-1` | Left stick vertical (-1 up, +1 down) | | `axis-2` | Right stick horizontal (-1 left, +1 right) | | `axis-3` | Right stick vertical (-1 up, +1 down) | ```typescript { deviceID: 'gamepad-0', type: 'gamepad', sourceType: 'axis', sourceKey: 'axis-0' } ``` ## BindingManager The `BindingManager` is the central hub for input processing. It is available through the engine: ```typescript const bindingManager = gameEngine.managers.bindingManager; ``` ### Action API | Method | Signature | Description | |--------|-----------|-------------| | `registerAction` | `(action : Action) => void` | Register a new action. Throws if already registered. | | `getAction` | `(name : string) => Action \| null` | Look up a registered action by name | ### Binding API | Method | Signature | Description | |--------|-----------|-------------| | `registerBinding` | `(definition : BindingDefinition) => void` | Create and register a binding from a definition | | `unregisterBindings` | `(actionName : string, context? : string \| null) => void` | Remove all bindings for an action in a context | | `getBindingsForAction` | `(actionName : string, context? : string \| null) => Binding[]` | Get all bindings for an action | ### Context API | Method | Signature | Description | |--------|-----------|-------------| | `registerContext` | `(name : string, exclusive? : boolean) => Context` | Register a context (default: exclusive) | | `activateContext` | `(name : string) => void` | Activate a context; deactivates other exclusive contexts | | `deactivateContext` | `(name : string) => void` | Deactivate a context | | `getActiveContexts` | `() => string[]` | List all currently active contexts | | `isContextActive` | `(name : string) => boolean` | Check if a context is active | | `getContext` | `(name : string) => Context \| null` | Look up a context by name | ### Configuration API | Method | Signature | Description | |--------|-----------|-------------| | `exportConfiguration` | `() => BindingConfiguration` | Export all actions, bindings, and contexts | | `importConfiguration` | `(config : BindingConfiguration) => void` | Import a configuration, replacing the current one | ### BindingConfiguration ```typescript interface BindingConfiguration { actions : Action[]; bindings : BindingDefinition[]; contexts : { name : string; exclusive : boolean; active : boolean; }[]; } ``` ### Input Capture API `captureInput()` listens for the next intentional input event and returns an `InputDefinition` you can plug directly into `registerBinding()`. It is designed for key rebinding UIs — the player clicks "rebind jump", presses a key, and you get back exactly the definition you need. | Method | Signature | Description | |----------------|---------------------------------------------------------------|------------------------------------------| | `captureInput` | `(options : CaptureInputOptions) => Promise` | Capture the next intentional input event | #### CaptureInputOptions ```typescript interface CaptureInputOptions { deviceTypes : DeviceType[]; sourceTypes ?: Array<'key' | 'button' | 'axis' | 'position' | 'wheel'>; signal ?: AbortSignal; } ``` | Option | Type | Required | Description | |---------------|---------------------------------------------------------------|----------|-------------------------------------------------------------------------| | `deviceTypes` | `DeviceType[]` | Yes | Which device types to listen for (`'keyboard'`, `'mouse'`, `'gamepad'`) | | `sourceTypes` | `Array<'key' \| 'button' \| 'axis' \| 'position' \| 'wheel'>` | No | Filter which source types to capture | | `signal` | `AbortSignal` | No | Cancel or timeout the capture via standard `AbortSignal` | #### Behavior While a capture is active, **all normal bindings are suppressed** — SAGE activates a temporary exclusive context so gameplay input does not fire. When the capture completes or is cancelled, the previous context state is automatically restored. Only one capture can be active at a time; calling `captureInput()` while one is already in progress throws an error. ::: tip Noise Filtering `captureInput()` automatically filters out unintentional input: * **Keyboard** — captures key-down only (ignores key-up and OS key repeat) * **Mouse** — captures button presses only (ignores movement and wheel) * **Gamepad** — captures button presses and axis movements beyond a 0.5 deadzone (ignores stick drift) ::: #### Examples Basic capture — listen for any keyboard or gamepad input: ```typescript const bindingManager = gameEngine.managers.bindingManager; const input = await bindingManager.captureInput({ deviceTypes: [ 'keyboard', 'gamepad' ], }); // input is a ready-to-use InputDefinition bindingManager.unregisterBindings('jump', 'gameplay'); bindingManager.registerBinding({ type: 'trigger', action: 'jump', input, context: 'gameplay', options: { edgeMode: 'rising' }, }); ``` Capture with timeout and cancellation: ```typescript const controller = new AbortController(); // Cancel on Escape key window.addEventListener('keydown', (e) => { if(e.code === 'Escape') { controller.abort(); } }, { once: true }); try { const input = await bindingManager.captureInput({ deviceTypes: [ 'keyboard', 'mouse', 'gamepad' ], sourceTypes: [ 'key', 'button' ], signal: AbortSignal.any([ controller.signal, AbortSignal.timeout(10_000), ]), }); // Use the captured input to update the binding bindingManager.unregisterBindings('shoot', 'gameplay'); bindingManager.registerBinding({ type: 'trigger', action: 'shoot', input, context: 'gameplay', options: { edgeMode: 'rising' }, }); } catch(err) { if(err instanceof DOMException && err.name === 'AbortError') { console.log('Capture cancelled'); } } ``` ## Contexts Contexts group bindings so they can be activated and deactivated together. An **exclusive** context deactivates all other exclusive contexts when activated. A **non-exclusive** context can be active alongside any others. ```typescript bindingManager.registerContext('gameplay', true); // exclusive bindingManager.registerContext('menu', true); // exclusive bindingManager.registerContext('always', false); // non-exclusive // Switching contexts bindingManager.activateContext('gameplay'); bindingManager.activateContext('menu'); // auto-deactivates 'gameplay' ``` Bindings without a context are always active, regardless of which contexts are enabled. ::: info When a context is reactivated, all bindings in that context have their edge state reset. This prevents stale input state from a previous activation from swallowing the first input event. ::: ## Subscribing to Actions Once actions and bindings are registered, subscribe to action events using `engine.subscribeAction()`: ```typescript engine.subscribeAction('jump', (event) => { console.log('Jump! Value:', event.payload.value); console.log('Device:', event.payload.deviceId); }); ``` The `subscribeAction()` method provides typed `ActionPayload` with `value` and `deviceId` properties. Within behaviors, subscribe directly on the event bus: ```typescript this.entity.eventBus.subscribe('action:move_forward', (event) => { const isActive = event.payload.value === true; // handle movement... }); ``` ## Complete Example: WASD Movement ### Registering Actions and Bindings ```typescript const bindingManager = gameEngine.managers.bindingManager; // Register movement actions const actions = [ { name: 'move_forward', type: 'digital' as const }, { name: 'move_backward', type: 'digital' as const }, { name: 'move_left', type: 'digital' as const }, { name: 'move_right', type: 'digital' as const }, { name: 'jump', type: 'digital' as const }, ]; actions.forEach((action) => bindingManager.registerAction(action)); // Register context bindingManager.registerContext('gameplay', true); // Define WASD bindings const bindings : BindingDefinition[] = [ { type: 'trigger', action: 'move_forward', input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'KeyW' }, context: 'gameplay', options: { edgeMode: 'both' }, }, { type: 'trigger', action: 'move_backward', input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'KeyS' }, context: 'gameplay', options: { edgeMode: 'both' }, }, { type: 'trigger', action: 'move_left', input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'KeyA' }, context: 'gameplay', options: { edgeMode: 'both' }, }, { type: 'trigger', action: 'move_right', input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'KeyD' }, context: 'gameplay', options: { edgeMode: 'both' }, }, { type: 'trigger', action: 'jump', input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'Space' }, context: 'gameplay', options: { edgeMode: 'rising' }, }, ]; bindings.forEach((b) => bindingManager.registerBinding(b)); bindingManager.activateContext('gameplay'); ``` ### Handling Actions ```typescript engine.subscribeAction('move_forward', (event) => { if(event.payload.value === true) { player.velocity.z = -player.speed; } else if(!player.isMovingBackward) { player.velocity.z = 0; } }); ``` ## Multiple Input Devices Bind the same action to multiple devices: ```typescript // Keyboard bindingManager.registerBinding({ type: 'trigger', action: 'jump', input: { deviceID: 'keyboard-0', type: 'keyboard', sourceKey: 'Space' }, context: 'gameplay', }); // Gamepad A button bindingManager.registerBinding({ type: 'trigger', action: 'jump', input: { deviceID: 'gamepad-0', type: 'gamepad', sourceType: 'button', sourceKey: 'button-0' }, context: 'gameplay', }); ``` ### Gamepad Analog Sticks ```typescript bindingManager.registerAction({ name: 'look_x', type: 'analog', minValue: -1, maxValue: 1 }); bindingManager.registerBinding({ type: 'value', action: 'look_x', input: { deviceID: 'gamepad-0', type: 'gamepad', sourceType: 'axis', sourceKey: 'axis-2' }, options: { deadzone: 0.15, emitOnChange: true }, }); ``` ## Entity Integration Entity definitions can declare required actions: ```typescript const playerDef : GameEntityDefinition = { type: 'player', defaultState: { /* ... */ }, behaviors: [ PlayerMovementBehavior ], actions: [ { name: 'move_forward', type: 'digital' }, { name: 'move_backward', type: 'digital' }, { name: 'jump', type: 'digital' }, ], }; ``` ## Saving and Loading Bindings Export and import binding configurations for user-customizable controls: ```typescript // Save const config = bindingManager.exportConfiguration(); localStorage.setItem('controls', JSON.stringify(config)); // Load const saved = localStorage.getItem('controls'); if(saved) { bindingManager.importConfiguration(JSON.parse(saved)); } ``` The binding system is storage-agnostic -- it produces and consumes plain JSON-serializable objects. Use localStorage, IndexedDB, files, or a server API as needed. --- --- url: /guides/input-system.md description: >- Build a fully interactive scene with keyboard, mouse, and gamepad controls using SAGE's input binding system. --- # Input System This guide walks you through SAGE's input binding system by building a controllable cube with movement, jumping, shooting, sprinting, and crouching — all driven by actions that work across keyboard, mouse, and gamepad simultaneously. The full source is in `examples/src/examples/input-demo/`. ::: tip Try it live Run this example at [Examples > Input Demo](/examples/input-demo). ::: ## What the input system does SAGE's input system adds a layer of indirection between raw hardware input and game logic: ``` Physical Input --> Binding --> Action --> Game Logic [KeyW pressed] [value +1.0] [moveForward] [move cube forward] [Left stick Y] [value -0.7] [moveForward] [move cube forward] [LMB click] [trigger] [shoot] [spawn bullet] ``` Your game logic only knows about **actions** like `moveForward` and `shoot`. It never touches raw key codes or gamepad axes. This means you can remap controls, support new devices, or add accessibility options without changing a single line of game code. ## Project structure ``` input-demo/ ├── index.vue # Vue component — UI and orchestration ├── constants.ts # Tuning values (speeds, sizes, deadzones) ├── types.ts # TypeScript interfaces ├── input/ │ ├── bindings.ts # Action registration and binding config │ ├── helpers.ts # Factory functions for input descriptors │ ├── labels.ts # Human-readable input labels │ ├── display.ts # UI display data │ └── highlights.ts # Active input tracking for visual feedback ├── game/ │ ├── actions.ts # Action event subscriptions │ ├── movement.ts # Cube movement and camera rotation │ └── bullets.ts # Projectile spawning and management └── level/ └── setup.ts # BabylonJS scene setup ``` The separation matters: the `input/` module knows about bindings but nothing about cubes. The `game/` module knows about actions but nothing about key codes. That is the whole point. ## Step 1: Define actions Actions are the abstract things your game responds to. You register them with the binding manager before setting up any bindings. There are two types: * **analog** — continuous values with a range (movement, camera rotation) * **digital** — boolean on/off (jump, shoot, sprint) Both types support an optional `label` for display in settings UIs (e.g., showing "Strafe" instead of `move_right`). If omitted, fall back to `name`. ```typescript const bindingManager = engine.managers.bindingManager; // Analog actions — have a value range bindingManager.registerAction({ type: 'analog', name: 'moveForward', label: 'Move Forward', minValue: -1, maxValue: 1 }); bindingManager.registerAction({ type: 'analog', name: 'moveRight', label: 'Strafe', minValue: -1, maxValue: 1 }); bindingManager.registerAction({ type: 'analog', name: 'lookX', label: 'Look Horizontal', minValue: -1, maxValue: 1 }); bindingManager.registerAction({ type: 'analog', name: 'lookY', label: 'Look Vertical', minValue: -1, maxValue: 1 }); // Digital actions — on or off bindingManager.registerAction({ type: 'digital', name: 'jump', label: 'Jump' }); bindingManager.registerAction({ type: 'digital', name: 'shoot', label: 'Shoot' }); bindingManager.registerAction({ type: 'digital', name: 'sprint', label: 'Sprint' }); bindingManager.registerAction({ type: 'digital', name: 'crouch', label: 'Crouch' }); ``` ## Step 2: Create input descriptors Every binding needs an `InputDefinition` that identifies the physical input. These are verbose objects, so the example wraps them in helper functions: ```typescript import type { InputDefinition } from '@skewedaspect/sage'; export function keyboardInput(sourceKey : string) : InputDefinition { return { deviceID: 'keyboard-0', type: 'keyboard', sourceType: 'key', sourceKey, }; } export function mouseInput(button : string) : InputDefinition { return { deviceID: 'mouse-0', type: 'mouse', sourceType: 'button', sourceKey: `button-${ button }`, }; } export function gamepadAxis(sourceKey : string) : InputDefinition { return { deviceID: 'gamepad-0', type: 'gamepad', sourceType: 'axis', sourceKey, }; } export function gamepadButton(sourceKey : string) : InputDefinition { return { deviceID: 'gamepad-0', type: 'gamepad', sourceType: 'button', sourceKey, }; } ``` Keyboard keys use DOM `KeyboardEvent.code` values (`'KeyW'`, `'Space'`, `'ShiftLeft'`). Gamepad buttons and axes use the standard mapping (`'button-0'` for A/Cross, `'axis-0'` for left stick X). ## Step 3: Register bindings Bindings connect physical inputs to actions. There are three binding types, each with different behavior: ### Value bindings — continuous input Value bindings fire whenever the input value changes. For keyboard keys, that means firing with the `scale` value when pressed and `0` when released. For gamepad axes, the raw analog value is passed through (optionally multiplied by `scale`). ```typescript // W key: moveForward = +1.0 when pressed, 0 when released { type: 'value', action: 'moveForward', context: 'gameplay', input: keyboardInput('KeyW'), options: { scale: 1.0 } }, // S key: moveForward = -1.0 when pressed (backward) { type: 'value', action: 'moveForward', context: 'gameplay', input: keyboardInput('KeyS'), options: { scale: -1.0 } }, // Gamepad left stick Y: continuous -1 to +1 with deadzone // scale: -1.0 inverts the axis (gamepad "up" is negative, but we want it positive) { type: 'value', action: 'moveForward', context: 'gameplay', input: gamepadAxis('axis-1'), options: { scale: -1.0, deadzone: 0.15 } }, ``` The `scale` option multiplies the raw input. For keyboard keys (which output 0 or 1), this lets you use negative values for opposite directions. The `deadzone` option filters out small analog values to prevent stick drift from triggering movement. Multiple bindings can map to the same action. The W key, S key, and left stick Y all contribute to `moveForward`. SAGE handles combining them. ### Trigger bindings — one-shot events Trigger bindings fire once per press, not continuously while held. The `edgeMode` option controls when: * `'rising'` — fire when the button goes from released to pressed (most common) * `'falling'` — fire on release * `'both'` — fire on both transitions ```typescript // Jump on spacebar press { type: 'trigger', action: 'jump', context: 'gameplay', input: keyboardInput('Space'), options: { edgeMode: 'rising' } }, // Jump on gamepad A button { type: 'trigger', action: 'jump', context: 'gameplay', input: gamepadButton('button-0'), options: { edgeMode: 'rising' } }, ``` The shoot action demonstrates binding three different devices to one action — keyboard, mouse, and gamepad all trigger `shoot`: ```typescript { type: 'trigger', action: 'shoot', context: 'gameplay', input: keyboardInput('KeyF'), options: { edgeMode: 'rising' } }, { type: 'trigger', action: 'shoot', context: 'gameplay', input: mouseInput('0'), // Left mouse button options: { edgeMode: 'rising' } }, { type: 'trigger', action: 'shoot', context: 'gameplay', input: gamepadButton('button-7'), // Right trigger options: { edgeMode: 'rising' } }, ``` ### Toggle bindings — hold to activate Toggle bindings fire on both press and release, reporting the current held state. The payload value is `true` while held and `false` on release. Use these for "hold to activate" mechanics. ```typescript // Hold Shift to sprint { type: 'toggle', action: 'sprint', context: 'gameplay', input: keyboardInput('ShiftLeft') }, // Hold C to crouch { type: 'toggle', action: 'crouch', context: 'gameplay', input: keyboardInput('KeyC') }, ``` ## Step 4: Register and activate contexts All bindings reference a `context`. Contexts let you have different binding sets for different game states — gameplay controls during play, menu navigation during menus. ```typescript bindingManager.registerContext('gameplay', true); bindingManager.registerContext('menu', true); // Start with gameplay active bindingManager.activateContext('gameplay'); ``` The second parameter (`true`) allows multiple contexts to be active simultaneously. To switch contexts: ```typescript bindingManager.deactivateContext('gameplay'); bindingManager.activateContext('menu'); ``` Only bindings in active contexts fire events. This means you can have the same key do different things in different contexts without conflicts. ## Step 5: Subscribe to action events Once bindings are set up, you subscribe to actions using `engine.subscribeAction()`. You pass just the action name — SAGE handles the `action:` event prefix internally. ### Analog actions (movement and look) ```typescript engine.subscribeAction('moveForward', (event) => { const value = Number(event.payload.value); movement.forward = value; // plain game state }); engine.subscribeAction('moveRight', (event) => { const value = Number(event.payload.value); movement.right = value; }); engine.subscribeAction('lookX', (event) => { look.x = Number(event.payload.value); }); ``` ### Trigger actions (jump and shoot) ```typescript engine.subscribeAction('jump', (event) => { // Only jump when on the ground if(cube.position.y <= 0.51) { movement.velocityY = JUMP_VELOCITY; } }); engine.subscribeAction('shoot', (event) => { spawnBullet(scene, cube, bullets); }); ``` The `event.payload.deviceId` field tells you which device triggered the action, if you need to distinguish between input sources. ### Toggle actions (sprint and crouch) ```typescript engine.subscribeAction('sprint', (event) => { isSprinting = Boolean(event.payload.value); }); engine.subscribeAction('crouch', (event) => { isCrouching = Boolean(event.payload.value); }); ``` For toggles, `event.payload.value` is `true` when pressed and `false` when released. ## Step 6: Movement with modifiers The game loop reads the accumulated input state and applies movement each frame. Sprint and crouch modify the base movement speed: ```typescript export function updateCubeMovement( cube : Mesh, movement : MovementState, actionValues : ActionValues ) : void { // Speed selection: sprint doubles speed, crouch halves it const speed = actionValues.sprint ? MOVE_SPEED_SPRINT : MOVE_SPEED_NORMAL; const scale = actionValues.crouch ? MOVE_SCALE_CROUCH : 1.0; // Apply movement cube.position.z -= movement.forward * speed * scale; cube.position.x += movement.right * speed * scale; // Gravity and jump physics movement.velocityY -= GRAVITY; cube.position.y += movement.velocityY; // Ground collision const groundY = actionValues.crouch ? GROUND_Y_CROUCH : GROUND_Y_NORMAL; if(cube.position.y < groundY) { cube.position.y = groundY; movement.velocityY = 0; } // Crouch visual — squash the cube cube.scaling.y = actionValues.crouch ? CROUCH_SCALE : 1.0; // Keep in bounds cube.position.x = Math.max(-BOUNDS, Math.min(BOUNDS, cube.position.x)); cube.position.z = Math.max(-BOUNDS, Math.min(BOUNDS, cube.position.z)); } ``` Camera rotation works the same way — the look input values drive ArcRotateCamera angles: ```typescript export function updateCamera(camera : ArcRotateCamera, look : LookState) : void { camera.alpha += look.x * CAMERA_ROTATE_SPEED; camera.beta -= look.y * CAMERA_ROTATE_SPEED; // Clamp beta to prevent camera flipping camera.beta = Math.max(CAMERA_BETA_MIN, Math.min(CAMERA_BETA_MAX, camera.beta)); } ``` ## Step 7: The bullet system Shooting spawns a glowing sphere that travels forward until it expires or leaves the arena: ```typescript export function spawnBullet(scene : Scene, cube : Mesh, bullets : Bullet[]) : void { const mesh = MeshBuilder.CreateSphere('bullet', { diameter: 0.15 }, scene); mesh.position = cube.position.clone(); mesh.position.y = 0.5; const mat = new StandardMaterial('bulletMat', scene); mat.diffuseColor = new Color3(1, 0.4, 0.2); mat.emissiveColor = new Color3(0.8, 0.2, 0.1); mesh.material = mat; bullets.push({ mesh, velocity: { x: 0, z: -BULLET_SPEED }, life: BULLET_LIFETIME, }); } ``` Each frame, bullets are updated and expired ones are cleaned up. The reverse iteration pattern avoids index shifting issues when removing elements: ```typescript export function updateBullets(bullets : Bullet[]) : void { for(let i = bullets.length - 1; i >= 0; i--) { const bullet = bullets[i]; bullet.mesh.position.x += bullet.velocity.x; bullet.mesh.position.z += bullet.velocity.z; bullet.life--; if(bullet.life <= 0 || Math.abs(bullet.mesh.position.x) > BULLET_BOUNDS || Math.abs(bullet.mesh.position.z) > BULLET_BOUNDS) { bullet.mesh.dispose(); bullets.splice(i, 1); } } } ``` ## Step 8: The render loop Everything comes together in the render loop. Note that gamepad input requires polling each frame: ```typescript engine.renderEngine.runRenderLoop(() => { // Poll gamepad state — required each frame for gamepad input engine.managers.inputManager.pollGamepads(); // Update game state updateCubeMovement(cube, movement, actionValues); updateCamera(camera, look); updateBullets(bullets); // Render scene.render(); }); ``` ::: warning Gamepad polling SAGE does not automatically poll gamepad state. You must call `engine.managers.inputManager.pollGamepads()` each frame, or gamepad inputs will not be detected. Keyboard and mouse events are handled automatically via DOM event listeners. ::: ## Reactivity strategy The example deliberately separates Vue reactive state from game state: * **Game state** (`movement`, `look`, `bullets`) — plain objects, updated 60+ times per second in the game loop. No Vue reactivity overhead. * **UI state** (`actionValues`, `displayBindings`, `eventLog`) — Vue reactive objects, updated by action subscriptions and read by the template. This "dual-write" pattern keeps the game loop fast while still powering responsive UI updates. ## Resource cleanup On component unmount, detach any event listeners and dispose the scene: ```typescript onBeforeUnmount(() => { inputHighlightTracker?.detach(); scene?.dispose(); }); ``` ## Rebinding controls Most games let players rebind their controls. SAGE's `captureInput()` API makes this straightforward -- it returns a `Promise` that resolves with the next intentional input, ready to plug directly into `registerBinding()`. ### The rebinding flow A typical rebind goes like this: 1. Player clicks a "rebind" button next to an action (e.g., "Jump: Space") 2. UI shows a "Press any key..." prompt 3. `captureInput()` waits for intentional input, suppressing all gameplay bindings 4. Player presses the desired key or button 5. The promise resolves with an `InputDefinition` 6. You remove the old binding, register the new one, and update the UI ```typescript async function rebindAction( bindingManager : BindingManager, actionName : string, context : string ) : Promise { // Show the "press any key" prompt showRebindPrompt(actionName); // Capture the next input from any device const input = await bindingManager.captureInput({ deviceTypes: [ 'keyboard', 'mouse', 'gamepad' ], sourceTypes: [ 'key', 'button' ], }); // Swap the binding bindingManager.unregisterBindings(actionName, context); bindingManager.registerBinding({ type: 'trigger', action: actionName, input, context, options: { edgeMode: 'rising' }, }); // Persist the new configuration const config = bindingManager.exportConfiguration(); localStorage.setItem('controls', JSON.stringify(config)); hideRebindPrompt(); return input; } ``` While `captureInput()` is waiting, SAGE activates a temporary exclusive context that suppresses all normal bindings. Your game won't process any input until the capture resolves or is cancelled. ### Cancellation and timeouts You probably don't want the capture to wait forever. Use an `AbortSignal` to support both an escape key and a timeout: ```typescript async function rebindWithCancel( bindingManager : BindingManager, actionName : string, context : string ) : Promise { const controller = new AbortController(); // Let the player press Escape to cancel const onEscape = (e : KeyboardEvent) : void => { if(e.code === 'Escape') { controller.abort(); } }; window.addEventListener('keydown', onEscape); try { const input = await bindingManager.captureInput({ deviceTypes: [ 'keyboard', 'mouse', 'gamepad' ], signal: AbortSignal.any([ controller.signal, AbortSignal.timeout(10_000), // 10 second timeout ]), }); // Success — update the binding bindingManager.unregisterBindings(actionName, context); bindingManager.registerBinding({ type: 'trigger', action: actionName, input, context, options: { edgeMode: 'rising' }, }); return input; } catch(err) { if(err instanceof DOMException && err.name === 'AbortError') { // Player cancelled or timed out — no changes return null; } throw err; } finally { window.removeEventListener('keydown', onEscape); hideRebindPrompt(); } } ``` ::: tip `captureInput()` automatically restores the previous context state when it completes or is cancelled. You don't need to manually reactivate your gameplay context. ::: ::: warning Only one capture can be active at a time. Calling `captureInput()` while another capture is in progress will throw an error. Make sure your UI disables other rebind buttons while a capture is active. ::: ## What you learned * Actions abstract game intent from physical input * Three binding types: **value** (continuous), **trigger** (one-shot), **toggle** (hold) * `engine.subscribeAction()` connects actions to game logic * Contexts let you swap binding sets for different game states * Gamepad requires per-frame polling with `inputManager.pollGamepads()` * Separate game state from Vue reactive state for performance * `captureInput()` enables key rebinding UIs with built-in noise filtering and cancellation ## Next steps * [Entity Behaviors](./entity-behaviors.md) — compose game objects from reusable behavior modules --- --- url: /getting-started/installation.md description: >- Install SAGE and its dependencies, then create a minimal spinning cube to verify everything works. --- # Installation ## Prerequisites * **Node.js 18+** and npm (or your preferred package manager) * **TypeScript** knowledge -- SAGE is written in TypeScript and expects you to use it * A bundler like **Vite** (recommended), Webpack, or similar ## Install Packages SAGE requires BabylonJS and Havok as peer dependencies: ```bash npm install @skewedaspect/sage @babylonjs/core @babylonjs/havok ``` If you are building a Vue application, also install the Vue integration package: ```bash npm install @skewedaspect/sage-vue ``` ## TypeScript Configuration Make sure your `tsconfig.json` includes these settings: ```json { "compilerOptions": { "target": "ES2023", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "lib": ["ESNext", "DOM", "DOM.Iterable"] } } ``` ## Quick Start: Spinning Cube The fastest way to verify your setup is to get a cube on screen and spinning. This uses SAGE's `createGameEngine()` factory and BabylonJS directly for scene setup. ### HTML Create an `index.html` with a full-viewport canvas: ```html SAGE - Hello Cube ``` ### Game Code Create `src/main.ts`: ```typescript import { createGameEngine } from '@skewedaspect/sage'; import { ArcRotateCamera, Color3, Color4, HemisphericLight, MeshBuilder, StandardMaterial, Vector3, } from '@babylonjs/core'; async function main() : Promise { const canvas = document.getElementById('gameCanvas') as HTMLCanvasElement; // Create the engine -- no entity definitions needed for this demo const engine = await createGameEngine(canvas, []); // Create a scene with physics enabled const scene = engine.engines.sceneEngine.createScene(); scene.clearColor = new Color4(0.12, 0.12, 0.14, 1); // Camera const camera = new ArcRotateCamera( 'camera', Math.PI / 4, Math.PI / 3, 5, Vector3.Zero(), scene ); camera.attachControl(canvas, true); // Light new HemisphericLight('light', new Vector3(0, 1, 0), scene).intensity = 0.8; // Cube const cube = MeshBuilder.CreateBox('cube', { size: 1.5 }, scene); const material = new StandardMaterial('cubeMat', scene); material.diffuseColor = new Color3(0.5, 0.17, 0.17); cube.material = material; // Spin the cube every frame engine.managers.gameManager.registerFrameCallback((dt : number) => { cube.rotation.x += 0.3 * dt; cube.rotation.y += 0.6 * dt; scene.render(); }); // Start the game loop engine.managers.gameManager.start(); } main(); ``` Run it with Vite: ```bash npx vite ``` You should see a dark scene with a red cube rotating. Click and drag to orbit the camera. ## Vue Integration If you are using Vue 3, the `@skewedaspect/sage-vue` package provides a `SageCanvas` component that handles engine creation, canvas management, and resize handling for you. ```vue ``` `SageCanvas` provides a scoped slot with `loading`, `error`, and `engine` properties, so you can overlay UI on top of the canvas: ```vue ``` You can also pass entity definitions directly via the `:entity-definitions` prop instead of registering them manually after engine creation. ::: tip Child components can access the engine instance using the `useSageEngine()` composable from `@skewedaspect/sage-vue`, without needing to pass it as a prop. ::: ## Next Steps This page got you from zero to spinning cube. For the full walkthrough with explanations of every piece, see the [Hello Cube Guide](/guides/hello-cube). From there: * [Architecture](/concepts/architecture) -- understand how the engine is put together * [Entities](/concepts/entities) -- learn the entity-behavior composition system * [Input & Bindings](/api/input) -- set up keyboard, mouse, and gamepad input * [Levels](/api/levels) -- load scenes from YAML and GLB files --- --- url: /guides/level-loading.md description: >- Load levels from YAML configs with spawn points, property handlers, level transitions, and puzzle mechanics built on GameLevel subclasses. --- # Level Loading This guide walks you through a three-level puzzle game that loads scenes from GLB files, spawns entities at Blender-defined positions, and transitions between levels. You will learn SAGE's YAML level format, how to write `GameLevel` subclasses for per-level logic, how property handlers process scene metadata, and how to build interactive puzzle mechanics. The full source is in `examples/src/examples/level-loading/`. ::: tip Try it live Run this example at [Examples > Level Loading](/examples/level-loading). ::: ## Project structure ``` level-loading/ ├── index.vue # Vue component — UI, loading screen, event log ├── types.ts # Entity state interfaces and type guards ├── constants.ts # Colors, player defaults, UI limits ├── entities/ │ ├── definitions.ts # All entity definitions (player, door, button, etc.) │ └── behaviors/ │ ├── PlayerMovementBehavior.ts │ ├── DoorBehavior.ts │ ├── ButtonBehavior.ts │ ├── CollectibleBehavior.ts │ ├── BoxBehavior.ts │ └── PressurePlateBehavior.ts ├── game/ │ └── LevelController.ts # Orchestrator — transitions, input, proximity checks ├── input/ │ ├── bindings.ts # Movement and interact bindings │ └── helpers.ts └── levels/ ├── loader.ts # YAML parsing with Vite ?raw imports ├── level1.yaml # Button + timer puzzle ├── level2.yaml # Zone + key collection puzzle ├── level3.yaml # Pressure plate + box puzzle ├── buttonTimerLevel.ts # GameLevel subclass for Level 1 ├── zoneKeyLevel.ts # GameLevel subclass for Level 2 └── pressurePlateLevel.ts # GameLevel subclass for Level 3 ``` ## What this example demonstrates * YAML level configuration format (name, class, scene, spawns, entities, postProcessing) * Loading GLB scenes with automatic entity spawning at Blender-defined spawn points * Writing `GameLevel` subclasses that own per-level game logic * Property handlers that process Blender custom properties into SAGE entities * Level transitions via `LevelManager.transition()` * Puzzle mechanics: buttons, doors, keys, pressure plates, and boxes * Loading screen with progress reporting from `buildScene()` * The `LevelController` orchestrator pattern ## Step 1: The YAML level config format Each level is defined in a YAML file. Here is Level 1: ```yaml name: Level 1 class: buttonTimer scene: /assets/level-loading/simple-arena.glb physics: true spawns: player_start: entity: player config: health: 100 maxHealth: 100 button_center: entity: button config: pressed: false preload: - /assets/level-loading/simple-arena.glb postProcessing: bloom: weight: 0.3 threshold: 0.8 tonemap: operator: aces entities: door: config: locked: true ``` The fields: * **`name`** -- unique level identifier, also used as the key for `transition()` calls * **`class`** -- references a registered `GameLevel` subclass (the level's behavior) * **`scene`** -- path to a GLB file; SAGE loads it and processes its metadata * **`physics`** -- enables Havok physics on the scene (`true` for default gravity, or an object with custom `gravity: { x, y, z }`) * **`spawns`** -- maps Blender empty names to entity types with optional config overrides * **`preload`** -- assets to preload and cache before the level starts * **`config`** -- arbitrary data passed to custom Level classes, accessed via `this._config.config` * **`postProcessing`** -- declarative post-processing: bloom, SSAO, tone mapping, chromatic aberration, vignette, grain, sharpen * **`entities`** -- configures entities that exist in the GLB as entity markers (not spawns) For the complete specification of every field, default values, and annotated YAML, see the [Levels API Reference](/api/levels). The `spawns` section is the bridge between Blender and SAGE. In Blender, an artist places an Empty and sets a custom property `spawn = "player_start"`. In the YAML, `player_start` maps to the `player` entity type. When the level loads, SAGE creates a player entity at that Empty's position. ## Step 2: Loading YAML configs Level configs are loaded from YAML using Vite's `?raw` imports and the `js-yaml` library: ```typescript import yaml from 'js-yaml'; import type { LevelConfig } from '@skewedaspect/sage'; import level1Yaml from './level1.yaml?raw'; import level2Yaml from './level2.yaml?raw'; import level3Yaml from './level3.yaml?raw'; export function loadAllLevelConfigs() : LevelConfig[] { return [ yaml.load(level1Yaml) as LevelConfig, yaml.load(level2Yaml) as LevelConfig, yaml.load(level3Yaml) as LevelConfig, ]; } ``` The `?raw` suffix tells Vite to bundle the YAML as a string at build time rather than fetching it at runtime. This eliminates network requests for level configs. ## Step 3: Entity definitions Entity definitions tell SAGE what behaviors to attach and what default state to use when an entity type is created. The level-loading example defines players, doors, buttons, keys, boxes, and pressure plates. The player definition includes a `mesh` descriptor so SAGE can auto-create the visual: ```typescript export function createPlayerDefinition() : GameEntityDefinition { return { type: 'player', behaviors: [ PlayerMovementBehavior ], mesh: { source: 'capsule', params: { height: 1.8, radius: 0.4 }, material: { type: 'pbr', color: COLORS.player, metallic: 0.1, roughness: 0.7, }, }, defaultState: { position: { x: 0, y: 0, z: 0 }, health: PLAYER_HEALTH, maxHealth: PLAYER_HEALTH, speed: PLAYER_SPEED, }, }; } ``` The door definition is minimal because the door mesh comes from the GLB scene (via the `entity` custom property in Blender): ```typescript export function createDoorDefinition() : GameEntityDefinition { return { type: 'door', behaviors: [ DoorBehavior ], defaultState: { position: { x: 0, y: 1.5, z: 0 }, isOpen: false, locked: false, }, }; } ``` All definitions are passed to `SageCanvas` via the `:entity-definitions` prop, which registers them before the engine starts. ## Step 4: GameLevel subclasses Each level has its own `GameLevel` subclass that encapsulates per-level game logic. The base `GameLevel` class handles scene loading, entity spawning at spawn points, and property handler processing. Subclasses override `buildScene()` to add level-specific setup. ### ButtonTimerLevel (Level 1) Press a button to unlock the door for 30 seconds. Get through before the timer expires. ```typescript export class ButtonTimerLevel extends GameLevel { private _timerActive = false; private _timerRemaining = 0; protected override async buildScene() : Promise { this.$emitProgress(15, 'Loading scene geometry...'); const scene = await super.buildScene(); // Subscribe to button:pressed (emitted by ButtonBehavior) this._unsubscribers.push( this.gameEngine.eventBus.subscribe('button:pressed', () => this._onButtonPressed()) ); return scene; } private _onButtonPressed() : void { this._timerActive = true; this._timerRemaining = 30; // Unlock the door immediately for(const door of this.gameEngine.managers.entityManager.getByType('door')) { door.eventBus.publish({ type: 'door:unlock', payload: {} }); } this.gameEngine.eventBus.publish({ type: 'level:timer-started', payload: { duration: 30 }, }); } } ``` The `$emitProgress()` helper publishes `level:progress` events that drive the loading screen. The controller subscribes to these and updates the Vue UI. ### ZoneKeyLevel (Level 2) Stand on a summoning platform for 3 seconds to make a key appear. Collect it to unlock the door. ```typescript export class ZoneKeyLevel extends GameLevel { private _zoneTimer = 0; private _keySpawned = false; updateZone(dt : number, callbacks : ZoneLevelCallbacks) : void { const player = this.gameEngine.managers.entityManager.getByType('player')[0]; if(!player?.node) { return; } const dist = /* horizontal distance to platform center */; const isInZone = dist <= SUMMONING_PLATFORM_RADIUS; if(isInZone) { this._zoneTimer += dt; if(this._zoneTimer >= 3.0) { this._spawnKey(); this._keySpawned = true; } } } } ``` This level also builds custom visuals (a glowing platform and spotlight) programmatically in `buildScene()`, demonstrating that levels can combine GLB assets with runtime-generated geometry. ### PressurePlateLevel (Level 3) Push a box onto a pressure plate to make a key appear. If the plate is released, the key vanishes. You need to collect the key while something holds the plate down. ```typescript export class PressurePlateLevel extends GameLevel { updateProximity() : void { for(const plate of this.gameEngine.managers.entityManager.getByType('pressure_plate')) { let somethingOnPlate = false; // Check player and box proximity to the plate // ... if(somethingOnPlate && !isActivated) { plate.eventBus.publish({ type: 'plate:activate', payload: {} }); } else if(!somethingOnPlate && isActivated) { plate.eventBus.publish({ type: 'plate:deactivate', payload: {} }); } } } } ``` The level subscribes to `pressure_plate:activated` and `pressure_plate:deactivated` events to spawn and despawn the key dynamically. ## Step 5: Entity behaviors for interactive objects ### DoorBehavior The door subscribes to `door:unlock` and `door:lock` events. It animates the door's Y rotation smoothly between closed (0) and open (-90 degrees): ```typescript export class DoorBehavior extends GameEntityBehavior { name = 'door'; eventSubscriptions = [ 'door:unlock', 'door:lock' ]; processEvent(event : GameEvent, state : DoorState) : boolean { if(event.type === 'door:unlock' && state.locked) { state.locked = false; this.targetRotation = -Math.PI / 2; return true; } else if(event.type === 'door:lock' && !state.locked) { state.locked = true; this.targetRotation = 0; return true; } return false; } update(deltaTime : number) : void { // Animate toward targetRotation at SWING_SPEED // ... this.node.rotation.y = this.currentRotation; } } ``` ### ButtonBehavior The button subscribes to `interact` events. When pressed, it emits `button:pressed` to the global event bus, animates downward, and changes color: ```typescript private pressButton(state : ButtonState) : void { state.pressed = true; this.$emit({ type: 'button:pressed', payload: { entityId: this.entity!.id }, }); this.node!.position.y -= 0.1; const mesh = this.node!.getChildMeshes()[0]; if(mesh?.material) { (mesh.material as { diffuseColor ?: Color3 }).diffuseColor = new Color3(COLORS.buttonPressed.r, COLORS.buttonPressed.g, COLORS.buttonPressed.b); } } ``` Note the use of `$emit()` -- this publishes to the *global* event bus so the level subclass can react, while `processEvent()` handles events on the *entity's* event bus. ## Step 6: Property handlers Before creating a controller, you register SAGE's built-in property handlers with the level manager. These handlers process Blender custom properties (`spawn`, `entity`, `collider`, `sound`, `trigger`, etc.) when a GLB scene loads: ```typescript import { registerAllPropertyHandlers } from '@skewedaspect/sage'; registerAllPropertyHandlers(levelManager); ``` This single call enables all built-in handlers. When a GLB is loaded, SAGE walks the scene graph and processes each node's metadata: * `spawn` properties create entities at the Empty's position (handled by `GameLevel` directly) * `entity` properties wrap existing meshes as SAGE entities (handled by `GameLevel` directly) * `collider` properties add physics shapes (`"box"`, `"sphere"`, `"mesh"`, or `"none"`) * `trigger` properties create invisible intersection zones that emit `trigger:enter`/`trigger:exit` events * `sound` properties attach spatial audio (with `sound_volume`, `sound_loop`, etc.) * `lod_distances` properties set up level-of-detail switching based on camera distance * `occluder` properties mark meshes as occlusion cullers for rendering optimization * `visible` properties control mesh visibility For a complete reference of every metadata key, its value type, and detailed behavior, see [Scene Node Metadata and Property Handlers](/api/levels#scene-node-metadata-and-property-handlers). ## Step 7: Level transitions The level manager handles loading, unloading, and transitioning between levels. The controller calls `transition()` to switch levels: ```typescript async loadLevel(levelName : string) : Promise { this._enterState('loading'); this.callbacks.onProgress(levelName, 0, 'Preparing level...'); // transition() unloads the current level, loads the new one, // and processes all spawn points and entity markers await this.engine.managers.levelManager.transition(levelName); this._currentLevelName = levelName; this._setupSceneCamera(); this._enterState('playing'); } ``` The transition flow: 1. The current level's `$dispose()` is called, cleaning up entities and event subscriptions 2. The new level's `buildScene()` is called, which loads the GLB and spawns entities 3. `level:progress` events drive the loading screen UI 4. `level:transition:complete` fires when everything is ready ## Step 8: The LevelController orchestrator The controller manages everything that spans across levels: * **Initialization**: registers handlers, level classes, configs, and input bindings * **Level transitions**: load, next, reload * **Input routing**: forwards movement actions to the player entity each frame * **Interaction proximity**: checks player distance to buttons and boxes, shows/hides prompts * **Collection proximity**: auto-collects keys when the player walks near them * **North zone trigger**: when the player reaches the door area while unlocked, triggers the next level * **Per-level delegation**: calls level-specific update methods (timer, zone check, plate check) ```typescript update(dt : number) : void { if(this._state !== 'playing') { return; } this._publishMovement(); this._updateBoxCarry(); this._checkCollectibleProximity(); this._checkInteractionProximity(); this._checkNorthZoneTrigger(); // Delegate to active level subclass const currentLevel = this.engine.managers.levelManager.currentLevel; if(currentLevel instanceof ButtonTimerLevel) { currentLevel.updateTimer(dt, { /* callbacks */ }); } else if(currentLevel instanceof ZoneKeyLevel) { currentLevel.updateZone(dt, { /* callbacks */ }); } else if(currentLevel instanceof PressurePlateLevel) { currentLevel.updateProximity(); } } ``` The interaction system checks player proximity each frame and publishes events to the entity and global event buses: ```typescript private _checkInteractionProximity() : void { const playerPos = player.node.position; let interactionPrompt : string | null = null; // Check boxes for(const box of entityManager.getByType('box')) { if(box.node && Vector3.Distance(playerPos, box.node.position) < INTERACTION_DISTANCE) { interactionPrompt = 'to Pick Up'; break; } } // Check buttons // ... if(interactionPrompt) { this.engine.eventBus.publish({ type: 'interaction:available', payload: { prompt: interactionPrompt }, }); } } ``` ## Step 9: Post-processing per level Each level can define its own post-processing stack in the YAML config. The three levels demonstrate different moods: ```yaml # Level 1: Warm tutorial feel postProcessing: bloom: weight: 0.3 threshold: 0.8 tonemap: operator: aces # Level 2: Cool mysterious vibe postProcessing: bloom: weight: 0.15 threshold: 0.9 chromaticAberration: amount: 0.4 vignette: weight: 0.4 tonemap: operator: reinhard # Level 3: Gritty final level postProcessing: bloom: weight: 0.5 threshold: 0.7 grain: intensity: 15 animated: true sharpen: edge: 0.3 tonemap: operator: hable ``` SAGE applies these automatically when the level loads. ## What you learned * YAML level configs define scenes, spawn points, entity overrides, physics, custom data, and post-processing * `GameLevel` subclasses encapsulate per-level game logic with `buildScene()` and `$dispose()` * `registerAllPropertyHandlers()` enables automatic processing of Blender custom properties (`collider`, `trigger`, `lod_distances`, `occluder`, `visible`, `sound`) * `spawn` and `entity` metadata keys are handled directly by `GameLevel`, not via property handlers * `LevelManager.transition()` handles the full load/unload cycle with lifecycle hooks * `$emitProgress()` drives loading screens from inside `buildScene()` * The `config` field in YAML passes arbitrary data to custom Level subclasses * Level-specific behaviors (timers, zones, plates) live in level subclasses, not the controller * The controller handles cross-level concerns: input routing, proximity, and transitions ## Next steps * [Levels API Reference](/api/levels) -- exhaustive reference for every config field, property handler, default value, and post-processing parameter * [Sandbox](./sandbox.md) -- a first-person environment with GLB scenes, doors, elevators, and spatial audio * [Blender Workflow](./blender-workflow.md) -- how to author levels in Blender for SAGE --- --- url: /api/levels.md description: >- API reference for SAGE's level system -- LevelManager, Level base class, GameLevel, YAML configuration, transitions, property handlers, and progress reporting. --- # Levels The level system organizes your game into discrete, self-contained sections. Each level encapsulates its own scene, assets, physics, and entity spawning. The `LevelManager` handles creation, loading, activation, transitions, and disposal. ## LevelManager The `LevelManager` is a factory and lifecycle manager for levels. It is available through the engine: ```typescript const levelManager = gameEngine.managers.levelManager; ``` ### Properties | Property | Type | Description | |----------|------|-------------| | `currentLevel` | `Level \| null` | The currently active level | | `propertyHandlers` | `Map` | Registered scene node property handlers | ### Configuration API | Method | Signature | Description | |--------|-----------|-------------| | `registerLevelConfig` | `(config : LevelConfig) => void` | Register a level configuration for later instantiation | | `registerLevelClass` | `(name : string, levelClass : LevelConstructor) => void` | Register a custom Level class referenced by configs | | `getLevelConfig` | `(name : string) => LevelConfig \| null` | Look up a registered config | ### Loading API | Method | Signature | Description | |--------|-----------|-------------| | `loadLevel(name)` | `(string) => Promise` | Load a registered level by name | | `loadLevel(config)` | `(LevelConfig) => Promise` | Load from a config (auto-registers it) | | `activateLevel(name)` | `(string) => Promise` | Load (if needed) and set as current level | | `activateLevel(config)` | `(LevelConfig) => Promise` | Load from config and set as current | | `getLevel` | `(name : string) => Level \| null` | Get a loaded level by name | | `unloadLevel` | `(name : string) => Promise` | Dispose and remove a loaded level | | `unloadCurrentLevel` | `() => Promise` | Unload the active level | ### Transition API | Method | Signature | Description | |--------|-----------|-------------| | `transition` | `(levelName : string, options? : TransitionOptions) => Promise` | Full level-to-level transition | ### Property Handler API | Method | Signature | Description | |--------|-----------|-------------| | `registerPropertyHandler` | `(property : string, handler : PropertyHandler) => void` | Register a handler for scene node metadata | | `unregisterPropertyHandler` | `(property : string) => void` | Remove a property handler | | `clearAllPropertyHandlers` | `() => void` | Remove all property handlers | ## Level Base Class The abstract `Level` class is the foundation for all levels. You typically use `GameLevel` (the YAML-driven default) or subclass `Level` directly for fully custom levels. ### Key Methods | Method | Description | |--------|-------------| | `load()` | Triggers `buildScene()` and emits progress/completion events | | `$dispose()` | Disposes the scene and all resources | | `$emitProgress(progress, message)` | Emit a `level:progress` event (0--100) | ### Lifecycle Hooks | Hook | When Called | Description | |------|------------|-------------| | `onActivate()` | Level becomes active during a transition | Start music, show UI, unpause physics | | `onDeactivate()` | Level is being replaced during a transition | Save state, stop sounds, fade out | Both hooks are optional and async. ### Creating a Custom Level Extend `Level` and implement `buildScene`: ```typescript import { Level } from '@skewedaspect/sage'; import { Scene, Vector3, PhysicsShapeType, Color4 } from '@babylonjs/core'; class SpaceStation extends Level { protected async buildScene() : Promise { const { sceneEngine } = this.gameEngine.engines; const scene = sceneEngine.createScene(); this.$emitProgress(10, 'Creating scene...'); scene.clearColor = new Color4(0, 0, 0.05, 1); sceneEngine.enablePhysics(scene, new Vector3(0, -1.62, 0)); this.$emitProgress(20, 'Physics configured...'); sceneEngine.createFreeCamera('camera', new Vector3(0, 2, -5), scene); sceneEngine.createHemisphericLight('ambient', new Vector3(0, 1, 0), scene, 0.3); this.$emitProgress(40, 'Camera and lighting...'); await sceneEngine.loadModel('assets/models/station.glb', scene); this.$emitProgress(90, 'Station loaded...'); return scene; } async onActivate() : Promise { // Start ambient sound, show HUD, etc. } async onDeactivate() : Promise { // Save player progress, stop sounds } } ``` ## LevelConfig (YAML) Levels can be defined declaratively in YAML. The `GameLevel` class processes these configs automatically. ```typescript interface LevelConfig { name : string; scene ?: string; class ?: string; config ?: Record; spawns ?: Record; entities ?: Record; physics ?: boolean | { gravity ?: { x : number; y : number; z : number } }; preload ?: string[]; environment ?: EnvironmentConfig; cameras ?: Record; lights ?: Record; sounds ?: Record; postProcessing ?: PostProcessingConfig; } ``` ### LevelConfig Properties | Property | Type | Required | Description | |----------|------|----------|-------------| | `name` | `string` | **Yes** | Unique level identifier. Used as the key for `transition()` and `loadLevel()` calls. | | `scene` | `string` | No | Path to the scene file (`.glb`, `.gltf`, `.babylon`). The file is imported into the scene and its nodes are processed for metadata. | | `class` | `string` | No | Name of a registered custom Level class. If omitted, `GameLevel` is used. | | `config` | `Record` | No | Arbitrary configuration data passed to the Level instance. See [Custom Config Data](#custom-config-data). | | `spawns` | `Record` | No | Spawn point definitions. Keys match the `spawn` metadata values on scene nodes. | | `entities` | `Record` | No | Entity config overrides. Keys match the `entity` metadata values on scene nodes. | | `physics` | `boolean \| { gravity }` | No | Enable physics. `true` uses default gravity (0, -9.81, 0). An object allows custom gravity. | | `preload` | `string[]` | No | Asset paths to cache before building the scene. | | `environment` | `EnvironmentConfig` | No | Skybox and IBL (image-based lighting) configuration. See [Environment Configuration](#environment-configuration). | | `cameras` | `Record` | No | Camera configuration. Keys match Blender camera names (override) or define new cameras (create). See [Camera Configuration](#camera-configuration). | | `lights` | `Record` | No | Light configuration. Keys match Blender light names (override) or define new lights (create). See [Light Configuration](#light-configuration). | | `sounds` | `Record` | No | Level-scoped sounds tied to the level lifecycle. See [Level Sounds](#level-sounds). | | `postProcessing` | `PostProcessingConfig` | No | Declarative post-processing effects applied after the scene is built. | ### Custom Config Data The `config` field lets you pass arbitrary data to custom `GameLevel` subclasses. When a level uses a custom class (via the `class` field), the subclass can access this data through `this.config.config` (or more commonly, `this._config.config`): ```yaml name: Arena class: arenaLevel scene: /assets/arena.glb physics: true config: difficulty: hard enemyCount: 12 timeLimit: 120 theme: fire ``` ```typescript import type { Scene } from '@babylonjs/core'; import { GameLevel } from '@skewedaspect/sage'; export class ArenaLevel extends GameLevel { protected override async buildScene() : Promise { const scene = await super.buildScene(); // Access the custom config data const difficulty = this._config.config?.difficulty as string ?? 'normal'; const enemyCount = this._config.config?.enemyCount as number ?? 5; const timeLimit = this._config.config?.timeLimit as number ?? 60; // Use the config to drive level-specific logic if(difficulty === 'hard') { this._spawnExtraTraps(); } for(let i = 0; i < enemyCount; i++) { this._spawnEnemy(); } return scene; } } ``` The `config` field is ignored by `GameLevel` itself -- it only stores it. This makes it a clean extension point for your own level classes without modifying the engine. ### SpawnDefinition ```typescript interface SpawnDefinition { entity : string; config ?: Record; } ``` | Property | Type | Required | Description | |----------|------|----------|-------------| | `entity` | `string` | **Yes** | The entity type name to spawn. Must match a registered entity definition. | | `config` | `Record` | No | State overrides merged into the entity's `defaultState`. | When a spawn point is processed, SAGE creates a new entity of the given type at the spawn node's position, rotation, and scale. The `config` values are merged on top of the entity definition's `defaultState`: ```yaml spawns: player_start: entity: player config: health: 100 maxHealth: 100 speed: 8 ``` The spawn node (typically a Blender Empty) is disposed after the entity is created. If the entity definition includes a `mesh` descriptor, SAGE auto-creates the mesh at the spawn position. ### EntityDefinition ```typescript interface EntityDefinition { config ?: Record; } ``` | Property | Type | Required | Description | |----------|------|----------|-------------| | `config` | `Record` | No | State overrides merged into the entity's `defaultState`. | Entity definitions configure entities that already exist as meshes in the scene file. Unlike spawns (which create new entities at empty nodes), the `entities` section wraps existing scene meshes as SAGE entities: ```yaml entities: door: config: locked: true health: 200 crate: config: weight: 50 breakable: true ``` In Blender, you set a custom property `entity = "door"` on the mesh. When the level loads, SAGE finds that mesh, creates a `door` entity, and attaches the existing mesh as the entity's node. The `config` values merge into the entity's initial state. ### Physics Configuration Enable physics with default Earth gravity: ```yaml physics: true ``` Or specify custom gravity as a vector: ```yaml # Low gravity (moon-like) physics: gravity: x: 0 y: -1.62 z: 0 ``` ```yaml # Zero gravity (space) physics: gravity: x: 0 y: 0 z: 0 ``` ```yaml # Side-scrolling gravity (pushes objects to the right) physics: gravity: x: 5 y: -9.81 z: 0 ``` When `physics: true`, the default gravity vector is `(0, -9.81, 0)`. Physics must be enabled before any collider property handlers can create physics aggregates, so `GameLevel` initializes physics early in the `buildScene()` pipeline. ## Complete YAML Reference This annotated example shows every possible field in a level config: ```yaml # ----------------------------------------------- # Level name (required) # Used as the key for transition() and loadLevel() # ----------------------------------------------- name: My Level # ----------------------------------------------- # Custom Level class (optional) # References a class registered with registerLevelClass() # If omitted, the default GameLevel class is used # ----------------------------------------------- class: myCustomLevel # ----------------------------------------------- # Scene file (optional) # Path to a .glb, .gltf, or .babylon file # All meshes and nodes are imported into the scene # Node metadata is processed by property handlers # ----------------------------------------------- scene: /assets/levels/my-level.glb # ----------------------------------------------- # Physics (optional) # true = Havok physics with default gravity (0, -9.81, 0) # object = custom gravity vector # omit = no physics # ----------------------------------------------- physics: gravity: x: 0 y: -9.81 z: 0 # ----------------------------------------------- # Asset preloading (optional) # Paths are loaded sequentially and cached before # buildScene() runs. Supports GLB fragment syntax # for preloading specific nodes from a file. # ----------------------------------------------- preload: - /assets/models/environment.glb - /assets/models/props.glb#barrel - /assets/models/props.glb#crate - /assets/audio/ambient.mp3 # ----------------------------------------------- # Custom config data (optional) # Arbitrary key/value pairs passed to the Level # instance. Accessed via this._config.config in # GameLevel subclasses. Ignored by GameLevel itself. # ----------------------------------------------- config: difficulty: hard enemyCount: 12 timeLimit: 120 # ----------------------------------------------- # Environment (optional) # Configures the skybox (visible background) and # IBL (image-based lighting for PBR reflections). # See "Environment Configuration" for details on # file format behavior and the three modes. # ----------------------------------------------- environment: ibl: /assets/textures/studio.hdr iblResolution: 256 skybox: /assets/textures/sky_8k.jpg skyboxSize: 5000 rotation: 1.57 # ----------------------------------------------- # Spawn points (optional) # Keys match the 'spawn' metadata values on scene # nodes (Blender Empties). Each spawn creates a new # entity at that node's position/rotation/scale. # ----------------------------------------------- spawns: # A Blender Empty with custom property spawn="player_start" player_start: entity: player config: health: 100 maxHealth: 100 # A Blender Empty with custom property spawn="enemy_patrol" enemy_patrol: entity: enemy config: patrolRadius: 10 aggroRange: 15 # Spawn with no config override (uses entity defaultState) item_chest: entity: chest # ----------------------------------------------- # Entity markers (optional) # Keys match the 'entity' metadata values on scene # meshes. Unlike spawns, these wrap EXISTING scene # meshes as SAGE entities (no new mesh is created). # ----------------------------------------------- entities: door: config: locked: true crate: config: weight: 50 breakable: true # ----------------------------------------------- # Cameras (optional) # Keys that match a Blender camera name apply # overrides. Keys that don't match create a new # camera (type is required for new cameras). # ----------------------------------------------- cameras: # Override an imported camera's clipping planes Camera: minZ: 0.1 maxZ: 500 # Create a new arcRotate camera MainCamera: type: arcRotate alpha: 1.5708 beta: 1.0472 radius: 25 target: { x: 0, y: 2, z: 0 } active: true attachControl: true wheelPrecision: 50 lowerRadiusLimit: 10 upperRadiusLimit: 50 lowerBetaLimit: 0.1 upperBetaLimit: 1.4708 fov: 0.8 minZ: 0.1 # ----------------------------------------------- # Lights (optional) # Keys that match a Blender light name apply # overrides. Keys that don't match create a new # light (type is required for new lights). # Note: HemisphericLight has no Blender equivalent # so it is always created via create mode. # ----------------------------------------------- lights: # Override a Blender sun's intensity Sun: intensity: 0.8 diffuse: { r: 1, g: 0.95, b: 0.8 } # Create an ambient hemispheric light ambient: type: hemispheric direction: { x: 0, y: 1, z: 0 } intensity: 0.5 diffuse: { r: 0.9, g: 0.9, b: 1.0 } groundColor: { r: 0.2, g: 0.15, b: 0.1 } specular: { r: 0, g: 0, b: 0 } # Create a spot light spotlight1: type: spot position: { x: 5, y: 8, z: 0 } direction: { x: 0, y: -1, z: 0 } angle: 0.8 exponent: 3 intensity: 1.5 # ----------------------------------------------- # Level sounds (optional) # Non-spatial audio tied to the level lifecycle. # Paused on deactivate, resumed on activate, # disposed on unload. Requires AudioManager # (audioChannels in SageOptions). # ----------------------------------------------- sounds: background-music: url: /assets/audio/exploration.ogg channel: music loop: true autoplay: true volume: 0.7 ambient-wind: url: /assets/audio/wind-loop.ogg channel: ambient loop: true autoplay: true volume: 0.3 # ----------------------------------------------- # Post-processing effects (optional) # Applied after the scene is built. All values are # optional within each effect block. # ----------------------------------------------- postProcessing: bloom: weight: 0.3 # Bloom intensity (default: 0.15) threshold: 0.8 # Brightness cutoff (default: 0.9) scale: 0.5 # Resolution scale (default: 0.5) kernel: 64 # Blur kernel size (default: 64) ssao: radius: 2.0 # Sampling radius (default: 2.0) samples: 16 # Sample count (default: 8) totalStrength: 1.0 # Effect intensity (default: 1.0) tonemap: operator: aces # Options: hable, reinhard, hejidawson, photographic, aces chromaticAberration: amount: 0.5 # Color fringe intensity (default: 30) grain: intensity: 15 # Grain density (default: 30) animated: true # Randomize per frame (default: false) vignette: weight: 3.0 # Darkening intensity (default: 1.5) stretch: 0.5 # Oval stretch factor (default: 0) color: # Vignette tint (default: black) r: 0.1 g: 0 b: 0 sharpen: edge: 0.3 # Edge sharpening (default: 0.3) color: 1.0 # Color sharpening (default: 1.0) ``` ## Asset Preloading Add a `preload` array to cache assets before `buildScene()` runs. GLB fragment syntax (`model.glb#NodeName`) lets you preload specific nodes: ```yaml preload: - assets/models/environment.glb - assets/models/props.glb#barrel - assets/models/props.glb#crate - assets/audio/ambient.mp3 ``` Assets are loaded sequentially and cached by the `AssetManager`. Later calls to `assetManager.load()` with the same path return instantly from the cache. ## Environment Configuration The `environment` section configures your level's skybox and image-based lighting (IBL). IBL provides realistic PBR reflections by sampling an environment texture; the skybox provides the visible background. You can use one or both. Before this feature, setting up a skybox and IBL required a custom `GameLevel` subclass with manual BabylonJS calls. Now it's fully declarative: ```yaml environment: ibl: /assets/textures/studio.hdr skybox: /assets/textures/milkyway_8k.jpg skyboxSize: 5000 ``` ### EnvironmentConfig ```typescript interface EnvironmentConfig { ibl ?: string; iblResolution ?: number; skybox ?: string; skyboxSize ?: number; rotation ?: number; } ``` ### Properties | Property | Type | Default | Description | |----------|------|---------|-------------| | `ibl` | `string` | -- | Path to an HDR or `.env` file used as the PBR reflection source. This texture is not visible in the scene -- it only affects how PBR materials reflect light. | | `iblResolution` | `number` | `256` | Resolution of the IBL cubemap in pixels. Higher values produce sharper reflections but use more memory. `256` is sufficient for most scenes. | | `skybox` | `string` | -- | Path to the skybox texture. File format determines behavior (see below). | | `skyboxSize` | `number` | `1000` | Diameter of the skybox in world units. For HDR/env files this is passed to `createDefaultSkybox()`. For JPG/PNG files this is the sphere diameter. | | `rotation` | `number` | -- | Y-axis rotation in radians applied to the environment texture. Use this to align the skybox with your scene. | ### File Format Behavior The file extension of the `skybox` and `ibl` paths determines how SAGE creates the texture: | Extension | Texture Type | Notes | |-----------|-------------|-------| | `.hdr` | `HDRCubeTexture` | Uncompressed HDR data. Highest quality for reflections and skyboxes. Larger file sizes. | | `.env` | `CubeTexture` (pre-filtered) | BabylonJS pre-filtered format. Smaller files, faster loading. Use `createEnvTexture` in the BabylonJS sandbox to convert HDR files. | | `.jpg`, `.png` | `Texture` on a sphere | Creates a `StandardMaterial` sphere with the image as emissive texture. No IBL data -- this only provides a visible background. Ideal for high-resolution panoramic images that would lose quality as cubemaps. | ### Three Modes The environment system supports three configurations depending on which fields you provide: **IBL only** -- Sets the PBR reflection source with no visible skybox. Useful when you have your own sky geometry or when the scene is indoors. ```yaml environment: ibl: /assets/textures/studio_small.hdr iblResolution: 128 ``` **Skybox only with HDR/env** -- The skybox file is used for both the visible background and IBL reflections. This is the simplest setup when your skybox texture is in HDR or env format. ```yaml environment: skybox: /assets/textures/sky.hdr ``` **Skybox only with JPG/PNG** -- Creates a visible sphere background with no IBL. PBR materials will not reflect the environment. Use this when you have a high-resolution panoramic image and don't need reflections. ```yaml environment: skybox: /assets/textures/milkyway_8k.jpg skyboxSize: 5000 ``` **Separate IBL and skybox (recommended for quality)** -- Uses a low-resolution HDR for fast, accurate reflections and a high-resolution image for the visible background. This avoids the quality loss of converting a detailed panorama to a cubemap. ```yaml # Low-res HDR for reflections, high-res JPG for the visible sky environment: ibl: /assets/textures/milkyway.hdr iblResolution: 256 skybox: /assets/textures/milkyway_8k.jpg skyboxSize: 5000 ``` ::: tip PBR materials require an environment texture to produce realistic reflections. If your scene uses PBR materials (the default for Blender exports), make sure to set either `ibl` or a skybox with an HDR/env file. Without an environment texture, PBR surfaces will appear flat and unreflective. ::: ## Camera Configuration The `cameras` section lets you override cameras imported from Blender or create new ones entirely from YAML. Cameras are processed after the scene file is loaded but before post-processing (which requires an active camera). ### CameraDefinition ```typescript interface CameraDefinition { type ?: 'free' | 'arcRotate' | 'universal'; active ?: boolean; attachControl ?: boolean; position ?: Vec3Config; target ?: Vec3Config; rotation ?: Vec3Config; fov ?: number; minZ ?: number; maxZ ?: number; speed ?: number; alpha ?: number; beta ?: number; radius ?: number; lowerRadiusLimit ?: number; upperRadiusLimit ?: number; lowerBetaLimit ?: number; upperBetaLimit ?: number; wheelPrecision ?: number; } ``` ### Override vs. Create Camera keys work in two modes depending on whether the name matches an existing camera in the scene: * **Override mode** -- The key matches a camera name from Blender (e.g., `Camera`, `MainCamera`). The camera already exists in the scene; SAGE applies the YAML properties as overrides. The `type` field is omitted. * **Create mode** -- The key is a new name that doesn't exist in the scene. The `type` field is **required** and SAGE creates a fresh camera of that type. If a key doesn't match a scene camera *and* has no `type`, SAGE logs a warning and skips it. ### Properties | Property | Type | Default | Description | |----------|------|---------|-------------| | `type` | `'free' \| 'arcRotate' \| 'universal'` | -- | Camera type. Required for create mode, omit for override mode. | | `active` | `boolean` | `false` | Mark this camera as the active camera for the level. | | `attachControl` | `boolean` | `true` | Attach canvas input controls to the active camera. Set to `false` to prevent user interaction (e.g., for cutscenes). Only applies to the camera that becomes active. | | `position` | `Vec3Config` | `(0, 0, 0)` | Camera position in world space. Used in create mode for free and universal cameras. | | `target` | `Vec3Config` | `(0, 0, 0)` | Look-at target. Used by arcRotate cameras (both create and override). | | `rotation` | `Vec3Config` | -- | Camera rotation in radians. Used by free and universal cameras (both create and override). | | `fov` | `number` | -- | Field of view in radians. Applies to all camera types. | | `minZ` | `number` | -- | Near clipping plane distance. | | `maxZ` | `number` | -- | Far clipping plane distance. | | `speed` | `number` | -- | Movement speed. Applies to free and universal cameras only. | | `alpha` | `number` | `Math.PI / 2` | Horizontal rotation angle in radians. ArcRotate only. | | `beta` | `number` | `Math.PI / 3` | Vertical rotation angle in radians. ArcRotate only. | | `radius` | `number` | `10` | Distance from the target. ArcRotate only. | | `lowerRadiusLimit` | `number` | -- | Minimum zoom distance. ArcRotate only. | | `upperRadiusLimit` | `number` | -- | Maximum zoom distance. ArcRotate only. | | `lowerBetaLimit` | `number` | -- | Minimum vertical angle (prevents looking from below). ArcRotate only. | | `upperBetaLimit` | `number` | -- | Maximum vertical angle (prevents looking from above). ArcRotate only. | | `wheelPrecision` | `number` | -- | Mouse wheel zoom sensitivity. Higher values = slower zoom. ArcRotate only. | ### Active Camera Selection When `cameras` is present in the config, the active camera is selected as follows: 1. The first camera with `active: true` becomes active. 2. If no camera has `active: true`, the first camera in the record becomes active. When `cameras` is **not** present, the first camera imported from the scene file (if any) is activated automatically. The active camera's canvas controls are attached unless `attachControl: false` is explicitly set on that camera's definition. ### Examples Override a Blender-imported camera's clipping plane: ```yaml # The scene file contains a camera named "Camera" cameras: Camera: minZ: 0.1 maxZ: 500 ``` Create an arcRotate camera for a top-down view: ```yaml cameras: MainCamera: type: arcRotate alpha: 1.5708 # PI/2 — looking from the side beta: 1.0472 # PI/3 — angled down radius: 25 target: { x: 0, y: 2, z: 0 } attachControl: true wheelPrecision: 50 minZ: 0.1 lowerRadiusLimit: 10 upperRadiusLimit: 50 lowerBetaLimit: 0.1 upperBetaLimit: 1.4708 ``` Create a free camera for first-person movement: ```yaml cameras: PlayerCam: type: free position: { x: 0, y: 1.8, z: -5 } rotation: { x: 0, y: 0, z: 0 } speed: 0.5 fov: 0.8 minZ: 0.1 active: true ``` ## Light Configuration The `lights` section lets you override lights imported from Blender or create new ones from YAML. Lights are processed after cameras and before post-processing. ### LightDefinition ```typescript interface LightDefinition { type ?: 'hemispheric' | 'directional' | 'point' | 'spot'; intensity ?: number; diffuse ?: ColorConfig; specular ?: ColorConfig; position ?: Vec3Config; direction ?: Vec3Config; groundColor ?: ColorConfig; angle ?: number; exponent ?: number; } ``` ### Override vs. Create Lights follow the same pattern as cameras: * **Override mode** -- The key matches a light name from Blender. SAGE applies the YAML properties (intensity, color, etc.) to the existing light. The `type` field is omitted. * **Create mode** -- The key is a new name. The `type` field is **required** and SAGE creates a new light. ::: tip HemisphericLight does not exist in Blender or glTF. If you want ambient hemisphere lighting, you must always use create mode with `type: hemispheric`. This is the most common use case for the `lights` section. ::: ### Properties | Property | Type | Default | Description | |----------|------|---------|-------------| | `type` | `'hemispheric' \| 'directional' \| 'point' \| 'spot'` | -- | Light type. Required for create mode, omit for override mode. | | `intensity` | `number` | -- | Light brightness. Applies to all light types. | | `diffuse` | `ColorConfig` | -- | Diffuse color as `{ r, g, b }` with values `0.0`--`1.0`. | | `specular` | `ColorConfig` | -- | Specular highlight color as `{ r, g, b }` with values `0.0`--`1.0`. | | `position` | `Vec3Config` | `(0, 0, 0)` | World position. Used by point and spot lights. | | `direction` | `Vec3Config` | `(0, -1, 0)` | Light direction vector. Used by hemispheric, directional, and spot lights. | | `groundColor` | `ColorConfig` | -- | Ground reflection color. Hemispheric lights only. | | `angle` | `number` | `Math.PI / 3` | Cone angle in radians. Spot lights only. | | `exponent` | `number` | `2` | Falloff exponent controlling how light fades from center to edge of the cone. Spot lights only. | ### Vec3Config and ColorConfig Both are simple objects used throughout camera and light configuration: ```typescript interface Vec3Config { x : number; y : number; z : number; } interface ColorConfig { r : number; g : number; b : number; } ``` In YAML, these can be written in block or inline form: ```yaml # Block form direction: x: 0 y: -1 z: 0 # Inline form direction: { x: 0, y: -1, z: 0 } ``` ### Examples Override a Blender sun light's intensity: ```yaml # The scene file contains a light named "Sun" lights: Sun: intensity: 2.0 diffuse: { r: 1, g: 0.95, b: 0.8 } ``` Create a hemispheric ambient light (always create mode since glTF has no hemispheric lights): ```yaml lights: ambient: type: hemispheric direction: { x: 0, y: 1, z: 0 } intensity: 0.7 diffuse: { r: 0.9, g: 0.9, b: 1.0 } groundColor: { r: 0.2, g: 0.15, b: 0.1 } specular: { r: 0, g: 0, b: 0 } ``` Create a spot light: ```yaml lights: spotlight1: type: spot position: { x: 5, y: 8, z: 0 } direction: { x: 0, y: -1, z: 0 } angle: 0.8 exponent: 3 intensity: 1.5 diffuse: { r: 1, g: 0.9, b: 0.7 } ``` Combine overrides and new lights in a single config: ```yaml # Dim the imported sun and add ambient fill lights: Sun: intensity: 0.5 ambientFill: type: hemispheric direction: { x: 0, y: 1, z: 0 } intensity: 0.4 diffuse: { r: 0.6, g: 0.6, b: 0.8 } groundColor: { r: 0.1, g: 0.1, b: 0.1 } ``` ## Level Sounds The `sounds` section defines audio that is tied to the level's lifecycle. Sounds are created when the level loads, paused when the level is deactivated (during a transition), resumed when the level is reactivated, and disposed when the level is unloaded. This is the right tool for background music, ambient soundscapes, and any non-spatial audio that belongs to a level. Before this feature, level sounds required a custom `GameLevel` subclass with manual `AudioManager` calls and hand-written `onActivate`/`onDeactivate` hooks. Now it's declarative: ```yaml sounds: background-music: url: /assets/audio/exploration.ogg channel: music loop: true autoplay: true volume: 0.7 ``` ::: warning Prerequisites Level sounds require the `AudioManager` to be initialized. You must pass `audioChannels` in your `SageOptions` when creating the engine: ```typescript const engine = await createGameEngine(canvas, { audioChannels: [ 'music', 'sfx', 'ambient' ], }); ``` If no `AudioManager` is configured, SAGE logs a warning and skips sound creation. ::: ### LevelSoundConfig ```typescript interface LevelSoundConfig { url : string; channel ?: string; loop ?: boolean; autoplay ?: boolean; volume ?: number; } ``` ### Properties | Property | Type | Default | Description | |----------|------|---------|-------------| | `url` | `string` | (required) | Path to the audio file (`.ogg`, `.mp3`, `.wav`, etc.). | | `channel` | `string` | -- | Audio channel name (e.g., `'music'`, `'sfx'`, `'ambient'`). Must match a channel name passed in `audioChannels`. Controls volume grouping. | | `loop` | `boolean` | `false` | Whether the sound loops continuously. | | `autoplay` | `boolean` | `false` | Whether the sound starts playing immediately when the level finishes loading. | | `volume` | `number` | `1` | Playback volume from `0.0` (silent) to `1.0` (full). This is the per-sound volume, independent of the channel volume. | ### Lifecycle Behavior Level sounds are managed automatically through the level lifecycle: | Lifecycle Event | Sound Behavior | |-----------------|----------------| | **Level loads** | Sounds are created via `AudioManager.createSound()`. Sounds with `autoplay: true` begin playing. | | **Level deactivates** | All currently playing sounds are paused. SAGE tracks which sounds were playing so only those are resumed later. | | **Level activates** | Sounds that were playing before deactivation are resumed. Sounds that were already paused (e.g., not yet triggered) remain paused. | | **Level unloads** | All sounds are disposed. | ::: tip If you subclass `GameLevel` and override `onActivate()` or `onDeactivate()`, make sure to call `super.onActivate()` or `super.onDeactivate()` so that level sound management continues to work. ::: ### Examples Background music that loops for the duration of the level: ```yaml sounds: theme: url: /assets/audio/dungeon-theme.ogg channel: music loop: true autoplay: true volume: 0.6 ``` Ambient soundscape layered with music: ```yaml sounds: music: url: /assets/audio/forest-theme.ogg channel: music loop: true autoplay: true volume: 0.5 wind: url: /assets/audio/wind-loop.ogg channel: ambient loop: true autoplay: true volume: 0.3 birds: url: /assets/audio/bird-calls.ogg channel: ambient loop: true autoplay: true volume: 0.2 ``` A sound that is loaded but not auto-played (triggered later by game logic): ```yaml sounds: victory-fanfare: url: /assets/audio/victory.ogg channel: sfx loop: false autoplay: false volume: 1.0 ``` ::: info Level sounds vs. the `sound` property handler The `sounds` YAML section creates **non-spatial** audio tied to the level lifecycle -- think background music and ambient loops. The `sound` property handler (set on Blender meshes) creates **spatial** audio positioned in 3D space -- think a torch crackling or a waterfall. They serve different purposes and can be used together in the same level. ::: ## Post-Processing Declarative post-processing effects are applied to the level's scene after it is built and disposed automatically with the scene. A scene must have an active camera for post-processing to take effect. ### PostProcessingConfig ```typescript interface PostProcessingConfig { bloom ?: { weight ?: number; threshold ?: number; scale ?: number; kernel ?: number }; ssao ?: { radius ?: number; samples ?: number; totalStrength ?: number }; tonemap ?: { operator ?: 'hable' | 'reinhard' | 'hejidawson' | 'photographic' | 'aces' }; chromaticAberration ?: { amount ?: number }; grain ?: { intensity ?: number; animated ?: boolean }; vignette ?: { weight ?: number; stretch ?: number; color ?: { r : number; g : number; b : number } }; sharpen ?: { edge ?: number; color ?: number }; } ``` ### Bloom Bloom makes bright areas of the scene glow and bleed light into surrounding pixels. It simulates the way real cameras handle very bright light sources. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `weight` | `number` | `0.15` | Intensity of the bloom glow. Higher values produce a stronger, more visible bloom. Range: `0.0` (off) to `1.0` (very strong). Values around `0.2`--`0.5` are typical. | | `threshold` | `number` | `0.9` | Brightness cutoff. Only pixels brighter than this value produce bloom. Lower values bloom more of the scene. Range: `0.0` to `1.0`. | | `scale` | `number` | `0.5` | Resolution scale of the bloom texture. Lower values are cheaper but blurrier. Range: `0.0` to `1.0`. | | `kernel` | `number` | `64` | Size of the blur kernel. Larger kernels produce smoother, wider bloom halos but cost more. Common values: `32`, `64`, `128`. | ```yaml # Subtle bloom for a clean look postProcessing: bloom: weight: 0.2 threshold: 0.85 # Heavy bloom for a dreamy or sci-fi feel postProcessing: bloom: weight: 0.8 threshold: 0.6 kernel: 128 ``` ### SSAO (Screen-Space Ambient Occlusion) SSAO darkens creases, corners, and contact points where ambient light would naturally be occluded. It adds depth and realism to scenes, making geometry feel grounded. SSAO uses its own dedicated pipeline (`SSAO2RenderingPipeline`), separate from the main `DefaultRenderingPipeline`. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `radius` | `number` | `2.0` | Sampling radius around each pixel. Larger values darken wider areas but can look unrealistic. Range: `0.5` to `8.0` for most scenes. | | `samples` | `number` | `8` | Number of samples per pixel. More samples reduce noise but cost more. Common values: `8`, `16`, `32`. | | `totalStrength` | `number` | `1.0` | Overall strength of the darkening effect. Higher values make occlusion more pronounced. Range: `0.0` to `2.0`. | SSAO is ideal for architectural scenes, dungeons, or any environment where you want creases and corners to feel darker and more defined. It is one of the more expensive effects, so consider using fewer samples on lower-end hardware. ```yaml # Subtle ambient occlusion for an indoor scene postProcessing: ssao: radius: 1.5 samples: 16 totalStrength: 0.8 # Strong SSAO for a dungeon with deep crevices postProcessing: ssao: radius: 3.0 samples: 32 totalStrength: 1.5 ``` ::: info SAGE creates the SSAO pipeline with `ssaoRatio: 0.5` and `blurRatio: 1.0`. These are not currently configurable through the YAML config. ::: ### Tone Mapping Tone mapping controls how HDR (high dynamic range) colors are compressed into the displayable range. It affects the overall color feel and contrast of the scene. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `operator` | `string` | `'hable'` | The tone mapping algorithm. See below for options. | | Operator | BabylonJS Mapping | Description | |----------|-------------------|-------------| | `hable` | `TONEMAPPING_STANDARD` | A filmic curve with gentle highlight rolloff. Good general-purpose choice. | | `reinhard` | `TONEMAPPING_STANDARD` | Classic Reinhard mapping. Similar to Hable in BabylonJS's implementation. | | `hejidawson` | `TONEMAPPING_STANDARD` | Hejl-Dawson mapping. Also maps to standard in BabylonJS. | | `photographic` | `TONEMAPPING_STANDARD` | Photographic-style mapping. Also maps to standard in BabylonJS. | | `aces` | `TONEMAPPING_ACES` | Academy Color Encoding System. Richer colors with a more cinematic feel. The only option that uses a different BabylonJS algorithm. | ::: tip In practice, the only meaningful choice is between `aces` and everything else. The operators `hable`, `reinhard`, `hejidawson`, and `photographic` all map to `TONEMAPPING_STANDARD` in BabylonJS. If you want a cinematic look with richer, more saturated colors, use `aces`. ::: ```yaml postProcessing: tonemap: operator: aces ``` ### Chromatic Aberration Simulates the color fringing seen in real camera lenses, where red, green, and blue channels are slightly offset. Adds a subtle cinematic or retro quality. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `amount` | `number` | `30` | Intensity of the color fringing. The BabylonJS default is `30`, but for subtle use values of `0.3`--`2.0` work well. Higher values produce an extreme, obviously distorted effect. | ::: warning The BabylonJS default of `30` is very strong. For most games, use values between `0.3` and `2.0` for a subtle cinematic effect. The SAGE examples use `0.4`. ::: ```yaml # Subtle lens fringe postProcessing: chromaticAberration: amount: 0.4 # Heavy distortion (horror, glitch effect) postProcessing: chromaticAberration: amount: 5.0 ``` ### Film Grain Adds a noise texture over the image, simulating analog film grain. Useful for gritty, horror, or retro aesthetics. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `intensity` | `number` | `30` | Density of the grain noise. The BabylonJS default is `30`, which is quite visible. Values of `5`--`20` produce a subtle texture. | | `animated` | `boolean` | `false` | When `true`, the grain pattern changes every frame, creating a shimmering film-like quality. When `false`, the grain is static. | ```yaml # Subtle animated grain postProcessing: grain: intensity: 10 animated: true # Heavy static grain (VHS / found footage look) postProcessing: grain: intensity: 40 animated: false ``` ### Vignette Darkens the edges and corners of the screen, drawing focus toward the center. Often combined with a color tint for mood. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `weight` | `number` | `1.5` | Intensity of the edge darkening. Higher values produce a more pronounced effect. Range: `0.0` to `10.0`. Values around `1.0`--`4.0` are typical. | | `stretch` | `number` | `0` | How much to stretch the vignette into an oval. `0` is circular; higher values elongate it horizontally. Range: `0` to `1.0`. | | `color` | `{ r, g, b }` | Black `(0, 0, 0)` | The tint color of the vignette. Each channel is `0.0`--`1.0`. | ```yaml # Standard dark vignette postProcessing: vignette: weight: 3.0 stretch: 0.5 # Red-tinted vignette (damage indicator, horror) postProcessing: vignette: weight: 4.0 color: r: 0.5 g: 0 b: 0 # Warm sepia vignette (vintage look) postProcessing: vignette: weight: 2.5 stretch: 0.3 color: r: 0.3 g: 0.15 b: 0.05 ``` ### Sharpen Enhances edge contrast to make the image appear crisper. The `edge` parameter controls how much edges are sharpened, while `color` controls how much the color channels are boosted. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `edge` | `number` | `0.3` | Edge sharpening intensity. Higher values make edges more pronounced. Range: `0.0` to `1.0`. | | `color` | `number` | `1.0` | Color channel sharpening. Higher values make colors more vivid at edges. Range: `0.0` to `1.0`. Lower values desaturate sharpened edges. | ```yaml # Subtle sharpening postProcessing: sharpen: edge: 0.2 color: 0.8 # Strong sharpening with desaturated edges postProcessing: sharpen: edge: 0.5 color: 0.3 ``` ### Pipeline Architecture Most effects use the BabylonJS `DefaultRenderingPipeline`, created with the name `'sage-default'`. SSAO uses a separate `SSAO2RenderingPipeline` named `'sage-ssao'`. Both pipelines are attached to all cameras in the scene. ### Runtime Tweaks Access BabylonJS pipelines directly for runtime adjustments: ```typescript import { DefaultRenderingPipeline } from '@babylonjs/core'; const pipeline = scene.postProcessRenderPipelineManager .supportedPipelines .find((p) => p.name === 'sage-default') as DefaultRenderingPipeline; if(pipeline) { pipeline.bloomWeight = 1.0; } ``` ## Scene Node Metadata and Property Handlers When a level's scene file is loaded, `GameLevel` walks every transform node and mesh in the scene and reads its metadata. For GLB/glTF files, BabylonJS stores Blender custom properties in `node.metadata.gltf.extras`. `GameLevel` normalizes this automatically so property handlers receive flat metadata objects. Two metadata keys are handled directly by `GameLevel` (not via property handlers): * **`spawn`** -- Marks a node as a spawn point. The value must match a key in the config's `spawns` section. * **`entity`** -- Marks a node as an entity marker. The value must match a key in the config's `entities` section. All other metadata keys are dispatched to registered property handlers. ### Built-in Property Handlers SAGE ships with handlers for six metadata keys. Register them all at once: ```typescript import { registerAllPropertyHandlers } from '@skewedaspect/sage'; registerAllPropertyHandlers(levelManager); ``` Or register them individually: ```typescript import { registerColliderHandler, registerLodHandler, registerOccluderHandler, registerSoundHandler, registerTriggerHandler, registerVisibleHandler, } from '@skewedaspect/sage'; registerColliderHandler(levelManager); registerTriggerHandler(levelManager); // ... etc ``` ### Property Handler Reference This table lists every metadata key the built-in handlers look for on scene nodes. Set these as custom properties in Blender (or directly in node metadata) to trigger the corresponding handler behavior. | Metadata Key | Value Type | Handler | Description | |--------------|------------|---------|-------------| | `spawn` | `string` | Built-in (GameLevel) | Marks a node as a spawn point. Value must match a key in the YAML `spawns` section. The node is disposed after the entity is created. | | `entity` | `string` | Built-in (GameLevel) | Marks a node as an entity. Value must match a key in the YAML `entities` section. The existing mesh is attached to the created entity. | | `collider` | `string` | `registerColliderHandler` | Physics collider shape. Values: `"box"`, `"sphere"`, `"mesh"`, `"none"`. Creates a static physics aggregate (mass = 0). | | `collider_mesh` | `boolean` | (Used by collider) | Set on a child mesh to designate it as the collision geometry for a parent with `collider = "mesh"`. The child is made invisible. | | `trigger` | `string` | `registerTriggerHandler` | Trigger zone name. The mesh is made invisible. Emits `trigger:enter` and `trigger:exit` events when other meshes intersect it. | | `lod_distances` | `string` | `registerLodHandler` | Comma-separated distances for LOD levels (e.g., `"10,25,50"`). Child meshes are assigned as LOD levels in order. Beyond the last distance, the mesh is hidden. | | `occluder` | `boolean` | `registerOccluderHandler` | If `true`, marks the mesh as an occlusion culling occluder. The mesh is made invisible but used by the engine to hide objects behind it. | | `visible` | `boolean \| string \| number` | `registerVisibleHandler` | Controls mesh visibility. Accepts `true`/`false`, `"true"`/`"false"`, or `1`/`0`. | | `sound` | `string` | `registerSoundHandler` | Path to a sound file. Creates a spatial audio source at the node's position. See [Sound Properties](#sound-properties) for additional prefixed properties. | ### Collider Details The collider handler creates static physics aggregates (mass = 0) for scene geometry. The four collider types: | Value | Physics Shape | Use Case | |-------|---------------|----------| | `"box"` | `PhysicsShapeType.BOX` | Rectangular objects like walls, crates, platforms | | `"sphere"` | `PhysicsShapeType.SPHERE` | Round objects like boulders, balls | | `"mesh"` | `PhysicsShapeType.MESH` | Complex shapes that need precise collision. If a child node has `collider_mesh = true`, that child's geometry is used instead (and the child is hidden). | | `"none"` | None | Explicitly disables collision on a node | ### Trigger Details Triggers create invisible intersection zones. When any non-trigger mesh enters or exits the zone, events are published to the global event bus: | Event | Payload | |-------|---------| | `trigger:enter` | `{ trigger : string, other : AbstractMesh }` | | `trigger:exit` | `{ trigger : string, other : AbstractMesh }` | Triggers automatically detect meshes added after the handler runs (e.g., dynamically spawned entities). ### LOD Details The `lod_distances` property takes a comma-separated string of distances. The mesh's child meshes are used as LOD levels in order: ``` lod_distances = "10,25,50" ``` * **0--10 units**: First child mesh (highest detail) * **10--25 units**: Second child mesh (medium detail) * **25--50 units**: Third child mesh (low detail) * **50+ units**: Not visible (null LOD) ### Sound Properties The sound handler uses a prefixed property convention. The primary `sound` property contains the file path, and additional properties use the `sound_` prefix: | Metadata Key | Type | Default | Description | |--------------|------|---------|-------------| | `sound` | `string` | (required) | Path to the audio file | | `sound_volume` | `number` | `1` | Playback volume, `0.0` to `1.0` | | `sound_loop` | `boolean` | `true` | Whether to loop playback | | `sound_spatial` | `boolean` | `true` | Enable 3D spatial audio (positional sound) | | `sound_distance` | `number` | `100` | Maximum audible distance for spatial audio | | `sound_autoplay` | `boolean` | `true` | Start playback immediately | | `sound_channel` | `string` | `'ambient'` | Audio channel name for volume grouping | ### Writing a Custom Property Handler Register custom handlers to process your own Blender custom properties: ```typescript import type { TransformNode } from '@babylonjs/core'; import type { LevelInstance, PropertyHandler } from '@skewedaspect/sage'; // Register a handler for nodes with a 'waypoint' metadata property. // In Blender, set a custom property: waypoint = "patrol_route_a" levelManager.registerPropertyHandler('waypoint', ( node : TransformNode, value : unknown, level : LevelInstance, gameEngine ) => { const routeName = value as string; const position = node.position.clone(); // Store the waypoint for your AI system const aiManager = gameEngine.managers.entityManager; gameEngine.eventBus.publish({ type: 'waypoint:registered', payload: { route: routeName, position: { x: position.x, y: position.y, z: position.z }, nodeName: node.name, }, }); // Optionally hide the waypoint marker node.setEnabled(false); }); ``` The handler function receives four arguments: | Argument | Type | Description | |----------|------|-------------| | `node` | `TransformNode` | The scene node that has this metadata property | | `value` | `unknown` | The value of the metadata property | | `level` | `LevelInstance` | The level being loaded (provides access to `name` and `scene`) | | `gameEngine` | `GameEngine` | The game engine instance (provides access to all managers and engines) | Handlers can be synchronous or async. Errors thrown inside a handler are caught and logged without aborting the level load. ## Transitions The `transition()` method orchestrates a full level-to-level transition with lifecycle hooks and events. ```typescript async transition(levelName : string, options ?: TransitionOptions) : Promise ``` The manager tracks the current level internally — you only pass the target level name. ### TransitionOptions ```typescript interface TransitionOptions { keepAlive ?: boolean; preloadOnly ?: boolean; } ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `keepAlive` | `boolean` | `false` | Keep the old level loaded in memory after transitioning | | `preloadOnly` | `boolean` | `false` | Load the new level but do not activate it | ### Transition Flow 1. Emit `level:transition:start` with `{ from, to }` 2. Call `onDeactivate()` on the old level (if defined) 3. Emit `level:transition:progress` with `{ stage: 'loading', levelName }` 4. Load the new level (`buildScene()` runs) 5. Dispose old level (unless `keepAlive: true`) 6. Set new level as current 7. Call `onActivate()` on the new level (if defined) 8. Emit `level:transition:complete` If `preloadOnly: true`, the flow stops after step 4. If any step throws, `level:transition:error` is emitted with `{ from, to, error }`, and the exception re-throws. ### Usage ```typescript // Basic transition await levelManager.transition('desert'); // Keep old level in memory await levelManager.transition('desert', { keepAlive: true }); // Preload without switching await levelManager.transition('desert', { preloadOnly: true }); ``` ### Transition Events ```typescript gameEngine.eventBus.subscribe('level:transition:start', (event) => { showLoadingScreen(); }); gameEngine.eventBus.subscribe('level:transition:complete', (event) => { hideLoadingScreen(); }); gameEngine.eventBus.subscribe('level:transition:error', (event) => { const { from, to, error } = event.payload; showErrorScreen(`Failed to transition from ${ from } to ${ to }`); }); ``` ::: info SAGE does not provide built-in visual transitions (fades, wipes, etc.). Use the transition events to drive your own UI overlays or canvas effects. ::: ## Progress Events Levels emit progress events during loading. Use these to build loading screens. | Event | Payload | Description | |-------|---------|-------------| | `level:progress` | `{ levelName, progress, message }` | Loading progress (0--100) | | `level:complete` | `{ levelName }` | Level loaded successfully | | `level:error` | `{ levelName, message }` | Level loading failed | ### Emitting Progress Inside `buildScene()`, call `$emitProgress()`: ```typescript protected async buildScene() : Promise { const { sceneEngine } = this.gameEngine.engines; const scene = sceneEngine.createScene(); this.$emitProgress(0, 'Starting...'); sceneEngine.enablePhysics(scene); this.$emitProgress(25, 'Physics enabled...'); await sceneEngine.loadModel('assets/level.glb', scene); this.$emitProgress(75, 'Models loaded...'); this.$emitProgress(95, 'Finalizing...'); return scene; } ``` ### Subscribing to Progress ```typescript gameEngine.eventBus.subscribe('level:progress', (event) => { const { levelName, progress, message } = event.payload; updateLoadingBar(progress); }); gameEngine.eventBus.subscribe('level:complete', () => { hideLoadingScreen(); }); ``` ## GameLevel buildScene Pipeline When `GameLevel.buildScene()` is called, it executes the following steps in order: 1. **Preload assets** -- If `preload` is configured, all listed assets are loaded and cached. 2. **Create scene** -- An empty BabylonJS scene is created. 3. **Enable physics** -- If `physics` is configured, Havok physics is initialized with the given gravity. 4. **Load scene file** -- If `scene` is configured, the GLB/glTF/Babylon file is imported into the scene. 5. **Set up environment** -- If `environment` is configured, the IBL texture and/or skybox are created. This runs before cameras so the background is ready when the scene renders. 6. **Process cameras** -- Cameras from the `cameras` config are applied (overrides) or created. The active camera is set and canvas controls are attached. 7. **Process lights** -- Lights from the `lights` config are applied (overrides) or created. 8. **Apply post-processing** -- If `postProcessing` is configured, rendering pipelines are set up. Requires an active camera from step 6. 9. **Process node properties** -- All scene nodes are walked. Metadata is normalized from glTF extras. Each node is checked for `spawn` and `entity` markers, and all registered property handlers are invoked for matching metadata keys. 10. **Process spawn points** -- Spawn points collected in step 9 are matched against the `spawns` config. Entities are created at each spawn position. 11. **Process entity nodes** -- Entity markers collected in step 9 are matched against the `entities` config. Existing meshes are wrapped as SAGE entities. 12. **Load level sounds** -- If `sounds` is configured and `AudioManager` is available, level-scoped sounds are created. Sounds with `autoplay: true` begin playing. This is the last step so all other scene elements are ready before audio starts. Subclasses that override `buildScene()` should call `super.buildScene()` to retain this pipeline. Adding custom logic before or after `super.buildScene()` is the standard pattern: ```typescript protected override async buildScene() : Promise { this.$emitProgress(5, 'Custom setup...'); // Runs the full GameLevel pipeline (steps 1-12) const scene = await super.buildScene(); // Add your custom level logic here this._setupPuzzleMechanics(); this._subscribeToEvents(); return scene; } ``` ## Level Lifecycle 1. **Registration** -- Config registered with `registerLevelConfig()` 2. **Loading** -- `loadLevel()` creates the instance and calls `buildScene()` 3. **Activation** -- Level set as `currentLevel`, `onActivate()` fires 4. **Active** -- Scene is rendered, entities are updated 5. **Deactivation** -- `onDeactivate()` fires during transition 6. **Disposal** -- `$dispose()` cleans up the scene and resources ## Custom Disposal Override `$dispose()` for custom cleanup. Always call `super.$dispose()`: ```typescript class MyLevel extends Level { private ambientSound : Sound | null = null; async $dispose() : Promise { if(this.ambientSound) { this.ambientSound.stop(); this.ambientSound.dispose(); this.ambientSound = null; } await super.$dispose(); } } ``` `GameLevel.$dispose()` automatically destroys all spawned entities, disposes all level sounds, and clears internal collections before calling `Level.$dispose()`, which disposes the BabylonJS scene. ## LevelContext The runtime context injected into levels by the `LevelManager`: ```typescript interface LevelContext { gameEngine : GameEngine; propertyHandlers : Map; logger ?: LoggingUtility; } ``` ## PropertyHandler Handlers process custom metadata properties on scene nodes during level loading: ```typescript type PropertyHandler = ( node : TransformNode, value : unknown, level : LevelInstance, gameEngine : GameEngine ) => void | Promise; ``` Register handlers on the level manager: ```typescript levelManager.registerPropertyHandler('sound', (node, value, level, gameEngine) => { // Process nodes with a 'sound' metadata property }); ``` ## Teardown The `LevelManager` implements `Disposable`. Calling `gameEngine.$teardown()` automatically disposes all loaded levels, clears the registry, and resets the current level. --- --- url: /ai/llms-txt.md description: Machine-readable documentation for LLMs and AI assistants. --- # LLM Documentation SAGE documentation is available in machine-readable formats for use with LLMs and AI coding assistants. ## Available Formats | Format | URL | Description | |--------|-----|-------------| | Index | [`/llms.txt`](/llms.txt) | Table of contents with page summaries | | Full | [`/llms-full.txt`](/llms-full.txt) | Complete documentation in a single file | ## Usage These files are generated automatically by the [vitepress-plugin-llms](https://github.com/nicepkg/vitepress-plugin-llms) plugin. Every page on this site is also available as clean markdown. ### With Claude Code Point Claude at the full documentation: ``` Fetch https://sage.skewedaspect.com/llms-full.txt and use it as context for helping me build a SAGE game. ``` ### With MCP If you're using an MCP-capable client, you can use the context7 plugin to fetch SAGE documentation on demand. ### Per-Page Every documentation page has copy/download buttons in the top-right corner that export the page as clean markdown for pasting into AI prompts. --- --- url: /guides/migration.md --- # Migration Guide ## 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(); // After import { GameEventBus } from '@skewedaspect/sage'; const bus = new GameEventBus(); ``` ### 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); ``` ## New Features Available After Migration Once you've completed the migration, you can optionally adopt these new features: * **[Large World Rendering](/api/scenes)** — `largeWorldRendering: true` in `SageOptions` * **[Clustered Lighting](/api/levels)** — `clustering:` section in level YAML * **[Entity Highlighting](/api/levels)** — `outlines:` section in level YAML + `entity.highlight()` * **[Frame Graph Rendering](/api/levels)** — `renderer: 'frameGraph'` in `postProcessing:` config * **[Geospatial Camera](/api/levels)** — `type: 'geospatial'` in camera config * **[Entity Messaging](/guides/entity-messaging)** — `entity.send()` / `entity.request()` * **[Input Capture](/guides/input-system)** — `bindingManager.captureInput()` for key rebinding --- --- url: /api/physics.md description: >- API reference for SAGE's physics system -- Havok integration via BabylonJS, PhysicsAggregate, static and dynamic bodies, forces, collision detection, and constraints. --- # Physics SAGE uses Havok Physics through BabylonJS for rigid body simulation. The physics system handles gravity, collisions, forces, impulses, and constraints. It initializes automatically with the game engine and integrates with the entity and behavior systems. ## Getting Started Physics is initialized when you create the engine: ```typescript import { createGameEngine } from '@skewedaspect/sage'; const gameEngine = await createGameEngine(canvas); ``` To enable physics on a scene, use the `SceneEngine`: ```typescript const sceneEngine = gameEngine.engines.sceneEngine; const scene = sceneEngine.createScene(); // Default gravity (0, -9.81, 0) sceneEngine.enablePhysics(scene); // Custom gravity sceneEngine.enablePhysics(scene, new Vector3(0, -1.62, 0)); // Moon gravity ``` In YAML level configs, enable physics with a single flag: ```yaml name: my-level scene: levels/my-level.glb physics: true ``` Or with custom gravity: ```yaml physics: gravity: x: 0 y: -4.9 z: 0 ``` ## PhysicsAggregate The `PhysicsAggregate` is BabylonJS's unified wrapper around a physics body and its collision shape. It is the primary way to add physics to meshes. ```typescript import { PhysicsAggregate, PhysicsShapeType } from '@babylonjs/core'; ``` ### Creating Physics Bodies ```typescript // Static body (mass = 0, does not move) const groundAggregate = new PhysicsAggregate( groundMesh, PhysicsShapeType.BOX, { mass: 0 }, scene ); // Dynamic body (has mass, affected by forces) const ballAggregate = new PhysicsAggregate( ballMesh, PhysicsShapeType.SPHERE, { mass: 1, restitution: 0.7, friction: 0.5, }, scene ); ``` ### PhysicsShapeType | Shape | Constant | Best For | |-------|----------|----------| | Box | `PhysicsShapeType.BOX` | Crates, walls, floors | | Sphere | `PhysicsShapeType.SPHERE` | Balls, projectiles | | Capsule | `PhysicsShapeType.CAPSULE` | Characters | | Cylinder | `PhysicsShapeType.CYLINDER` | Pillars, barrels | | Mesh | `PhysicsShapeType.MESH` | Complex static geometry | | Convex Hull | `PhysicsShapeType.CONVEX_HULL` | Complex dynamic objects | ### Aggregate Options | Property | Type | Default | Description | |----------|------|---------|-------------| | `mass` | `number` | `0` | Mass in kg. `0` = static (immovable). | | `restitution` | `number` | `0` | Bounciness (0 = no bounce, 1 = full bounce) | | `friction` | `number` | `0.5` | Surface friction | ### SceneEngine Helper The `SceneEngine` provides a convenience method: ```typescript const aggregate = sceneEngine.addPhysics( mesh, PhysicsShapeType.BOX, { mass: 0 }, scene ); ``` ## Static vs. Dynamic Bodies | Property | Static (`mass = 0`) | Dynamic (`mass > 0`) | |----------|---------------------|----------------------| | Movement | Never moves | Affected by forces and gravity | | Collisions | Other objects bounce off | Responds to collisions | | Use case | Ground, walls, platforms | Players, projectiles, props | | Performance | Cheap | More expensive | ## Applying Forces and Impulses ### Forces Forces are continuous -- apply them each frame for sustained acceleration: ```typescript const body = aggregate.body; // Apply force at center of mass body.applyForce( new Vector3(100, 0, 0), // Force vector aggregate.transformNode.position // Application point ); ``` ### Impulses Impulses are instantaneous -- apply them once for a sudden velocity change: ```typescript // Jump impulse body.applyImpulse( new Vector3(0, jumpForce, 0), aggregate.transformNode.position ); ``` ### Setting Velocity Directly ```typescript // Set linear velocity body.setLinearVelocity(new Vector3(5, 0, 0)); // Get current velocity const velocity = body.getLinearVelocity(); // Set angular velocity body.setAngularVelocity(new Vector3(0, Math.PI, 0)); ``` ## Physics in Behaviors The recommended pattern is to use the `onNodeAttached` lifecycle hook to create physics bodies: ```typescript import { GameEntityBehavior } from '@skewedaspect/sage'; import type { GameEvent } from '@skewedaspect/sage'; import type { TransformNode } from '@babylonjs/core'; import { Mesh, PhysicsAggregate, PhysicsShapeType, Vector3 } from '@babylonjs/core'; interface PhysicsState { mass : number; restitution : number; friction : number; } class PhysicsBodyBehavior extends GameEntityBehavior { name = 'PhysicsBodyBehavior'; eventSubscriptions = [ 'physics:applyForce', 'physics:setVelocity' ]; private _aggregate : PhysicsAggregate | null = null; onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void { const mesh = node as Mesh; const scene = node.getScene(); const state = this.entity!.state as PhysicsState; this._aggregate = new PhysicsAggregate( mesh, PhysicsShapeType.BOX, { mass: state.mass, restitution: state.restitution, friction: state.friction, }, scene ); } processEvent(event : GameEvent, state : PhysicsState) : boolean { if(event.type === 'physics:applyForce' && this._aggregate) { const { x, y, z } = event.payload.force; this._aggregate.body.applyForce( new Vector3(x, y, z), this._aggregate.transformNode.position ); return true; } if(event.type === 'physics:setVelocity' && this._aggregate) { const { x, y, z } = event.payload.velocity; this._aggregate.body.setLinearVelocity(new Vector3(x, y, z)); return true; } return false; } update(_dt : number, state : PhysicsState) : void { if(this._aggregate) { // Sync physics position back to entity state const pos = this._aggregate.transformNode.position; (state as Record).position = { x: pos.x, y: pos.y, z: pos.z, }; } } onNodeDetached() : void { this._aggregate?.dispose(); this._aggregate = null; } async destroy() : Promise { this._aggregate?.dispose(); this._aggregate = null; } } ``` ### Registering a Physics Entity ```typescript gameEngine.managers.entityManager.registerEntityDefinition({ type: 'prop:crate', defaultState: { position: { x: 0, y: 5, z: 0 }, mass: 1.5, restitution: 0.4, friction: 0.8, }, behaviors: [ PhysicsBodyBehavior ], }); const crate = await gameEngine.managers.entityManager.createEntity('prop:crate'); ``` ## Collision Detection Use BabylonJS `ActionManager` for mesh-level collision callbacks: ```typescript import { ActionManager, ExecuteCodeAction } from '@babylonjs/core'; class CollisionBehavior extends GameEntityBehavior { name = 'CollisionBehavior'; eventSubscriptions = []; onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void { const mesh = node as Mesh; if(!mesh.actionManager) { mesh.actionManager = new ActionManager(mesh.getScene()); } mesh.actionManager.registerAction( new ExecuteCodeAction( { trigger: ActionManager.OnIntersectionEnterTrigger }, (evt) => { this.$emit({ type: 'entity:collision', payload: { otherMeshId: evt.source.id, }, }); } ) ); } processEvent() : boolean { return false; } } ``` ## Collision Filtering Group objects into collision layers to control which objects interact: ```typescript const PLAYER_GROUP = 1; const ENEMY_GROUP = 2; const ENVIRONMENT_GROUP = 4; const PROJECTILE_GROUP = 8; // Player collides with environment and enemies playerAggregate.body.setCollisionFilteringGroups( PLAYER_GROUP, ENVIRONMENT_GROUP | ENEMY_GROUP ); // Projectiles collide with enemies and environment, not other projectiles projectileAggregate.body.setCollisionFilteringGroups( PROJECTILE_GROUP, ENVIRONMENT_GROUP | ENEMY_GROUP ); ``` ## Constraints Physics constraints create mechanical connections between bodies: ```typescript import { DistanceConstraint } from '@babylonjs/core'; // Create a distance constraint (rope/chain) const constraint = new DistanceConstraint({ pivotA: new Vector3(0, 0, 0), pivotB: new Vector3(0, 0, 0), maxDistance: 5, }); constraint.attachAll( true, // collision enabled pivotBody, // static anchor point swingingBody // the object that swings ); ``` ## Character Controller Pattern A common pattern for character movement that balances physics realism with responsive controls: ```typescript class CharacterController extends GameEntityBehavior<{ moveSpeed : number; jumpForce : number; }> { name = 'CharacterController'; eventSubscriptions = [ 'physics:applyForce' ]; private _aggregate : PhysicsAggregate | null = null; onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void { const scene = node.getScene(); this._aggregate = new PhysicsAggregate( node, PhysicsShapeType.CAPSULE, { mass: 80, friction: 0.8, restitution: 0 }, scene ); } processEvent() : boolean { return false; } update(_dt : number, state : { moveSpeed : number; jumpForce : number }) : void { if(!this._aggregate) { return; } const body = this._aggregate.body; const velocity = body.getLinearVelocity(); // Apply horizontal drag for responsive stopping body.applyForce( new Vector3(-velocity.x * 10, 0, -velocity.z * 10), this._aggregate.transformNode.position ); } onNodeDetached() : void { this._aggregate?.dispose(); this._aggregate = null; } async destroy() : Promise { this._aggregate?.dispose(); this._aggregate = null; } } ``` ## Debugging Physics SAGE provides built-in collider debug visualization through `gameEngine.debug.colliders`. You can enable it per-entity, per-node, or declaratively through entity definitions and level YAML configs: ```typescript // Show colliders for a specific entity entity.showCollider(); // all colliders, default color entity.showCollider('#ff0000'); // all colliders in red // Or declaratively in entity definitions const def : GameEntityDefinition = { type: 'crate', debugCollider: true, // auto-visualize during level loading // ... }; ``` See the [Collider Debugging guide](/guides/collider-debugging) for the full API, config-driven usage, and YAML examples. ## Performance Tips 1. **Use simple shapes** -- Prefer `BOX`, `SPHERE`, `CAPSULE` over `MESH` and `CONVEX_HULL` 2. **Make static objects mass 0** -- Static bodies are dramatically cheaper than dynamic ones 3. **Collision filtering** -- Reduce the number of collision pairs the engine checks 4. **Sleep parameters** -- Havok automatically sleeps objects at rest; avoid waking them unnecessarily 5. **Limit stacking** -- Large stacks of dynamic objects are expensive and can become unstable 6. **Continuous collision detection** -- Enable for fast-moving objects to prevent tunneling through thin walls --- --- url: /guides/physics-playground.md description: >- Integrate Havok physics with SAGE entities using PhysicsBodyBehavior, physics state interfaces, and the onNodeAttached lifecycle hook. --- # Physics Playground This guide walks you through a physics sandbox that spawns crates, balls, cylinders, and dumbbells into a Havok-powered scene. You will learn how to wrap physics in a behavior, how the `onNodeAttached` lifecycle hook eliminates manual initialization, and how to build a controller that orchestrates spawning, cleanup, and statistics. The full source is in `examples/src/examples/physics-playground/`. ::: tip Try it live Run this example at [Examples > Physics Playground](/examples/physics-playground). ::: ## Project structure ``` physics-playground/ ├── index.vue # Vue component — UI panels and engine wiring ├── types.ts # Physics state interfaces ├── constants.ts # Tuning constants and material presets ├── behaviors/ │ ├── PhysicsBodyBehavior.ts # Core behavior — creates PhysicsAggregate │ └── PlayerVehicleBehavior.ts ├── entities/ │ └── definitions.ts # Entity definitions (ground, crate, ball, etc.) ├── game/ │ ├── scene.ts # Scene creation with physics enabled │ ├── entities.ts # Mesh creation + entity-node binding │ ├── PhysicsPlaygroundLevel.ts │ └── PhysicsController.ts # Orchestrator └── input/ ├── bindings.ts # Input binding configuration └── helpers.ts # Input helper utilities ``` ## What this example demonstrates * Enabling Havok physics on a SAGE scene * Writing a `PhysicsBodyBehavior` that uses `onNodeAttached()` for automatic initialization * Defining physics configuration in entity state (mass, restitution, friction) * The entity-mesh-physics binding pattern: create entity, create mesh, call `attachToNode()` * Material presets and tuning constants for realistic-feeling objects * A controller that manages spawning, cleanup, and live statistics ## Step 1: Physics state interfaces The key insight of this example is that physics entities have their position driven *by the physics engine*, not by game code. The state interfaces capture both the physics configuration (read at creation time) and the synced position/rotation (written each frame by the behavior). ```typescript export interface PhysicsConfig { shapeType : 'box' | 'sphere' | 'cylinder' | 'convex'; mass : number; // 0 = static, > 0 = dynamic restitution : number; // bounciness (0-1) friction : number; // surface grip (0-1) } export interface PhysicsBodyState { position : { x : number; y : number; z : number }; rotation : { x : number; y : number; z : number; w : number }; physics : PhysicsConfig; physicsAggregate ?: PhysicsAggregate; } ``` Concrete entity states extend `PhysicsBodyState` with type-specific properties: ```typescript export interface CrateState extends PhysicsBodyState { material : 'wood' | 'metal' | 'plastic'; } export interface BallState extends PhysicsBodyState { color : string; } ``` The `physicsAggregate` reference is set by the behavior after initialization, giving external code direct access to the Havok body for applying forces or reading velocity. ## Step 2: Material presets and tuning constants Physics tuning values are extracted into constants so you can experiment without hunting through behavior code. The example provides presets for common materials: ```typescript export const MATERIAL_WOOD = { mass: 1.5, restitution: 0.3, friction: 0.6, }; export const MATERIAL_RUBBER = { mass: 0.8, restitution: 0.85, friction: 0.9, }; export const MATERIAL_GROUND = { mass: 0, // Static! restitution: 0.5, friction: 0.8, }; ``` These values are realistic starting points. Games often exaggerate them for feel -- a restitution of 0.85 makes rubber balls satisfyingly bouncy even if real rubber is closer to 0.5. ## Step 3: Setting up a physics-enabled scene Physics must be enabled on a scene before creating any physics bodies. The setup function creates the scene through SAGE's scene engine, then enables Havok: ```typescript export function setupScene(engine : GameEngine, canvas : HTMLCanvasElement) : Scene { const scene = engine.engines.sceneEngine.createScene(); scene.clearColor = new Color4(0.1, 0.1, 0.12, 1); // CRITICAL: Enable physics with gravity before any PhysicsAggregates engine.engines.sceneEngine.enablePhysics(scene, new Vector3(0, -9.81, 0)); // Camera — orbiting view good for watching physics const camera = new ArcRotateCamera( 'camera', Math.PI / 4, Math.PI / 3.5, 18, new Vector3(0, 2, 0), scene ); camera.attachControl(canvas, true); camera.lowerBetaLimit = 0.1; camera.upperBetaLimit = (Math.PI / 2) - 0.1; // Lighting const hemiLight = new HemisphericLight('hemiLight', new Vector3(0, 1, 0), scene); hemiLight.intensity = 0.8; return scene; } ``` The scene is wrapped in a `GameLevel` subclass so the level manager can own its lifecycle: ```typescript export class PhysicsPlaygroundLevel extends Level { protected async buildScene() : Promise { return setupScene(this.config.engine, this.config.canvas); } } ``` ## Step 4: PhysicsBodyBehavior and the onNodeAttached lifecycle This is the core of the example. `PhysicsBodyBehavior` extends `GameEntityBehavior` and uses the `onNodeAttached()` lifecycle hook to create a Havok `PhysicsAggregate` the moment a mesh is bound to the entity. ```typescript export class PhysicsBodyBehavior extends GameEntityBehavior { name = 'physics-body'; eventSubscriptions = [ 'physics:applyForce', 'physics:applyImpulse', 'physics:setVelocity' ]; private _aggregate : PhysicsAggregate | null = null; private _mesh : Mesh | null = null; private _initialized = false; onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void { if(this._initialized) { return; } const mesh = node as Mesh; const scene = node.getScene(); this._mesh = mesh; this._initialized = true; const state = this.entity!.state as PhysicsBodyState; // Ensure the mesh has a rotation quaternion (required for physics) if(!mesh.rotationQuaternion) { mesh.rotationQuaternion = Quaternion.FromEulerAngles(0, 0, 0); } // Create the PhysicsAggregate from state configuration this._aggregate = new PhysicsAggregate( mesh, this._mapShapeType(state.physics.shapeType), { mass: state.physics.mass, restitution: state.physics.restitution, friction: state.physics.friction, }, scene ); // Store reference in state for external access state.physicsAggregate = this._aggregate; } } ``` The `update()` method reverses the typical behavior flow -- it reads position from the mesh (which is moved by Havok) and writes it to entity state: ```typescript update(_deltaTime : number, state : PhysicsBodyState) : void { if(!this._aggregate || !this._mesh) { return; } const pos = this._mesh.position; state.position.x = pos.x; state.position.y = pos.y; state.position.z = pos.z; const rot = this._mesh.rotationQuaternion; if(rot) { state.rotation.x = rot.x; state.rotation.y = rot.y; state.rotation.z = rot.z; state.rotation.w = rot.w; } } ``` The behavior also exposes a public API for applying forces and impulses, and handles event-driven physics via `processEvent()`: ```typescript processEvent(event : GameEvent, _state : PhysicsBodyState) : boolean { if(!this._aggregate) { return false; } switch(event.type) { case 'physics:applyImpulse': { const { x = 0, y = 0, z = 0 } = (event.payload ?? {}) as VectorPayload; this.applyImpulse(new Vector3(x, y, z)); return true; } // ... other cases } return false; } ``` ::: tip Why onNodeAttached instead of manual initialize? A `PhysicsAggregate` needs both a scene and a mesh. Rather than forcing the caller to remember to call `behavior.initialize(scene, mesh)`, the `onNodeAttached()` hook fires automatically when `entityManager.attachToNode(entity, mesh)` is called. The scene comes from `node.getScene()`. Zero manual wiring. ::: ## Step 5: Entity definitions Each physics entity type gets a definition that configures its default state and attaches the physics behavior: ```typescript export function createCrateDefinition(logEvent : LogEventFn) : GameEntityDefinition { return { type: 'physics:crate', name: 'Crate', defaultState: { position: { x: 0, y: SPAWN_HEIGHT, z: 0 }, rotation: { x: 0, y: 0, z: 0, w: 1 }, physics: { shapeType: 'box', mass: MATERIAL_WOOD.mass, restitution: MATERIAL_WOOD.restitution, friction: MATERIAL_WOOD.friction, }, material: 'wood', }, behaviors: [ PhysicsBodyBehavior ], tags: [ 'physics', 'prop', 'dynamic' ], poolable: true, poolSize: 10, onCreate: (state) => { logEvent(`Crate spawned (${ state.physics.mass.toFixed(1) }kg)`, 'spawn'); }, }; } ``` The ground is defined the same way, but with `mass: 0` to make it static: ```typescript export function createGroundDefinition() : GameEntityDefinition { return { type: 'physics:ground', defaultState: { position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0, w: 1 }, physics: { shapeType: 'box', mass: 0, // static! restitution: 0.5, friction: 0.8, }, size: GROUND_SIZE, }, behaviors: [ PhysicsBodyBehavior ], tags: [ 'physics', 'environment', 'static' ], }; } ``` The dumbbell definition demonstrates compound entities with `children`: ```typescript children: [ { type: 'physics:ball', name: 'Dumbbell Weight (Left)', position: { x: -1.0, y: 0, z: 0 } }, { type: 'physics:ball', name: 'Dumbbell Weight (Right)', position: { x: 1.0, y: 0, z: 0 } }, { type: 'physics:cylinder', name: 'Dumbbell Bar', position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: Math.PI / 2 } }, ], ``` ## Step 6: The entity-mesh-physics binding pattern The `PhysicsEntityManager` class handles the three-step pattern that connects everything: 1. Create the entity through SAGE's entity manager 2. Create the corresponding BabylonJS mesh 3. Call `attachToNode()` to bind them -- this triggers `onNodeAttached()` on all behaviors ```typescript async spawnCrate(config : SpawnConfig) : Promise> { const entityManager = this.engine.managers.entityManager; const position = this.getRandomSpawnPosition(); // 1. Create entity with custom physics config const entity = await entityManager.createEntity('physics:crate', { initialState: { position, physics: { shapeType: 'box', mass: config.mass, restitution: config.restitution, friction: config.friction, }, }, }); // 2. Create mesh const mesh = MeshBuilder.CreateBox( `crate-${ entity.id }`, { size: CRATE_SIZE }, this.scene ); mesh.position.set(position.x, position.y, position.z); // 3. Bind — PhysicsBodyBehavior.onNodeAttached() creates the PhysicsAggregate entityManager.attachToNode(entity, mesh); return entity; } ``` After `attachToNode()`, the entity's physics body exists and is simulating. The mesh is accessible at `entity.node`, and the physics aggregate is at `entity.state.physicsAggregate`. No other setup is needed. ## Step 7: The PhysicsController orchestrator The controller ties together scene setup, entity management, input handling, per-frame updates, and cleanup. It follows the same pattern as earlier examples (`GameController`, `LevelController`). ```typescript export class PhysicsController { private readonly engine : GameEngine; private readonly callbacks : PhysicsControllerCallbacks; private entityManager : PhysicsEntityManager | null = null; async initialize() : Promise { this.scene = this.engine.managers.levelManager.currentLevel?.scene ?? null; this.entityManager = new PhysicsEntityManager(this.engine, this.scene!); registerAllDefinitions(this.engine.managers.entityManager, this.callbacks.onLogEvent); await this.entityManager.createGround(); this.playerVehicle = await this.entityManager.createPlayerVehicle(); setupBindings(this.engine); this.subscribeToActions(); } } ``` The per-frame update handles custom logic that sits alongside SAGE's automatic entity updates: ```typescript update(_deltaTime : number) : void { this.updatePlayerInput(); this.checkPlayerFallen(); this.cleanupFallenObjects(); this.callbacks.onStatsChanged(this.computeStats()); } ``` Statistics are computed by iterating dynamic entities and checking their physics state: ```typescript private computeStats() : PhysicsStats { const dynamicEntities = [ ...em.getByType('physics:crate'), ...em.getByType('physics:ball'), ...em.getByType('physics:cylinder'), ...em.getByType('physics:dumbbell'), ] as GameEntity[]; let activeCount = 0; let totalMass = 0; for(const entity of dynamicEntities) { totalMass += entity.state.physics.mass; const velocity = entity.state.physicsAggregate?.body.getLinearVelocity(); if(velocity && velocity.length() > ACTIVE_VELOCITY_THRESHOLD) { activeCount++; } } return { entityCount: dynamicEntities.length, activeCount, totalMass }; } ``` Fallen objects (below `y = -20`) are cleaned up each frame by disposing their mesh and destroying the entity. ## Step 8: Wiring it up in Vue The Vue component creates the controller on `engine-ready`, registers a frame callback, and starts the engine: ```typescript async function onEngineReady(engine : GameEngine) : Promise { engine.managers.levelManager.registerLevelClass('physics-playground', PhysicsPlaygroundLevel); engine.managers.levelManager.registerLevelConfig(levelConfig); await engine.managers.levelManager.activateLevel('playground'); controller = new PhysicsController(engine, canvas, { onStatsChanged: (newStats) => { /* update reactive refs */ }, onLogEvent: logEvent, }); await controller.initialize(); engine.managers.gameManager.registerFrameCallback((dt) => controller?.update(dt)); await engine.start(); } ``` UI sliders for mass, restitution, and friction are bound to the controller via watchers: ```typescript watch(mass, (newMass) => { if(controller) { controller.mass = newMass; } }); ``` When the user spawns an object, the controller uses the current slider values as the spawn config. ## What you learned * Physics must be enabled on a scene before creating any physics bodies * `PhysicsBodyBehavior` uses `onNodeAttached()` to automatically create a `PhysicsAggregate` * Physics state flows in reverse: the physics engine writes position to entity state each frame * The three-step binding pattern (create entity, create mesh, `attachToNode()`) triggers all behavior lifecycle hooks automatically * Material presets extract tuning values so physics feel can be iterated without code changes * The controller pattern orchestrates spawning, cleanup, statistics, and input routing ## Next steps * [Level Loading](./level-loading.md) -- load levels from YAML with spawn points and transitions * [Sandbox](./sandbox.md) -- first-person physics with GLB scenes from Blender --- --- url: /guides/sandbox.md description: >- Build a first-person 3D sandbox combining GLB scene loading, physics character controller, doors with spatial audio, a multi-floor elevator, pickups, and input contexts. --- # Sandbox This guide walks you through a comprehensive first-person sandbox that brings together every major SAGE system: GLB scene loading from Blender, Havok physics character controller, entity behaviors for doors and elevators, spatial audio, pickups, pushable props, and input context switching. It is the closest thing to a real game in the examples. The full source is in `examples/src/examples/sandbox/`. ::: tip Try it live Run this example at [Examples > Sandbox](/examples/sandbox). ::: ## Project structure ``` sandbox/ ├── index.vue # Vue component — loading screen, elevator UI, crosshair ├── types.ts # Controller callback and event payload types ├── player.ts # First-person character controller (Havok PhysicsCharacterController) ├── behaviors/ │ ├── DoorBehavior.ts # Sliding door with spatial sound effects │ ├── ElevatorBehavior.ts # Multi-floor elevator with ANIMATED physics │ ├── InteractBehavior.ts # Generic interaction (buttons, locked doors) │ ├── PickupBehavior.ts # Pick up and drop items (key) │ ├── CrateBehavior.ts # Openable crate with lid animation │ └── PropBehavior.ts # Pushable physics prop ├── components/ │ └── elevatorPanel.vue # Floor selection UI overlay ├── entities/ │ └── definitions.ts # Entity type definitions ├── game/ │ ├── SandboxController.ts # Orchestrator │ └── index.ts ├── input/ │ ├── bindings.ts # Gameplay + elevator_ui contexts │ └── helpers.ts ├── levels/ │ ├── loader.ts # YAML parsing │ ├── sandbox.yaml # Level config │ └── source/ # Blender source files └── utils/ └── audio.ts # Sound loading utility ``` ## What this example demonstrates * First-person camera and physics character controller * GLB scene loading with Blender-authored entities and spawn points * Entity behaviors: sliding doors, a multi-floor elevator, pickups, openable crates, pushable props * Spatial audio: door open/close sounds attached to meshes, ambient background * Input contexts: switching between gameplay (WASD + interact) and elevator UI (mouse + Escape) * Raycast-based interaction with prompt UI * Platform following: player rides the elevator smoothly via frame-delta compensation ## Step 1: The YAML level config The sandbox uses a single level defined in YAML: ```yaml name: Sandbox scene: /assets/sandbox/SAGE_dev-box.glb physics: true spawns: player_start: entity: player preload: - /assets/sandbox/SAGE_dev-box.glb postProcessing: bloom: weight: 0.3 threshold: 0.8 tonemap: operator: aces ``` The GLB file is authored in Blender with custom properties on meshes: `entity` markers for doors, buttons, elevators, keys, crates, and props; `spawn` markers for the player start position; `collider` properties for physics shapes. SAGE's property handlers process all of this automatically during scene load. ## Step 2: Entity definitions Entity definitions map Blender entity types to behaviors. The sandbox defines eight types: ```typescript export function getAllEntityDefinitions() : GameEntityDefinition[] { return [ { type: 'player', defaultState: {}, behaviors: [] }, { type: 'door', defaultState: {}, behaviors: [ DoorBehavior ] }, { type: 'button', defaultState: {}, behaviors: [ InteractBehavior ] }, { type: 'key', defaultState: {}, behaviors: [ PickupBehavior ] }, { type: 'elevator', defaultState: {}, behaviors: [ ElevatorBehavior ] }, { type: 'elevator_button', defaultState: {}, behaviors: [ InteractBehavior ] }, { type: 'crate', defaultState: {}, behaviors: [ InteractBehavior, CrateBehavior ] }, { type: 'prop', defaultState: {}, behaviors: [ PropBehavior ] }, ] as GameEntityDefinition[]; } ``` Notice how minimal these are -- the behaviors read their configuration from Blender metadata via `onNodeAttached()` rather than from the definition's `defaultState`. This puts the artist in control of per-instance configuration. ## Step 3: First-person player controller The player controller uses Havok's `PhysicsCharacterController` for capsule-based movement with proper collision response. It is a standalone function rather than a behavior because it manages the camera directly and needs raw per-frame key state. ```typescript export function createPlayer( scene : Scene, canvas : HTMLCanvasElement, spawnPos : Vector3 ) : Player { const startPos = spawnPos.add(new Vector3(0, (PLAYER_HEIGHT / 2) + 0.1, 0)); const controller = new PhysicsCharacterController( startPos, { capsuleHeight: 1.8, capsuleRadius: 0.2 }, scene ); const camera = new FreeCamera('playerCamera', new Vector3( startPos.x, startPos.y + EYE_OFFSET, startPos.z ), scene); // Remove default keyboard movement -- we handle it via the character controller camera.inputs.removeByType('FreeCameraKeyboardMoveInput'); camera.attachControl(canvas, true); // ... } ``` Movement uses a state machine with three states: `on_ground`, `start_jump`, and `in_air`. Each frame, the controller builds a movement direction from WASD keys relative to the camera's facing, then sets the desired velocity: ```typescript function update(dt : number) : void { const support = controller.checkSupport(deltaTime, DOWN); charState = getNextState(support.supportedState); const forward = camera.getDirection(Vector3.Forward()); forward.y = 0; forward.normalize(); const right = camera.getDirection(Vector3.Right()); right.y = 0; right.normalize(); const moveDir = Vector3.Zero(); if(keys['KeyW']) { moveDir.addInPlace(forward); } if(keys['KeyS']) { moveDir.subtractInPlace(forward); } if(keys['KeyD']) { moveDir.addInPlace(right); } if(keys['KeyA']) { moveDir.subtractInPlace(right); } // Set velocity based on state (ground/jumping/air) controller.setVelocity(desiredVelocity); controller.integrate(deltaTime, support, GRAVITY); // Sync capsule and camera to physics position const pos = controller.getPosition(); camera.position.set(pos.x, pos.y + EYE_OFFSET, pos.z); } ``` Pointer lock gives proper FPS mouse look -- clicking the canvas captures the cursor. ## Step 4: DoorBehavior with spatial audio The door behavior demonstrates BabylonJS animations combined with spatial sound. When activated, the door slides along its Z axis with a smooth animation, playing a positional sound effect: ```typescript export class DoorBehavior extends GameEntityBehavior { name = 'door'; eventSubscriptions = [ 'activate' ]; private openSound : StaticSound | null = null; private closeSound : StaticSound | null = null; onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void { this.node = node; this.closedZ = node.position.z; // Enable physics body to track mesh position during animations const mesh = node as AbstractMesh; if(mesh.physicsBody) { mesh.physicsBody.disablePreStep = false; } this.loadSounds(node); } private async loadSounds(node : TransformNode) : Promise { const mesh = node as AbstractMesh; this.openSound = await loadSound('doorOpen', '/assets/sandbox/door-open.mp3', { spatialNode: mesh, volume: 0.5, }); this.closeSound = await loadSound('doorClose', '/assets/sandbox/door-close.mp3', { spatialNode: mesh, volume: 0.5, }); } } ``` The `loadSound` utility wraps BabylonJS 8's audio API and handles spatial attachment: ```typescript export async function loadSound( name : string, url : string, options ?: { spatialNode ?: AbstractMesh; volume ?: number } ) : Promise { const audioEngine = await CreateAudioEngineAsync(); const sound = await CreateSoundAsync(name, url, { spatialEnabled: !!options?.spatialNode, volume: options?.volume ?? 1.0, }); if(options?.spatialNode) { sound.spatial?.attach(options.spatialNode); } await audioEngine.unlockAsync(); return sound; } ``` The door toggles with a BabylonJS `Animation`: ```typescript private toggle() : void { this.isOpen = !this.isOpen; if(this.isOpen) { this.openSound?.play(); } else { this.closeSound?.play(); } const targetZ = this.isOpen ? this.closedZ - DOOR_SLIDE_DISTANCE : this.closedZ; const duration = this.isOpen ? OPEN_DURATION : CLOSE_DURATION; const anim = new Animation( 'doorSlide', 'position.z', ANIM_FPS, Animation.ANIMATIONTYPE_FLOAT, Animation.ANIMATIONLOOPMODE_CONSTANT ); anim.setKeys([ { frame: 0, value: this.node!.position.z }, { frame: duration, value: targetZ }, ]); this.node!.animations = [ anim ]; this.node!.getScene().beginAnimation(this.node!, 0, duration, false); } ``` Setting `physicsBody.disablePreStep = false` in `onNodeAttached()` is critical -- it tells Havok to update the collision body to match the animated mesh position each frame. Without it, the door would slide visually but its collider would stay in place. ## Step 5: The elevator system The elevator is the most complex behavior in the examples. It manages multi-floor movement using ANIMATED physics bodies and communicates floor changes through the event bus. ### ElevatorBehavior ```typescript export class ElevatorBehavior extends GameEntityBehavior { name = 'elevator'; eventSubscriptions = [ 'activate', 'elevator:call' ]; private currentFloor = 1; private floorCount = 4; private floorSpacing = 3.0; onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void { this.node = node; this.baseY = node.position.y; // Read configuration from Blender metadata this.floorCount = (node.metadata?.floor_count as number) ?? 4; this.floorSpacing = (node.metadata?.floor_spacing as number) ?? 3.0; // Set physics to ANIMATED so we drive position, not the physics engine const mesh = node as AbstractMesh; if(mesh.physicsBody) { mesh.physicsBody.setMotionType(PhysicsMotionType.ANIMATED); } } } ``` The key physics concept is `PhysicsMotionType.ANIMATED`. Unlike DYNAMIC bodies (driven by forces) or STATIC bodies (immovable), ANIMATED bodies have their position set by game code but still push other objects around. The elevator uses `setTargetTransform()` to move smoothly: ```typescript update(dt : number) : void { if(!this.moving) { this.zeroVelocity(); return; } const diff = this.targetY - this.currentY; if(Math.abs(diff) < 0.01) { // Arrived this.moving = false; this.currentFloor = this.targetFloor; this.$emit({ type: 'elevator:arrived', payload: { floor: this.currentFloor } }); return; } const step = Math.sign(diff) * Math.min(ELEVATOR_SPEED * dt, Math.abs(diff)); this.currentY += step; this.applyTargetTransform(); // Emit displacement so the player can follow this.$emit({ type: 'elevator:frame-delta', payload: { deltaY: step } }); } ``` ### Platform following The player needs to ride the elevator smoothly. The character controller does not automatically follow moving platforms, so the elevator emits `elevator:frame-delta` events each frame with the exact Y displacement. The controller passes this to the player, which applies the shortfall after physics integration: ```typescript // In the player update loop if(platformGrace > 0 && Math.abs(pendingPlatformDY) > 0.0001) { const posAfter = controller.getPosition(); const alreadyMoved = posAfter.y - posBefore.y; const shortfall = pendingPlatformDY - alreadyMoved; if(Math.abs(shortfall) > 0.001) { posAfter.y += shortfall; controller.setPosition(posAfter); } } ``` A grace period of 3 frames prevents jitter when the player steps on or off the platform. ### Elevator panel UI When the player interacts with an elevator button, the elevator emits `elevator:panel-open`. The controller catches this, switches the input context to `elevator_ui`, releases pointer lock, and shows the Vue panel: ```typescript eventBus.subscribe('elevator:panel-open', (event) => { const payload = event.payload as ElevatorPanelPayload; this.elevatorPanelOpen = true; this.engine.managers.bindingManager.activateContext('elevator_ui'); document.exitPointerLock(); this.callbacks.onElevatorPanelOpen(payload.currentFloor, payload.floorCount); }); ``` When the player selects a floor or presses Escape, the controller sends `elevator:call`, switches back to the `gameplay` context, and re-captures the pointer. ## Step 6: InteractBehavior -- generic interaction with locking The interact behavior reads its configuration from Blender metadata and dispatches `activate` events to target entities: ```typescript export class InteractBehavior extends GameEntityBehavior { name = 'interact'; promptText = 'to interact'; locked = false; private target : string | null = null; private context : Record = {}; onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void { this.target = (node.metadata?.target as string) ?? null; this.defaultPrompt = (node.metadata?.prompt as string) ?? 'to interact'; this.locked = !!(node.metadata?.locked); this.promptText = this.locked ? 'Locked' : this.defaultPrompt; // Parse extra context from Blender metadata const rawContext = node.metadata?.context as string | undefined; this.context = rawContext ? JSON.parse(rawContext) : {}; } processEvent(event : GameEvent) : boolean { if(event.type !== 'interact') { return false; } if(this.locked) { return false; } if(this.target) { this.$emit({ type: 'activate', payload: { target: this.target, ...this.context }, }); } return true; } } ``` The `target` property is the Blender object name of the entity to activate. For example, an elevator button has `target = "Elevator"` and `context = '{"floor": 2}'`, so interacting with it sends `{ type: 'activate', payload: { target: 'Elevator', floor: 2 } }` to the global event bus. Locking is visual too -- when locked, the button light material changes to red. When unlocked (e.g., by using a key), it turns green: ```typescript unlock() : void { this.locked = false; this.promptText = this.defaultPrompt; this.unlockSound?.play(); if(this.lightMaterial) { this.lightMaterial.emissiveColor = EMISSIVE_UNLOCKED.clone(); } } ``` ## Step 7: PickupBehavior -- pick up and drop items The pickup behavior handles item reparenting. When picked up, the item is reparented to the camera so it follows the player's view. Physics bodies are disposed during carry and recreated on drop: ```typescript private pickup() : void { // Dispose physics so it doesn't interfere with reparenting const mesh = this.node as AbstractMesh; if(mesh.physicsBody) { mesh.physicsBody.dispose(); } // Reparent to camera this.originalParent = this.node!.parent as TransformNode | null; this.node!.setParent(this.camera!, false); this.node!.position.copyFrom(HELD_POSITION); this.node!.rotation.copyFrom(HELD_ROTATION); this.held = true; } private drop() : void { // Raycast forward to find drop position const ray = new Ray(this.camera!.position, forward, DROP_MAX_DISTANCE); const pick = scene.pickWithRay(ray, (mesh) => mesh !== this.node); const dropPos = pick?.pickedPoint?.clone() ?? /* fallback */; // Restore parent and recreate physics this.node!.setParent(this.originalParent); this.node!.setAbsolutePosition(dropPos); new PhysicsAggregate(mesh, PhysicsShapeType.BOX, { mass: 5 }, scene); this.held = false; } ``` The controller checks if the player is holding a key when interacting with a locked button, and calls `interact.unlock()` if so. ## Step 8: PropBehavior -- pushable physics objects The prop behavior is the simplest -- it switches the mesh's physics body from STATIC to DYNAMIC and sets a mass, letting the player push it around: ```typescript export class PropBehavior extends GameEntityBehavior { name = 'prop'; onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void { const mass = (node.metadata?.mass as number) ?? 20; const mesh = node as AbstractMesh; if(mesh.physicsBody) { mesh.physicsBody.setMotionType(PhysicsMotionType.DYNAMIC); mesh.physicsBody.setMassProperties({ mass }); } for(const child of node.getChildMeshes()) { if(child.physicsBody) { child.physicsBody.setMotionType(PhysicsMotionType.DYNAMIC); child.physicsBody.setMassProperties({ mass }); } } } } ``` Objects in the Blender scene marked with `entity = "prop"` become pushable when the level loads. The artist controls mass via a `mass` custom property on the mesh. ## Step 9: Input contexts The sandbox uses two input contexts: `gameplay` for normal FPS controls, and `elevator_ui` for the floor selection panel. ```typescript export function setupBindings(engine : GameEngine) : void { const bindingManager = engine.managers.bindingManager; bindingManager.registerAction({ type: 'digital', name: 'activate' }); bindingManager.registerAction({ type: 'digital', name: 'drop' }); bindingManager.registerAction({ type: 'digital', name: 'cancel_ui' }); const bindings : BindingDefinition[] = [ // Gameplay context { type: 'trigger', action: 'activate', context: 'gameplay', input: keyboardInput('KeyE'), options: { edgeMode: 'rising' } }, { type: 'trigger', action: 'drop', context: 'gameplay', input: keyboardInput('KeyQ'), options: { edgeMode: 'rising' } }, // Elevator UI context { type: 'trigger', action: 'cancel_ui', context: 'elevator_ui', input: keyboardInput('Escape'), options: { edgeMode: 'rising' } }, ]; // Register exclusive contexts bindingManager.registerContext('elevator_ui', true); bindingManager.registerContext('gameplay', true); bindingManager.activateContext('gameplay'); } ``` Contexts are exclusive (`true` as the second argument), so activating `elevator_ui` automatically deactivates `gameplay`. This means E and Q stop working while the elevator panel is open, and Escape only works in the elevator UI context. ## Step 10: The SandboxController The controller orchestrates everything: ```typescript export class SandboxController { async initialize() : Promise { registerAllPropertyHandlers(levelManager); for(const def of getAllEntityDefinitions()) { entityManager.registerEntityDefinition(def); } setupBindings(this.engine); for(const config of loadAllLevelConfigs()) { levelManager.registerLevelConfig(config); } gameManager.registerFrameCallback((dt) => this.update(dt)); await this.engine.start(); await levelManager.transition('Sandbox'); this.setupLighting(scene); this.setupAmbientSound(); this.player = createPlayer(scene, canvas, spawnPos); this.subscribeToActions(); this.subscribeToElevatorEvents(); } } ``` Each frame, the controller updates the player, then performs a raycast from the camera to detect interactable entities: ```typescript private update(dt : number) : void { this.player?.update(dt); const result = this.engine.raycast.pickEntityForward( scene, this.player!.camera, INTERACT_DISTANCE ); if(result?.entity.hasBehavior(InteractBehavior)) { const interact = result.entity.getBehavior(InteractBehavior); this.interactTarget = result.entity; this.callbacks.onInteractPrompt(interact?.promptText ?? 'to interact'); } else { this.interactTarget = null; this.callbacks.onInteractPrompt(null); } } ``` The `pickEntityForward` utility raycasts from the camera center and returns the first hit entity, making interaction detection straightforward. ## How it all ties together The data flow for a locked door interaction: 1. Player walks toward an elevator button -- the controller's raycast hits the button entity 2. `InteractBehavior.locked` is true -- the prompt shows "Locked" 3. Player picks up a key (PickupBehavior reparents it to the camera) 4. Player looks at the button again -- controller detects a held key, prompt changes to "to unlock" 5. Player presses E -- controller calls `interact.unlock()`, which plays a sound and changes the light color 6. Player presses E again -- `InteractBehavior.processEvent()` emits `activate` with the elevator as the target 7. `ElevatorBehavior.processEvent()` receives the activate, opens the floor panel 8. Controller switches to `elevator_ui` context, releases pointer lock 9. Player clicks Floor 3 -- controller sends `elevator:call` with `{ floor: 3 }` 10. Elevator moves, emitting `elevator:frame-delta` each frame 11. Player rides the elevator via platform-following compensation This chain of events crosses behaviors, the event bus, the controller, input contexts, and the Vue UI -- but each piece is simple and self-contained. ## What you learned * `PhysicsCharacterController` provides capsule-based FPS movement with proper collision * Behaviors read per-instance configuration from Blender metadata via `node.metadata` * `PhysicsMotionType.ANIMATED` lets you drive physics body position while still pushing objects * Spatial audio attaches sounds to meshes so they play from the right position in 3D space * Input contexts let you cleanly switch between gameplay controls and UI modes * Platform following requires explicit frame-delta compensation for smooth rides * Raycast-based interaction (`pickEntityForward`) makes entity detection straightforward * The controller pattern keeps game logic out of Vue and behavior coordination centralized ## Next steps * [Blender Workflow](./blender-workflow.md) -- how to author the GLB scenes this example loads * [Level Loading](./level-loading.md) -- YAML configs and level transitions in more detail --- --- url: /api/scenes.md description: >- API reference for SAGE's scene system -- SceneEngine utilities, cameras, lighting, meshes, materials, model loading, asset management, and raycasting. --- # Scenes The scene system provides the visual foundation for your game. Powered by BabylonJS, the `SceneEngine` wraps common scene creation tasks while giving you full access to the underlying BabylonJS API when needed. ## SceneEngine The `SceneEngine` is available through the engine: ```typescript const sceneEngine = gameEngine.engines.sceneEngine; ``` ### Scene Methods | Method | Signature | Description | |--------|-----------|-------------| | `createScene` | `() => Scene` | Create a new BabylonJS scene | | `enablePhysics` | `(scene, gravity?) => void` | Enable Havok physics on a scene | ### Camera Methods | Method | Signature | Description | |--------|-----------|-------------| | `createFreeCamera` | `(name, position, scene) => FreeCamera` | First-person camera with 6DOF | ### Light Methods | Method | Signature | Description | |--------|-----------|-------------| | `createHemisphericLight` | `(name, direction, scene, intensity?) => HemisphericLight` | Ambient light from a hemisphere | | `createDirectionalLight` | `(name, direction, scene, intensity?) => DirectionalLight` | Directional light (sun) | | `createPointLight` | `(name, position, scene, intensity?) => PointLight` | Omnidirectional point light | ### Mesh Methods | Method | Signature | Description | |--------|-----------|-------------| | `createSphere` | `(name, options, scene) => Mesh` | Create a sphere mesh | | `createBox` | `(name, options, scene) => Mesh` | Create a box mesh | | `createGround` | `(name, options, scene) => Mesh` | Create a ground plane | | `createCylinder` | `(name, options, scene) => Mesh` | Create a cylinder mesh | ### Physics Methods | Method | Signature | Description | |--------|-----------|-------------| | `addPhysics` | `(mesh, shapeType, options, scene) => PhysicsAggregate` | Add a physics body to a mesh | ### Model Loading | Method | Signature | Description | |--------|-----------|-------------| | `loadModel` | `(path, scene) => Promise` | Load a GLB/glTF model | | `importMeshes` | `(path, scene) => Promise` | Import meshes from a file | ## Creating a Scene In most cases, scenes are created inside a `Level`'s `buildScene()` method. Here is the general pattern: ```typescript const scene = sceneEngine.createScene(); sceneEngine.enablePhysics(scene); const camera = sceneEngine.createFreeCamera( 'mainCamera', new Vector3(0, 5, -10), scene ); camera.setTarget(Vector3.Zero()); sceneEngine.createHemisphericLight('ambient', new Vector3(0, 1, 0), scene, 0.4); sceneEngine.createDirectionalLight('sun', new Vector3(0.5, -0.6, 0.5), scene); const ground = sceneEngine.createGround('ground', { width: 50, height: 50 }, scene); sceneEngine.addPhysics(ground, PhysicsShapeType.BOX, { mass: 0 }, scene); ``` ## Cameras BabylonJS cameras define the player's viewport. Common types: | Camera | Use Case | |--------|----------| | `FreeCamera` | First-person, 6 degrees of freedom | | `ArcRotateCamera` | Third-person, orbits around a target | | `FollowCamera` | Follows a specific mesh | ```typescript import { FreeCamera, ArcRotateCamera, Vector3 } from '@babylonjs/core'; // First-person const fps = new FreeCamera('fps', new Vector3(0, 5, -10), scene); fps.setTarget(Vector3.Zero()); fps.attachControl(canvas, true); // Third-person orbit const orbit = new ArcRotateCamera( 'orbit', Math.PI / 4, // alpha Math.PI / 3, // beta 10, // radius Vector3.Zero(), // target scene ); orbit.attachControl(canvas, true); ``` ## Lighting ```typescript import { HemisphericLight, DirectionalLight, PointLight, Vector3 } from '@babylonjs/core'; // Ambient hemisphere light const ambient = new HemisphericLight('ambient', new Vector3(0, 1, 0), scene); ambient.intensity = 0.4; // Directional sunlight const sun = new DirectionalLight('sun', new Vector3(0.5, -0.6, 0.5), scene); sun.intensity = 0.7; // Point light const torch = new PointLight('torch', new Vector3(5, 3, 0), scene); torch.intensity = 0.8; ``` ## Meshes and Materials Create meshes with the SceneEngine utilities or BabylonJS `MeshBuilder` directly: ```typescript import { MeshBuilder, StandardMaterial, PBRMaterial, Color3 } from '@babylonjs/core'; const sphere = MeshBuilder.CreateSphere('sphere', { diameter: 2, segments: 32 }, scene); sphere.position.y = 1; // Standard material const mat = new StandardMaterial('mat', scene); mat.diffuseColor = new Color3(0.2, 0.4, 0.8); mat.specularColor = new Color3(0.3, 0.3, 0.3); sphere.material = mat; // PBR material const pbr = new PBRMaterial('pbr', scene); pbr.albedoColor = new Color3(0.8, 0.2, 0.1); pbr.metallic = 0.7; pbr.roughness = 0.3; ``` ## Model Loading Load external 3D models (GLB/glTF): ```typescript // Via SceneEngine const container = await sceneEngine.loadModel('assets/models/character.glb', scene); container.addAllToScene(); // Via BabylonJS SceneLoader directly import { SceneLoader } from '@babylonjs/core'; const result = await SceneLoader.ImportMeshAsync('', 'assets/models/', 'character.glb', scene); ``` ## AssetManager For production games, use the `AssetManager` for centralized loading with caching, reference counting, and GLB fragment extraction: ```typescript const assetManager = gameEngine.managers.assetManager; ``` ### Loading Assets ```typescript // Load a full GLB const container = await assetManager.load('models/environment.glb'); container.addAllToScene(); ``` ### Fragment Syntax Extract individual meshes from a multi-object GLB using `path#meshName`: ```typescript const chestLid = await assetManager.load('models/props.glb#chest_lid'); const torch = await assetManager.load('models/props.glb#wall_torch'); ``` The underlying container is loaded only once, regardless of how many fragments you extract. ### Instancing and Cloning ```typescript // GPU instances -- share geometry and material, very cheap const barrel1 = assetManager.instance('models/props.glb#barrel'); const barrel2 = assetManager.instance('models/props.glb#barrel'); barrel1.position.x = 5; barrel2.position.x = -5; // Independent clone -- for meshes that need modification const unique = assetManager.clone('models/props.glb#barrel'); unique.scaling.y = 1.5; ``` Use `instance()` for many identical copies (props, trees, crates). Use `clone()` when a copy needs its own geometry or material. ### Preloading Batch-load assets with event bus progress tracking: ```typescript gameEngine.eventBus.subscribe('asset:progress', (event) => { const { path, loaded, total } = event.payload; console.log(`Loaded ${ loaded }/${ total }: ${ path }`); }); await assetManager.preload([ 'models/environment.glb', 'models/props.glb#barrel', 'models/props.glb#crate', 'models/characters.glb#knight', ]); ``` ### Reference Counting and Disposal Each `load()` call increments a reference count. Call `dispose()` to decrement -- the container is freed only when the count reaches zero: ```typescript await assetManager.load('models/props.glb#barrel'); await assetManager.load('models/props.glb#barrel'); assetManager.dispose('models/props.glb#barrel'); // refCount: 2 -> 1 assetManager.dispose('models/props.glb#barrel'); // refCount: 0, freed // Or release everything at once assetManager.disposeAll(); ``` `disposeAll()` is called automatically during level teardown. ## Raycasting SAGE wraps BabylonJS picking with entity-aware raycasting that resolves picked meshes back to their owning entities: ```typescript const raycast = gameEngine.raycast; ``` ### Screen Picking ```typescript const result = raycast.pickEntity(scene, pointerX, pointerY); if(result) { console.log('Clicked:', result.entity.type); console.log('Hit point:', result.point); } // Pick all entities under the cursor const results = raycast.pickEntities(scene, pointerX, pointerY); ``` ### Ray Picking ```typescript import { Ray, Vector3 } from '@babylonjs/core'; const ray = new Ray( new Vector3(0, 5, 0), new Vector3(0, -1, 0), 100 ); const result = raycast.pickEntityWithRay(scene, ray); const results = raycast.pickEntitiesWithRay(scene, ray); ``` ### Forward Picking (FPS Style) Cast a ray from the camera's forward direction: ```typescript const result = raycast.pickEntityForward(scene, camera, 50); if(result) { console.log('Looking at:', result.entity.type); } ``` The third parameter is `maxDistance` (default 1000). ### EntityPickFilter Filter results by type, tags, or custom predicates. All conditions are AND'd: ```typescript import type { EntityPickFilter } from '@skewedaspect/sage'; // By type const enemies = raycast.pickEntities(scene, x, y, { type: 'enemy' }); // By tags (entity must have ALL listed tags) const interactable = raycast.pickEntity(scene, x, y, { tags: [ 'interactable', 'unlocked' ], }); // Custom predicate const alive = raycast.pickEntity(scene, x, y, { predicate: (entity) => entity.state.health > 0, }); // Combined const target = raycast.pickEntityForward(scene, camera, 100, { type: 'enemy', tags: [ 'visible' ], predicate: (entity) => entity.state.health > 0, }); ``` ### EntityPickResult ```typescript interface EntityPickResult { entity : GameEntity; point : Vector3; distance : number; normal : Vector3; mesh : AbstractMesh; pickingInfo : PickingInfo; } ``` | Field | Type | Description | |-------|------|-------------| | `entity` | `GameEntity` | The resolved entity that owns the picked mesh | | `point` | `Vector3` | World-space intersection point | | `distance` | `number` | Distance from ray origin to hit point | | `normal` | `Vector3` | Surface normal at the hit point | | `mesh` | `AbstractMesh` | The specific mesh that was picked | | `pickingInfo` | `PickingInfo` | The raw BabylonJS picking result | ### Parent-Chain Resolution When a picked mesh belongs to a child entity, the child entity is returned. If the mesh is not directly associated with an entity, the raycast helper walks up the scene graph parent chain until it finds one. ## Environment Effects ### Fog ```typescript scene.fogEnabled = true; scene.fogColor = new Color3(0.8, 0.9, 0.8); scene.fogDensity = 0.01; ``` ### Particle Systems ```typescript import { ParticleSystem, Color4, Texture, Vector3 } from '@babylonjs/core'; const fire = new ParticleSystem('fire', 2000, scene); fire.particleTexture = new Texture('assets/textures/flame.png', scene); fire.emitter = new Vector3(5, 0, 10); fire.color1 = new Color4(1, 0.9, 0.3, 1); fire.color2 = new Color4(1, 0.5, 0.2, 1); fire.minSize = 0.3; fire.maxSize = 1.5; fire.emitRate = 500; fire.start(); ``` ## Debugging Use the BabylonJS inspector for visual debugging: ```typescript import '@babylonjs/inspector'; window.addEventListener('keydown', (e) => { if(e.key === 'F12') { if(scene.debugLayer.isVisible) { scene.debugLayer.hide(); } else { scene.debugLayer.show(); } } }); ``` --- --- url: /api/serialization.md description: >- API reference for SAGE's serialization system -- SaveManager, serialize/deserialize, SaveData structure, ID remapping, and extension hooks. --- # Serialization & Save/Load The `SaveManager` provides a storage-agnostic serialization system for capturing and restoring game state. It produces plain JSON-serializable objects that you can persist however you like -- localStorage, IndexedDB, a server API, or clipboard copy/paste during development. ## Accessing the SaveManager ```typescript const engine = await createGameEngine({ canvas }); const saveManager = engine.managers.saveManager; ``` ## SaveManager API | Method | Signature | Description | |--------|-----------|-------------| | `serialize` | `() => SaveData` | Capture current game state | | `deserialize` | `(data : SaveData) => Promise` | Restore game state from a save | | `onBeforeSerialize` | `(hook : () => Record) => void` | Register a pre-save hook | | `onAfterDeserialize` | `(hook : (custom : Record) => void) => void` | Register a post-load hook | | `$teardown` | `() => Promise` | Clean up hooks (called automatically) | ## SaveData The `serialize()` method returns a `SaveData` object: ```typescript interface SaveData { version : number; levelName : string; entities : SerializedEntity[]; custom : Record; } ``` | Field | Type | Description | |-------|------|-------------| | `version` | `number` | Schema version (currently `1`) | | `levelName` | `string` | Name of the current level | | `entities` | `SerializedEntity[]` | All entity snapshots | | `custom` | `Record` | Merged data from `onBeforeSerialize` hooks | ## SerializedEntity Each entity is captured as: ```typescript interface SerializedEntity { id : string; type : string; name ?: string; tags : string[]; state : object; parentId ?: string; transform ?: { position : { x : number; y : number; z : number }; rotation : { x : number; y : number; z : number }; scaling : { x : number; y : number; z : number }; }; } ``` | Field | Type | Description | |-------|------|-------------| | `id` | `string` | Entity ID at save time (remapped on load) | | `type` | `string` | Entity definition type name | | `name` | `string?` | Optional human-readable name | | `tags` | `string[]` | Entity tags | | `state` | `object` | Deep clone of `entity.state` | | `parentId` | `string?` | Parent entity ID for hierarchy | | `transform` | `object?` | Position, rotation, scaling from the scene node | ## Saving Game State ```typescript const saveData = saveManager.serialize(); // Store however you like localStorage.setItem('save-slot-1', JSON.stringify(saveData)); ``` ### What Gets Serialized * All entities with their state, tags, names, and hierarchy * Transform data (position, rotation, scaling) for entities attached to scene nodes * Custom data from `onBeforeSerialize` hooks ### What Does NOT Get Serialized * **Entity definitions** -- Referenced by `type` name; must be registered when deserializing * **Behavior internal state** -- Only `entity.state` is captured. Expose behavior-internal data through the entity state if it needs to persist. * **Scene configuration** -- Camera position, lighting, post-processing. The level's own setup handles restoration upon transition. ## Loading Game State ```typescript const json = localStorage.getItem('save-slot-1'); if(json) { const saveData = JSON.parse(json) as SaveData; await saveManager.deserialize(saveData); } ``` ### Deserialization Flow The restore process follows this sequence: 1. **Level transition** -- Transitions to the level named in the save data. This runs the full level lifecycle (teardown of current level, setup of the new one). 2. **Clear level-spawned entities** -- Destroys all entities created by the level's setup, since they are about to be recreated from the save. 3. **Recreate entities** -- Creates each entity by its `type` name with the saved name, tags, and state. Entities receive **new IDs**. 4. **Restore transforms** -- Applies saved position, rotation, and scaling to entities that have scene nodes. 5. **Restore hierarchy** -- Re-establishes parent-child relationships using the old-to-new ID mapping. 6. **Run afterDeserialize hooks** -- Calls all registered hooks with the `custom` data from the save. ## ID Remapping Entities always receive fresh IDs when recreated. The deserializer maintains an internal old-to-new ID mapping to restore parent-child relationships correctly. If your game logic stores entity IDs externally (e.g., "the player entity is X"), use the `onAfterDeserialize` hook to update those references: ```typescript let playerEntityId : string; saveManager.onAfterDeserialize((custom) => { // Your game tracks the player entity ID in custom data playerEntityId = custom.playerEntityId as string; }); ``` ::: warning Old entity IDs are not valid after deserialization. Any external references to entity IDs must be updated through `onAfterDeserialize` hooks. ::: ## Extension Hooks Hooks let you save and restore custom data that lives outside the entity system -- scores, quest progress, UI state, or anything else your game tracks independently. ### onBeforeSerialize Register a callback that returns custom data to include in the save: ```typescript saveManager.onBeforeSerialize(() => { return { score: gameState.score, questLog: gameState.quests.getCompletedIds(), playTime: gameState.totalPlayTime, }; }); ``` Multiple hooks can be registered. Their returned objects are merged (shallow) into the `custom` field. If two hooks return the same key, the later one wins. ### onAfterDeserialize Register a callback to process custom data when loading: ```typescript saveManager.onAfterDeserialize((custom) => { gameState.score = custom.score as number; gameState.quests.restoreCompleted(custom.questLog as string[]); gameState.totalPlayTime = custom.playTime as number; }); ``` Each registered hook receives the full `custom` object from the save data. ## Full Example ```typescript const engine = await createGameEngine({ canvas }); const saveManager = engine.managers.saveManager; // --- Register hooks --- saveManager.onBeforeSerialize(() => { return { score: currentScore, difficulty: currentDifficulty, }; }); saveManager.onAfterDeserialize((custom) => { currentScore = custom.score as number; currentDifficulty = custom.difficulty as string; updateScoreDisplay(currentScore); }); // --- Save --- function saveGame() : void { const data = saveManager.serialize(); localStorage.setItem('save-slot-1', JSON.stringify(data)); } // --- Load --- async function loadGame() : Promise { const json = localStorage.getItem('save-slot-1'); if(!json) { console.warn('No save data found'); return; } await saveManager.deserialize(JSON.parse(json) as SaveData); } ``` ## Multiple Save Slots The `SaveManager` is slot-agnostic. Implement save slots in your storage layer: ```typescript function saveToSlot(slot : number) : void { const data = saveManager.serialize(); localStorage.setItem(`save-slot-${ slot }`, JSON.stringify(data)); } async function loadFromSlot(slot : number) : Promise { const json = localStorage.getItem(`save-slot-${ slot }`); if(json) { await saveManager.deserialize(JSON.parse(json) as SaveData); } } function listSaveSlots() : number[] { const slots : number[] = []; for(let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if(key?.startsWith('save-slot-')) { slots.push(parseInt(key.replace('save-slot-', ''), 10)); } } return slots.sort(); } ``` ## Storage Considerations The save system is deliberately storage-agnostic. It produces and consumes plain JavaScript objects. You can: * Save to `localStorage` or `sessionStorage` * Save to IndexedDB for larger data * Send to a server API for cloud saves * Write to files in Electron/Tauri apps * Copy to clipboard during development ## Versioning The `version` field in `SaveData` is currently `1`. Future versions of SAGE may increment this to support schema migrations. When loading saves, check the version if your game needs to handle saves from older versions: ```typescript async function loadGame() : Promise { const json = localStorage.getItem('save'); if(!json) { return; } const data = JSON.parse(json) as SaveData; if(data.version !== 1) { console.error(`Unsupported save version: ${ data.version }`); return; } await saveManager.deserialize(data); } ``` ## Teardown The `SaveManager` implements `Disposable`. Calling `gameEngine.$teardown()` automatically clears all registered hooks. --- --- url: /api/timer.md description: >- API reference for SAGE's GameTimer -- pause-aware delay, interval, cooldown, and cancelAll for game-time scheduling. --- # Timer The `GameTimer` provides pause-aware timing primitives for game logic. It offers one-shot delays, repeating intervals, and pollable cooldowns -- all operating in game-time milliseconds derived from the engine's delta time. When the game is paused, all timers freeze automatically. ## Accessing the Timer ```typescript const engine = await createGameEngine({ canvas }); const timer = engine.timer; ``` ## API Reference | Method | Signature | Returns | Description | |--------|-----------|---------|-------------| | `delay` | `(ms : number, callback : () => void)` | `() => void` | One-shot timer; returns a cancel function | | `interval` | `(ms : number, callback : () => void)` | `() => void` | Repeating timer; returns a cancel function | | `cooldown` | `(ms : number)` | `CooldownHandle` | Pollable cooldown handle | | `cancelAll` | `()` | `void` | Cancel all active delays, intervals, and cooldowns | ## delay Fires a callback once after `ms` milliseconds of game time. Returns a cancel function. ```typescript showHitIndicator(); const cancel = engine.timer.delay(200, () => { hideHitIndicator(); }); // Changed your mind? Cancel it. cancel(); ``` The `ms` parameter must be >= 0. Passing a negative value throws a `RangeError`. ## interval Fires a callback repeatedly every `ms` milliseconds of game time. Returns a cancel function. ```typescript // Regenerate 1 HP every 2 seconds const cancel = engine.timer.interval(2000, () => { player.state.hp = Math.min(player.state.hp + 1, player.state.maxHp); }); // Stop regeneration cancel(); ``` The `ms` parameter must be > 0 (strictly positive). Passing zero or a negative value throws a `RangeError`. ::: warning An interval of `0` is not allowed because it would fire infinitely within a single frame. Use `delay(0, ...)` for a next-frame callback instead. ::: ## cooldown Creates a pollable cooldown handle. Starts in the `ready` state. After calling `reset()`, the cooldown becomes not-ready until `ms` milliseconds of game time have elapsed. ```typescript const fireCooldown = engine.timer.cooldown(500); function tryShoot() : void { if(fireCooldown.ready) { shootProjectile(); fireCooldown.reset(); // Starts the 500ms cooldown } } ``` ### CooldownHandle ```typescript interface CooldownHandle { readonly ready : boolean; reset() : void; } ``` | Property / Method | Type | Description | |-------------------|------|-------------| | `ready` | `boolean` | `true` when the cooldown has elapsed (or has never been reset) | | `reset()` | `() => void` | Start (or restart) the cooldown timer | Cooldowns are passive -- they do not fire callbacks. Check `ready` whenever you need to gate an action. This makes them ideal for ability cooldowns, rate-limiting player actions, or any "can I do this yet?" check. ## cancelAll Clears all active delays, intervals, and cooldowns: ```typescript engine.timer.cancelAll(); ``` This is called automatically during level transitions, so you do not need to manually clean up timers when switching levels. Any `CooldownHandle` references you are still holding will report `ready: true` after `cancelAll()` is called. ## Game Time vs. Wall Time All durations are measured in **game-time milliseconds**. The timer advances by the engine's delta time each frame via an internal `tick(dtMs)` call. Key consequences: | Behavior | Detail | |----------|--------| | **Pausing freezes all timers** | When the game is paused, `tick()` is not called -- delays, intervals, and cooldowns all freeze | | **Frame rate affects granularity** | A 500ms delay fires on the first frame where accumulated time >= 500ms. At 60fps (~16.7ms/frame), that is within one frame of accuracy. | | **No built-in time scale** | There is no time multiplier. Slow-motion or fast-forward would need to be implemented at the engine loop level. | ## Usage Patterns ### Timed Power-Up ```typescript function activateShield(entity : GameEntity) : void { entity.state.shielded = true; engine.timer.delay(5000, () => { entity.state.shielded = false; }); } ``` ### Spawn Wave Timer ```typescript let wave = 0; const cancelWaves = engine.timer.interval(10000, () => { wave++; spawnEnemyWave(wave); if(wave >= maxWaves) { cancelWaves(); } }); ``` ### Rate-Limited Action ```typescript const dashCooldown = engine.timer.cooldown(3000); engine.subscribeAction('dash', () => { if(dashCooldown.ready) { performDash(); dashCooldown.reset(); } }); ``` --- --- url: /integration/vue.md description: >- Integrate the SAGE game engine with Vue 3 using the @skewedaspect/sage-vue package. Covers the SageCanvas component and all composables for building reactive game UI. --- # Vue 3 Integration The `@skewedaspect/sage-vue` package provides a canvas wrapper component and a set of Vue 3 composables for building reactive game UI on top of the SAGE engine. Entity state, input actions, events, and timers are all exposed as Vue refs with automatic lifecycle cleanup. ## Installation ```bash npm install @skewedaspect/sage-vue ``` `@skewedaspect/sage-vue` has peer dependencies on `vue` (3.x) and `@skewedaspect/sage`. ## SageCanvas `SageCanvas` is the root component that creates (or accepts) a SAGE engine instance and renders the WebGL canvas. All composables described below must be used inside a component that is a descendant of ``. ### Props | Prop | Type | Default | Description | |------|------|---------|-------------| | `entityDefinitions` | `GameEntityDefinition[]` | `[]` | Entity definitions to register with the engine on creation. | | `options` | `SageOptions` | `{}` | Engine configuration passed to `createGameEngine()`. | | `engine` | `GameEngine` | --- | Optional pre-created engine instance. When provided, the component will not create or dispose the engine itself. | ### Events | Event | Payload | Description | |-------|---------|-------------| | `engine-ready` | `GameEngine` | Fired once the engine has initialized and is ready to use. | | `engine-error` | `unknown` | Fired if engine creation fails. | ### Slot Props The default slot receives scoped props for rendering loading and error states: | Slot Prop | Type | Description | |-----------|------|-------------| | `loading` | `boolean` | `true` until the engine finishes initializing. | | `error` | `unknown` | The error object if initialization failed, otherwise `null`. | | `engine` | `GameEngine \| null` | The engine instance once ready. | ### Basic Usage ```vue ``` ### External Engine If you need to create the engine yourself (for example, to configure it before mounting or to share it across multiple components), pass a pre-created `GameEngine` instance via the `engine` prop. When you do this, `SageCanvas` will **not** create or dispose the engine -- you own its lifecycle. ```vue ``` ::: warning When using the `engine` prop, `SageCanvas` will not call `stop()` or `dispose()` on unmount. You are responsible for cleaning up the engine when it is no longer needed. ::: ### Overlay Behavior `SageCanvas` renders a `.sage-overlay` div on top of the canvas that holds slot content. The overlay itself has `pointer-events: none` so clicks pass through to the canvas, but all child elements inside the overlay automatically get `pointer-events: auto` restored. This means your HUD buttons, menus, and other UI elements are interactive by default without any extra CSS. ## useSageEngine Returns the raw engine reference and a computed event bus. This is the foundation all other composables build on. ```typescript import { useSageEngine } from '@skewedaspect/sage-vue'; const { engine, eventBus } = useSageEngine(); // engine is ShallowRef // eventBus is ComputedRef ``` The engine ref starts as `null` and is populated once the canvas mounts and the engine initializes. **Signature:** ```typescript function useSageEngine() : { engine : ShallowRef; eventBus : ComputedRef; } ``` ::: warning Throws an error if called outside of a `` component tree. Always ensure your component is a descendant of ``. ::: ## useSageEvent Subscribes to engine events by pattern with automatic cleanup on unmount. ```typescript import { useSageEvent } from '@skewedaspect/sage-vue'; // Listen for all entity events using a wildcard useSageEvent('entity:*', (event) => { console.log(`Entity event: ${ event.type }`, event.payload); }); // RegExp patterns work too useSageEvent(/^player:/, (event) => { handlePlayerEvent(event); }); ``` The subscription is created immediately if the engine is available, or deferred via a `watch` until it becomes available. When the engine ref changes (e.g., hot reload), the old subscription is torn down and a new one is created. The subscription is automatically removed when the component unmounts. **Signature:** ```typescript function useSageEvent(pattern : string | RegExp, handler : EventHandler) : void ``` ## useSageAction Returns a reactive ref bound to an input action's current value. The ref updates automatically whenever the engine publishes an `action:` event. ```typescript import { useSageAction } from '@skewedaspect/sage-vue'; // Track whether the "jump" action is active const jump = useSageAction('jump'); // Track an analog stick axis const moveX = useSageAction('moveX'); ``` The ref is `Ref` -- `boolean` for button-style actions (pressed/released), `number` for axis-style actions (analog stick deflection, trigger pressure). **Signature:** ```typescript function useSageAction(actionName : string) : Ref ``` ### Template Example: Action Indicator ```vue ``` ## useSageState Returns a reactive ref synced to an entity's state, updated via `entity:state-changed` events. Optionally tracks a single property instead of the entire state object. ```typescript import { useSageState } from '@skewedaspect/sage-vue'; // Track entire entity state const playerState = useSageState('player-entity-id'); // Track a single property const hp = useSageState('player-entity-id', 'hp'); ``` The composable reads the initial state from the entity (if it exists at call time) and then listens for `entity:state-changed` events to keep the ref in sync. Only events matching the specified `entityId` trigger updates. Cleanup is automatic on unmount. **Signature:** ```typescript function useSageState(entityId : string, key ?: string) : Ref ``` ### Template Example: Game HUD ```vue ``` ## useSageTimer Wraps the engine's `GameTimer` with automatic lifecycle management. All active delays and intervals created through this composable are cancelled when the component unmounts. ```typescript import { useSageTimer } from '@skewedaspect/sage-vue'; const { delay, interval, cooldown } = useSageTimer(); // One-shot delay -- cancelled automatically on unmount delay(3000, () => { showTutorialHint(); }); // Repeating interval -- also auto-cancelled const cancelRegen = interval(1000, () => { regenerateHealth(); }); // Manual cancel is still available cancelRegen(); // Cooldowns are passive pollable objects const attackCooldown = cooldown(500); if(attackCooldown.ready) { attack(); attackCooldown.reset(); } ``` If the engine is not yet available when a timer function is called, `delay` and `interval` return no-op cancel functions and `cooldown` returns a handle that is permanently not-ready. These are safe to call unconditionally. **Signature:** ```typescript function useSageTimer() : { delay : (ms : number, callback : () => void) => () => void; interval : (ms : number, callback : () => void) => () => void; cooldown : (ms : number) => CooldownHandle; } ``` ### Template Example: Ability Bar with Cooldowns ```vue ``` ## Quick Reference | Composable | Returns | Auto-cleanup | |---|---|---| | `useSageEngine()` | `{ engine, eventBus }` | No (refs only) | | `useSageEvent(pattern, handler)` | `void` | Yes -- unsubscribes on unmount | | `useSageAction(actionName)` | `Ref` | Yes -- unsubscribes on unmount | | `useSageState(entityId, key?)` | `Ref` | Yes -- unsubscribes on unmount | | `useSageTimer()` | `{ delay, interval, cooldown }` | Yes -- cancels delays and intervals on unmount | ::: tip All composables except `useSageEngine()` handle the "engine not ready yet" case gracefully. You can call them in `setup()` without waiting for the `engine-ready` event -- they will wire up their subscriptions as soon as the engine becomes available. ::: --- --- url: /getting-started.md description: >- An overview of SAGE, the SkewedAspect Game Engine -- a TypeScript game engine built on BabylonJS with entity composition, event-driven architecture, and Havok physics. --- # What is SAGE? SAGE (SkewedAspect Game Engine) is a TypeScript game engine built on [BabylonJS](https://www.babylonjs.com/) and [Havok Physics](https://www.havok.com/). It provides a structured, event-driven architecture for building 3D games in the browser without forcing you into a monolithic framework. You get entity composition, input abstraction, level management, and physics integration out of the box -- and full access to the underlying BabylonJS engine when you need to drop down a level. SAGE is code-first. There is no visual editor. You define entities, behaviors, levels, and input bindings in TypeScript (or YAML for levels), and the engine wires everything together. If you prefer writing code over dragging widgets, this is the engine for you. ## Key Features ### Entity-Behavior Composition Build game objects by composing reusable behaviors instead of deep inheritance hierarchies. Entities are defined as blueprints with a type, default state, and a list of behaviors. Create instances at runtime, attach them to scene nodes, query them by name, tag, or type. ### Event-Driven Architecture All communication flows through a central event bus with wildcard pattern matching. Behaviors subscribe to events, entities emit events, and systems stay decoupled. No direct references between components -- just publish and subscribe. ### Havok Physics Full rigid body physics with static and dynamic bodies, forces, impulses, and collision detection. Physics bodies are configured declaratively via scene node metadata or programmatically at runtime. ### YAML Level System Define levels in YAML with scene file references, entity placement, spawn points, lighting overrides, and post-processing configuration (bloom, SSAO, tone mapping). Load GLB scenes exported from Blender and let SAGE process node metadata automatically. ### Input Abstraction Map keyboard, mouse, and gamepad inputs to named actions organized into contexts. Supports analog and digital bindings, edge detection (pressed/released), toggle modes, and context switching for different game states (menu vs. gameplay). ### Asset Management Centralized asset loading with caching, reference counting, GLB fragment extraction, and mesh instancing. Load what you need, reuse what you can, and let the engine handle cleanup. ### Vue 3 Integration First-class Vue composables via `@skewedaspect/sage-vue`. The `SageCanvas` component handles engine lifecycle, canvas sizing, and provides the engine instance to child components through Vue's dependency injection. ### Save and Serialize Snapshot and restore full game state with a storage-agnostic serialization system. Supports ID remapping, custom serialization hooks, and versioned save data. ### Additional Systems * **Scene Transitions** -- orchestrated level-to-level transitions with preloading and lifecycle hooks * **Post-Processing** -- declarative post-processing via level YAML * **Entity Hierarchy** -- parent-child entity relationships with cascading lifecycle * **Entity Inheritance** -- entity definitions can extend other definitions with deep merge * **Object Pooling** -- opt-in per-entity-type pooling for high-frequency create/destroy patterns * **State Machine** -- generic FSM utility plus entity behavior wrapper * **Game Timer** -- pause-aware delay, interval, and cooldown primitives * **Raycasting** -- entity-aware wrappers over BabylonJS scene picking ## Architecture at a Glance SAGE follows a layered architecture with strict separation of concerns: ``` +------------------------------------------------------------------+ | GameEngine (orchestrator) | +----------------+-----------------+-------------------------------+ | Event Bus | Entity System | Scene Engine | | (pub/sub) | (composition) | (BabylonJS wrapper) | +----------------+-----------------+-------------------------------+ | Binding | Game Manager | Level Manager | | Manager | (game loop) | (level lifecycle) | +----------------+-----------------+-------------------------------+ | Asset Manager | Save Manager | Input Manager | +----------------+-----------------+-------------------------------+ ``` The `createGameEngine()` factory wires everything together: it initializes BabylonJS, Havok physics, the event bus, all managers, and returns a `GameEngine` instance ready to use. Data flows through events. A keypress becomes an input event, which triggers a binding action, which a behavior processes to update entity state, which the game loop picks up to render the next frame. For a deeper dive, see the [Architecture](/concepts/architecture) page. ## Rendering Defaults SAGE enables **anti-aliasing** and **HiDPI scaling** (`adaptToDeviceRatio`) by default. Without HiDPI scaling, rendering on Retina/4K displays looks blurry because the engine renders at CSS pixel resolution and upscales. These defaults produce crisp, clean output on all displays. To override them (e.g. for performance on very high-DPI screens): ```typescript const engine = await createGameEngine(canvas, entities, { renderOptions: { antialias: false, // disable anti-aliasing adaptToDeviceRatio: false, // render at CSS resolution, not native }, }); ``` You can also force a specific rendering backend: ```typescript renderOptions: { engine: 'webgpu', // 'webgl', 'webgpu', or 'auto' (default) } ``` ## What SAGE Is Good For * **3D browser games** built with BabylonJS and Havok physics * **Code-first workflows** where you want full control over game logic in TypeScript * **Composition-based designs** where entities are assembled from reusable behaviors * **Projects that need structure** but not a heavyweight framework -- SAGE gives you patterns without locking you in * **Vue-based game UIs** where you want reactive bindings to game state ## What SAGE Is Not * **Not a general-purpose framework.** SAGE is specifically for 3D games rendered with BabylonJS. It does not abstract over multiple renderers or support 2D-only workflows. * **Not production-stable yet.** SAGE is pre-1.0 (currently v0.8.0). The API is solidifying but breaking changes can still happen between minor versions. Use it for new projects, prototypes, and game jams -- not for shipping a commercial title tomorrow. * **Not a visual editor.** There is no drag-and-drop scene editor. You write code. Level geometry comes from Blender (or any tool that exports GLB), but game logic lives in TypeScript. * **Not batteries-included for networking.** SAGE handles local game state. Multiplayer, matchmaking, and netcode are outside its scope. ## Where to Go Next * [Installation](/getting-started/installation) -- get SAGE installed and a spinning cube on screen * [Architecture](/concepts/architecture) -- understand how the engine is structured * [Entities](/concepts/entities) -- learn the entity-behavior composition model * [Events](/concepts/events) -- master the event bus * [Hello Cube Guide](/guides/hello-cube) -- full walkthrough of the simplest SAGE application