Skip to content

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;

Typed by the entity registry ​

LevelManager, Level, GameLevel, LevelContext, and LevelConstructor carry the same Registry generic parameter createGameEngine does, defaulting to the widest entity registry, NodeEntityRegistry (see Entities). A game that types its own registry with satisfies NodeEntityRegistry and types its levels extends GameLevel<typeof entityClasses> spawns typed inside those levels, the same way it spawns typed on the engine:

typescript
import { GameLevel, HavokPhysicsBodyBehavior, NamedBehavior, defineEntity } from '@skewedaspect/sage';
import type { NodeEntityRegistry } from '@skewedaspect/sage';

export const CrateEntity = defineEntity({
    type: 'CrateEntity',
    tags: [ 'prop', 'pushable' ],
    behaviors: [ HavokPhysicsBodyBehavior, NamedBehavior ] as const,
});

const entityClasses = { CrateEntity } satisfies NodeEntityRegistry;

class ArenaLevel extends GameLevel<typeof entityClasses>
{
    // this.simulation.spawn(...) here is typed against entityClasses, not against a bare string name.
}

A registry typed only as NodeEntityRegistry, rather than inferred from an actual object with literal keys, leaves spawn a string name over a plain state bag, on a level the same as on the engine. registerAllPropertyHandlers and the six built-in register*Handler functions each take what registers a handler and nothing more -- Pick<LevelManager, 'registerPropertyHandler'> -- so any level manager satisfies them whatever registry it carries.

Properties ​

PropertyTypeDescription
currentLevelLevel | nullThe currently active level
loadingLevelLevel | nullThe level currently inside load(), while a transition builds the incoming level's scene
propertyHandlersMap<string, PropertyHandler>Registered scene node property handlers

Configuration API ​

MethodSignatureDescription
registerLevelConfig(config : LevelConfig) => voidRegister a level configuration for later instantiation
unregisterLevelConfig(name : string) => Promise<void>Unload the level registered under the name if it is loaded, then forget its configuration
registerLevelClass(name : string, levelClass : LevelConstructor) => voidRegister a custom Level class referenced by configs
getLevelConfig(name : string) => LevelConfig | nullLook up a registered config

unregisterLevelConfig(name) unloads the level registered under that name if it is loaded, the same as unloadLevel: it disposes the level, forgets it, and, if it was the current level, clears currentLevel and unbinds the engine. It then forgets the configuration, so getLevelConfig(name) answers null and loadLevel(name) throws a "not registered" error. A name nothing is registered under changes nothing.

Loading API ​

MethodSignatureDescription
loadLevel(name, options?)(string, LevelLoadOptions?) => Promise<Level>Load a registered level by name
loadLevel(config, options?)(LevelConfig, LevelLoadOptions?) => Promise<Level>Load from a config (auto-registers it)
activateLevel(name)(string) => Promise<Level>Load (if needed), set as current level, and fire the lifecycle hooks
activateLevel(config)(LevelConfig) => Promise<Level>Load from config, set as current, and fire the lifecycle hooks
getLevel(name : string) => Level | nullGet a loaded level by name
unloadLevel(name : string) => Promise<void>Dispose and remove a loaded level
unloadCurrentLevel() => Promise<void>Unload the active level

Transition API ​

MethodSignatureDescription
transition(levelName : string, options? : TransitionOptions) => Promise<void>Full level-to-level transition

Property Handler API ​

MethodSignatureDescription
registerPropertyHandler(property : string, handler : PropertyHandler) => voidRegister a handler for scene node metadata
unregisterPropertyHandler(property : string) => voidRemove a property handler
clearAllPropertyHandlers() => voidRemove 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 ​

MethodDescription
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)
createScene()Protected. Builds an empty scene through the scene engine and makes it the level's scene

Lifecycle Hooks ​

HookWhen CalledDescription
onActivate()Level becomes current, through activateLevel() or a transitionStart music, show UI, unpause physics
onDeactivate()Level is replaced as current, through activateLevel() or a transitionSave state, stop sounds, fade out

Both hooks are optional and async. Activating the level that is already current fires neither: it rebinds the engine to that level's simulation and nothing else.

Creating a Custom Level ​

Extend Level and implement buildScene. A level reaches everything it has, the scene engine, the asset manager, and the audio manager where there is one, through this.context, which the manager hands it at construction; there is no engine reference on it. A level that spawns entities binds a simulation to its scene with the base class's own rebuildSimulation once the scene exists; every NodeEntity builds its node in the simulation's own scene, so nothing can spawn before that call.

A subclass creates its scene with the protected createScene(), which builds an empty scene through the scene engine in this.context and makes it the level's scene at once. If buildScene throws after calling it, the failed load disposes the level, which releases the scene:

typescript
import { Level } from '@skewedaspect/sage';
import { Color4, FreeCamera, Scene, Vector3 } from '@babylonjs/core';

