Skip to content

Physics

SAGE uses Havok Physics through BabylonJS for rigid body simulation. The physics system handles gravity, collisions, forces, impulses, and constraints. It initializes automatically with the game engine and integrates with the entity and behavior systems.

Getting Started

Physics is initialized when you create the engine:

typescript
import { createGameEngine } from '@skewedaspect/sage';

const gameEngine = await createGameEngine(canvas);

To enable physics on a scene, use the SceneEngine:

typescript
const sceneEngine = gameEngine.engines.sceneEngine;
const scene = sceneEngine.createScene();

// Default gravity (0, -9.81, 0)
sceneEngine.enablePhysics(scene);

// Custom gravity
sceneEngine.enablePhysics(scene, new Vector3(0, -1.62, 0)); // Moon gravity

In YAML level configs, enable physics with a single flag:

yaml
name: my-level
scene: levels/my-level.glb
physics: true

Or with custom gravity:

yaml
physics:
  gravity:
    x: 0
    y: -4.9
    z: 0

PhysicsAggregate

The PhysicsAggregate is BabylonJS's unified wrapper around a physics body and its collision shape. It is the primary way to add physics to meshes.

typescript
import { PhysicsAggregate, PhysicsShapeType } from '@babylonjs/core';

Creating Physics Bodies

typescript
// Static body (mass = 0, does not move)
const groundAggregate = new PhysicsAggregate(
    groundMesh,
    PhysicsShapeType.BOX,
    { mass: 0 },
    scene
);

// Dynamic body (has mass, affected by forces)
const ballAggregate = new PhysicsAggregate(
    ballMesh,
    PhysicsShapeType.SPHERE,
    {
        mass: 1,
        restitution: 0.7,
        friction: 0.5,
    },
    scene
);

PhysicsShapeType

ShapeConstantBest For
BoxPhysicsShapeType.BOXCrates, walls, floors
SpherePhysicsShapeType.SPHEREBalls, projectiles
CapsulePhysicsShapeType.CAPSULECharacters
CylinderPhysicsShapeType.CYLINDERPillars, barrels
MeshPhysicsShapeType.MESHComplex static geometry
Convex HullPhysicsShapeType.CONVEX_HULLComplex dynamic objects

Aggregate Options

PropertyTypeDefaultDescription
massnumber0Mass in kg. 0 = static (immovable).
restitutionnumber0Bounciness (0 = no bounce, 1 = full bounce)
frictionnumber0.5Surface friction

SceneEngine Helper

The SceneEngine provides a convenience method:

typescript
const aggregate = sceneEngine.addPhysics(
    mesh,
    PhysicsShapeType.BOX,
    { mass: 0 },
    scene
);

Static vs. Dynamic Bodies

PropertyStatic (mass = 0)Dynamic (mass > 0)
MovementNever movesAffected by forces and gravity
CollisionsOther objects bounce offResponds to collisions
Use caseGround, walls, platformsPlayers, projectiles, props
PerformanceCheapMore expensive

Applying Forces and Impulses

Forces

Forces are continuous -- apply them each frame for sustained acceleration:

typescript
const body = aggregate.body;

// Apply force at center of mass
body.applyForce(
    new Vector3(100, 0, 0),          // Force vector
    aggregate.transformNode.position  // Application point
);

Impulses

Impulses are instantaneous -- apply them once for a sudden velocity change:

typescript
// Jump impulse
body.applyImpulse(
    new Vector3(0, jumpForce, 0),
    aggregate.transformNode.position
);

Setting Velocity Directly

typescript
// Set linear velocity
body.setLinearVelocity(new Vector3(5, 0, 0));

// Get current velocity
const velocity = body.getLinearVelocity();

// Set angular velocity
body.setAngularVelocity(new Vector3(0, Math.PI, 0));

Physics in Behaviors

The recommended pattern is to use the onNodeAttached lifecycle hook to create physics bodies:

typescript
import { GameEntityBehavior } from '@skewedaspect/sage';
import type { GameEvent } from '@skewedaspect/sage';
import type { TransformNode } from '@babylonjs/core';
import { Mesh, PhysicsAggregate, PhysicsShapeType, Vector3 } from '@babylonjs/core';

interface PhysicsState
{
    mass : number;
    restitution : number;
    friction : number;
}

class PhysicsBodyBehavior extends GameEntityBehavior<PhysicsState>
{
    name = 'PhysicsBodyBehavior';
    eventSubscriptions = [ 'physics:applyForce', 'physics:setVelocity' ];

    private _aggregate : PhysicsAggregate | null = null;

    onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void
    {
        const mesh = node as Mesh;
        const scene = node.getScene();
        const state = this.entity!.state as PhysicsState;

        this._aggregate = new PhysicsAggregate(
            mesh,
            PhysicsShapeType.BOX,
            {
                mass: state.mass,
                restitution: state.restitution,
                friction: state.friction,
            },
            scene
        );
    }

    processEvent(event : GameEvent, state : PhysicsState) : boolean
    {
        if(event.type === 'physics:applyForce' && this._aggregate)
        {
            const { x, y, z } = event.payload.force;
            this._aggregate.body.applyForce(
                new Vector3(x, y, z),
                this._aggregate.transformNode.position
            );
            return true;
        }

        if(event.type === 'physics:setVelocity' && this._aggregate)
        {
            const { x, y, z } = event.payload.velocity;
            this._aggregate.body.setLinearVelocity(new Vector3(x, y, z));
            return true;
        }

        return false;
    }

