Skip to content

Physics Playground ​

This guide walks through the physics-playground example: crates, balls, cylinders, and dumbbells spawned with the mass, bounciness, and friction the sliders hold, and a bulldozer driven from the keyboard or a gamepad that pushes them around. Anything that falls off the edge is despawned; the bulldozer comes back to its start.

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: sliders, buttons, statistics, event log, boot
├── events.ts                       # EventMap augmentation: action:*, vehicle:ready
├── constants.ts                    # Every tunable value
├── behaviors/
│   ├── index.ts                    # Re-exports HavokPhysicsBodyBehavior from @skewedaspect/sage
│   └── PlayerVehicleBehavior.ts    # Tank-style driving through the body's shared state
├── entities/
│   ├── GroundEntity.ts             # defineEntity classes, one per entity class
│   ├── CrateEntity.ts
│   ├── BallEntity.ts
│   ├── CylinderEntity.ts
│   ├── DumbbellEntity.ts
│   ├── BulldozerEntity.ts
│   └── index.ts                    # The entity registry
├── input/
│   ├── actions.ts                  # Actions, the 'physics' context, keyboard and gamepad bindings
│   └── helpers.ts
├── propStats.vue                   # The Objects / Moving / Total Mass panel
└── game/
    ├── PhysicsController.ts        # Spawning, actions, fall cleanup
    ├── PhysicsPlaygroundLevel.ts   # Custom Level: enables physics, binds the simulation
    ├── scene.ts                    # Camera and lights
    └── meshes.ts                   # The mesh source, standard plus three hand-built meshes

Enable physics on the scene ​

Physics is enabled per scene, before any body is created. The example's custom Level enables it right after creating the scene, then binds a simulation to that scene so every entity spawns into it:

typescript
// game/PhysicsPlaygroundLevel.ts
import { Vector3 } from '@babylonjs/core';
import type { Scene } from '@babylonjs/core';
import { Level } from '@skewedaspect/sage';
import type { LevelConfig, LevelContext, MeshLoader } from '@skewedaspect/sage';

export class PhysicsPlaygroundLevel extends Level<typeof entityClasses>
{
    readonly #meshSource : MeshLoader | undefined;

    constructor(config : LevelConfig, context : LevelContext<typeof entityClasses>)
    {
        super(config, context);

        this.#meshSource = config.meshSource;
    }