class SpaceStation extends Level
{
    protected async buildScene() : Promise<Scene>
    {
        const { sceneEngine } = this.context;
        const scene = this.createScene();
        this.$emitProgress(10, 'Creating scene...');

        this.rebuildSimulation(scene, stationMeshSource);

        scene.clearColor = new Color4(0, 0, 0.05, 1);
        await sceneEngine.enablePhysics(scene, new Vector3(0, -1.62, 0));
        this.$emitProgress(20, 'Physics configured...');

        const camera = new FreeCamera('camera', new Vector3(0, 2, -5), scene);
        camera.setTarget(Vector3.Zero());
        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<void>
    {
        // Start ambient sound, show HUD, etc.
    }

    async onDeactivate() : Promise<void>
    {
        // 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;
    meshSource ?: MeshLoader;
    fixedStepDuration ?: number;
    scene ?: string | SceneConfig;
    class ?: string;
    config ?: Record<string, unknown>;
    spawns ?: Record<string, SpawnDefinition>;
    entities ?: Record<string, EntityDefinition>;
    physics ?: boolean | { gravity ?: Vec3Config };
    preload ?: string[];
    environment ?: EnvironmentConfig;
    cameras ?: Record<string, CameraDefinition>;
    lights ?: Record<string, LightDefinition>;
    sounds ?: Record<string, LevelSoundConfig>;
    postProcessing ?: PostProcessingConfig;
    clustering ?: ClusteringConfig;
    outlines ?: Record<string, OutlineLayerConfig>;
}

meshSource is a function, so a config that carries one is built in TypeScript, or has it attached after the YAML is parsed. A config with no meshSource builds its simulation without one, and every NodeEntity that level spawns then gets a TransformNode and no mesh. Nothing warns about it: the level loads, the spawns succeed, and nothing is drawn.

LevelConfig Properties ​

PropertyTypeRequiredDescription
namestringYesUnique level identifier. Used as the key for transition() and loadLevel() calls.
meshSourceMeshLoaderNoBuilds a NodeEntity's mesh from its name and state, into this level's scene. Left unset, every entity this level spawns gets a node with no mesh, and nothing warns. See Authoring.
fixedStepDurationnumberNoSeconds per fixed step for this level's simulation. Left unset, the simulation takes the engine's fixedStepDuration option (default 1 / 60). See The fixed step.
scenestring | SceneConfigNoPath to the scene file (.glb, .gltf, .babylon), or { path, rightHanded? }. The file is imported into the scene and its nodes are processed for metadata.
classstringNoName of a registered custom Level class. If omitted, GameLevel is used.
configRecord<string, unknown>NoArbitrary configuration data passed to the Level instance. See Custom Config Data.
spawnsRecord<string, SpawnDefinition>NoSpawn point definitions. Keys match the spawn metadata values on scene nodes.
entitiesRecord<string, EntityDefinition>NoEntity marker definitions. Keys match the entity metadata values on scene nodes.
physicsboolean | { gravity }NoEnable physics. true uses default gravity (0, -9.81, 0). An object allows custom gravity.
preloadstring[]NoAsset paths to cache before building the scene.
environmentEnvironmentConfigNoSkybox and IBL (image-based lighting) configuration. See Environment Configuration.
camerasRecord<string, CameraDefinition>NoCamera configuration. Keys match Blender camera names (override) or define new cameras (create). See Camera Configuration.
lightsRecord<string, LightDefinition>NoLight configuration. Keys match Blender light names (override) or define new lights (create). See Light Configuration.
soundsRecord<string, LevelSoundConfig>NoLevel-scoped sounds tied to the level lifecycle. See Level Sounds.
postProcessingPostProcessingConfigNoDeclarative post-processing effects applied after the scene is built.
clusteringClusteringConfigNoClustered forward+ lighting for point and spot lights. See Clustered Lighting.
outlinesRecord<string, OutlineLayerConfig>NoNamed selection outline layers. See Outline Layers.

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<Scene>
    {
        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;
    }

    private _spawnExtraTraps() : void { /* level-specific spawning */ }
    private _spawnEnemy() : void { /* level-specific spawning */ }
}

GameLevel itself ignores the config field beyond storing it, which makes it a clean extension point for your own level classes without modifying the engine.

SpawnDefinition ​

typescript
interface SpawnDefinition
{
    entity : string;
    displayName ?: string;
    tags ?: string[];
    config ?: Record<string, unknown>;
    debugCollider ?: ColliderDebugConfig;
}
PropertyTypeRequiredDescription
entitystringYesThe registry name to spawn.
displayNamestringNoA per-instance label, written into the entity's displayName state field, never into its own name.
tagsstring[]NoThe tags the spawned entity starts with.
configRecord<string, unknown>NoPer-spawn state, layered over the class's defaults.
debugColliderColliderDebugConfigNoWritten into the entity's own debugCollider state field.

When a spawn point is processed, SAGE spawns an entity of the given class through the level's simulation, with the spawn node's position and rotation written into its state. The spawn node's scale is not carried.

yaml
spawns:
    player_start:
        entity: PlayerEntity
        displayName: Hero
        tags: [ character, controllable ]
        config:
            health: 100
            maxHealth: 100
            speed: 8

The spawn node (typically a Blender Empty) is disposed after the entity is created. The entity's mesh comes from the level's meshSource, keyed by the entity's name.

EntityDefinition ​

typescript
type EntityDefinition = SpawnDefinition;

EntityDefinition and SpawnDefinition are one type: an entity marker's definition just adopts the marked node instead of building one.

PropertyTypeRequiredDescription
entitystringYesThe registry name to spawn, adopting the marked node.
displayNamestringNoA per-instance label, written into displayName.
tagsstring[]NoThe tags the entity starts with.
configRecord<string, unknown>NoPer-spawn state, layered over the class's defaults.
debugColliderColliderDebugConfigNoWritten into the entity's own debugCollider state field.

Entity definitions describe entities whose node already exists in the scene file. Unlike spawns, which build a new node at an empty, the entities section makes the marked node the entity's own:

yaml
entities:
    door:
        entity: DoorEntity
        config:
            locked: true
            health: 200
    crate:
        entity: CrateEntity
        tags: [ pushable ]
        config:
            weight: 50

In Blender, you set a custom property entity = "door" on the mesh. When the level loads, SAGE spawns a DoorEntity that adopts that node: no TransformNode is built, no mesh is loaded, and the entity's nodeName state field records the node's name. See Authoring.

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 and cached before buildScene()
# runs, six at a time. 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 and rotation.
# -----------------------------------------------
spawns:
    # A Blender Empty with custom property spawn="player_start"
    player_start:
        entity: PlayerEntity
        displayName: Hero
        tags: [ character ]
        config:
            health: 100
            maxHealth: 100