    update(_dt : number, state : PhysicsState) : void
    {
        if(this._aggregate)
        {
            // Sync physics position back to entity state
            const pos = this._aggregate.transformNode.position;
            (state as Record<string, unknown>).position = {
                x: pos.x,
                y: pos.y,
                z: pos.z,
            };
        }
    }

    onNodeDetached() : void
    {
        this._aggregate?.dispose();
        this._aggregate = null;
    }

    async destroy() : Promise<void>
    {
        this._aggregate?.dispose();
        this._aggregate = null;
    }
}

Registering a Physics Entity

typescript
gameEngine.managers.entityManager.registerEntityDefinition({
    type: 'prop:crate',
    defaultState: {
        position: { x: 0, y: 5, z: 0 },
        mass: 1.5,
        restitution: 0.4,
        friction: 0.8,
    },
    behaviors: [ PhysicsBodyBehavior ],
});

const crate = await gameEngine.managers.entityManager.createEntity('prop:crate');

Collision Detection

Use BabylonJS ActionManager for mesh-level collision callbacks:

typescript
import { ActionManager, ExecuteCodeAction } from '@babylonjs/core';

class CollisionBehavior extends GameEntityBehavior
{
    name = 'CollisionBehavior';
    eventSubscriptions = [];

    onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void
    {
        const mesh = node as Mesh;
        if(!mesh.actionManager)
        {
            mesh.actionManager = new ActionManager(mesh.getScene());
        }

        mesh.actionManager.registerAction(
            new ExecuteCodeAction(
                { trigger: ActionManager.OnIntersectionEnterTrigger },
                (evt) =>
                {
                    this.$emit({
                        type: 'entity:collision',
                        payload: {
                            otherMeshId: evt.source.id,
                        },
                    });
                }
            )
        );
    }

    processEvent() : boolean { return false; }
}

Collision Filtering

Group objects into collision layers to control which objects interact:

typescript
const PLAYER_GROUP = 1;
const ENEMY_GROUP = 2;
const ENVIRONMENT_GROUP = 4;
const PROJECTILE_GROUP = 8;

// Player collides with environment and enemies
playerAggregate.body.setCollisionFilteringGroups(
    PLAYER_GROUP,
    ENVIRONMENT_GROUP | ENEMY_GROUP
);

// Projectiles collide with enemies and environment, not other projectiles
projectileAggregate.body.setCollisionFilteringGroups(
    PROJECTILE_GROUP,
    ENVIRONMENT_GROUP | ENEMY_GROUP
);

Constraints

Physics constraints create mechanical connections between bodies:

typescript
import { DistanceConstraint } from '@babylonjs/core';

// Create a distance constraint (rope/chain)
const constraint = new DistanceConstraint({
    pivotA: new Vector3(0, 0, 0),
    pivotB: new Vector3(0, 0, 0),
    maxDistance: 5,
});

constraint.attachAll(
    true,           // collision enabled
    pivotBody,      // static anchor point
    swingingBody    // the object that swings
);

Character Controller Pattern

A common pattern for character movement that balances physics realism with responsive controls:

typescript
class CharacterController extends GameEntityBehavior<{
    moveSpeed : number;
    jumpForce : number;
}>
{
    name = 'CharacterController';
    eventSubscriptions = [ 'physics:applyForce' ];

    private _aggregate : PhysicsAggregate | null = null;

    onNodeAttached(node : TransformNode, gameEngine : GameEngine) : void
    {
        const scene = node.getScene();
        this._aggregate = new PhysicsAggregate(
            node,
            PhysicsShapeType.CAPSULE,
            { mass: 80, friction: 0.8, restitution: 0 },
            scene
        );
    }

    processEvent() : boolean { return false; }

    update(_dt : number, state : { moveSpeed : number; jumpForce : number }) : void
    {
        if(!this._aggregate) { return; }

        const body = this._aggregate.body;
        const velocity = body.getLinearVelocity();

        // Apply horizontal drag for responsive stopping
        body.applyForce(
            new Vector3(-velocity.x * 10, 0, -velocity.z * 10),
            this._aggregate.transformNode.position
        );
    }

    onNodeDetached() : void
    {
        this._aggregate?.dispose();
        this._aggregate = null;
    }

    async destroy() : Promise<void>
    {
        this._aggregate?.dispose();
        this._aggregate = null;
    }
}

Debugging Physics

SAGE provides built-in collider debug visualization through gameEngine.debug.colliders. You can enable it per-entity, per-node, or declaratively through entity definitions and level YAML configs:

typescript
// Show colliders for a specific entity
entity.showCollider();                // all colliders, default color
entity.showCollider('#ff0000');       // all colliders in red

// Or declaratively in entity definitions
const def : GameEntityDefinition = {
    type: 'crate',
    debugCollider: true,              // auto-visualize during level loading
    // ...
};

See the Collider Debugging guide for the full API, config-driven usage, and YAML examples.

Performance Tips

  1. Use simple shapes -- Prefer BOX, SPHERE, CAPSULE over MESH and CONVEX_HULL
  2. Make static objects mass 0 -- Static bodies are dramatically cheaper than dynamic ones
  3. Collision filtering -- Reduce the number of collision pairs the engine checks
  4. Sleep parameters -- Havok automatically sleeps objects at rest; avoid waking them unnecessarily
  5. Limit stacking -- Large stacks of dynamic objects are expensive and can become unstable
  6. Continuous collision detection -- Enable for fast-moving objects to prevent tunneling through thin walls

Released under the MIT License.