Skip to content

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 utilities

What this example demonstrates

  • Enabling Havok physics on a SAGE scene
  • Writing a PhysicsBodyBehavior that uses onNodeAttached() 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).

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

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

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

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

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

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

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

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

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

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

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

  1. Create the entity through SAGE's entity manager
  2. Create the corresponding BabylonJS mesh
  3. Call attachToNode() to bind them -- this triggers onNodeAttached() on all behaviors
typescript
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).

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

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

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

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

typescript
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
  • PhysicsBodyBehavior uses onNodeAttached() to automatically create a PhysicsAggregate
  • 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

Released under the MIT License.