    # A Blender Empty with custom property spawn="enemy_patrol"
    enemy_patrol:
        entity: EnemyEntity
        config:
            patrolRadius: 10
            aggroRange: 15

    # Spawn with no config override (uses the class's defaults)
    item_chest:
        entity: ChestEntity

# -----------------------------------------------
# Entity markers (optional)
# Keys match the 'entity' metadata values on scene
# meshes. Unlike spawns, these adopt the EXISTING
# scene node as the entity's own (no node is built,
# no mesh is loaded).
# -----------------------------------------------
entities:
    door:
        entity: DoorEntity
        config:
            locked: true
    crate:
        entity: CrateEntity
        tags: [ pushable ]
        config:
            weight: 50

# -----------------------------------------------
# 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 and cached by the AssetManager, six at a time, so a preload costs about as long as its slowest asset rather than the sum of them all. Later calls to assetManager.load() with the same path return instantly from the cache. Every path is attempted; if any of them fail, the preload throws an AggregateError naming every failed path.

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.

A skybox and IBL are 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 ​

PropertyTypeDefaultDescription
iblstringPath 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.
iblResolutionnumber256Resolution of the IBL cubemap in pixels. Higher values produce sharper reflections but use more memory.
skyboxstringPath to the skybox texture. File format determines behavior (see below).
skyboxSizenumber1000Diameter of the skybox in world units. For HDR/env files this is passed to createDefaultSkybox(). For JPG/PNG files this is the sphere diameter.
rotationnumberY-axis rotation in radians applied to scene.environmentTexture. Only the HDR/env modes set that texture, so rotation is a no-op in the JPG/PNG sphere mode.

File Format Behavior ​

The file extension of the skybox and ibl paths determines how SAGE creates the texture:

ExtensionTexture TypeNotes
.hdrHDRCubeTextureUncompressed HDR data. Highest quality for reflections and skyboxes. Larger file sizes.
.envCubeTexture (pre-filtered)BabylonJS pre-filtered format. Smaller files, faster loading. Convert an HDR in the BabylonJS sandbox, or call CreateEnvTextureAsync from @babylonjs/core/Misc/environmentTextureTools yourself.
.jpg, .pngTexture on a sphereCreates a StandardMaterial sphere with the image as emissive texture. No IBL data, since 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.

With ibl alone, the scene gets the PBR reflection source and no visible skybox. This is useful when you have your own sky geometry, or when the scene is indoors.

yaml
environment:
    ibl: /assets/textures/studio_small.hdr
    iblResolution: 128

With skybox alone, in HDR or env format, that one file serves as both the visible background and the IBL reflection source. This is the simplest setup when your skybox texture is already in that format.

yaml
environment:
    skybox: /assets/textures/sky.hdr

With skybox alone, in JPG or PNG, the scene gets a visible sphere background and no IBL, so 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

With both ibl and skybox set separately, recommended for quality, a low-resolution HDR gives fast, accurate reflections while a high-resolution image gives the visible background, avoiding 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' | 'geospatial';
    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;

    // geospatial
    planetRadius ?: number;
    yaw ?: number;
    pitch ?: number;
    center ?: Vec3Config;
    checkCollisions ?: boolean;
    radiusMin ?: number;
    radiusMax ?: number;
    pitchMin ?: number;
    pitchMax ?: number;
    yawMin ?: number;
    yawMax ?: number;
}

A geospatial camera orbits a spherical planet. planetRadius is required to create one; the rest tune its orbit and limits. A geospatial entry naming no planetRadius is logged and skipped rather than falling back to a plain free camera.

Override vs. Create ​

