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 3What 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
GameLevelsubclasses 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
LevelControllerorchestrator pattern
Step 1: The YAML level config format
Each level is defined in a YAML file. Here is Level 1:
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: trueThe fields:
name-- unique level identifier, also used as the key fortransition()callsclass-- references a registeredGameLevelsubclass (the level's behavior)scene-- path to a GLB file; SAGE loads it and processes its metadataphysics-- enables Havok physics on the scene (truefor default gravity, or an object with customgravity: { x, y, z })spawns-- maps Blender empty names to entity types with optional config overridespreload-- assets to preload and cache before the level startsconfig-- arbitrary data passed to custom Level classes, accessed viathis._config.configpostProcessing-- declarative post-processing: bloom, SSAO, tone mapping, chromatic aberration, vignette, grain, sharpenentities-- 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:
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:
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):
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.
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.
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.
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):
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:
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:
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:
spawnproperties create entities at the Empty's position (handled byGameLeveldirectly)entityproperties wrap existing meshes as SAGE entities (handled byGameLeveldirectly)colliderproperties add physics shapes ("box","sphere","mesh", or"none")triggerproperties create invisible intersection zones that emittrigger:enter/trigger:exiteventssoundproperties attach spatial audio (withsound_volume,sound_loop, etc.)lod_distancesproperties set up level-of-detail switching based on camera distanceoccluderproperties mark meshes as occlusion cullers for rendering optimizationvisibleproperties 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:
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:
- The current level's
$dispose()is called, cleaning up entities and event subscriptions - The new level's
buildScene()is called, which loads the GLB and spawns entities level:progressevents drive the loading screen UIlevel:transition:completefires 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)
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:
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:
# 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: hableSAGE 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
GameLevelsubclasses encapsulate per-level game logic withbuildScene()and$dispose()registerAllPropertyHandlers()enables automatic processing of Blender custom properties (collider,trigger,lod_distances,occluder,visible,sound)spawnandentitymetadata keys are handled directly byGameLevel, not via property handlersLevelManager.transition()handles the full load/unload cycle with lifecycle hooks$emitProgress()drives loading screens from insidebuildScene()- The
configfield 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
