Skip to content

Level Loading

This guide walks you through a three-level puzzle game that loads scenes from GLB files, spawns entities at Blender-defined positions, and transitions between levels. You will learn SAGE's YAML level format, how to write GameLevel subclasses for per-level logic, how property handlers process scene metadata, and how to build interactive puzzle mechanics.

The full source is in examples/src/examples/level-loading/.

Try it live

Run this example at Examples > Level Loading.

Project structure

level-loading/
├── index.vue                   # Vue component — UI, loading screen, event log
├── types.ts                    # Entity state interfaces and type guards
├── constants.ts                # Colors, player defaults, UI limits
├── entities/
│   ├── definitions.ts          # All entity definitions (player, door, button, etc.)
│   └── behaviors/
│       ├── PlayerMovementBehavior.ts
│       ├── DoorBehavior.ts
│       ├── ButtonBehavior.ts
│       ├── CollectibleBehavior.ts
│       ├── BoxBehavior.ts
│       └── PressurePlateBehavior.ts
├── game/
│   └── LevelController.ts     # Orchestrator — transitions, input, proximity checks
├── input/
│   ├── bindings.ts             # Movement and interact bindings
│   └── helpers.ts
└── levels/
    ├── loader.ts               # YAML parsing with Vite ?raw imports
    ├── level1.yaml             # Button + timer puzzle
    ├── level2.yaml             # Zone + key collection puzzle
    ├── level3.yaml             # Pressure plate + box puzzle
    ├── buttonTimerLevel.ts     # GameLevel subclass for Level 1
    ├── zoneKeyLevel.ts         # GameLevel subclass for Level 2
    └── pressurePlateLevel.ts   # GameLevel subclass for Level 3

What this example demonstrates

  • YAML level configuration format (name, class, scene, spawns, entities, postProcessing)
  • Loading GLB scenes with automatic entity spawning at Blender-defined spawn points
  • Writing GameLevel subclasses that own per-level game logic
  • Property handlers that process Blender custom properties into SAGE entities
  • Level transitions via LevelManager.transition()
  • Puzzle mechanics: buttons, doors, keys, pressure plates, and boxes
  • Loading screen with progress reporting from buildScene()
  • The LevelController orchestrator pattern

Step 1: The YAML level config format

Each level is defined in a YAML file. Here is Level 1:

yaml
name: Level 1
class: buttonTimer
scene: /assets/level-loading/simple-arena.glb
physics: true

spawns:
  player_start:
    entity: player
    config:
      health: 100
      maxHealth: 100

  button_center:
    entity: button
    config:
      pressed: false

preload:
  - /assets/level-loading/simple-arena.glb

postProcessing:
  bloom:
    weight: 0.3
    threshold: 0.8
  tonemap:
    operator: aces

entities:
  door:
    config:
      locked: true

