Skip to content

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 interactions

Step 1: A level config

Each level is a LevelConfig, written in YAML and parsed with js-yaml from a Vite ?raw import.

yaml
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: true
  • class names a registered GameLevel subclass to run the level with. Left out, the level runs as a plain GameLevel.
  • spawns maps a spawn marker's value to the class-table name to spawn there, with optional per-spawn config state, a per-instance name written into displayName, and tags.
  • entities maps an entity marker'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:

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

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

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

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

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

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

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

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

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

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

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

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

typescript
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 LevelConfig names 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 GameLevel subclass adds loading steps in buildScene() 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

Released under the MIT License.