A camera key that matches a camera name already in the scene, imported from Blender (Camera, MainCamera, and the like), is override mode: that camera already exists, so SAGE applies the YAML properties to it and type is omitted. A key that names no scene camera is create mode instead, and there type is required; SAGE creates a fresh camera of that type. A key that neither matches a scene camera nor carries a type is logged and skipped.

Properties ​

PropertyTypeDefaultDescription
type'free' | 'arcRotate' | 'universal' | 'geospatial'Camera type. Required for create mode, omit for override mode.
activebooleanfalseMark this camera as the active camera for the level.
attachControlbooleantrueAttach 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.
positionVec3Config(0, 0, 0)Camera position in world space. Used in create mode for free and universal cameras.
targetVec3Config(0, 0, 0)The point the camera looks at and orbits. Used by arcRotate cameras (both create and override).
rotationVec3ConfigCamera rotation in radians. Used by free and universal cameras (both create and override).
fovnumberField of view in radians. Applies to all camera types.
minZnumberNear clipping plane distance.
maxZnumberFar clipping plane distance.
speednumberMovement speed. Applies to free and universal cameras only.
alphanumberMath.PI / 2Horizontal rotation angle in radians. ArcRotate only.
betanumberMath.PI / 3Vertical rotation angle in radians. ArcRotate only.
radiusnumber10Distance from the orbited point. ArcRotate only.
lowerRadiusLimitnumberMinimum zoom distance. ArcRotate only.
upperRadiusLimitnumberMaximum zoom distance. ArcRotate only.
lowerBetaLimitnumberMinimum vertical angle (prevents looking from below). ArcRotate only.
upperBetaLimitnumberMaximum vertical angle (prevents looking from above). ArcRotate only.
wheelPrecisionnumberMouse 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 last camera with active: true becomes active. Each entry marked active replaces the one before it, so with several marked, the one furthest down the record wins.
  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' | 'rectarea';
    intensity ?: number;
    diffuse ?: ColorConfig;
    specular ?: ColorConfig;
    position ?: Vec3Config;
    direction ?: Vec3Config;
    groundColor ?: ColorConfig;
    angle ?: number;
    exponent ?: number;
    width ?: number;
    height ?: number;
}

Override vs. Create ​

Lights follow the same pattern as cameras. A key that matches a light name from Blender is override mode: SAGE applies the YAML properties (intensity, color, direction) to the existing light, and type is omitted. A new name is create mode, and there type is required.

TIP

The glTF light extension BabylonJS reads, KHR_lights_punctual, builds directional, point, and spot lights and nothing else, so no scene file imports a hemispheric light for a key to override. Ambient hemisphere lighting is always create mode with type: hemispheric, which is the most common use of the lights section.

Properties ​

PropertyTypeDefaultDescription
type'hemispheric' | 'directional' | 'point' | 'spot' | 'rectarea'Light type. Required for create mode, omit for override mode.
intensitynumberLight brightness. Applies to all light types.
diffuseColorConfigDiffuse color as { r, g, b } with values 0.0--1.0.
specularColorConfigSpecular highlight color as { r, g, b } with values 0.0--1.0.
positionVec3Config(0, 0, 0)World position. Used by point and spot lights.
directionVec3Config(0, -1, 0)Light direction vector. Used by hemispheric, directional, and spot lights.
groundColorColorConfigGround reflection color. Hemispheric lights only.
anglenumberMath.PI / 3Cone angle in radians. Spot lights only.
exponentnumber2Falloff exponent controlling how light fades from center to edge of the cone. Spot lights only.
width, heightnumber1The emitting rectangle. Rect area lights only. A rect area light emits along its local -Z; a direction parents it to a pivot rotated to face that way.

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.

Level sounds are declarative:

yaml
sounds:
    background-music:
        url: /assets/audio/exploration.ogg
        channel: music
        loop: true
        autoplay: true
        volume: 0.7

Prerequisites

Level sounds require the AudioManager to be initialized. You must pass audioChannels in your SageOptions when creating the engine:

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