    protected async buildScene() : Promise<Scene>
    {
        const { sceneEngine } = this.context;

        const scene = this.createScene();
        await sceneEngine.enablePhysics(scene, new Vector3(0, GRAVITY_Y, 0));

        setupScene(scene);

        this.rebuildSimulation(scene, this.#meshSource);

        return scene;
    }
}

enablePhysics loads and compiles the Havok module the first time anything asks for it, so the level awaits it before building anything that needs a body. The level reaches its dependencies through this.context, which every Level gets from the level manager; the only thing this level's own config carries beyond the name and class is the mesh source.

A YAML level does the same with physics: true.

The physics behavior ​

Every prop and the bulldozer carry HavokPhysicsBodyBehavior, the physics behavior @skewedaspect/sage provides -- this example writes no physics behavior of its own. It gives an entity one PhysicsAggregate, built from its own state, in onMeshLoaded, and disposes it in onDestroy.

typescript
interface PhysicsBodyState
{
    shape : 'box' | 'sphere' | 'cylinder' | 'convex' | 'mesh' | 'compound';
    parts : CompoundPart[];
    motion : 'static' | 'dynamic' | 'kinematic';
    mass : number;
    friction : number;
    restitution : number;
    position : Vector3State;
    rotation : QuaternionState;
    velocity : Vector3State;
    angularVelocity : Vector3State;
}

shape and parts are read once, when the body is built. Every other field is a getter and a setter over the body itself once there is one: reading state.velocity asks the body, and writing it tells the body, so there is nothing kept in step and nothing to fall out of step (see Physics). position and rotation are the fields NodeEntity already declares, shared rather than declared beside them, so a physics prop's transform is the same field a non-physics entity would use to place its node.

The aggregate goes on entity.node, never on the mesh: the node is what state.position is computed from, and with the body on the node the field the body answers for and the node the entity reads are the same place. A body on the mesh, one level down, would be dragged around by the node above it instead.

A TransformNode has no geometry of its own, so a box, sphere, or cylinder is measured from the entity's mesh -- its bounding box, and for a bare node with no mesh at all, everything hanging beneath it. A convex hull or a triangle mesh reads the mesh's own vertices directly. Building any shape on a node with nothing to measure throws, naming the entity, rather than producing a body of a size nobody chose.

The fall check reads state.position, and the statistics read state.mass and state.velocity, straight off the shared state -- no operation call needed for any of it. A restored entity rebuilds its aggregate in onMeshLoaded from the state the snapshot held, the same as a fresh spawn.

Compound bodies ​

A convex hull around a dumbbell fills the gap between the weights, so it would rock like a pill. The dumbbell is a compound shape instead: one shape container holding a child shape per part, each placed at its own offset and rotation. defaultState.parts lists the three children as plain JSON:

typescript
// entities/DumbbellEntity.ts
const DumbbellBase = defineEntity({
    type: 'DumbbellEntity',
    behaviors,
    tags: [ 'physics', 'prop', 'dynamic' ],
    defaultState: {
        shape: 'compound',
        parts: [
            {
                shape: 'sphere',
                position: { x: -DUMBBELL_BAR_LENGTH / 2, y: 0, z: 0 },
                rotation: UPRIGHT,
                extents: WEIGHT,
            },
            {
                shape: 'sphere',
                position: { x: DUMBBELL_BAR_LENGTH / 2, y: 0, z: 0 },
                rotation: UPRIGHT,
                extents: WEIGHT,
            },
            {
                shape: 'cylinder',
                position: { x: 0, y: 0, z: 0 },
                rotation: ALONG_X,
                extents: { x: DUMBBELL_BAR_DIAMETER, y: DUMBBELL_BAR_LENGTH, z: DUMBBELL_BAR_DIAMETER },
            },
        ],
    },
});

Building the container, the three Havok shapes, and disposing all of it in onDestroy is the behavior's own work; the entity only ever declares the parts. One merged mesh over one compound body -- built by hand in game/meshes.ts -- is what keeps the dumbbell a single entity, with no per-weight children to manage.

Driving through shared state ​

HavokPhysicsBodyBehavior exposes one operation, applyImpulse(impulse, location?), for a one-off shove -- this example never calls it. The bulldozer instead drives by reading and writing the fields the body behavior already computes: velocity, angularVelocity, position, and rotation are shared state, and two behaviors declaring the same field share it. Reading one asks the body and writing one tells it, so PlayerVehicleBehavior reads this step's transform and velocity from the body itself and whatever it writes is what the body carries into the next physics step.

typescript
// behaviors/PlayerVehicleBehavior.ts
export class PlayerVehicleBehavior extends Behavior<PlayerVehicleState, OpsOf<typeof HavokPhysicsBodyBehavior>>
{
    static readonly events = [ 'action:playerMoveForward', 'action:playerMoveRight' ] as const;

    readonly defaults : PlayerVehicleState = {
        position: { x: 0, y: 0, z: 0 },
        rotation: { x: 0, y: 0, z: 0, w: 1 }, // eslint-disable-line id-length
        velocity: { x: 0, y: 0, z: 0 },
        angularVelocity: { x: 0, y: 0, z: 0 },
        inputForward: 0,
        inputRight: 0,
    };

    onEvent(event : GameEvent) : boolean
    {
        if(event.type === 'action:playerMoveForward')
        {
            this.state.inputForward = event.payload;
            return true;
        }

        if(event.type === 'action:playerMoveRight')
        {
            this.state.inputRight = event.payload;
            return true;
        }

        return false;
    }

