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;

Properties

PropertyTypeDescription
currentLevelLevel | nullThe currently active level
propertyHandlersMap<string, PropertyHandler>Registered scene node property handlers

Configuration API

MethodSignatureDescription
registerLevelConfig(config : LevelConfig) => voidRegister a level configuration for later instantiation
registerLevelClass(name : string, levelClass : LevelConstructor) => voidRegister a custom Level class referenced by configs
getLevelConfig(name : string) => LevelConfig | nullLook up a registered config

Loading API

MethodSignatureDescription
loadLevel(name)(string) => Promise<Level>Load a registered level by name
loadLevel(config)(LevelConfig) => Promise<Level>Load from a config (auto-registers it)
activateLevel(name)(string) => Promise<Level>Load (if needed) and set as current level
activateLevel(config)(LevelConfig) => Promise<Level>Load from config and set as current
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)

Lifecycle Hooks

HookWhen CalledDescription
onActivate()Level becomes active during a transitionStart music, show UI, unpause physics
onDeactivate()Level is being replaced during a transitionSave 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<Scene>
    {
        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<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;
    scene ?: string;
    class ?: string;
    config ?: Record<string, unknown>;
    spawns ?: Record<string, SpawnDefinition>;
    entities ?: Record<string, EntityDefinition>;
    physics ?: boolean | { gravity ?: { x : number; y : number; z : number } };
    preload ?: string[];
    environment ?: EnvironmentConfig;
    cameras ?: Record<string, CameraDefinition>;
    lights ?: Record<string, LightDefinition>;
    sounds ?: Record<string, LevelSoundConfig>;
    postProcessing ?: PostProcessingConfig;
}

LevelConfig Properties

PropertyTypeRequiredDescription
namestringYesUnique level identifier. Used as the key for transition() and loadLevel() calls.
scenestringNoPath to the scene file (.glb, .gltf, .babylon). 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 config overrides. 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.

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

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<string, unknown>;
}
PropertyTypeRequiredDescription
entitystringYesThe entity type name to spawn. Must match a registered entity definition.
configRecord<string, unknown>NoState 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<string, unknown>;
}
PropertyTypeRequiredDescription
configRecord<string, unknown>NoState 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

PropertyTypeDefaultDescription
iblstring--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.
iblResolutionnumber256Resolution of the IBL cubemap in pixels. Higher values produce sharper reflections but use more memory. 256 is sufficient for most scenes.
skyboxstring--Path 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.
rotationnumber--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:

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. Use createEnvTexture in the BabylonJS sandbox to convert HDR files.
.jpg, .pngTexture on a sphereCreates 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

PropertyTypeDefaultDescription
type'free' | 'arcRotate' | 'universal'--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)Look-at target. Used by arcRotate cameras (both create and override).
rotationVec3Config--Camera rotation in radians. Used by free and universal cameras (both create and override).
fovnumber--Field of view in radians. Applies to all camera types.
minZnumber--Near clipping plane distance.
maxZnumber--Far clipping plane distance.
speednumber--Movement 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 target. ArcRotate only.
lowerRadiusLimitnumber--Minimum zoom distance. ArcRotate only.
upperRadiusLimitnumber--Maximum zoom distance. ArcRotate only.
lowerBetaLimitnumber--Minimum vertical angle (prevents looking from below). ArcRotate only.
upperBetaLimitnumber--Maximum vertical angle (prevents looking from above). ArcRotate only.
wheelPrecisionnumber--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

PropertyTypeDefaultDescription
type'hemispheric' | 'directional' | 'point' | 'spot'--Light type. Required for create mode, omit for override mode.
intensitynumber--Light brightness. Applies to all light types.
diffuseColorConfig--Diffuse color as { r, g, b } with values 0.0--1.0.
specularColorConfig--Specular 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.
groundColorColorConfig--Ground 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.

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

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

PropertyTypeDefaultDescription
urlstring(required)Path to the audio file (.ogg, .mp3, .wav, etc.).
channelstring--Audio 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 -- 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.

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'. 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 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 existing mesh is attached to the created entity.
colliderstringregisterColliderHandlerPhysics collider shape. Values: "box", "sphere", "mesh", "none". Creates a static physics aggregate (mass = 0).
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.
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"). Child meshes are assigned as LOD levels in order. Beyond the last distance, the mesh is hidden.
occluderbooleanregisterOccluderHandlerIf true, marks the mesh as an occlusion culling occluder. The mesh is made invisible but used by the engine to hide objects behind it.
visibleboolean | string | numberregisterVisibleHandlerControls mesh visibility. Accepts true/false, "true"/"false", or 1/0.
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 static physics aggregates (mass = 0) for scene geometry. The four collider types:

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

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. 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 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:

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:

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)
gameEngineGameEngineThe 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<void>

The manager tracks the current level internally — you only pass the target level name.

TransitionOptions

typescript
interface TransitionOptions
{
    keepAlive ?: boolean;
    preloadOnly ?: boolean;
}
OptionTypeDefaultDescription
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. 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.

EventPayloadDescription
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<Scene>
{
    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<Scene>
{
    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<void>
    {
        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<string, PropertyHandler>;
    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<void>;

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.

Released under the MIT License.