const engine = await createGameEngine(canvas, { /* your entity registry */ }, {
    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 ​

PropertyTypeDefaultDescription
urlstring(required)Path to the audio file (.ogg, .mp3, .wav).
channelstringAudio channel name (e.g., 'music', 'sfx', 'ambient'). Must match a channel name passed in audioChannels. Controls volume grouping.
loopbooleanfalseWhether the sound loops continuously.
autoplaybooleanfalseWhether the sound starts playing immediately when the level finishes loading.
volumenumber1Playback 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 EventSound Behavior
Level loadsSounds are created via AudioManager.createSound(). Sounds with autoplay: true begin playing.
Level deactivatesAll currently playing sounds are paused. SAGE tracks which sounds were playing so only those are resumed later.
Level activatesSounds that were playing before deactivation are resumed. Sounds that were already paused (e.g., not yet triggered) remain paused.
Level unloadsAll 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

Level sounds vs. the sound property handler

The sounds YAML section creates non-spatial audio tied to the level lifecycle, such as background music and ambient loops. The sound property handler, set on Blender meshes, creates spatial audio positioned in 3D space, such as 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
{
    renderer ?: 'pipeline' | 'frameGraph';
    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 };
    volumetric ?: { extinction ?: number; scattering ?: number };
}
FieldMeaning
rendererThe rendering backend: pipeline, BabylonJS's DefaultRenderingPipeline with a separate SSAO pipeline beside it, or frameGraph, a chain of Babylon frame-graph tasks loaded on demand. Defaults to pipeline.
volumetricVolumetric lighting. Recognized by both backends; neither currently wires it up to the scene's lights, so it logs a warning and applies nothing.

Bloom, SSAO, tone mapping, chromatic aberration, grain, vignette, and sharpening are configured the same way under either renderer.

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.

ParameterTypeDefaultDescription
weightnumber0.15Intensity 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.
thresholdnumber0.9Brightness cutoff. Only pixels brighter than this value produce bloom. Lower values bloom more of the scene. Range: 0.0 to 1.0.
scalenumber0.5Resolution scale of the bloom texture. Lower values are cheaper but blurrier. Range: 0.0 to 1.0.
kernelnumber64Size 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.

ParameterTypeDefaultDescription
radiusnumber2.0Sampling radius around each pixel. Larger values darken wider areas but can look unrealistic. Range: 0.5 to 8.0 for most scenes.
samplesnumber8Number of samples per pixel. More samples reduce noise but cost more. Common values: 8, 16, 32.
totalStrengthnumber1.0Overall 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.

ParameterTypeDefaultDescription
operatorstring'hable'The tone mapping algorithm. See below for options.
OperatorBabylonJS MappingDescription
hableTONEMAPPING_STANDARDA filmic curve with gentle highlight rolloff. Good general-purpose choice.
reinhardTONEMAPPING_STANDARDClassic Reinhard mapping. Similar to Hable in BabylonJS's implementation.
hejidawsonTONEMAPPING_STANDARDHejl-Dawson mapping. Also maps to standard in BabylonJS.
photographicTONEMAPPING_STANDARDPhotographic-style mapping. Also maps to standard in BabylonJS.
acesTONEMAPPING_ACESAcademy 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.

ParameterTypeDefaultDescription
amountnumber30Intensity 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.

ParameterTypeDefaultDescription
intensitynumber30Density of the grain noise. The BabylonJS default is 30, which is quite visible. Values of 5--20 produce a subtle texture.
animatedbooleanfalseWhen 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.

ParameterTypeDefaultDescription
weightnumber1.5Intensity 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.
stretchnumber0How 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.

ParameterTypeDefaultDescription
edgenumber0.3Edge sharpening intensity. Higher values make edges more pronounced. Range: 0.0 to 1.0.
colornumber1.0Color 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' and attached to every camera in the scene. SSAO uses a separate SSAO2RenderingPipeline named 'sage-ssao', constructed without a camera list, so it does not attach itself to any camera.

GameLevel applies its postProcessing config itself. A Level subclass that builds its own scene applies one by calling applyPostProcessing(scene, config, logger), the exported dispatcher that picks the frame graph or the pipeline backend from the config's renderer field and reports what it did through the logger it is handed.

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

await applyPostProcessing(scene, config, logger);

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 that into one flat record: the node's own top-level keys with its glTF extras merged onto them, extras winning where both name one. The gltf container itself is left out, since it is the loader's record and never an authored property, so a node with no extras and no top-level keys of its own normalizes to an empty record and the walk skips it.

Two metadata keys are handled directly by GameLevel, not via property handlers.

KeyMarksValue
spawnA spawn pointMust match a key in the config's spawns section
entityAn entity marker, whose node the spawned entity adoptsMust 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,
    registerOcclusionHandler,
    registerSoundHandler,
    registerTriggerHandler,
    registerVisibleHandler,
} from '@skewedaspect/sage';

registerColliderHandler(levelManager);
registerTriggerHandler(levelManager);
// ... etc

Registering is the game's to do, and a scene loaded with nothing registered says so: a level warns for each of SAGE's own six property names it finds on a node with no handler registered for it, naming the node and the property. Whatever else a glb's exporter wrote it leaves alone.

BUILT_IN_PROPERTIES is that list of six, exported as a value, so a game that registers handlers of its own can check what it has covered:

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