    onUpdate() : void
    {
        if(this.#isGrounded())
        {
            this.#drive();
        }
        else
        {
            this.#coast();
        }

        this.#keepUpright();
    }

    // Low enough to be sitting on the ground, and not falling fast.
    #isGrounded() : boolean
    {
        const nearGround = this.state.position.y < GROUND_HEIGHT_THRESHOLD;
        const notFalling = this.state.velocity.y > -GROUND_VELOCITY_THRESHOLD;

        return nearGround && notFalling;
    }

    #drive() : void
    {
        const velocity = this.state.velocity;

        // The bulldozer only ever rotates about Y, so its heading is the quaternion's Y angle.
        const heading = 2 * Math.atan2(this.state.rotation.y, this.state.rotation.w);
        const targetSpeed = this.state.inputForward * PLAYER_MAX_SPEED;
        const targetX = -Math.sin(heading) * targetSpeed;
        const targetZ = -Math.cos(heading) * targetSpeed;

        this.state.velocity = {
            x: velocity.x + ((targetX - velocity.x) * PLAYER_VELOCITY_BLEND),
            y: velocity.y,
            z: velocity.z + ((targetZ - velocity.z) * PLAYER_VELOCITY_BLEND),
        };

        this.state.angularVelocity = { x: 0, y: this.state.inputRight * PLAYER_TURN_SPEED, z: 0 };
    }

    #coast() : void
    {
        const velocity = this.state.velocity;

        this.state.velocity = {
            x: velocity.x * PLAYER_AIR_DAMPING,
            y: velocity.y,
            z: velocity.z * PLAYER_AIR_DAMPING,
        };

        const spin = this.state.angularVelocity;

        this.state.angularVelocity = {
            x: spin.x * PLAYER_AIR_SPIN_DAMPING,
            y: spin.y * PLAYER_AIR_SPIN_DAMPING,
            z: spin.z * PLAYER_AIR_SPIN_DAMPING,
        };
    }

    // Kills any spin that is not about Y, and rights the bulldozer once it has tipped too far.
    #keepUpright() : void
    {
        const spin = this.state.angularVelocity;

        if(Math.abs(spin.x) > 0.01 || Math.abs(spin.z) > 0.01)
        {
            this.state.angularVelocity = { x: 0, y: spin.y, z: 0 };
        }

        const worldUp = Vector3.Up();
        const bodyUp = Vector3.Up().applyRotationQuaternion(toQuaternion(this.state.rotation));
        const tilt = Math.acos(Math.min(1, Math.max(-1, Vector3.Dot(bodyUp, worldUp))));

        if(tilt > MAX_TILT_ANGLE)
        {
            const axis = Vector3.Cross(bodyUp, worldUp).normalize();
            const righting = axis.scale(tilt * PLAYER_UPRIGHT_CORRECTION);

            this.state.angularVelocity = { x: righting.x, y: spin.y, z: righting.z };
        }
    }
}

PlayerVehicleBehavior's Requires names HavokPhysicsBodyBehavior's own operations even though it never calls one: naming them is what makes an entity that composes the driver without the body a compile error, so the shared fields it reads and writes are always computed from a real body. The behavior also keeps the bulldozer upright and reads whether it is airborne from state.position.y and state.velocity.y, both answered by the same body. Each of those fields answers with a live view, so a read of one component asks the body and a write to one tells it.

Entity definitions ​

Each prop is a defineEntity class carrying its mesh, its tags, and its shape. The subclass adds pooling, which the definition object has no field for:

typescript
// entities/CrateEntity.ts
const behaviors = [ HavokPhysicsBodyBehavior ] as const;

const CrateBase = defineEntity({
    type: 'CrateEntity',
    behaviors,
    tags: [ 'physics', 'prop', 'dynamic' ],
    mesh: {
        source: 'box',
        params: { size: CRATE_SIZE },
        material: { type: 'standard', color: COLORS.wood },
    },
    defaultState: {
        shape: 'box',
    },
});

export class CrateEntity extends CrateBase
{
    static override readonly poolable = true;
    static override readonly poolSize = CRATE_POOL_SIZE;
}

A class defineEntity builds already types state and ops from its behaviors tuple, so a subclass that adds nothing but pooling declares nothing else.

A spawn supplies what differs per instance, which is where the slider values go:

typescript
simulation.spawn('CrateEntity', {
    position: { x: Math.cos(angle) * radius, y: SPAWN_HEIGHT, z: Math.sin(angle) * radius },
    mass,
    restitution,
    friction,
});

The ground uses the same behavior with motion: 'static', which is what makes Havok never move it -- mass: 0 alongside it documents that a static body's mass is not meaningful, but the motion type is what decides the body is immovable:

typescript
// entities/GroundEntity.ts
const GroundBase = defineEntity({
    type: 'GroundEntity',
    behaviors,
    tags: [ 'physics', 'environment', 'static' ],
    defaultState: {
        shape: 'box',
        ...MATERIAL_GROUND, // { motion: 'static', mass: 0, restitution: 0.5, friction: 0.8 }
        position: { x: 0, y: -GROUND_THICKNESS / 2, z: 0 },
    },
});

The ground, the dumbbell, and the bulldozer declare no meshConfig. The example's mesh source checks for those three first and builds them by hand, then falls through to the standard source for everything else: a slab with a matte material, two spheres and a bar merged into one mesh, and a glb whose parts are merged into one mesh so the convex hull wraps the whole vehicle. The standard source would answer with nothing for a class with no meshConfig, and the body build would then throw for want of a mesh.

typescript
// game/meshes.ts
export function buildPlaygroundMeshSource(assets : MeshAssetSource) : MeshLoader
{
    const standard = buildStandardMeshSource(entityClasses, assets);

    return async (scene, name, state) : Promise<AbstractMesh | undefined> =>
    {
        if(name === 'GroundEntity')
        {
            return buildGroundMesh(scene);
        }

        if(name === 'DumbbellEntity')
        {
            return buildDumbbellMesh(scene);
        }

        if(name === 'BulldozerEntity')
        {
            return buildBulldozerMesh(scene);
        }

        return standard(scene, name, state);
    };
}

Pooling ​

Every prop and the bulldozer are poolable, each with its own poolSize. A despawned entity's node and mesh return to its class's pool, and the next spawn of that class claims them instead of loading a mesh. The entity and its behaviors are constructed fresh every time: onMeshLoaded runs on the claimed node and builds a new aggregate, and onDestroy disposes the old one before the node goes back. The controller prewarms the prop pools at boot with simulation.prewarm(name, count). The bulldozer's pool holds one, so a reset after a fall reclaims the imported glb instead of importing it again.

Input ​

input/actions.ts declares the playground's whole input configuration -- actions, one context, keyboard and gamepad bindings -- as one JSON-compatible object. importConfiguration registers the actions and the context and hands the binding definitions back, and InputManager.bind builds each one's reader from the device it names:

typescript
export function createInputSystem(inputManager : InputManager) : void
{
    const definitions = importConfiguration(configuration, inputManager.actions, inputManager.contexts);

    for(const definition of definitions)
    {
        inputManager.bind(definition);
    }
}

The controller activates the physics context once, at boot, through the shared activateContext helper every example uses. The bulldozer answers the two analog actions in PlayerVehicleBehavior, the way any entity answers an action.

The spawn and clear actions have no entity to answer them. The controller registers itself as a subscriber on the engine's bus and hears them under the same delivery rules an entity does, along with vehicle:ready, which PlayerVehicleBehavior broadcasts from onMeshLoaded once the bulldozer's glb has loaded and its body exists:

typescript
class PhysicsController
{
    readonly #engine : GameEngine<typeof entityClasses>;
    readonly #callbacks : PhysicsControllerCallbacks;
    readonly #unsubscribers : Unsubscribe[] = [];

