Level Loading
This guide walks through the level-loading example: three puzzle levels, each a YAML config over the same Blender-authored arena. It covers how a level config names its scene and its entities, how spawn markers and entity markers become entities, how a GameLevel subclass adds a level's own rules, and how the game moves between levels. The full reference is on the Levels page.
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: loading screen, overlays, event log
├── constants.ts # Speeds, distances, colors, the exit zone
├── events.ts # This example's events, declared onto sage-core's EventMap
├── behaviors/ # One behavior per concern
├── entities/ # defineEntity definitions, plus lookups by class
├── input/ # Actions and bindings declared onto InputManager
├── levels/
│ ├── level1.yaml # Button timer
│ ├── level2.yaml # Zone key
│ ├── level3.yaml # Pressure plate
│ ├── arenaLevel.ts # What the puzzles share
│ ├── buttonTimerLevel.ts
│ ├── zoneKeyLevel.ts
│ ├── pressurePlateLevel.ts
│ └── loader.ts # Parses the YAML, attaches the mesh source
└── game/
└── LevelController.ts # Registration, transitions, the player's interactionsStep 1: A level config
Each level is a LevelConfig, written in YAML and parsed with js-yaml from a Vite ?raw import.
name: Level 1
class: buttonTimer
scene: /assets/level-loading/simple-arena.glb
physics: true
preload:
- /assets/level-loading/simple-arena.glb
spawns:
player_start:
entity: PlayerEntity
config:
speed: 5
button_center:
entity: ButtonEntity
entities:
door:
entity: DoorEntity
config:
locked: true
cameras:
MainCamera:
type: arcRotate
alpha: 1.5708
beta: 1.0472
radius: 25
target: { x: 0, y: 2, z: 0 }
attachControl: trueclassnames a registeredGameLevelsubclass to run the level with. Left out, the level runs as a plainGameLevel.spawnsmaps aspawnmarker's value to the class-table name to spawn there, with optional per-spawnconfigstate, a per-instancenamewritten intodisplayName, andtags.entitiesmaps anentitymarker's value to the class-table name whose entity adopts the marked node, with the same optional fields.
The three levels differ only in class, spawns, and the puzzle their class runs. button_center holds a ButtonEntity in Level 1 and a PressurePlateEntity in Level 3: a spawn point is a place, and the level decides what stands there.
Step 2: Spawn markers and entity markers
A Blender Empty with a custom property spawn = "player_start" is a spawn point. When the level loads, GameLevel spawns the class the spawns entry names, with the Empty's position and rotation written into the entity's state, and disposes the Empty. The entity builds its own node and loads its mesh.
A mesh with a custom property entity = "door" is an entity marker. GameLevel spawns the class the entities entry names with nodeName set to the marked node's name, and the entity adopts that node: no TransformNode is built and no mesh is loaded. The node's authored transform, parent, and children stay as Blender exported them.
The example's DoorEntity declares no mesh, because the door is the mesh already in the arena:
const behaviors = [ DoorBehavior ] as const;
const DoorBase = defineEntity({
type: 'DoorEntity',
behaviors,
});
export class DoorEntity extends DoorBase
{
declare readonly state : StateFor<typeof behaviors>;
declare readonly ops : OpsFor<typeof behaviors>;
}Every other entity is a definition with a primitive mesh and a material, loaded through the standard mesh source. The button:
const ButtonBase = defineEntity({
type: 'ButtonEntity',
behaviors,
mesh: {
source: 'cylinder',
params: { diameter: 1, height: 0.3 },
material: {
color: COLORS.button,
},
},
});See Authoring for defineEntity, meshConfig, and adoption in full.
Step 3: Moving an adopted node
An adopted entity's update does not write state.position or state.rotation onto its node, so a behavior that moves an adopted prop moves the node directly. DoorBehavior records the rotation Blender gave the door in onMeshLoaded, then swings relative to it:
onMeshLoaded() : void
{
if(!(this.entity instanceof NodeEntity))
{
return;
}
const authored = this.entity.node.rotationQuaternion;
if(authored)
{
this.#closedRotation = authored.clone();
}
else
{
this.#closedRotation = Quaternion.Identity();
}
this.#applyAngle();
}
#applyAngle() : void
{
if(this.#closedRotation === undefined || !(this.entity instanceof NodeEntity))
{
return;
}
const swing = Quaternion.RotationAxis(Vector3.Up(), this.state.angle);
this.entity.node.rotationQuaternion = this.#closedRotation.multiply(swing);
}locked and angle live in state, so a snapshot holds everything the door needs to come back the same.
Step 4: The mesh source
A NodeEntity loads its mesh through the mesh source the level hands to rebuildSimulation. The config carries it as meshSource, a function, so the loader attaches it after parsing the YAML:
export function loadAllLevelConfigs(meshSource : EntityMeshLoader) : LevelConfig[]
{
const configs : LevelConfig[] = [];
for(const text of [ level1Yaml, level2Yaml, level3Yaml ])
{
const config = prefixAssetPaths(yaml.load(text) as LevelConfig);
configs.push({ ...config, meshSource });
}
return configs;
}The controller builds the source from the entity class table:
const meshSource = buildStandardMeshSource(entityClasses, assetManager);
for(const config of loadAllLevelConfigs(meshSource))
{
levelManager.registerLevelConfig(config);
}A class that declares no meshConfig gets a node with no mesh, which is what an adopted entity wants.
Step 5: A level class per puzzle
ArenaLevel extends GameLevel with what the three puzzles share: reaching the simulation the level built, unlocking and locking the door, spawning a key on the pedestal, registering on the simulation's bus, and a per-frame updatePuzzle hook the controller calls. Each puzzle extends it. Level 1:
export class ButtonTimerLevel extends ArenaLevel
{
protected override async buildScene() : Promise<Scene>
{
const step = createStepTimer();
const scene = await super.buildScene();
this.$emitProgress(90, 'Setting up level mechanics...');
await step(LOAD_STEP_DELAY);
this.$emitProgress(95, 'Finalizing...');
await step(LOAD_STEP_DELAY);
return scene;
}
override startPuzzle(callbacks : PuzzleCallbacks) : void
{
super.startPuzzle(callbacks);
this.subscribe(SUBSCRIBER_ID, [ 'button:pressed', 'button:expired' ], (event) =>
{
if(event.type === 'button:pressed')
{
this.#onButtonPressed(event.payload.seconds);
}
else if(event.type === 'button:expired')
{
this.#onWindowExpired();
}
});
}
updatePuzzle() : void
{
const button = findButton(this.simulation);
if(button !== undefined && button.state.unlockRemaining > 0)
{
this.callbacks.onTimerUpdate(button.state.unlockRemaining);
}
}
}super.buildScene() binds the simulation to the new scene, loads the GLB, processes the markers, and spawns. The level's own $emitProgress calls feed the loading screen after that.
The rules run off events rather than a per-frame read. ButtonBehavior broadcasts button:pressed when it accepts the press, counts the thirty-second window down in its own state, and broadcasts button:expired when it runs out; the level decides that the window is what holds the door open. A level is not an entity, but the bus is not sealed to entities either: anything holding the simulation may register a subscriber on it and hear events under the same delivery rules (Events). ArenaLevel.subscribe is that registration, and every subscription it makes ends when the level is disposed:
protected subscribe(
id : string,
subscriptions : readonly EventSubscription[],
handle : (event : GameEvent) => void
) : void
{
const unsubscribe = this.simulation.bus.register({
id,
receive: (event : GameEvent) : boolean =>
{
handle(event);
return false;
},
}, subscriptions);
this.#unsubscribers.push(unsubscribe);
}That leaves updatePuzzle the one thing that really does happen every frame: redrawing the number on screen. It reads the seconds out of the button's state, which is also where a save finds them -- a game saved twenty seconds into the window comes back with ten seconds left, not thirty.
Register each class under the name the config's class field uses:
levelManager.registerLevelClass('buttonTimer', ButtonTimerLevel);
levelManager.registerLevelClass('zoneKey', ZoneKeyLevel);
levelManager.registerLevelClass('pressurePlate', PressurePlateLevel);Step 6: Coordinating entities from outside
Behaviors own their own state and appearance. Deciding that the player is close enough to press the button, or that the box is resting on the plate, involves more than one entity, and that decision is made where both entities can be read: the controller for the player's interactions, the level for its rules. Each sends a targeted event to the one entity it concerns.
const button = findButton(simulation);
if(button !== undefined && !button.state.pressed
&& floorDistance(playerPosition, button.state.position) < INTERACTION_DISTANCE)
{
button.emit('button:press', {}, button.id);
}The controller hears the interact key itself, as a subscriber on the simulation's bus. Every level builds its own simulation, so the registration moves to the new bus on each transition:
this.#unsubscribeActions = simulation.bus.register({
id: CONTROLLER_ID,
receive: (event : GameEvent) : boolean =>
{
if(event.type === 'action:interact')
{
this.#interact(simulation);
}
return false;
},
}, [ 'action:interact' ]);Two presses in one rendered frame arrive as two events. Which entity a press concerns is still the controller's decision, and picking a box up hands the box the player's ID; from there the box follows its carrier under its own power:
onUpdate() : void
{
if(this.state.carrierID === null)
{
return;
}
const carrier = this.entity.getEntity(this.state.carrierID);
if(carrier === undefined || !carrier.hasBehavior(PlayerMovementBehavior))
{
return;
}
const carrierPosition = carrier.state.position;
this.state.position.x = carrierPosition.x;
this.state.position.y = carrierPosition.y + BOX_CARRY_HEIGHT;
this.state.position.z = carrierPosition.z - BOX_CARRY_OFFSET;
}A relationship is an ID in state, resolved at the moment of use and never held across steps, so the box moves in the same fixed step as the player it is following and a save taken mid-carry restores a box that is still being carried.
Step 7: Transitions
The controller loads a level through the level manager's transition, which deactivates the current level, loads the new one, disposes the old one, and activates the new one:
await levelManager.transition(levelName);Loading the new level rebuilds engine.simulation for the new scene, so the old level's entities are gone with it. level:transition:start, level:transition:complete, and level:progress fire on the engine's event bus; the controller forwards them to the event log and the loading screen. See Transitions.
Reloading the current level is the one case the transition does not cover on its own: the manager answers a name it already holds with that same loaded instance, so the controller unloads the current level first.
Property handlers
Before loading, register SAGE's built-in property handlers:
registerAllPropertyHandlers(engine.managers.levelManager);They process collider, trigger, sound, lod_distances, occluder, and visible on scene nodes. The spawn and entity markers are GameLevel's own. See Scene Node Metadata.
What you learned
- A
LevelConfignames a scene, a level class, and what each spawn marker and entity marker becomes - A spawn point is a place; an entity marker's node is the entity's own, adopted rather than built
- An adopted node is moved directly by a behavior, relative to its authored transform
- A
GameLevelsubclass adds loading steps inbuildScene()and its own rules on top - Cross-entity decisions are made outside the entities and delivered as targeted events
- A controller and a level hear the news back by registering a subscriber on the simulation's bus
Next steps
- Levels: every config field and property handler
- Authoring: entity definitions,
meshConfig, and adoption in full - Blender Workflow: authoring the GLB