for(const property of BUILT_IN_PROPERTIES)
{
    console.log(property, levelManager.propertyHandlers.has(property));
}

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 KeyValue TypeHandlerDescription
spawnstringBuilt-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.
entitystringBuilt-in (GameLevel)Marks a node as an entity. Value must match a key in the YAML entities section. The spawned entity adopts the node as its own.
colliderstringregisterColliderHandlerPhysics collider shape. Values: "box", "sphere", "mesh", "none". Creates a physics aggregate, static unless collider_motion says otherwise. Any other value warns through the handler logger, naming the node and the four values it accepts, and builds nothing.
collider_meshboolean(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.
collider_motionstring(Used by collider)"static", "dynamic", or "kinematic". Defaults to "static"; an unrecognized value warns and falls back to it.
collider_massnumber(Used by collider)Mass of the physics body, in kilograms. Sets the mass and nothing else: it does not decide the motion type. A static body's mass is always 0 regardless of this value.
triggerstringregisterTriggerHandlerTrigger zone name. The mesh is made invisible. Emits trigger:enter and trigger:exit events when other meshes intersect it.
lod_distancesstringregisterLodHandlerComma-separated distances for LOD levels (e.g., "10,25,50"). The mesh itself renders up to the first distance; its child meshes take over in order past each one. A distance left over with no child to fill it hides the mesh beyond that point.
occlusionstringregisterOcclusionHandlerSets the mesh up for BabylonJS's occlusion culling: "optimistic" or "strict" sets the mesh's occlusionType, and BabylonJS then skips the mesh on a frame the previous frame's query found it hidden. Any other value warns and leaves the mesh's occlusion type alone.
visibleboolean | string | numberregisterVisibleHandlerControls mesh visibility. Accepts true/false, "true"/"false", or 1/0. Any other value, including an unrecognized string, leaves the mesh visible.
soundstringregisterSoundHandlerPath to a sound file. Creates a spatial audio source at the node's position. See Sound Properties for additional prefixed properties.

Collider Details ​

The collider handler creates physics aggregates for scene geometry. Its value names the shape, and only these four values work. Anything else warns through the handler logger, naming the node and the four values it accepts, and builds nothing:

ValuePhysics ShapeUse Case
"box"PhysicsShapeType.BOXRectangular objects like walls, crates, platforms
"sphere"PhysicsShapeType.SPHERERound objects like boulders, balls
"mesh"PhysicsShapeType.MESHComplex 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"NoneExplicitly disables collision on a node: it builds nothing, the same as a node carrying no collider at all

box and sphere work on either a mesh or a bare TransformNode, measured from the node's own bounds when it has geometry and from everything hanging under it when it does not. mesh needs real geometry.

A collider is static unless the node says otherwise:

Metadata KeyTypeDefaultDescription
collider_motionstring"static"The same three words HavokPhysicsBodyBehavior's own motion field uses: "static", "dynamic", or "kinematic". An unrecognized value warns and builds a static body.
collider_massnumber1Mass of the physics body, in kilograms. Sets the mass and nothing else: a node carrying mass but no collider_motion is still static. A static body's mass is always 0 regardless of this value.

See the Blender workflow guide for how these are set in Blender.

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:

EventPayload
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. Each distance is the point past which the next child mesh takes over, so the mesh carrying the property is itself the highest detail level:

lod_distances = "10,25,50"

With three child meshes:

  • 0--10 units: The mesh itself (highest detail)
  • 10--25 units: First child mesh (medium detail)
  • 25--50 units: Second child mesh (low detail)
  • 50+ units: Third child mesh (lowest detail)

Nothing is hidden there: every distance has a child to fill it. To stop rendering the mesh in the distance, give it one more distance than it has children. With the same three children, "10,25,50,120" puts the third child beyond 50 and hides the mesh past 120.

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 KeyTypeDefaultDescription
soundstring(required)Path to the audio file
sound_volumenumber1Playback volume, 0.0 to 1.0
sound_loopbooleantrueWhether to loop playback
sound_spatialbooleantrueEnable 3D spatial audio (positional sound)
sound_distancenumber100Maximum audible distance for spatial audio
sound_autoplaybooleantrueStart playback immediately
sound_channelstring'ambient'Audio channel name for volume grouping

Writing a Custom Property Handler ​

Register custom handlers to process your own Blender custom properties. A handler that publishes its own event declares that event into the event map first, the same way any other emission does:

typescript
import type { TransformNode } from '@babylonjs/core';
import type { HandlerContext, LevelInstance } from '@skewedaspect/sage';

declare module '@skewedaspect/sage-core'
{
    interface EventMap
    {
        'waypoint:registered' : {
            route : string;
            position : { x : number; y : number; z : number };
            nodeName : string;
        };
    }
}

// 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,
    context : HandlerContext
) =>
{
    const routeName = value as string;
    const position = node.position.clone();

    context.bus.emit({
        type: 'waypoint:registered',
        senderID: level.name,
        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:

ArgumentTypeDescription
nodeTransformNodeThe scene node that has this metadata property
valueunknownThe value of the metadata property
levelLevelInstanceThe level being loaded (provides access to name and scene)
contextHandlerContextThe three things a handler is handed: bus, logger, and, where the game opted into audio, audioManager

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
declare function transition(levelName : string, options ?: TransitionOptions) : Promise<void>;

The manager tracks the current level internally. You pass only the name of the level to transition to.

TransitionOptions ​

typescript
interface LevelLoadOptions
{
    forRestore ?: boolean;
}

interface TransitionOptions extends LevelLoadOptions
{
    keepAlive ?: boolean;
    preloadOnly ?: boolean;
}
OptionTypeDefaultDescription
forRestorebooleanfalseThe load is restoring a save. The level runs no marker-driven spawning of its own; the restored snapshot is the sole source of entities. The save manager sets this.
keepAlivebooleanfalseKeep the old level loaded in memory after transitioning
preloadOnlybooleanfalseLoad the new level but do not activate it

Transition Flow ​

  1. Emit level:transition:start with { from, to }
  2. Emit level:transition:progress with { stage: 'loading', levelName }
  3. Load the new level (buildScene() runs, unless the level is already loaded). A transition to the level already current, or a restore (forRestore: true), unloads the target first so this step rebuilds it fresh rather than answering with the instance already there; for a restart specifically, the old level's onDeactivate() runs right before that rebuild disposes it, since that is the last moment it exists. A level already loaded answers with the instance already loaded and logs at debug, with no warning.
  4. Stop here if preloadOnly: true: the new level stays loaded, and the current level stays current, bound, and undeactivated.
  5. Call onDeactivate() on the old level (if defined and step 3 didn't already call it for a restart)
  6. Dispose the old level, unless keepAlive: true or step 3 already disposed it
  7. Set the new level as current and bind the engine to its simulation
  8. Call onActivate() on the new level (if defined)
  9. Emit level:transition:complete

If any step throws, level:transition:error is emitted with { from, to, error } and the exception re-throws. The level that was current is restored as current, with its own simulation rebound, but only while that level is still loaded. A keepAlive transition deactivated that level at step 5 without unloading it, so restoring it also calls its onActivate() again, resuming what the deactivation paused. Every other restore finds a level this attempt never deactivated, and calls neither hook.

Two failure points come after the old level is already gone: a restart's reload, which throws after the rebuild has disposed the instance it is replacing, and a transition without keepAlive whose new level throws from onActivate, after the old level has already been unloaded. Past either point there is no loaded level to put back, so the catch leaves nothing current and the engine unbound.

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.bus.on('level:transition:start', () =>
{
    showLoadingScreen();
});

gameEngine.bus.on('level:transition:complete', () =>
{
    hideLoadingScreen();
});

gameEngine.bus.on('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 such as fades or wipes. 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.

EventPayloadDescription
level:progress{ levelName, progress, message? }Loading progress (0--100)
level:complete{ levelName, message?, level }Level loaded successfully. level is the loaded instance.
level:error{ levelName, message, error }Level loading failed

Emitting Progress ​

Inside buildScene(), call $emitProgress():

typescript
import { Level } from '@skewedaspect/sage';
import type { Scene } from '@babylonjs/core';

class ExampleLevel extends Level
{
    protected async buildScene() : Promise<Scene>
    {
        const { sceneEngine } = this.context;
        const scene = this.createScene();
        this.$emitProgress(0, 'Starting...');

        await 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.bus.on('level:progress', (event) =>
{
    const { progress } = event.payload;
    updateLoadingBar(progress);
});

gameEngine.bus.on('level:complete', () =>
{
    hideLoadingScreen();
});

Type-Safe Event Payloads

gameEngine.bus is an EventBus, typed against sage-core's one EventMap, so event.payload above is already LevelProgressPayload, with no cast needed. Import the type directly when you need to name it, such as in a handler function's own parameter:

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

function updateLoadingBar(payload : LevelProgressPayload) : void
{
    setProgress(payload.progress);
}

Every library event has an importable payload type this way: LevelCompletePayload, LevelErrorPayload, LevelTransitionStartPayload, LevelTransitionProgressPayload, LevelTransitionCompletePayload, LevelTransitionErrorPayload, TriggerEnterPayload, TriggerExitPayload, AssetProgressPayload, AssetCompletePayload, GamePausedPayload, and GameResumedPayload.

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. Bind a simulation -- this.rebuildSimulation(scene, meshSource), the base class's own method, builds a fresh NodeSimulation for this scene, with the config's meshSource and fixedStepDuration. Everything the level spawns goes through it.
  4. Enable physics -- If physics is configured, Havok physics is initialized with the given gravity.
  5. Load scene file -- If scene is configured, the GLB/glTF/Babylon file is imported into the scene.
  6. 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.
  7. Process cameras -- Cameras from the cameras config are applied (overrides) or created. The active camera is set and canvas controls are attached.
  8. Process lights -- Lights from the lights config are applied (overrides) or created, and clustered lighting is set up when clustering is enabled.
  9. Apply post-processing -- If postProcessing is configured, rendering pipelines are set up. Requires an active camera from step 7.
  10. Create the outline manager -- Built for every level, unconditionally, with whatever layers the outlines config declares. Reachable afterward as level.outlines.
  11. 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.
  12. Process spawn points -- Spawn points collected in step 11 are matched against the spawns config. Entities are spawned at each spawn position. Skipped when the level is loaded with forRestore.
  13. Process entity nodes -- Entity markers collected in step 11 are matched against the entities config. Each spawned entity adopts its marked node. Skipped when the level is loaded with forRestore.
  14. 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
import { GameLevel } from '@skewedaspect/sage';
import type { Scene } from '@babylonjs/core';

class PuzzleLevel extends GameLevel
{
    protected override async buildScene() : Promise<Scene>
    {
        this.$emitProgress(5, 'Custom setup...');

        // Runs the full GameLevel pipeline (steps 1-14)
        const scene = await super.buildScene();

        // Add your custom level logic here
        this._setupPuzzleMechanics();
        this._subscribeToEvents();

        return scene;
    }

    private _setupPuzzleMechanics() : void { /* level-specific setup */ }
    private _subscribeToEvents() : void { /* level-specific setup */ }
}

Level Lifecycle ​

  1. Registration -- Config registered with registerLevelConfig()
  2. Loading -- loadLevel() creates the instance and calls buildScene()
  3. Activation -- activateLevel() or a transition sets the level as currentLevel and fires onActivate(). Activating the level already current fires neither hook.
  4. Active -- Scene is rendered, entities are updated
  5. Deactivation -- The outgoing level's onDeactivate() fires before the incoming level becomes current
  6. Disposal -- dispose() cleans up the scene and resources

Custom Disposal ​

Override dispose() for custom cleanup. Always call super.dispose():

typescript
import { Level } from '@skewedaspect/sage';
import type { Scene, StaticSound } from '@babylonjs/core';

class MyLevel extends Level
{
    private ambientSound : StaticSound | null = null;

    protected async buildScene() : Promise<Scene>
    {
        return this.createScene();
    }

    override async dispose() : Promise<void>
    {
        if(this.ambientSound)
        {
            this.ambientSound.stop();
            this.ambientSound.dispose();
            this.ambientSound = null;
        }

        await super.dispose();
    }
}

GameLevel.dispose() despawns every entity the level spawned from its markers (level.spawnedEntities), disposes all level sounds, and clears internal collections before calling Level.dispose(), which disposes the outline manager, the clustered light container, and the BabylonJS scene. Entities a game spawned itself through the engine's own simulation are not despawned by the level; the scene they lived in is gone, and the next level's rebuildSimulation replaces the simulation.

Clustered Lighting ​

typescript
interface ClusteringConfig
{
    enabled : boolean;
    horizontalTiles ?: number;
    verticalTiles ?: number;
    depthSlices ?: number;
    maxRange ?: number;
}

When enabled, every eligible point and spot light in the scene, imported or created from lights, is moved into a ClusteredLightContainer, and PBR materials are switched to glTF light falloff. The container is reachable as level.clusteredLights. Hemispheric, directional, and rect area lights are unaffected.

Outline Layers ​

typescript
interface OutlineLayerConfig
{
    color ?: ColorConfig;
    thickness ?: number;
    occlusionStrength ?: number;
}

Named screen-space outline layers for entity highlighting. GameLevel builds an OutlineManager from the config, reachable as level.outlines, and a game highlights a NodeEntity with level.outlines.highlightEntity(layerName, entity) and clears it with unhighlightEntity.

LevelContext ​

The context the LevelManager constructs a level with, naming every dependency a level has. There is no engine reference in it; a Level subclass reaches everything through this.context. LevelContext carries the same Registry generic parameter as LevelManager and Level, defaulting to NodeEntityRegistry.

typescript
interface LevelContext<Registry extends NodeEntityRegistry = NodeEntityRegistry>
{
    readonly bus : EventBus;
    readonly sceneEngine : SceneEngine;
    readonly assetManager : AssetManager;
    readonly audioManager ?: AudioManager;
    readonly rebuildSimulation : (scene : Scene, meshSource ?: MeshLoader) => NodeSimulation<Registry>;
    readonly canvas : GameCanvas;
    readonly largeWorldRendering : boolean;
    readonly propertyHandlers : Map<string, PropertyHandler>;
    readonly logger : LoggingUtility;
}
FieldMeaning
busThe engine's event bus, which a level publishes its progress, completion, and failure on
sceneEngineCreates the level's scene, imports its scene file, and enables physics on it
assetManagerWhat a level preloads through
audioManagerPresent only for a game that opted into audio; what a level's sounds are created through
rebuildSimulationBuilds the simulation for the scene a level just created, already named after the level, with the fixed-step duration the level's configuration names or the engine's when it names none
canvasWhat an active camera attaches its controls to
largeWorldRenderingWhether large world rendering is on, which decides the floating-origin radius physics is enabled with
propertyHandlersThe handler map the level manager owns
loggerThe logging utility a level names its own logger through

PropertyHandler ​

Handlers process custom metadata properties on scene nodes during level loading, handed the node, the property's value, the level, and the two dependencies a handler has:

typescript
type PropertyHandler = (
    node : TransformNode,
    value : unknown,
    level : LevelInstance,
    context : HandlerContext
) => void | Promise<void>;

interface HandlerContext
{
    readonly bus : EventBus;
    readonly audioManager ?: AudioManager;
    readonly logger : LoggerInterface;
}

The logger is the game's own logging utility, under the name PropertyHandlers, so a warning about an authored property is written where the game's own logging is written and at the level the game set. Every handler SAGE provides that has a warning to give -- collider, sound, lod_distances, and occlusion -- gives it through this logger.

Register handlers on the level manager:

typescript
levelManager.registerPropertyHandler('sound', (node, value, level, context) =>
{
    // Process nodes with a 'sound' metadata property
});

A throwing handler is caught by the level's own dispatch, logged against the property name and the node name, and the walk continues to the next property and the next node.

Teardown ​

The LevelManager implements Disposable. Calling await gameEngine.stop() tears the whole engine down, including every loaded level, forgetting every registered level config and resetting the current level along the way.

Released under the MIT License.