    constructor(engine : GameEngine<typeof entityClasses>, callbacks : PhysicsControllerCallbacks)
    {
        this.#engine = engine;
        this.#callbacks = callbacks;
    }

    spawnCrate() : void {}
    spawnBall() : void {}
    spawnCylinder() : void {}
    spawnDumbbell() : void {}
    clearAll() : void {}

    subscribe() : void
    {
        const bus = this.#engine.bus;

        this.#unsubscribers.push(
            bus.on('action:spawnCrate', () => { this.spawnCrate(); }),
            bus.on('action:spawnBall', () => { this.spawnBall(); }),
            bus.on('action:spawnCylinder', () => { this.spawnCylinder(); }),
            bus.on('action:spawnDumbbell', () => { this.spawnDumbbell(); }),
            bus.on('action:clearAll', () => { this.clearAll(); }),
            bus.on('vehicle:ready', () =>
            {
                this.#callbacks.onLogEvent('Bulldozer ready! Use WASD or left stick to move.', 'success');
            })
        );
    }
}

The fall check ​

One scan of the props a frame despawns whatever fell off the edge. The UI reads the rest itself:

typescript
for(const prop of simulation.getEntitiesByTag(PROP_TAG))
{
    if(!prop.hasBehavior(HavokPhysicsBodyBehavior))
    {
        throw new Error(`Prop "${ prop.id }" has no physics body.`);
    }

    if(prop.state.position.y < FALL_THRESHOLD)
    {
        simulation.despawn(prop.id);
    }
}

hasBehavior narrows a GameEntity to the behavior's read-only state, so the controller reads a prop's position without holding a reference to the behavior or the body. The bulldozer is checked the same way, by ID and looked up when the fall check needs it: a live entity reference is resolved at the moment of use and never kept across fixed steps.

The three numbers on screen -- Objects, Moving, Total Mass -- are propStats.vue's own work, not the controller's. It reaches the same props through useSageEntities(PROP_TAG, HavokPhysicsBodyBehavior), which narrows a UI view the same way hasBehavior narrows an entity, and computes each number from state:

typescript
// propStats.vue
const props = useSageEntities(PROP_TAG, HavokPhysicsBodyBehavior);

const entityCount = computed(() => props.value.length);

const totalMass = computed(() =>
{
    let total = 0;

    for(const prop of props.value)
    {
        total += prop.state.mass;
    }

    return total;
});

const activeCount = computed(() =>
{
    let moving = 0;

    for(const prop of props.value)
    {
        const { x, y, z } = prop.state.velocity;

        if(Math.hypot(x, y, z) > ACTIVE_VELOCITY_THRESHOLD)
        {
            moving += 1;
        }
    }

    return moving;
});

A view observes and never calls an operation, so this reads state.velocity rather than an operation -- exactly the field computed from the body, refreshed every frame like any other.

Next steps ​

  • Physics: the full reference, including shapes, motion types, and applyImpulse
  • Behaviors: onMeshLoaded and onDestroy
  • Input System: actions, contexts, and bindings in depth

Released under the MIT License.