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:
import { createGameEngine } from '@skewedaspect/sage';
const gameEngine = await createGameEngine(canvas);To enable physics on a scene, use the SceneEngine:
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 gravityIn YAML level configs, enable physics with a single flag:
name: my-level
scene: levels/my-level.glb
physics: trueOr with custom gravity:
physics:
gravity:
x: 0
y: -4.9
z: 0PhysicsAggregate
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.
import { PhysicsAggregate, PhysicsShapeType } from '@babylonjs/core';Creating Physics Bodies
// 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
| Shape | Constant | Best For |
|---|---|---|
| Box | PhysicsShapeType.BOX | Crates, walls, floors |
| Sphere | PhysicsShapeType.SPHERE | Balls, projectiles |
| Capsule | PhysicsShapeType.CAPSULE | Characters |
| Cylinder | PhysicsShapeType.CYLINDER | Pillars, barrels |
| Mesh | PhysicsShapeType.MESH | Complex static geometry |
| Convex Hull | PhysicsShapeType.CONVEX_HULL | Complex dynamic objects |
Aggregate Options
| Property | Type | Default | Description |
|---|---|---|---|
mass | number | 0 | Mass in kg. 0 = static (immovable). |
restitution | number | 0 | Bounciness (0 = no bounce, 1 = full bounce) |
friction | number | 0.5 | Surface friction |
SceneEngine Helper
The SceneEngine provides a convenience method:
const aggregate = sceneEngine.addPhysics(
mesh,
PhysicsShapeType.BOX,
{ mass: 0 },
scene
);Static vs. Dynamic Bodies
| Property | Static (mass = 0) | Dynamic (mass > 0) |
|---|---|---|
| Movement | Never moves | Affected by forces and gravity |
| Collisions | Other objects bounce off | Responds to collisions |
| Use case | Ground, walls, platforms | Players, projectiles, props |
| Performance | Cheap | More expensive |
Applying Forces and Impulses
Forces
Forces are continuous -- apply them each frame for sustained acceleration:
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:
// Jump impulse
body.applyImpulse(
new Vector3(0, jumpForce, 0),
aggregate.transformNode.position
);Setting Velocity Directly
// 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:
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
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:
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:
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:
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:
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:
// 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
- Use simple shapes -- Prefer
BOX,SPHERE,CAPSULEoverMESHandCONVEX_HULL - Make static objects mass 0 -- Static bodies are dramatically cheaper than dynamic ones
- Collision filtering -- Reduce the number of collision pairs the engine checks
- Sleep parameters -- Havok automatically sleeps objects at rest; avoid waking them unnecessarily
- Limit stacking -- Large stacks of dynamic objects are expensive and can become unstable
- Continuous collision detection -- Enable for fast-moving objects to prevent tunneling through thin walls
