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:
const levelManager = gameEngine.managers.levelManager;Typed by the entity registry
LevelManager, Level, GameLevel, LevelContext, and LevelConstructor carry the same Registry generic parameter createGameEngine does, defaulting to the widest entity registry, NodeEntityRegistry (see Entities). A game that types its own registry with satisfies NodeEntityRegistry and types its levels extends GameLevel<typeof entityClasses> spawns typed inside those levels, the same way it spawns typed on the engine:
import { GameLevel, HavokPhysicsBodyBehavior, NamedBehavior, defineEntity } from '@skewedaspect/sage';
import type { NodeEntityRegistry } from '@skewedaspect/sage';
export const CrateEntity = defineEntity({
type: 'CrateEntity',
tags: [ 'prop', 'pushable' ],
behaviors: [ HavokPhysicsBodyBehavior, NamedBehavior ] as const,
});
const entityClasses = { CrateEntity } satisfies NodeEntityRegistry;
class ArenaLevel extends GameLevel<typeof entityClasses>
{
// this.simulation.spawn(...) here is typed against entityClasses, not against a bare string name.
}A registry typed only as NodeEntityRegistry, rather than inferred from an actual object with literal keys, leaves spawn a string name over a plain state bag, on a level the same as on the engine. registerAllPropertyHandlers and the six built-in register*Handler functions each take what registers a handler and nothing more -- Pick<LevelManager, 'registerPropertyHandler'> -- so any level manager satisfies them whatever registry it carries.
Properties
| Property | Type | Description |
|---|---|---|
currentLevel | Level | null | The currently active level |
loadingLevel | Level | null | The level currently inside load(), while a transition builds the incoming level's scene |
propertyHandlers | Map<string, PropertyHandler> | Registered scene node property handlers |
Configuration API
| Method | Signature | Description |
|---|---|---|
registerLevelConfig | (config : LevelConfig) => void | Register a level configuration for later instantiation |
unregisterLevelConfig | (name : string) => Promise<void> | Unload the level registered under the name if it is loaded, then forget its configuration |
registerLevelClass | (name : string, levelClass : LevelConstructor) => void | Register a custom Level class referenced by configs |
getLevelConfig | (name : string) => LevelConfig | null | Look up a registered config |
unregisterLevelConfig(name) unloads the level registered under that name if it is loaded, the same as unloadLevel: it disposes the level, forgets it, and, if it was the current level, clears currentLevel and unbinds the engine. It then forgets the configuration, so getLevelConfig(name) answers null and loadLevel(name) throws a "not registered" error. A name nothing is registered under changes nothing.
Loading API
| Method | Signature | Description |
|---|---|---|
loadLevel(name, options?) | (string, LevelLoadOptions?) => Promise<Level> | Load a registered level by name |
loadLevel(config, options?) | (LevelConfig, LevelLoadOptions?) => Promise<Level> | Load from a config (auto-registers it) |
activateLevel(name) | (string) => Promise<Level> | Load (if needed), set as current level, and fire the lifecycle hooks |
activateLevel(config) | (LevelConfig) => Promise<Level> | Load from config, set as current, and fire the lifecycle hooks |
getLevel | (name : string) => Level | null | Get 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
| Method | Signature | Description |
|---|---|---|
transition | (levelName : string, options? : TransitionOptions) => Promise<void> | Full level-to-level transition |
Property Handler API
| Method | Signature | Description |
|---|---|---|
registerPropertyHandler | (property : string, handler : PropertyHandler) => void | Register a handler for scene node metadata |
unregisterPropertyHandler | (property : string) => void | Remove a property handler |
clearAllPropertyHandlers | () => void | Remove 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
| Method | Description |
|---|---|
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) |
createScene() | Protected. Builds an empty scene through the scene engine and makes it the level's scene |
Lifecycle Hooks
| Hook | When Called | Description |
|---|---|---|
onActivate() | Level becomes current, through activateLevel() or a transition | Start music, show UI, unpause physics |
onDeactivate() | Level is replaced as current, through activateLevel() or a transition | Save state, stop sounds, fade out |
Both hooks are optional and async. Activating the level that is already current fires neither: it rebinds the engine to that level's simulation and nothing else.
Creating a Custom Level
Extend Level and implement buildScene. A level reaches everything it has, the scene engine, the asset manager, and the audio manager where there is one, through this.context, which the manager hands it at construction; there is no engine reference on it. A level that spawns entities binds a simulation to its scene with the base class's own rebuildSimulation once the scene exists; every NodeEntity builds its node in the simulation's own scene, so nothing can spawn before that call.
A subclass creates its scene with the protected createScene(), which builds an empty scene through the scene engine in this.context and makes it the level's scene at once. If buildScene throws after calling it, the failed load disposes the level, which releases the scene:
import { Level } from '@skewedaspect/sage';
import { Color4, FreeCamera, Scene, Vector3 } from '@babylonjs/core';
class SpaceStation extends Level
{
protected async buildScene() : Promise<Scene>
{
const { sceneEngine } = this.context;
const scene = this.createScene();
this.$emitProgress(10, 'Creating scene...');
this.rebuildSimulation(scene, stationMeshSource);
scene.clearColor = new Color4(0, 0, 0.05, 1);
await sceneEngine.enablePhysics(scene, new Vector3(0, -1.62, 0));
this.$emitProgress(20, 'Physics configured...');
const camera = new FreeCamera('camera', new Vector3(0, 2, -5), scene);
camera.setTarget(Vector3.Zero());
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.
interface LevelConfig
{
name : string;
meshSource ?: MeshLoader;
fixedStepDuration ?: number;
scene ?: string | SceneConfig;
class ?: string;
config ?: Record<string, unknown>;
spawns ?: Record<string, SpawnDefinition>;
entities ?: Record<string, EntityDefinition>;
physics ?: boolean | { gravity ?: Vec3Config };
preload ?: string[];
environment ?: EnvironmentConfig;
cameras ?: Record<string, CameraDefinition>;
lights ?: Record<string, LightDefinition>;
sounds ?: Record<string, LevelSoundConfig>;
postProcessing ?: PostProcessingConfig;
clustering ?: ClusteringConfig;
outlines ?: Record<string, OutlineLayerConfig>;
}meshSource is a function, so a config that carries one is built in TypeScript, or has it attached after the YAML is parsed. A config with no meshSource builds its simulation without one, and every NodeEntity that level spawns then gets a TransformNode and no mesh. Nothing warns about it: the level loads, the spawns succeed, and nothing is drawn.
LevelConfig Properties
| Property | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Unique level identifier. Used as the key for transition() and loadLevel() calls. |
meshSource | MeshLoader | No | Builds a NodeEntity's mesh from its name and state, into this level's scene. Left unset, every entity this level spawns gets a node with no mesh, and nothing warns. See Authoring. |
fixedStepDuration | number | No | Seconds per fixed step for this level's simulation. Left unset, the simulation takes the engine's fixedStepDuration option (default 1 / 60). See The fixed step. |
scene | string | SceneConfig | No | Path to the scene file (.glb, .gltf, .babylon), or { path, rightHanded? }. The file is imported into the scene and its nodes are processed for metadata. |
class | string | No | Name of a registered custom Level class. If omitted, GameLevel is used. |
config | Record<string, unknown> | No | Arbitrary configuration data passed to the Level instance. See Custom Config Data. |
spawns | Record<string, SpawnDefinition> | No | Spawn point definitions. Keys match the spawn metadata values on scene nodes. |
entities | Record<string, EntityDefinition> | No | Entity marker definitions. Keys match the entity metadata values on scene nodes. |
physics | boolean | { gravity } | No | Enable physics. true uses default gravity (0, -9.81, 0). An object allows custom gravity. |
preload | string[] | No | Asset paths to cache before building the scene. |
environment | EnvironmentConfig | No | Skybox and IBL (image-based lighting) configuration. See Environment Configuration. |
cameras | Record<string, CameraDefinition> | No | Camera configuration. Keys match Blender camera names (override) or define new cameras (create). See Camera Configuration. |
lights | Record<string, LightDefinition> | No | Light configuration. Keys match Blender light names (override) or define new lights (create). See Light Configuration. |
sounds | Record<string, LevelSoundConfig> | No | Level-scoped sounds tied to the level lifecycle. See Level Sounds. |
postProcessing | PostProcessingConfig | No | Declarative post-processing effects applied after the scene is built. |
clustering | ClusteringConfig | No | Clustered forward+ lighting for point and spot lights. See Clustered Lighting. |
outlines | Record<string, OutlineLayerConfig> | No | Named selection outline layers. See Outline Layers. |
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):
name: Arena
class: arenaLevel
scene: /assets/arena.glb
physics: true
config:
difficulty: hard
enemyCount: 12
timeLimit: 120
theme: fireimport 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;
}
private _spawnExtraTraps() : void { /* level-specific spawning */ }
private _spawnEnemy() : void { /* level-specific spawning */ }
}GameLevel itself ignores the config field beyond storing it, which makes it a clean extension point for your own level classes without modifying the engine.
SpawnDefinition
interface SpawnDefinition
{
entity : string;
displayName ?: string;
tags ?: string[];
config ?: Record<string, unknown>;
debugCollider ?: ColliderDebugConfig;
}| Property | Type | Required | Description |
|---|---|---|---|
entity | string | Yes | The registry name to spawn. |
displayName | string | No | A per-instance label, written into the entity's displayName state field, never into its own name. |
tags | string[] | No | The tags the spawned entity starts with. |
config | Record<string, unknown> | No | Per-spawn state, layered over the class's defaults. |
debugCollider | ColliderDebugConfig | No | Written into the entity's own debugCollider state field. |
When a spawn point is processed, SAGE spawns an entity of the given class through the level's simulation, with the spawn node's position and rotation written into its state. The spawn node's scale is not carried.
spawns:
player_start:
entity: PlayerEntity
displayName: Hero
tags: [ character, controllable ]
config:
health: 100
maxHealth: 100
speed: 8The spawn node (typically a Blender Empty) is disposed after the entity is created. The entity's mesh comes from the level's meshSource, keyed by the entity's name.
EntityDefinition
type EntityDefinition = SpawnDefinition;EntityDefinition and SpawnDefinition are one type: an entity marker's definition just adopts the marked node instead of building one.
| Property | Type | Required | Description |
|---|---|---|---|
entity | string | Yes | The registry name to spawn, adopting the marked node. |
displayName | string | No | A per-instance label, written into displayName. |
tags | string[] | No | The tags the entity starts with. |
config | Record<string, unknown> | No | Per-spawn state, layered over the class's defaults. |
debugCollider | ColliderDebugConfig | No | Written into the entity's own debugCollider state field. |
Entity definitions describe entities whose node already exists in the scene file. Unlike spawns, which build a new node at an empty, the entities section makes the marked node the entity's own:
entities:
door:
entity: DoorEntity
config:
locked: true
health: 200
crate:
entity: CrateEntity
tags: [ pushable ]
config:
weight: 50In Blender, you set a custom property entity = "door" on the mesh. When the level loads, SAGE spawns a DoorEntity that adopts that node: no TransformNode is built, no mesh is loaded, and the entity's nodeName state field records the node's name. See Authoring.
Physics Configuration
Enable physics with default Earth gravity:
physics: trueOr specify custom gravity as a vector:
# Low gravity (moon-like)
physics:
gravity:
x: 0
y: -1.62
z: 0# Zero gravity (space)
physics:
gravity:
x: 0
y: 0
z: 0# Side-scrolling gravity (pushes objects to the right)
physics:
gravity:
x: 5
y: -9.81
z: 0When 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:
# -----------------------------------------------
# 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 and cached before buildScene()
# runs, six at a time. 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 and rotation.
# -----------------------------------------------
spawns:
# A Blender Empty with custom property spawn="player_start"
player_start:
entity: PlayerEntity
displayName: Hero
tags: [ character ]
config:
health: 100
maxHealth: 100
# A Blender Empty with custom property spawn="enemy_patrol"
enemy_patrol:
entity: EnemyEntity
config:
patrolRadius: 10
aggroRange: 15
# Spawn with no config override (uses the class's defaults)
item_chest:
entity: ChestEntity
# -----------------------------------------------
# Entity markers (optional)
# Keys match the 'entity' metadata values on scene
# meshes. Unlike spawns, these adopt the EXISTING
# scene node as the entity's own (no node is built,
# no mesh is loaded).
# -----------------------------------------------
entities:
door:
entity: DoorEntity
config:
locked: true
crate:
entity: CrateEntity
tags: [ pushable ]
config:
weight: 50
# -----------------------------------------------
# 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:
preload:
- assets/models/environment.glb
- assets/models/props.glb#barrel
- assets/models/props.glb#crate
- assets/audio/ambient.mp3Assets are loaded and cached by the AssetManager, six at a time, so a preload costs about as long as its slowest asset rather than the sum of them all. Later calls to assetManager.load() with the same path return instantly from the cache. Every path is attempted; if any of them fail, the preload throws an AggregateError naming every failed path.
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.
A skybox and IBL are fully declarative:
environment:
ibl: /assets/textures/studio.hdr
skybox: /assets/textures/milkyway_8k.jpg
skyboxSize: 5000EnvironmentConfig
interface EnvironmentConfig
{
ibl ?: string;
iblResolution ?: number;
skybox ?: string;
skyboxSize ?: number;
rotation ?: number;
}Properties
| Property | Type | Default | Description |
|---|---|---|---|
ibl | string | 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. | |
iblResolution | number | 256 | Resolution of the IBL cubemap in pixels. Higher values produce sharper reflections but use more memory. |
skybox | string | Path to the skybox texture. File format determines behavior (see below). | |
skyboxSize | number | 1000 | Diameter of the skybox in world units. For HDR/env files this is passed to createDefaultSkybox(). For JPG/PNG files this is the sphere diameter. |
rotation | number | Y-axis rotation in radians applied to scene.environmentTexture. Only the HDR/env modes set that texture, so rotation is a no-op in the JPG/PNG sphere mode. |
File Format Behavior
The file extension of the skybox and ibl paths determines how SAGE creates the texture:
| Extension | Texture Type | Notes |
|---|---|---|
.hdr | HDRCubeTexture | Uncompressed HDR data. Highest quality for reflections and skyboxes. Larger file sizes. |
.env | CubeTexture (pre-filtered) | BabylonJS pre-filtered format. Smaller files, faster loading. Convert an HDR in the BabylonJS sandbox, or call CreateEnvTextureAsync from @babylonjs/core/Misc/environmentTextureTools yourself. |
.jpg, .png | Texture on a sphere | Creates a StandardMaterial sphere with the image as emissive texture. No IBL data, since 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.
With ibl alone, the scene gets the PBR reflection source and no visible skybox. This is useful when you have your own sky geometry, or when the scene is indoors.
environment:
ibl: /assets/textures/studio_small.hdr
iblResolution: 128With skybox alone, in HDR or env format, that one file serves as both the visible background and the IBL reflection source. This is the simplest setup when your skybox texture is already in that format.
environment:
skybox: /assets/textures/sky.hdrWith skybox alone, in JPG or PNG, the scene gets a visible sphere background and no IBL, so PBR materials will not reflect the environment. Use this when you have a high-resolution panoramic image and don't need reflections.
environment:
skybox: /assets/textures/milkyway_8k.jpg
skyboxSize: 5000With both ibl and skybox set separately, recommended for quality, a low-resolution HDR gives fast, accurate reflections while a high-resolution image gives the visible background, avoiding the quality loss of converting a detailed panorama to a cubemap.
# 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: 5000TIP
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
interface CameraDefinition
{
type ?: 'free' | 'arcRotate' | 'universal' | 'geospatial';
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;
// geospatial
planetRadius ?: number;
yaw ?: number;
pitch ?: number;
center ?: Vec3Config;
checkCollisions ?: boolean;
radiusMin ?: number;
radiusMax ?: number;
pitchMin ?: number;
pitchMax ?: number;
yawMin ?: number;
yawMax ?: number;
}A geospatial camera orbits a spherical planet. planetRadius is required to create one; the rest tune its orbit and limits. A geospatial entry naming no planetRadius is logged and skipped rather than falling back to a plain free camera.
Override vs. Create
A camera key that matches a camera name already in the scene, imported from Blender (Camera, MainCamera, and the like), is override mode: that camera already exists, so SAGE applies the YAML properties to it and type is omitted. A key that names no scene camera is create mode instead, and there type is required; SAGE creates a fresh camera of that type. A key that neither matches a scene camera nor carries a type is logged and skipped.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
type | 'free' | 'arcRotate' | 'universal' | 'geospatial' | Camera type. Required for create mode, omit for override mode. | |
active | boolean | false | Mark this camera as the active camera for the level. |
attachControl | boolean | true | Attach 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. |
position | Vec3Config | (0, 0, 0) | Camera position in world space. Used in create mode for free and universal cameras. |
target | Vec3Config | (0, 0, 0) | The point the camera looks at and orbits. Used by arcRotate cameras (both create and override). |
rotation | Vec3Config | Camera rotation in radians. Used by free and universal cameras (both create and override). | |
fov | number | Field of view in radians. Applies to all camera types. | |
minZ | number | Near clipping plane distance. | |
maxZ | number | Far clipping plane distance. | |
speed | number | Movement speed. Applies to free and universal cameras only. | |
alpha | number | Math.PI / 2 | Horizontal rotation angle in radians. ArcRotate only. |
beta | number | Math.PI / 3 | Vertical rotation angle in radians. ArcRotate only. |
radius | number | 10 | Distance from the orbited point. ArcRotate only. |
lowerRadiusLimit | number | Minimum zoom distance. ArcRotate only. | |
upperRadiusLimit | number | Maximum zoom distance. ArcRotate only. | |
lowerBetaLimit | number | Minimum vertical angle (prevents looking from below). ArcRotate only. | |
upperBetaLimit | number | Maximum vertical angle (prevents looking from above). ArcRotate only. | |
wheelPrecision | number | 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:
- The last camera with
active: truebecomes active. Each entry marked active replaces the one before it, so with several marked, the one furthest down the record wins. - 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:
# The scene file contains a camera named "Camera"
cameras:
Camera:
minZ: 0.1
maxZ: 500Create an arcRotate camera for a top-down view:
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.4708Create a free camera for first-person movement:
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: trueLight 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
interface LightDefinition
{
type ?: 'hemispheric' | 'directional' | 'point' | 'spot' | 'rectarea';
intensity ?: number;
diffuse ?: ColorConfig;
specular ?: ColorConfig;
position ?: Vec3Config;
direction ?: Vec3Config;
groundColor ?: ColorConfig;
angle ?: number;
exponent ?: number;
width ?: number;
height ?: number;
}Override vs. Create
Lights follow the same pattern as cameras. A key that matches a light name from Blender is override mode: SAGE applies the YAML properties (intensity, color, direction) to the existing light, and type is omitted. A new name is create mode, and there type is required.
TIP
The glTF light extension BabylonJS reads, KHR_lights_punctual, builds directional, point, and spot lights and nothing else, so no scene file imports a hemispheric light for a key to override. Ambient hemisphere lighting is always create mode with type: hemispheric, which is the most common use of the lights section.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
type | 'hemispheric' | 'directional' | 'point' | 'spot' | 'rectarea' | Light type. Required for create mode, omit for override mode. | |
intensity | number | Light brightness. Applies to all light types. | |
diffuse | ColorConfig | Diffuse color as { r, g, b } with values 0.0--1.0. | |
specular | ColorConfig | Specular highlight color as { r, g, b } with values 0.0--1.0. | |
position | Vec3Config | (0, 0, 0) | World position. Used by point and spot lights. |
direction | Vec3Config | (0, -1, 0) | Light direction vector. Used by hemispheric, directional, and spot lights. |
groundColor | ColorConfig | Ground reflection color. Hemispheric lights only. | |
angle | number | Math.PI / 3 | Cone angle in radians. Spot lights only. |
exponent | number | 2 | Falloff exponent controlling how light fades from center to edge of the cone. Spot lights only. |
width, height | number | 1 | The emitting rectangle. Rect area lights only. A rect area light emits along its local -Z; a direction parents it to a pivot rotated to face that way. |
Vec3Config and ColorConfig
Both are simple objects used throughout camera and light configuration:
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:
# 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:
# 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):
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:
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:
# 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.
Level sounds are declarative:
sounds:
background-music:
url: /assets/audio/exploration.ogg
channel: music
loop: true
autoplay: true
volume: 0.7Prerequisites
Level sounds require the AudioManager to be initialized. You must pass audioChannels in your SageOptions when creating the engine:
import { createGameEngine } from '@skewedaspect/sage';
const engine = await createGameEngine(canvas, { /* your entity registry */ }, {
audioChannels: [ 'music', 'sfx', 'ambient' ],
});If no AudioManager is configured, SAGE logs a warning and skips sound creation.
LevelSoundConfig
interface LevelSoundConfig
{
url : string;
channel ?: string;
loop ?: boolean;
autoplay ?: boolean;
volume ?: number;
}Properties
| Property | Type | Default | Description |
|---|---|---|---|
url | string | (required) | Path to the audio file (.ogg, .mp3, .wav). |
channel | string | Audio channel name (e.g., 'music', 'sfx', 'ambient'). Must match a channel name passed in audioChannels. Controls volume grouping. | |
loop | boolean | false | Whether the sound loops continuously. |
autoplay | boolean | false | Whether the sound starts playing immediately when the level finishes loading. |
volume | number | 1 | Playback 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 Event | Sound Behavior |
|---|---|
| Level loads | Sounds are created via AudioManager.createSound(). Sounds with autoplay: true begin playing. |
| Level deactivates | All currently playing sounds are paused. SAGE tracks which sounds were playing so only those are resumed later. |
| Level activates | Sounds that were playing before deactivation are resumed. Sounds that were already paused (e.g., not yet triggered) remain paused. |
| Level unloads | All 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:
sounds:
theme:
url: /assets/audio/dungeon-theme.ogg
channel: music
loop: true
autoplay: true
volume: 0.6Ambient soundscape layered with music:
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.2A sound that is loaded but not auto-played (triggered later by game logic):
sounds:
victory-fanfare:
url: /assets/audio/victory.ogg
channel: sfx
loop: false
autoplay: false
volume: 1.0Level sounds vs. the sound property handler
The sounds YAML section creates non-spatial audio tied to the level lifecycle, such as background music and ambient loops. The sound property handler, set on Blender meshes, creates spatial audio positioned in 3D space, such as 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
interface PostProcessingConfig
{
renderer ?: 'pipeline' | 'frameGraph';
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 };
volumetric ?: { extinction ?: number; scattering ?: number };
}| Field | Meaning |
|---|---|
renderer | The rendering backend: pipeline, BabylonJS's DefaultRenderingPipeline with a separate SSAO pipeline beside it, or frameGraph, a chain of Babylon frame-graph tasks loaded on demand. Defaults to pipeline. |
volumetric | Volumetric lighting. Recognized by both backends; neither currently wires it up to the scene's lights, so it logs a warning and applies nothing. |
Bloom, SSAO, tone mapping, chromatic aberration, grain, vignette, and sharpening are configured the same way under either renderer.
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.
| Parameter | Type | Default | Description |
|---|---|---|---|
weight | number | 0.15 | Intensity 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. |
threshold | number | 0.9 | Brightness cutoff. Only pixels brighter than this value produce bloom. Lower values bloom more of the scene. Range: 0.0 to 1.0. |
scale | number | 0.5 | Resolution scale of the bloom texture. Lower values are cheaper but blurrier. Range: 0.0 to 1.0. |
kernel | number | 64 | Size of the blur kernel. Larger kernels produce smoother, wider bloom halos but cost more. Common values: 32, 64, 128. |
# 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: 128SSAO (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.
| Parameter | Type | Default | Description |
|---|---|---|---|
radius | number | 2.0 | Sampling radius around each pixel. Larger values darken wider areas but can look unrealistic. Range: 0.5 to 8.0 for most scenes. |
samples | number | 8 | Number of samples per pixel. More samples reduce noise but cost more. Common values: 8, 16, 32. |
totalStrength | number | 1.0 | Overall 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.
# 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.5INFO
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.
| Parameter | Type | Default | Description |
|---|---|---|---|
operator | string | 'hable' | The tone mapping algorithm. See below for options. |
| Operator | BabylonJS Mapping | Description |
|---|---|---|
hable | TONEMAPPING_STANDARD | A filmic curve with gentle highlight rolloff. Good general-purpose choice. |
reinhard | TONEMAPPING_STANDARD | Classic Reinhard mapping. Similar to Hable in BabylonJS's implementation. |
hejidawson | TONEMAPPING_STANDARD | Hejl-Dawson mapping. Also maps to standard in BabylonJS. |
photographic | TONEMAPPING_STANDARD | Photographic-style mapping. Also maps to standard in BabylonJS. |
aces | TONEMAPPING_ACES | Academy 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.
postProcessing:
tonemap:
operator: acesChromatic 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.
| Parameter | Type | Default | Description |
|---|---|---|---|
amount | number | 30 | Intensity 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.
# Subtle lens fringe
postProcessing:
chromaticAberration:
amount: 0.4
# Heavy distortion (horror, glitch effect)
postProcessing:
chromaticAberration:
amount: 5.0Film Grain
Adds a noise texture over the image, simulating analog film grain. Useful for gritty, horror, or retro aesthetics.
| Parameter | Type | Default | Description |
|---|---|---|---|
intensity | number | 30 | Density of the grain noise. The BabylonJS default is 30, which is quite visible. Values of 5--20 produce a subtle texture. |
animated | boolean | false | When true, the grain pattern changes every frame, creating a shimmering film-like quality. When false, the grain is static. |
# Subtle animated grain
postProcessing:
grain:
intensity: 10
animated: true
# Heavy static grain (VHS / found footage look)
postProcessing:
grain:
intensity: 40
animated: falseVignette
Darkens the edges and corners of the screen, drawing focus toward the center. Often combined with a color tint for mood.
| Parameter | Type | Default | Description |
|---|---|---|---|
weight | number | 1.5 | Intensity 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. |
stretch | number | 0 | How 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. |
# 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.05Sharpen
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.
| Parameter | Type | Default | Description |
|---|---|---|---|
edge | number | 0.3 | Edge sharpening intensity. Higher values make edges more pronounced. Range: 0.0 to 1.0. |
color | number | 1.0 | Color channel sharpening. Higher values make colors more vivid at edges. Range: 0.0 to 1.0. Lower values desaturate sharpened edges. |
# Subtle sharpening
postProcessing:
sharpen:
edge: 0.2
color: 0.8
# Strong sharpening with desaturated edges
postProcessing:
sharpen:
edge: 0.5
color: 0.3Pipeline Architecture
Most effects use the BabylonJS DefaultRenderingPipeline, created with the name 'sage-default' and attached to every camera in the scene. SSAO uses a separate SSAO2RenderingPipeline named 'sage-ssao', constructed without a camera list, so it does not attach itself to any camera.
GameLevel applies its postProcessing config itself. A Level subclass that builds its own scene applies one by calling applyPostProcessing(scene, config, logger), the exported dispatcher that picks the frame graph or the pipeline backend from the config's renderer field and reports what it did through the logger it is handed.
import { applyPostProcessing } from '@skewedaspect/sage';
await applyPostProcessing(scene, config, logger);Runtime Tweaks
Access BabylonJS pipelines directly for runtime adjustments:
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 that into one flat record: the node's own top-level keys with its glTF extras merged onto them, extras winning where both name one. The gltf container itself is left out, since it is the loader's record and never an authored property, so a node with no extras and no top-level keys of its own normalizes to an empty record and the walk skips it.
Two metadata keys are handled directly by GameLevel, not via property handlers.
| Key | Marks | Value |
|---|---|---|
spawn | A spawn point | Must match a key in the config's spawns section |
entity | An entity marker, whose node the spawned entity adopts | 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:
import { registerAllPropertyHandlers } from '@skewedaspect/sage';
registerAllPropertyHandlers(levelManager);Or register them individually:
import {
registerColliderHandler,
registerLodHandler,
registerOcclusionHandler,
registerSoundHandler,
registerTriggerHandler,
registerVisibleHandler,
} from '@skewedaspect/sage';
registerColliderHandler(levelManager);
registerTriggerHandler(levelManager);
// ... etcRegistering is the game's to do, and a scene loaded with nothing registered says so: a level warns for each of SAGE's own six property names it finds on a node with no handler registered for it, naming the node and the property. Whatever else a glb's exporter wrote it leaves alone.
BUILT_IN_PROPERTIES is that list of six, exported as a value, so a game that registers handlers of its own can check what it has covered:
import { BUILT_IN_PROPERTIES } from '@skewedaspect/sage';
for(const property of BUILT_IN_PROPERTIES)
{
console.log(property, levelManager.propertyHandlers.has(property));
}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 Key | Value Type | Handler | Description |
|---|---|---|---|
spawn | string | Built-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. |
entity | string | Built-in (GameLevel) | Marks a node as an entity. Value must match a key in the YAML entities section. The spawned entity adopts the node as its own. |
collider | string | registerColliderHandler | Physics collider shape. Values: "box", "sphere", "mesh", "none". Creates a physics aggregate, static unless collider_motion says otherwise. Any other value warns through the handler logger, naming the node and the four values it accepts, and builds nothing. |
collider_mesh | boolean | (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. |
collider_motion | string | (Used by collider) | "static", "dynamic", or "kinematic". Defaults to "static"; an unrecognized value warns and falls back to it. |
collider_mass | number | (Used by collider) | Mass of the physics body, in kilograms. Sets the mass and nothing else: it does not decide the motion type. A static body's mass is always 0 regardless of this value. |
trigger | string | registerTriggerHandler | Trigger zone name. The mesh is made invisible. Emits trigger:enter and trigger:exit events when other meshes intersect it. |
lod_distances | string | registerLodHandler | Comma-separated distances for LOD levels (e.g., "10,25,50"). The mesh itself renders up to the first distance; its child meshes take over in order past each one. A distance left over with no child to fill it hides the mesh beyond that point. |
occlusion | string | registerOcclusionHandler | Sets the mesh up for BabylonJS's occlusion culling: "optimistic" or "strict" sets the mesh's occlusionType, and BabylonJS then skips the mesh on a frame the previous frame's query found it hidden. Any other value warns and leaves the mesh's occlusion type alone. |
visible | boolean | string | number | registerVisibleHandler | Controls mesh visibility. Accepts true/false, "true"/"false", or 1/0. Any other value, including an unrecognized string, leaves the mesh visible. |
sound | string | registerSoundHandler | Path 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 physics aggregates for scene geometry. Its value names the shape, and only these four values work. Anything else warns through the handler logger, naming the node and the four values it accepts, and builds nothing:
| Value | Physics Shape | Use Case |
|---|---|---|
"box" | PhysicsShapeType.BOX | Rectangular objects like walls, crates, platforms |
"sphere" | PhysicsShapeType.SPHERE | Round objects like boulders, balls |
"mesh" | PhysicsShapeType.MESH | Complex 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" | None | Explicitly disables collision on a node: it builds nothing, the same as a node carrying no collider at all |
box and sphere work on either a mesh or a bare TransformNode, measured from the node's own bounds when it has geometry and from everything hanging under it when it does not. mesh needs real geometry.
A collider is static unless the node says otherwise:
| Metadata Key | Type | Default | Description |
|---|---|---|---|
collider_motion | string | "static" | The same three words HavokPhysicsBodyBehavior's own motion field uses: "static", "dynamic", or "kinematic". An unrecognized value warns and builds a static body. |
collider_mass | number | 1 | Mass of the physics body, in kilograms. Sets the mass and nothing else: a node carrying mass but no collider_motion is still static. A static body's mass is always 0 regardless of this value. |
See the Blender workflow guide for how these are set in Blender.
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:
| Event | Payload |
|---|---|
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. Each distance is the point past which the next child mesh takes over, so the mesh carrying the property is itself the highest detail level:
lod_distances = "10,25,50"With three child meshes:
- 0--10 units: The mesh itself (highest detail)
- 10--25 units: First child mesh (medium detail)
- 25--50 units: Second child mesh (low detail)
- 50+ units: Third child mesh (lowest detail)
Nothing is hidden there: every distance has a child to fill it. To stop rendering the mesh in the distance, give it one more distance than it has children. With the same three children, "10,25,50,120" puts the third child beyond 50 and hides the mesh past 120.
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 Key | Type | Default | Description |
|---|---|---|---|
sound | string | (required) | Path to the audio file |
sound_volume | number | 1 | Playback volume, 0.0 to 1.0 |
sound_loop | boolean | true | Whether to loop playback |
sound_spatial | boolean | true | Enable 3D spatial audio (positional sound) |
sound_distance | number | 100 | Maximum audible distance for spatial audio |
sound_autoplay | boolean | true | Start playback immediately |
sound_channel | string | 'ambient' | Audio channel name for volume grouping |
Writing a Custom Property Handler
Register custom handlers to process your own Blender custom properties. A handler that publishes its own event declares that event into the event map first, the same way any other emission does:
import type { TransformNode } from '@babylonjs/core';
import type { HandlerContext, LevelInstance } from '@skewedaspect/sage';
declare module '@skewedaspect/sage-core'
{
interface EventMap
{
'waypoint:registered' : {
route : string;
position : { x : number; y : number; z : number };
nodeName : string;
};
}
}
// 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,
context : HandlerContext
) =>
{
const routeName = value as string;
const position = node.position.clone();
context.bus.emit({
type: 'waypoint:registered',
senderID: level.name,
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:
| Argument | Type | Description |
|---|---|---|
node | TransformNode | The scene node that has this metadata property |
value | unknown | The value of the metadata property |
level | LevelInstance | The level being loaded (provides access to name and scene) |
context | HandlerContext | The three things a handler is handed: bus, logger, and, where the game opted into audio, audioManager |
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.
declare function transition(levelName : string, options ?: TransitionOptions) : Promise<void>;The manager tracks the current level internally. You pass only the name of the level to transition to.
TransitionOptions
interface LevelLoadOptions
{
forRestore ?: boolean;
}
interface TransitionOptions extends LevelLoadOptions
{
keepAlive ?: boolean;
preloadOnly ?: boolean;
}| Option | Type | Default | Description |
|---|---|---|---|
forRestore | boolean | false | The load is restoring a save. The level runs no marker-driven spawning of its own; the restored snapshot is the sole source of entities. The save manager sets this. |
keepAlive | boolean | false | Keep the old level loaded in memory after transitioning |
preloadOnly | boolean | false | Load the new level but do not activate it |
Transition Flow
- Emit
level:transition:startwith{ from, to } - Emit
level:transition:progresswith{ stage: 'loading', levelName } - Load the new level (
buildScene()runs, unless the level is already loaded). A transition to the level already current, or a restore (forRestore: true), unloads the target first so this step rebuilds it fresh rather than answering with the instance already there; for a restart specifically, the old level'sonDeactivate()runs right before that rebuild disposes it, since that is the last moment it exists. A level already loaded answers with the instance already loaded and logs at debug, with no warning. - Stop here if
preloadOnly: true: the new level stays loaded, and the current level stays current, bound, and undeactivated. - Call
onDeactivate()on the old level (if defined and step 3 didn't already call it for a restart) - Dispose the old level, unless
keepAlive: trueor step 3 already disposed it - Set the new level as current and bind the engine to its simulation
- Call
onActivate()on the new level (if defined) - Emit
level:transition:complete
If any step throws, level:transition:error is emitted with { from, to, error } and the exception re-throws. The level that was current is restored as current, with its own simulation rebound, but only while that level is still loaded. A keepAlive transition deactivated that level at step 5 without unloading it, so restoring it also calls its onActivate() again, resuming what the deactivation paused. Every other restore finds a level this attempt never deactivated, and calls neither hook.
Two failure points come after the old level is already gone: a restart's reload, which throws after the rebuild has disposed the instance it is replacing, and a transition without keepAlive whose new level throws from onActivate, after the old level has already been unloaded. Past either point there is no loaded level to put back, so the catch leaves nothing current and the engine unbound.
Usage
// 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
gameEngine.bus.on('level:transition:start', () =>
{
showLoadingScreen();
});
gameEngine.bus.on('level:transition:complete', () =>
{
hideLoadingScreen();
});
gameEngine.bus.on('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 such as fades or wipes. 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.
| Event | Payload | Description |
|---|---|---|
level:progress | { levelName, progress, message? } | Loading progress (0--100) |
level:complete | { levelName, message?, level } | Level loaded successfully. level is the loaded instance. |
level:error | { levelName, message, error } | Level loading failed |
Emitting Progress
Inside buildScene(), call $emitProgress():
import { Level } from '@skewedaspect/sage';
import type { Scene } from '@babylonjs/core';
class ExampleLevel extends Level
{
protected async buildScene() : Promise<Scene>
{
const { sceneEngine } = this.context;
const scene = this.createScene();
this.$emitProgress(0, 'Starting...');
await 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
gameEngine.bus.on('level:progress', (event) =>
{
const { progress } = event.payload;
updateLoadingBar(progress);
});
gameEngine.bus.on('level:complete', () =>
{
hideLoadingScreen();
});Type-Safe Event Payloads
gameEngine.bus is an EventBus, typed against sage-core's one EventMap, so event.payload above is already LevelProgressPayload, with no cast needed. Import the type directly when you need to name it, such as in a handler function's own parameter:
import type { LevelProgressPayload } from '@skewedaspect/sage';
function updateLoadingBar(payload : LevelProgressPayload) : void
{
setProgress(payload.progress);
}Every library event has an importable payload type this way: LevelCompletePayload, LevelErrorPayload, LevelTransitionStartPayload, LevelTransitionProgressPayload, LevelTransitionCompletePayload, LevelTransitionErrorPayload, TriggerEnterPayload, TriggerExitPayload, AssetProgressPayload, AssetCompletePayload, GamePausedPayload, and GameResumedPayload.
GameLevel buildScene Pipeline
When GameLevel.buildScene() is called, it executes the following steps in order:
- Preload assets -- If
preloadis configured, all listed assets are loaded and cached. - Create scene -- An empty BabylonJS scene is created.
- Bind a simulation --
this.rebuildSimulation(scene, meshSource), the base class's own method, builds a freshNodeSimulationfor this scene, with the config'smeshSourceandfixedStepDuration. Everything the level spawns goes through it. - Enable physics -- If
physicsis configured, Havok physics is initialized with the given gravity. - Load scene file -- If
sceneis configured, the GLB/glTF/Babylon file is imported into the scene. - Set up environment -- If
environmentis configured, the IBL texture and/or skybox are created. This runs before cameras so the background is ready when the scene renders. - Process cameras -- Cameras from the
camerasconfig are applied (overrides) or created. The active camera is set and canvas controls are attached. - Process lights -- Lights from the
lightsconfig are applied (overrides) or created, and clustered lighting is set up whenclusteringis enabled. - Apply post-processing -- If
postProcessingis configured, rendering pipelines are set up. Requires an active camera from step 7. - Create the outline manager -- Built for every level, unconditionally, with whatever layers the
outlinesconfig declares. Reachable afterward aslevel.outlines. - Process node properties -- All scene nodes are walked. Metadata is normalized from glTF extras. Each node is checked for
spawnandentitymarkers, and all registered property handlers are invoked for matching metadata keys. - Process spawn points -- Spawn points collected in step 11 are matched against the
spawnsconfig. Entities are spawned at each spawn position. Skipped when the level is loaded withforRestore. - Process entity nodes -- Entity markers collected in step 11 are matched against the
entitiesconfig. Each spawned entity adopts its marked node. Skipped when the level is loaded withforRestore. - Load level sounds -- If
soundsis configured andAudioManageris available, level-scoped sounds are created. Sounds withautoplay: truebegin 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:
import { GameLevel } from '@skewedaspect/sage';
import type { Scene } from '@babylonjs/core';
class PuzzleLevel extends GameLevel
{
protected override async buildScene() : Promise<Scene>
{
this.$emitProgress(5, 'Custom setup...');
// Runs the full GameLevel pipeline (steps 1-14)
const scene = await super.buildScene();
// Add your custom level logic here
this._setupPuzzleMechanics();
this._subscribeToEvents();
return scene;
}
private _setupPuzzleMechanics() : void { /* level-specific setup */ }
private _subscribeToEvents() : void { /* level-specific setup */ }
}Level Lifecycle
- Registration -- Config registered with
registerLevelConfig() - Loading --
loadLevel()creates the instance and callsbuildScene() - Activation --
activateLevel()or a transition sets the level ascurrentLeveland firesonActivate(). Activating the level already current fires neither hook. - Active -- Scene is rendered, entities are updated
- Deactivation -- The outgoing level's
onDeactivate()fires before the incoming level becomes current - Disposal --
dispose()cleans up the scene and resources
Custom Disposal
Override dispose() for custom cleanup. Always call super.dispose():
import { Level } from '@skewedaspect/sage';
import type { Scene, StaticSound } from '@babylonjs/core';
class MyLevel extends Level
{
private ambientSound : StaticSound | null = null;
protected async buildScene() : Promise<Scene>
{
return this.createScene();
}
override async dispose() : Promise<void>
{
if(this.ambientSound)
{
this.ambientSound.stop();
this.ambientSound.dispose();
this.ambientSound = null;
}
await super.dispose();
}
}GameLevel.dispose() despawns every entity the level spawned from its markers (level.spawnedEntities), disposes all level sounds, and clears internal collections before calling Level.dispose(), which disposes the outline manager, the clustered light container, and the BabylonJS scene. Entities a game spawned itself through the engine's own simulation are not despawned by the level; the scene they lived in is gone, and the next level's rebuildSimulation replaces the simulation.
Clustered Lighting
interface ClusteringConfig
{
enabled : boolean;
horizontalTiles ?: number;
verticalTiles ?: number;
depthSlices ?: number;
maxRange ?: number;
}When enabled, every eligible point and spot light in the scene, imported or created from lights, is moved into a ClusteredLightContainer, and PBR materials are switched to glTF light falloff. The container is reachable as level.clusteredLights. Hemispheric, directional, and rect area lights are unaffected.
Outline Layers
interface OutlineLayerConfig
{
color ?: ColorConfig;
thickness ?: number;
occlusionStrength ?: number;
}Named screen-space outline layers for entity highlighting. GameLevel builds an OutlineManager from the config, reachable as level.outlines, and a game highlights a NodeEntity with level.outlines.highlightEntity(layerName, entity) and clears it with unhighlightEntity.
LevelContext
The context the LevelManager constructs a level with, naming every dependency a level has. There is no engine reference in it; a Level subclass reaches everything through this.context. LevelContext carries the same Registry generic parameter as LevelManager and Level, defaulting to NodeEntityRegistry.
interface LevelContext<Registry extends NodeEntityRegistry = NodeEntityRegistry>
{
readonly bus : EventBus;
readonly sceneEngine : SceneEngine;
readonly assetManager : AssetManager;
readonly audioManager ?: AudioManager;
readonly rebuildSimulation : (scene : Scene, meshSource ?: MeshLoader) => NodeSimulation<Registry>;
readonly canvas : GameCanvas;
readonly largeWorldRendering : boolean;
readonly propertyHandlers : Map<string, PropertyHandler>;
readonly logger : LoggingUtility;
}| Field | Meaning |
|---|---|
bus | The engine's event bus, which a level publishes its progress, completion, and failure on |
sceneEngine | Creates the level's scene, imports its scene file, and enables physics on it |
assetManager | What a level preloads through |
audioManager | Present only for a game that opted into audio; what a level's sounds are created through |
rebuildSimulation | Builds the simulation for the scene a level just created, already named after the level, with the fixed-step duration the level's configuration names or the engine's when it names none |
canvas | What an active camera attaches its controls to |
largeWorldRendering | Whether large world rendering is on, which decides the floating-origin radius physics is enabled with |
propertyHandlers | The handler map the level manager owns |
logger | The logging utility a level names its own logger through |
PropertyHandler
Handlers process custom metadata properties on scene nodes during level loading, handed the node, the property's value, the level, and the two dependencies a handler has:
type PropertyHandler = (
node : TransformNode,
value : unknown,
level : LevelInstance,
context : HandlerContext
) => void | Promise<void>;
interface HandlerContext
{
readonly bus : EventBus;
readonly audioManager ?: AudioManager;
readonly logger : LoggerInterface;
}The logger is the game's own logging utility, under the name PropertyHandlers, so a warning about an authored property is written where the game's own logging is written and at the level the game set. Every handler SAGE provides that has a warning to give -- collider, sound, lod_distances, and occlusion -- gives it through this logger.
Register handlers on the level manager:
levelManager.registerPropertyHandler('sound', (node, value, level, context) =>
{
// Process nodes with a 'sound' metadata property
});A throwing handler is caught by the level's own dispatch, logged against the property name and the node name, and the walk continues to the next property and the next node.
Teardown
The LevelManager implements Disposable. Calling await gameEngine.stop() tears the whole engine down, including every loaded level, forgetting every registered level config and resetting the current level along the way.