The fields:

  • name -- unique level identifier, also used as the key for transition() calls
  • class -- references a registered GameLevel subclass (the level's behavior)
  • scene -- path to a GLB file; SAGE loads it and processes its metadata
  • physics -- enables Havok physics on the scene (true for default gravity, or an object with custom gravity: { x, y, z })
  • spawns -- maps Blender empty names to entity types with optional config overrides
  • preload -- assets to preload and cache before the level starts
  • config -- arbitrary data passed to custom Level classes, accessed via this._config.config
  • postProcessing -- declarative post-processing: bloom, SSAO, tone mapping, chromatic aberration, vignette, grain, sharpen
  • entities -- configures entities that exist in the GLB as entity markers (not spawns)

For the complete specification of every field, default values, and annotated YAML, see the Levels API Reference.

The spawns section is the bridge between Blender and SAGE. In Blender, an artist places an Empty and sets a custom property spawn = "player_start". In the YAML, player_start maps to the player entity type. When the level loads, SAGE creates a player entity at that Empty's position.

Step 2: Loading YAML configs

Level configs are loaded from YAML using Vite's ?raw imports and the js-yaml library:

typescript
import yaml from 'js-yaml';
import type { LevelConfig } from '@skewedaspect/sage';

import level1Yaml from './level1.yaml?raw';
import level2Yaml from './level2.yaml?raw';
import level3Yaml from './level3.yaml?raw';

export function loadAllLevelConfigs() : LevelConfig[]
{
    return [
        yaml.load(level1Yaml) as LevelConfig,
        yaml.load(level2Yaml) as LevelConfig,
        yaml.load(level3Yaml) as LevelConfig,
    ];
}

The ?raw suffix tells Vite to bundle the YAML as a string at build time rather than fetching it at runtime. This eliminates network requests for level configs.

Step 3: Entity definitions

Entity definitions tell SAGE what behaviors to attach and what default state to use when an entity type is created. The level-loading example defines players, doors, buttons, keys, boxes, and pressure plates.

The player definition includes a mesh descriptor so SAGE can auto-create the visual:

typescript
export function createPlayerDefinition() : GameEntityDefinition<PlayerState>
{
    return {
        type: 'player',
        behaviors: [ PlayerMovementBehavior ],
        mesh: {
            source: 'capsule',
            params: { height: 1.8, radius: 0.4 },
            material: {
                type: 'pbr',
                color: COLORS.player,
                metallic: 0.1,
                roughness: 0.7,
            },
        },
        defaultState: {
            position: { x: 0, y: 0, z: 0 },
            health: PLAYER_HEALTH,
            maxHealth: PLAYER_HEALTH,
            speed: PLAYER_SPEED,
        },
    };
}

The door definition is minimal because the door mesh comes from the GLB scene (via the entity custom property in Blender):

typescript
export function createDoorDefinition() : GameEntityDefinition<DoorState>
{
    return {
        type: 'door',
        behaviors: [ DoorBehavior ],
        defaultState: {
            position: { x: 0, y: 1.5, z: 0 },
            isOpen: false,
            locked: false,
        },
    };
}

All definitions are passed to SageCanvas via the :entity-definitions prop, which registers them before the engine starts.

Step 4: GameLevel subclasses

Each level has its own GameLevel subclass that encapsulates per-level game logic. The base GameLevel class handles scene loading, entity spawning at spawn points, and property handler processing. Subclasses override buildScene() to add level-specific setup.

ButtonTimerLevel (Level 1)

Press a button to unlock the door for 30 seconds. Get through before the timer expires.

typescript
export class ButtonTimerLevel extends GameLevel
{
    private _timerActive = false;
    private _timerRemaining = 0;

    protected override async buildScene() : Promise<Scene>
    {
        this.$emitProgress(15, 'Loading scene geometry...');
        const scene = await super.buildScene();

        // Subscribe to button:pressed (emitted by ButtonBehavior)
        this._unsubscribers.push(
            this.gameEngine.eventBus.subscribe('button:pressed', () => this._onButtonPressed())
        );

        return scene;
    }

    private _onButtonPressed() : void
    {
        this._timerActive = true;
        this._timerRemaining = 30;

        // Unlock the door immediately
        for(const door of this.gameEngine.managers.entityManager.getByType('door'))
        {
            door.eventBus.publish({ type: 'door:unlock', payload: {} });
        }

        this.gameEngine.eventBus.publish({
            type: 'level:timer-started',
            payload: { duration: 30 },
        });
    }
}

The $emitProgress() helper publishes level:progress events that drive the loading screen. The controller subscribes to these and updates the Vue UI.

ZoneKeyLevel (Level 2)

Stand on a summoning platform for 3 seconds to make a key appear. Collect it to unlock the door.

typescript
export class ZoneKeyLevel extends GameLevel
{
    private _zoneTimer = 0;
    private _keySpawned = false;

    updateZone(dt : number, callbacks : ZoneLevelCallbacks) : void
    {
        const player = this.gameEngine.managers.entityManager.getByType('player')[0];
        if(!player?.node) { return; }

        const dist = /* horizontal distance to platform center */;
        const isInZone = dist <= SUMMONING_PLATFORM_RADIUS;

        if(isInZone)
        {
            this._zoneTimer += dt;
            if(this._zoneTimer >= 3.0)
            {
                this._spawnKey();
                this._keySpawned = true;
            }
        }
    }
}

This level also builds custom visuals (a glowing platform and spotlight) programmatically in buildScene(), demonstrating that levels can combine GLB assets with runtime-generated geometry.

PressurePlateLevel (Level 3)

Push a box onto a pressure plate to make a key appear. If the plate is released, the key vanishes. You need to collect the key while something holds the plate down.

typescript
export class PressurePlateLevel extends GameLevel
{
    updateProximity() : void
    {
        for(const plate of this.gameEngine.managers.entityManager.getByType('pressure_plate'))
        {
            let somethingOnPlate = false;

            // Check player and box proximity to the plate
            // ...

            if(somethingOnPlate && !isActivated)
            {
                plate.eventBus.publish({ type: 'plate:activate', payload: {} });
            }
            else if(!somethingOnPlate && isActivated)
            {
                plate.eventBus.publish({ type: 'plate:deactivate', payload: {} });
            }
        }
    }
}

The level subscribes to pressure_plate:activated and pressure_plate:deactivated events to spawn and despawn the key dynamically.

Step 5: Entity behaviors for interactive objects

DoorBehavior

The door subscribes to door:unlock and door:lock events. It animates the door's Y rotation smoothly between closed (0) and open (-90 degrees):

typescript
export class DoorBehavior extends GameEntityBehavior<DoorState>
{
    name = 'door';
    eventSubscriptions = [ 'door:unlock', 'door:lock' ];

    processEvent(event : GameEvent, state : DoorState) : boolean
    {
        if(event.type === 'door:unlock' && state.locked)
        {
            state.locked = false;
            this.targetRotation = -Math.PI / 2;
            return true;
        }
        else if(event.type === 'door:lock' && !state.locked)
        {
            state.locked = true;
            this.targetRotation = 0;
            return true;
        }
        return false;
    }

    update(deltaTime : number) : void
    {
        // Animate toward targetRotation at SWING_SPEED
        // ...
        this.node.rotation.y = this.currentRotation;
    }
}

ButtonBehavior

The button subscribes to interact events. When pressed, it emits button:pressed to the global event bus, animates downward, and changes color:

typescript
private pressButton(state : ButtonState) : void
{
    state.pressed = true;

    this.$emit({
        type: 'button:pressed',
        payload: { entityId: this.entity!.id },
    });

    this.node!.position.y -= 0.1;

    const mesh = this.node!.getChildMeshes()[0];
    if(mesh?.material)
    {
        (mesh.material as { diffuseColor ?: Color3 }).diffuseColor =
            new Color3(COLORS.buttonPressed.r, COLORS.buttonPressed.g, COLORS.buttonPressed.b);
    }
}

Note the use of $emit() -- this publishes to the global event bus so the level subclass can react, while processEvent() handles events on the entity's event bus.

Step 6: Property handlers

Before creating a controller, you register SAGE's built-in property handlers with the level manager. These handlers process Blender custom properties (spawn, entity, collider, sound, trigger, etc.) when a GLB scene loads:

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

registerAllPropertyHandlers(levelManager);

This single call enables all built-in handlers. When a GLB is loaded, SAGE walks the scene graph and processes each node's metadata:

  • spawn properties create entities at the Empty's position (handled by GameLevel directly)
  • entity properties wrap existing meshes as SAGE entities (handled by GameLevel directly)
  • collider properties add physics shapes ("box", "sphere", "mesh", or "none")
  • trigger properties create invisible intersection zones that emit trigger:enter/trigger:exit events
  • sound properties attach spatial audio (with sound_volume, sound_loop, etc.)
  • lod_distances properties set up level-of-detail switching based on camera distance
  • occluder properties mark meshes as occlusion cullers for rendering optimization
  • visible properties control mesh visibility

For a complete reference of every metadata key, its value type, and detailed behavior, see Scene Node Metadata and Property Handlers.

Step 7: Level transitions

The level manager handles loading, unloading, and transitioning between levels. The controller calls transition() to switch levels:

typescript
async loadLevel(levelName : string) : Promise<void>
{
    this._enterState('loading');
    this.callbacks.onProgress(levelName, 0, 'Preparing level...');

    // transition() unloads the current level, loads the new one,
    // and processes all spawn points and entity markers
    await this.engine.managers.levelManager.transition(levelName);

    this._currentLevelName = levelName;
    this._setupSceneCamera();
    this._enterState('playing');
}

The transition flow:

  1. The current level's $dispose() is called, cleaning up entities and event subscriptions
  2. The new level's buildScene() is called, which loads the GLB and spawns entities
  3. level:progress events drive the loading screen UI
  4. level:transition:complete fires when everything is ready

Step 8: The LevelController orchestrator

The controller manages everything that spans across levels:

  • Initialization: registers handlers, level classes, configs, and input bindings
  • Level transitions: load, next, reload
  • Input routing: forwards movement actions to the player entity each frame
  • Interaction proximity: checks player distance to buttons and boxes, shows/hides prompts
  • Collection proximity: auto-collects keys when the player walks near them
  • North zone trigger: when the player reaches the door area while unlocked, triggers the next level
  • Per-level delegation: calls level-specific update methods (timer, zone check, plate check)
typescript
update(dt : number) : void
{
    if(this._state !== 'playing') { return; }

    this._publishMovement();
    this._updateBoxCarry();
    this._checkCollectibleProximity();
    this._checkInteractionProximity();
    this._checkNorthZoneTrigger();

    // Delegate to active level subclass
    const currentLevel = this.engine.managers.levelManager.currentLevel;
    if(currentLevel instanceof ButtonTimerLevel)
    {
        currentLevel.updateTimer(dt, { /* callbacks */ });
    }
    else if(currentLevel instanceof ZoneKeyLevel)
    {
        currentLevel.updateZone(dt, { /* callbacks */ });
    }
    else if(currentLevel instanceof PressurePlateLevel)
    {
        currentLevel.updateProximity();
    }
}

The interaction system checks player proximity each frame and publishes events to the entity and global event buses:

typescript
private _checkInteractionProximity() : void
{
    const playerPos = player.node.position;
    let interactionPrompt : string | null = null;

    // Check boxes
    for(const box of entityManager.getByType('box'))
    {
        if(box.node && Vector3.Distance(playerPos, box.node.position) < INTERACTION_DISTANCE)
        {
            interactionPrompt = 'to Pick Up';
            break;
        }
    }

    // Check buttons
    // ...

    if(interactionPrompt)
    {
        this.engine.eventBus.publish({
            type: 'interaction:available',
            payload: { prompt: interactionPrompt },
        });
    }
}

Step 9: Post-processing per level

Each level can define its own post-processing stack in the YAML config. The three levels demonstrate different moods:

yaml
# Level 1: Warm tutorial feel
postProcessing:
  bloom:
    weight: 0.3
    threshold: 0.8
  tonemap:
    operator: aces

# Level 2: Cool mysterious vibe
postProcessing:
  bloom:
    weight: 0.15
    threshold: 0.9
  chromaticAberration:
    amount: 0.4
  vignette:
    weight: 0.4
  tonemap:
    operator: reinhard

# Level 3: Gritty final level
postProcessing:
  bloom:
    weight: 0.5
    threshold: 0.7
  grain:
    intensity: 15
    animated: true
  sharpen:
    edge: 0.3
  tonemap:
    operator: hable

SAGE applies these automatically when the level loads.

What you learned

  • YAML level configs define scenes, spawn points, entity overrides, physics, custom data, and post-processing
  • GameLevel subclasses encapsulate per-level game logic with buildScene() and $dispose()
  • registerAllPropertyHandlers() enables automatic processing of Blender custom properties (collider, trigger, lod_distances, occluder, visible, sound)
  • spawn and entity metadata keys are handled directly by GameLevel, not via property handlers
  • LevelManager.transition() handles the full load/unload cycle with lifecycle hooks
  • $emitProgress() drives loading screens from inside buildScene()
  • The config field in YAML passes arbitrary data to custom Level subclasses
  • Level-specific behaviors (timers, zones, plates) live in level subclasses, not the controller
  • The controller handles cross-level concerns: input routing, proximity, and transitions

Next steps

  • Levels API Reference -- exhaustive reference for every config field, property handler, default value, and post-processing parameter
  • Sandbox -- a first-person environment with GLB scenes, doors, elevators, and spatial audio
  • Blender Workflow -- how to author levels in Blender for SAGE

Released under the MIT License.