Sandbox
This guide walks you through a comprehensive first-person sandbox that brings together every major SAGE system: GLB scene loading from Blender, Havok physics character controller, entity behaviors for doors and elevators, spatial audio, pickups, pushable props, and input context switching. It is the closest thing to a real game in the examples.
The full source is in examples/src/examples/sandbox/.
Try it live
Run this example at Examples > Sandbox.
Project structure
sandbox/
├── index.vue # Vue component — loading screen, elevator UI, crosshair
├── types.ts # Controller callback and event payload types
├── player.ts # First-person character controller (Havok PhysicsCharacterController)
├── behaviors/
│ ├── DoorBehavior.ts # Sliding door with spatial sound effects
│ ├── ElevatorBehavior.ts # Multi-floor elevator with ANIMATED physics
│ ├── InteractBehavior.ts # Generic interaction (buttons, locked doors)
│ ├── PickupBehavior.ts # Pick up and drop items (key)
│ ├── CrateBehavior.ts # Openable crate with lid animation
│ └── PropBehavior.ts # Pushable physics prop
├── components/
│ └── elevatorPanel.vue # Floor selection UI overlay
├── entities/
│ └── definitions.ts # Entity type definitions
├── game/
│ ├── SandboxController.ts # Orchestrator
│ └── index.ts
├── input/
│ ├── bindings.ts # Gameplay + elevator_ui contexts
│ └── helpers.ts
├── levels/
│ ├── loader.ts # YAML parsing
│ ├── sandbox.yaml # Level config
│ └── source/ # Blender source files
└── utils/
└── audio.ts # Sound loading utilityWhat this example demonstrates
- First-person camera and physics character controller
- GLB scene loading with Blender-authored entities and spawn points
- Entity behaviors: sliding doors, a multi-floor elevator, pickups, openable crates, pushable props
- Spatial audio: door open/close sounds attached to meshes, ambient background
- Input contexts: switching between gameplay (WASD + interact) and elevator UI (mouse + Escape)
- Raycast-based interaction with prompt UI
- Platform following: player rides the elevator smoothly via frame-delta compensation
Step 1: The YAML level config
The sandbox uses a single level defined in YAML:
name: Sandbox
scene: /assets/sandbox/SAGE_dev-box.glb
physics: true
spawns:
player_start:
entity: player
preload:
- /assets/sandbox/SAGE_dev-box.glb
postProcessing:
bloom:
weight: 0.3
threshold: 0.8
tonemap:
operator: acesThe GLB file is authored in Blender with custom properties on meshes: entity markers for doors, buttons, elevators, keys, crates, and props; spawn markers for the player start position; collider properties for physics shapes. SAGE's property handlers process all of this automatically during scene load.
Step 2: Entity definitions
Entity definitions map Blender entity types to behaviors. The sandbox defines eight types:
export function getAllEntityDefinitions() : GameEntityDefinition[]
{
return [
{ type: 'player', defaultState: {}, behaviors: [] },
{ type: 'door', defaultState: {}, behaviors: [ DoorBehavior ] },
{ type: 'button', defaultState: {}, behaviors: [ InteractBehavior ] },
{ type: 'key', defaultState: {}, behaviors: [ PickupBehavior ] },
{ type: 'elevator', defaultState: {}, behaviors: [ ElevatorBehavior ] },
{ type: 'elevator_button', defaultState: {}, behaviors: [ InteractBehavior ] },
{ type: 'crate', defaultState: {}, behaviors: [ InteractBehavior, CrateBehavior ] },
{ type: 'prop', defaultState: {}, behaviors: [ PropBehavior ] },
] as GameEntityDefinition[];
}Notice how minimal these are -- the behaviors read their configuration from Blender metadata via onNodeAttached() rather than from the definition's defaultState. This puts the artist in control of per-instance configuration.
Step 3: First-person player controller
The player controller uses Havok's PhysicsCharacterController for capsule-based movement with proper collision response. It is a standalone function rather than a behavior because it manages the camera directly and needs raw per-frame key state.
export function createPlayer(
scene : Scene, canvas : HTMLCanvasElement, spawnPos : Vector3
) : Player
{
const startPos = spawnPos.add(new Vector3(0, (PLAYER_HEIGHT / 2) + 0.1, 0));
const controller = new PhysicsCharacterController(
startPos,
{ capsuleHeight: 1.8, capsuleRadius: 0.2 },
scene
);
const camera = new FreeCamera('playerCamera', new Vector3(
startPos.x, startPos.y + EYE_OFFSET, startPos.z
), scene);
// Remove default keyboard movement -- we handle it via the character controller
camera.inputs.removeByType('FreeCameraKeyboardMoveInput');
camera.attachControl(canvas, true);
// ...
}Movement uses a state machine with three states: on_ground, start_jump, and in_air. Each frame, the controller builds a movement direction from WASD keys relative to the camera's facing, then sets the desired velocity:
function update(dt : number) : void
{
const support = controller.checkSupport(deltaTime, DOWN);
charState = getNextState(support.supportedState);
const forward = camera.getDirection(Vector3.Forward());
forward.y = 0;
forward.normalize();
const right = camera.getDirection(Vector3.Right());
right.y = 0;
right.normalize();
const moveDir = Vector3.Zero();
if(keys['KeyW']) { moveDir.addInPlace(forward); }
if(keys['KeyS']) { moveDir.subtractInPlace(forward); }
if(keys['KeyD']) { moveDir.addInPlace(right); }
if(keys['KeyA']) { moveDir.subtractInPlace(right); }
// Set velocity based on state (ground/jumping/air)
controller.setVelocity(desiredVelocity);
controller.integrate(deltaTime, support, GRAVITY);
// Sync capsule and camera to physics position
const pos = controller.getPosition();
camera.position.set(pos.x, pos.y + EYE_OFFSET, pos.z);
}Pointer lock gives proper FPS mouse look -- clicking the canvas captures the cursor.
Step 4: DoorBehavior with spatial audio
The door behavior demonstrates BabylonJS animations combined with spatial sound. When activated, the door slides along its Z axis with a smooth animation, playing a positional sound effect:
export class DoorBehavior extends GameEntityBehavior
{
name = 'door';
eventSubscriptions = [ 'activate' ];
private openSound : StaticSound | null = null;
private closeSound : StaticSound | null = null;
onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void
{
this.node = node;
this.closedZ = node.position.z;
// Enable physics body to track mesh position during animations
const mesh = node as AbstractMesh;
if(mesh.physicsBody)
{
mesh.physicsBody.disablePreStep = false;
}
this.loadSounds(node);
}
private async loadSounds(node : TransformNode) : Promise<void>
{
const mesh = node as AbstractMesh;
this.openSound = await loadSound('doorOpen', '/assets/sandbox/door-open.mp3', {
spatialNode: mesh, volume: 0.5,
});
this.closeSound = await loadSound('doorClose', '/assets/sandbox/door-close.mp3', {
spatialNode: mesh, volume: 0.5,
});
}
}The loadSound utility wraps BabylonJS 8's audio API and handles spatial attachment:
export async function loadSound(
name : string, url : string,
options ?: { spatialNode ?: AbstractMesh; volume ?: number }
) : Promise<StaticSound>
{
const audioEngine = await CreateAudioEngineAsync();
const sound = await CreateSoundAsync(name, url, {
spatialEnabled: !!options?.spatialNode,
volume: options?.volume ?? 1.0,
});
if(options?.spatialNode)
{
sound.spatial?.attach(options.spatialNode);
}
await audioEngine.unlockAsync();
return sound;
}The door toggles with a BabylonJS Animation:
private toggle() : void
{
this.isOpen = !this.isOpen;
if(this.isOpen) { this.openSound?.play(); }
else { this.closeSound?.play(); }
const targetZ = this.isOpen ? this.closedZ - DOOR_SLIDE_DISTANCE : this.closedZ;
const duration = this.isOpen ? OPEN_DURATION : CLOSE_DURATION;
const anim = new Animation(
'doorSlide', 'position.z', ANIM_FPS,
Animation.ANIMATIONTYPE_FLOAT,
Animation.ANIMATIONLOOPMODE_CONSTANT
);
anim.setKeys([
{ frame: 0, value: this.node!.position.z },
{ frame: duration, value: targetZ },
]);
this.node!.animations = [ anim ];
this.node!.getScene().beginAnimation(this.node!, 0, duration, false);
}Setting physicsBody.disablePreStep = false in onNodeAttached() is critical -- it tells Havok to update the collision body to match the animated mesh position each frame. Without it, the door would slide visually but its collider would stay in place.
Step 5: The elevator system
The elevator is the most complex behavior in the examples. It manages multi-floor movement using ANIMATED physics bodies and communicates floor changes through the event bus.
ElevatorBehavior
export class ElevatorBehavior extends GameEntityBehavior
{
name = 'elevator';
eventSubscriptions = [ 'activate', 'elevator:call' ];
private currentFloor = 1;
private floorCount = 4;
private floorSpacing = 3.0;
onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void
{
this.node = node;
this.baseY = node.position.y;
// Read configuration from Blender metadata
this.floorCount = (node.metadata?.floor_count as number) ?? 4;
this.floorSpacing = (node.metadata?.floor_spacing as number) ?? 3.0;
// Set physics to ANIMATED so we drive position, not the physics engine
const mesh = node as AbstractMesh;
if(mesh.physicsBody)
{
mesh.physicsBody.setMotionType(PhysicsMotionType.ANIMATED);
}
}
}The key physics concept is PhysicsMotionType.ANIMATED. Unlike DYNAMIC bodies (driven by forces) or STATIC bodies (immovable), ANIMATED bodies have their position set by game code but still push other objects around. The elevator uses setTargetTransform() to move smoothly:
update(dt : number) : void
{
if(!this.moving) { this.zeroVelocity(); return; }
const diff = this.targetY - this.currentY;
if(Math.abs(diff) < 0.01)
{
// Arrived
this.moving = false;
this.currentFloor = this.targetFloor;
this.$emit({ type: 'elevator:arrived', payload: { floor: this.currentFloor } });
return;
}
const step = Math.sign(diff) * Math.min(ELEVATOR_SPEED * dt, Math.abs(diff));
this.currentY += step;
this.applyTargetTransform();
// Emit displacement so the player can follow
this.$emit({ type: 'elevator:frame-delta', payload: { deltaY: step } });
}Platform following
The player needs to ride the elevator smoothly. The character controller does not automatically follow moving platforms, so the elevator emits elevator:frame-delta events each frame with the exact Y displacement. The controller passes this to the player, which applies the shortfall after physics integration:
// In the player update loop
if(platformGrace > 0 && Math.abs(pendingPlatformDY) > 0.0001)
{
const posAfter = controller.getPosition();
const alreadyMoved = posAfter.y - posBefore.y;
const shortfall = pendingPlatformDY - alreadyMoved;
if(Math.abs(shortfall) > 0.001)
{
posAfter.y += shortfall;
controller.setPosition(posAfter);
}
}A grace period of 3 frames prevents jitter when the player steps on or off the platform.
Elevator panel UI
When the player interacts with an elevator button, the elevator emits elevator:panel-open. The controller catches this, switches the input context to elevator_ui, releases pointer lock, and shows the Vue panel:
eventBus.subscribe('elevator:panel-open', (event) =>
{
const payload = event.payload as ElevatorPanelPayload;
this.elevatorPanelOpen = true;
this.engine.managers.bindingManager.activateContext('elevator_ui');
document.exitPointerLock();
this.callbacks.onElevatorPanelOpen(payload.currentFloor, payload.floorCount);
});When the player selects a floor or presses Escape, the controller sends elevator:call, switches back to the gameplay context, and re-captures the pointer.
Step 6: InteractBehavior -- generic interaction with locking
The interact behavior reads its configuration from Blender metadata and dispatches activate events to target entities:
export class InteractBehavior extends GameEntityBehavior
{
name = 'interact';
promptText = 'to interact';
locked = false;
private target : string | null = null;
private context : Record<string, unknown> = {};
onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void
{
this.target = (node.metadata?.target as string) ?? null;
this.defaultPrompt = (node.metadata?.prompt as string) ?? 'to interact';
this.locked = !!(node.metadata?.locked);
this.promptText = this.locked ? 'Locked' : this.defaultPrompt;
// Parse extra context from Blender metadata
const rawContext = node.metadata?.context as string | undefined;
this.context = rawContext ? JSON.parse(rawContext) : {};
}
processEvent(event : GameEvent) : boolean
{
if(event.type !== 'interact') { return false; }
if(this.locked) { return false; }
if(this.target)
{
this.$emit({
type: 'activate',
payload: { target: this.target, ...this.context },
});
}
return true;
}
}The target property is the Blender object name of the entity to activate. For example, an elevator button has target = "Elevator" and context = '{"floor": 2}', so interacting with it sends { type: 'activate', payload: { target: 'Elevator', floor: 2 } } to the global event bus.
Locking is visual too -- when locked, the button light material changes to red. When unlocked (e.g., by using a key), it turns green:
unlock() : void
{
this.locked = false;
this.promptText = this.defaultPrompt;
this.unlockSound?.play();
if(this.lightMaterial)
{
this.lightMaterial.emissiveColor = EMISSIVE_UNLOCKED.clone();
}
}Step 7: PickupBehavior -- pick up and drop items
The pickup behavior handles item reparenting. When picked up, the item is reparented to the camera so it follows the player's view. Physics bodies are disposed during carry and recreated on drop:
private pickup() : void
{
// Dispose physics so it doesn't interfere with reparenting
const mesh = this.node as AbstractMesh;
if(mesh.physicsBody) { mesh.physicsBody.dispose(); }
// Reparent to camera
this.originalParent = this.node!.parent as TransformNode | null;
this.node!.setParent(this.camera!, false);
this.node!.position.copyFrom(HELD_POSITION);
this.node!.rotation.copyFrom(HELD_ROTATION);
this.held = true;
}
private drop() : void
{
// Raycast forward to find drop position
const ray = new Ray(this.camera!.position, forward, DROP_MAX_DISTANCE);
const pick = scene.pickWithRay(ray, (mesh) => mesh !== this.node);
const dropPos = pick?.pickedPoint?.clone() ?? /* fallback */;
// Restore parent and recreate physics
this.node!.setParent(this.originalParent);
this.node!.setAbsolutePosition(dropPos);
new PhysicsAggregate(mesh, PhysicsShapeType.BOX, { mass: 5 }, scene);
this.held = false;
}The controller checks if the player is holding a key when interacting with a locked button, and calls interact.unlock() if so.
Step 8: PropBehavior -- pushable physics objects
The prop behavior is the simplest -- it switches the mesh's physics body from STATIC to DYNAMIC and sets a mass, letting the player push it around:
export class PropBehavior extends GameEntityBehavior
{
name = 'prop';
onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void
{
const mass = (node.metadata?.mass as number) ?? 20;
const mesh = node as AbstractMesh;
if(mesh.physicsBody)
{
mesh.physicsBody.setMotionType(PhysicsMotionType.DYNAMIC);
mesh.physicsBody.setMassProperties({ mass });
}
for(const child of node.getChildMeshes())
{
if(child.physicsBody)
{
child.physicsBody.setMotionType(PhysicsMotionType.DYNAMIC);
child.physicsBody.setMassProperties({ mass });
}
}
}
}Objects in the Blender scene marked with entity = "prop" become pushable when the level loads. The artist controls mass via a mass custom property on the mesh.
Step 9: Input contexts
The sandbox uses two input contexts: gameplay for normal FPS controls, and elevator_ui for the floor selection panel.
export function setupBindings(engine : GameEngine) : void
{
const bindingManager = engine.managers.bindingManager;
bindingManager.registerAction({ type: 'digital', name: 'activate' });
bindingManager.registerAction({ type: 'digital', name: 'drop' });
bindingManager.registerAction({ type: 'digital', name: 'cancel_ui' });
const bindings : BindingDefinition[] = [
// Gameplay context
{ type: 'trigger', action: 'activate', context: 'gameplay',
input: keyboardInput('KeyE'), options: { edgeMode: 'rising' } },
{ type: 'trigger', action: 'drop', context: 'gameplay',
input: keyboardInput('KeyQ'), options: { edgeMode: 'rising' } },
// Elevator UI context
{ type: 'trigger', action: 'cancel_ui', context: 'elevator_ui',
input: keyboardInput('Escape'), options: { edgeMode: 'rising' } },
];
// Register exclusive contexts
bindingManager.registerContext('elevator_ui', true);
bindingManager.registerContext('gameplay', true);
bindingManager.activateContext('gameplay');
}Contexts are exclusive (true as the second argument), so activating elevator_ui automatically deactivates gameplay. This means E and Q stop working while the elevator panel is open, and Escape only works in the elevator UI context.
Step 10: The SandboxController
The controller orchestrates everything:
export class SandboxController
{
async initialize() : Promise<void>
{
registerAllPropertyHandlers(levelManager);
for(const def of getAllEntityDefinitions())
{
entityManager.registerEntityDefinition(def);
}
setupBindings(this.engine);
for(const config of loadAllLevelConfigs())
{
levelManager.registerLevelConfig(config);
}
gameManager.registerFrameCallback((dt) => this.update(dt));
await this.engine.start();
await levelManager.transition('Sandbox');
this.setupLighting(scene);
this.setupAmbientSound();
this.player = createPlayer(scene, canvas, spawnPos);
this.subscribeToActions();
this.subscribeToElevatorEvents();
}
}Each frame, the controller updates the player, then performs a raycast from the camera to detect interactable entities:
private update(dt : number) : void
{
this.player?.update(dt);
const result = this.engine.raycast.pickEntityForward(
scene, this.player!.camera, INTERACT_DISTANCE
);
if(result?.entity.hasBehavior(InteractBehavior))
{
const interact = result.entity.getBehavior(InteractBehavior);
this.interactTarget = result.entity;
this.callbacks.onInteractPrompt(interact?.promptText ?? 'to interact');
}
else
{
this.interactTarget = null;
this.callbacks.onInteractPrompt(null);
}
}The pickEntityForward utility raycasts from the camera center and returns the first hit entity, making interaction detection straightforward.
How it all ties together
The data flow for a locked door interaction:
- Player walks toward an elevator button -- the controller's raycast hits the button entity
InteractBehavior.lockedis true -- the prompt shows "Locked"- Player picks up a key (PickupBehavior reparents it to the camera)
- Player looks at the button again -- controller detects a held key, prompt changes to "to unlock"
- Player presses E -- controller calls
interact.unlock(), which plays a sound and changes the light color - Player presses E again --
InteractBehavior.processEvent()emitsactivatewith the elevator as the target ElevatorBehavior.processEvent()receives the activate, opens the floor panel- Controller switches to
elevator_uicontext, releases pointer lock - Player clicks Floor 3 -- controller sends
elevator:callwith{ floor: 3 } - Elevator moves, emitting
elevator:frame-deltaeach frame - Player rides the elevator via platform-following compensation
This chain of events crosses behaviors, the event bus, the controller, input contexts, and the Vue UI -- but each piece is simple and self-contained.
What you learned
PhysicsCharacterControllerprovides capsule-based FPS movement with proper collision- Behaviors read per-instance configuration from Blender metadata via
node.metadata PhysicsMotionType.ANIMATEDlets you drive physics body position while still pushing objects- Spatial audio attaches sounds to meshes so they play from the right position in 3D space
- Input contexts let you cleanly switch between gameplay controls and UI modes
- Platform following requires explicit frame-delta compensation for smooth rides
- Raycast-based interaction (
pickEntityForward) makes entity detection straightforward - The controller pattern keeps game logic out of Vue and behavior coordination centralized
Next steps
- Blender Workflow -- how to author the GLB scenes this example loads
- Level Loading -- YAML configs and level transitions in more detail
