Physics Playground
This guide walks you through a physics sandbox that spawns crates, balls, cylinders, and dumbbells into a Havok-powered scene. You will learn how to wrap physics in a behavior, how the onNodeAttached lifecycle hook eliminates manual initialization, and how to build a controller that orchestrates spawning, cleanup, and statistics.
The full source is in examples/src/examples/physics-playground/.
Try it live
Run this example at Examples > Physics Playground.
Project structure
physics-playground/
├── index.vue # Vue component — UI panels and engine wiring
├── types.ts # Physics state interfaces
├── constants.ts # Tuning constants and material presets
├── behaviors/
│ ├── PhysicsBodyBehavior.ts # Core behavior — creates PhysicsAggregate
│ └── PlayerVehicleBehavior.ts
├── entities/
│ └── definitions.ts # Entity definitions (ground, crate, ball, etc.)
├── game/
│ ├── scene.ts # Scene creation with physics enabled
│ ├── entities.ts # Mesh creation + entity-node binding
│ ├── PhysicsPlaygroundLevel.ts
│ └── PhysicsController.ts # Orchestrator
└── input/
├── bindings.ts # Input binding configuration
└── helpers.ts # Input helper utilitiesWhat this example demonstrates
- Enabling Havok physics on a SAGE scene
- Writing a
PhysicsBodyBehaviorthat usesonNodeAttached()for automatic initialization - Defining physics configuration in entity state (mass, restitution, friction)
- The entity-mesh-physics binding pattern: create entity, create mesh, call
attachToNode() - Material presets and tuning constants for realistic-feeling objects
- A controller that manages spawning, cleanup, and live statistics
Step 1: Physics state interfaces
The key insight of this example is that physics entities have their position driven by the physics engine, not by game code. The state interfaces capture both the physics configuration (read at creation time) and the synced position/rotation (written each frame by the behavior).
export interface PhysicsConfig
{
shapeType : 'box' | 'sphere' | 'cylinder' | 'convex';
mass : number; // 0 = static, > 0 = dynamic
restitution : number; // bounciness (0-1)
friction : number; // surface grip (0-1)
}
export interface PhysicsBodyState
{
position : { x : number; y : number; z : number };
rotation : { x : number; y : number; z : number; w : number };
physics : PhysicsConfig;
physicsAggregate ?: PhysicsAggregate;
}Concrete entity states extend PhysicsBodyState with type-specific properties:
export interface CrateState extends PhysicsBodyState
{
material : 'wood' | 'metal' | 'plastic';
}
export interface BallState extends PhysicsBodyState
{
color : string;
}The physicsAggregate reference is set by the behavior after initialization, giving external code direct access to the Havok body for applying forces or reading velocity.
Step 2: Material presets and tuning constants
Physics tuning values are extracted into constants so you can experiment without hunting through behavior code. The example provides presets for common materials:
export const MATERIAL_WOOD = {
mass: 1.5,
restitution: 0.3,
friction: 0.6,
};
export const MATERIAL_RUBBER = {
mass: 0.8,
restitution: 0.85,
friction: 0.9,
};
export const MATERIAL_GROUND = {
mass: 0, // Static!
restitution: 0.5,
friction: 0.8,
};These values are realistic starting points. Games often exaggerate them for feel -- a restitution of 0.85 makes rubber balls satisfyingly bouncy even if real rubber is closer to 0.5.
Step 3: Setting up a physics-enabled scene
Physics must be enabled on a scene before creating any physics bodies. The setup function creates the scene through SAGE's scene engine, then enables Havok:
export function setupScene(engine : GameEngine, canvas : HTMLCanvasElement) : Scene
{
const scene = engine.engines.sceneEngine.createScene();
scene.clearColor = new Color4(0.1, 0.1, 0.12, 1);
// CRITICAL: Enable physics with gravity before any PhysicsAggregates
engine.engines.sceneEngine.enablePhysics(scene, new Vector3(0, -9.81, 0));
// Camera — orbiting view good for watching physics
const camera = new ArcRotateCamera(
'camera', Math.PI / 4, Math.PI / 3.5, 18,
new Vector3(0, 2, 0), scene
);
camera.attachControl(canvas, true);
camera.lowerBetaLimit = 0.1;
camera.upperBetaLimit = (Math.PI / 2) - 0.1;
// Lighting
const hemiLight = new HemisphericLight('hemiLight', new Vector3(0, 1, 0), scene);
hemiLight.intensity = 0.8;
return scene;
}The scene is wrapped in a GameLevel subclass so the level manager can own its lifecycle:
export class PhysicsPlaygroundLevel extends Level
{
protected async buildScene() : Promise<Scene>
{
return setupScene(this.config.engine, this.config.canvas);
}
}Step 4: PhysicsBodyBehavior and the onNodeAttached lifecycle
This is the core of the example. PhysicsBodyBehavior extends GameEntityBehavior and uses the onNodeAttached() lifecycle hook to create a Havok PhysicsAggregate the moment a mesh is bound to the entity.
export class PhysicsBodyBehavior extends GameEntityBehavior<PhysicsBodyState>
{
name = 'physics-body';
eventSubscriptions = [ 'physics:applyForce', 'physics:applyImpulse', 'physics:setVelocity' ];
private _aggregate : PhysicsAggregate | null = null;
private _mesh : Mesh | null = null;
private _initialized = false;
onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void
{
if(this._initialized) { return; }
const mesh = node as Mesh;
const scene = node.getScene();
this._mesh = mesh;
this._initialized = true;
const state = this.entity!.state as PhysicsBodyState;
// Ensure the mesh has a rotation quaternion (required for physics)
if(!mesh.rotationQuaternion)
{
mesh.rotationQuaternion = Quaternion.FromEulerAngles(0, 0, 0);
}
// Create the PhysicsAggregate from state configuration
this._aggregate = new PhysicsAggregate(
mesh,
this._mapShapeType(state.physics.shapeType),
{
mass: state.physics.mass,
restitution: state.physics.restitution,
friction: state.physics.friction,
},
scene
);
// Store reference in state for external access
state.physicsAggregate = this._aggregate;
}
}The update() method reverses the typical behavior flow -- it reads position from the mesh (which is moved by Havok) and writes it to entity state:
update(_deltaTime : number, state : PhysicsBodyState) : void
{
if(!this._aggregate || !this._mesh) { return; }
const pos = this._mesh.position;
state.position.x = pos.x;
state.position.y = pos.y;
state.position.z = pos.z;
const rot = this._mesh.rotationQuaternion;
if(rot)
{
state.rotation.x = rot.x;
state.rotation.y = rot.y;
state.rotation.z = rot.z;
state.rotation.w = rot.w;
}
}The behavior also exposes a public API for applying forces and impulses, and handles event-driven physics via processEvent():
processEvent(event : GameEvent, _state : PhysicsBodyState) : boolean
{
if(!this._aggregate) { return false; }
switch(event.type)
{
case 'physics:applyImpulse':
{
const { x = 0, y = 0, z = 0 } = (event.payload ?? {}) as VectorPayload;
this.applyImpulse(new Vector3(x, y, z));
return true;
}
// ... other cases
}
return false;
}Why onNodeAttached instead of manual initialize?
A PhysicsAggregate needs both a scene and a mesh. Rather than forcing the caller to remember to call behavior.initialize(scene, mesh), the onNodeAttached() hook fires automatically when entityManager.attachToNode(entity, mesh) is called. The scene comes from node.getScene(). Zero manual wiring.
Step 5: Entity definitions
Each physics entity type gets a definition that configures its default state and attaches the physics behavior:
export function createCrateDefinition(logEvent : LogEventFn) : GameEntityDefinition<CrateState>
{
return {
type: 'physics:crate',
name: 'Crate',
defaultState: {
position: { x: 0, y: SPAWN_HEIGHT, z: 0 },
rotation: { x: 0, y: 0, z: 0, w: 1 },
physics: {
shapeType: 'box',
mass: MATERIAL_WOOD.mass,
restitution: MATERIAL_WOOD.restitution,
friction: MATERIAL_WOOD.friction,
},
material: 'wood',
},
behaviors: [ PhysicsBodyBehavior ],
tags: [ 'physics', 'prop', 'dynamic' ],
poolable: true,
poolSize: 10,
onCreate: (state) =>
{
logEvent(`Crate spawned (${ state.physics.mass.toFixed(1) }kg)`, 'spawn');
},
};
}The ground is defined the same way, but with mass: 0 to make it static:
export function createGroundDefinition() : GameEntityDefinition<GroundState>
{
return {
type: 'physics:ground',
defaultState: {
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0, w: 1 },
physics: {
shapeType: 'box',
mass: 0, // static!
restitution: 0.5,
friction: 0.8,
},
size: GROUND_SIZE,
},
behaviors: [ PhysicsBodyBehavior ],
tags: [ 'physics', 'environment', 'static' ],
};
}The dumbbell definition demonstrates compound entities with children:
children: [
{ type: 'physics:ball', name: 'Dumbbell Weight (Left)', position: { x: -1.0, y: 0, z: 0 } },
{ type: 'physics:ball', name: 'Dumbbell Weight (Right)', position: { x: 1.0, y: 0, z: 0 } },
{ type: 'physics:cylinder', name: 'Dumbbell Bar', position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: Math.PI / 2 } },
],Step 6: The entity-mesh-physics binding pattern
The PhysicsEntityManager class handles the three-step pattern that connects everything:
- Create the entity through SAGE's entity manager
- Create the corresponding BabylonJS mesh
- Call
attachToNode()to bind them -- this triggersonNodeAttached()on all behaviors
async spawnCrate(config : SpawnConfig) : Promise<GameEntity<CrateState>>
{
const entityManager = this.engine.managers.entityManager;
const position = this.getRandomSpawnPosition();
// 1. Create entity with custom physics config
const entity = await entityManager.createEntity<CrateState>('physics:crate', {
initialState: {
position,
physics: {
shapeType: 'box',
mass: config.mass,
restitution: config.restitution,
friction: config.friction,
},
},
});
// 2. Create mesh
const mesh = MeshBuilder.CreateBox(
`crate-${ entity.id }`, { size: CRATE_SIZE }, this.scene
);
mesh.position.set(position.x, position.y, position.z);
// 3. Bind — PhysicsBodyBehavior.onNodeAttached() creates the PhysicsAggregate
entityManager.attachToNode(entity, mesh);
return entity;
}After attachToNode(), the entity's physics body exists and is simulating. The mesh is accessible at entity.node, and the physics aggregate is at entity.state.physicsAggregate. No other setup is needed.
Step 7: The PhysicsController orchestrator
The controller ties together scene setup, entity management, input handling, per-frame updates, and cleanup. It follows the same pattern as earlier examples (GameController, LevelController).
export class PhysicsController
{
private readonly engine : GameEngine;
private readonly callbacks : PhysicsControllerCallbacks;
private entityManager : PhysicsEntityManager | null = null;
async initialize() : Promise<void>
{
this.scene = this.engine.managers.levelManager.currentLevel?.scene ?? null;
this.entityManager = new PhysicsEntityManager(this.engine, this.scene!);
registerAllDefinitions(this.engine.managers.entityManager, this.callbacks.onLogEvent);
await this.entityManager.createGround();
this.playerVehicle = await this.entityManager.createPlayerVehicle();
setupBindings(this.engine);
this.subscribeToActions();
}
}The per-frame update handles custom logic that sits alongside SAGE's automatic entity updates:
update(_deltaTime : number) : void
{
this.updatePlayerInput();
this.checkPlayerFallen();
this.cleanupFallenObjects();
this.callbacks.onStatsChanged(this.computeStats());
}Statistics are computed by iterating dynamic entities and checking their physics state:
private computeStats() : PhysicsStats
{
const dynamicEntities = [
...em.getByType('physics:crate'),
...em.getByType('physics:ball'),
...em.getByType('physics:cylinder'),
...em.getByType('physics:dumbbell'),
] as GameEntity<PhysicsBodyState>[];
let activeCount = 0;
let totalMass = 0;
for(const entity of dynamicEntities)
{
totalMass += entity.state.physics.mass;
const velocity = entity.state.physicsAggregate?.body.getLinearVelocity();
if(velocity && velocity.length() > ACTIVE_VELOCITY_THRESHOLD)
{
activeCount++;
}
}
return { entityCount: dynamicEntities.length, activeCount, totalMass };
}Fallen objects (below y = -20) are cleaned up each frame by disposing their mesh and destroying the entity.
Step 8: Wiring it up in Vue
The Vue component creates the controller on engine-ready, registers a frame callback, and starts the engine:
async function onEngineReady(engine : GameEngine) : Promise<void>
{
engine.managers.levelManager.registerLevelClass('physics-playground', PhysicsPlaygroundLevel);
engine.managers.levelManager.registerLevelConfig(levelConfig);
await engine.managers.levelManager.activateLevel('playground');
controller = new PhysicsController(engine, canvas, {
onStatsChanged: (newStats) => { /* update reactive refs */ },
onLogEvent: logEvent,
});
await controller.initialize();
engine.managers.gameManager.registerFrameCallback((dt) => controller?.update(dt));
await engine.start();
}UI sliders for mass, restitution, and friction are bound to the controller via watchers:
watch(mass, (newMass) => { if(controller) { controller.mass = newMass; } });When the user spawns an object, the controller uses the current slider values as the spawn config.
What you learned
- Physics must be enabled on a scene before creating any physics bodies
PhysicsBodyBehaviorusesonNodeAttached()to automatically create aPhysicsAggregate- Physics state flows in reverse: the physics engine writes position to entity state each frame
- The three-step binding pattern (create entity, create mesh,
attachToNode()) triggers all behavior lifecycle hooks automatically - Material presets extract tuning values so physics feel can be iterated without code changes
- The controller pattern orchestrates spawning, cleanup, statistics, and input routing
Next steps
- Level Loading -- load levels from YAML with spawn points and transitions
- Sandbox -- first-person physics with GLB scenes from Blender
