Physics
SAGE uses Havok Physics through BabylonJS for rigid body simulation. The physics plugin is initialized with the engine, and each scene that wants physics enables it separately.
Enabling physics
import { Vector3 } from '@babylonjs/core';
const sceneEngine = engine.engines.sceneEngine;
const scene = sceneEngine.createScene();
// Default gravity (0, -9.8, 0)
sceneEngine.enablePhysics(scene);
// Custom gravity
sceneEngine.enablePhysics(scene, new Vector3(0, -1.62, 0));Each scene gets its own HavokPlugin instance over the shared Havok binary, so disposing one scene does not disturb another. In a YAML level config, physics: true enables it with default gravity and physics: { gravity: { x, y, z } } sets a custom vector. GameLevel enables physics before it loads the scene file, so collider property handlers find a physics-enabled scene.
Under largeWorldRendering, GameLevel passes Havok a floating-origin radius so physics keeps precision far from the origin.
PhysicsAggregate
PhysicsAggregate is BabylonJS's wrapper around a physics body and its collision shape:
import { PhysicsAggregate, PhysicsShapeType } from '@babylonjs/core';
// Static body (mass = 0, never moves)
const groundAggregate = new PhysicsAggregate(groundMesh, PhysicsShapeType.BOX, { mass: 0 }, scene);
// Dynamic body
const ballAggregate = new PhysicsAggregate(
ballMesh,
PhysicsShapeType.SPHERE,
{ mass: 1, restitution: 0.7, friction: 0.5 },
scene
);| Shape | Constant | Use |
|---|---|---|
| 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 |
| Option | Default | Meaning |
|---|---|---|
mass | 0 | Mass in kg. 0 is static. |
restitution | 0 | Bounciness, 0 to 1 |
friction | 0.5 | Surface friction |
sceneEngine.addPhysics(mesh, shapeType, options, scene) builds one with defaults of mass: 1, restitution: 0.75, and friction: 0.5.
Static and dynamic bodies
Static (mass = 0) | Dynamic (mass > 0) | |
|---|---|---|
| Movement | Never moves | Affected by forces and gravity |
| Collisions | Other objects bounce off | Responds to collisions |
| Use | Ground, walls, platforms | Players, projectiles, props |
| Cost | Cheap | Higher |
Forces, impulses, and velocity
const body = aggregate.body;
// Continuous: apply each step for sustained acceleration
body.applyForce(new Vector3(100, 0, 0), aggregate.transformNode.position);
// Instantaneous: apply once for a sudden velocity change
body.applyImpulse(new Vector3(0, jumpForce, 0), aggregate.transformNode.position);
// Direct
body.setLinearVelocity(new Vector3(5, 0, 0));
const velocity = body.getLinearVelocity();
body.setAngularVelocity(new Vector3(0, Math.PI, 0));Physics inside a behavior
A behavior on a NodeEntity creates its physics body in onMeshLoaded, the first hook where this.entity.mesh and this.entity.node are safe to read, and disposes it in onDestroy:
import { PhysicsAggregate, PhysicsShapeType, Vector3 } from '@babylonjs/core';
import { Behavior } from '@skewedaspect/sage';
import type { GameEntityEvent as GameEvent, NodeEntity } from '@skewedaspect/sage';
export interface PhysicsBodyState
{
position : { x : number; y : number; z : number };
rotation : { x : number; y : number; z : number; w : number };
mass : number;
restitution : number;
friction : number;
}
export class PhysicsBodyBehavior extends Behavior<PhysicsBodyState>
{
static readonly events = [ 'physics:applyForce' ] as const;
readonly defaults : PhysicsBodyState = {
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0, w: 1 },
mass: 1,
restitution: 0.3,
friction: 0.6,
};
#aggregate : PhysicsAggregate | undefined;
onMeshLoaded() : void
{
const entity = this.entity as NodeEntity;
if(entity.mesh === undefined)
{
return;
}
this.#aggregate = new PhysicsAggregate(
entity.mesh,
PhysicsShapeType.BOX,
{ mass: this.state.mass, restitution: this.state.restitution, friction: this.state.friction },
entity.node.getScene()
);
}
onEvent(event : GameEvent) : boolean
{
if(event.type === 'physics:applyForce' && this.#aggregate !== undefined)
{
const { x, y, z } = event.payload;
this.#aggregate.body.applyForce(new Vector3(x, y, z), this.#aggregate.transformNode.position);
return true;
}
return false;
}
onUpdate() : void
{
if(this.#aggregate === undefined)
{
return;
}
const position = this.#aggregate.transformNode.position;
this.state.position.x = position.x;
this.state.position.y = position.y;
this.state.position.z = position.z;
const rotation = this.#aggregate.transformNode.rotationQuaternion;
if(rotation !== null)
{
this.state.rotation.x = rotation.x;
this.state.rotation.y = rotation.y;
this.state.rotation.z = rotation.z;
this.state.rotation.w = rotation.w;
}
}
onDestroy() : void
{
this.#aggregate?.dispose();
this.#aggregate = undefined;
}
}Two things about this pattern:
- The aggregate is attached to the mesh, which sits under the entity's node.
NodeEntity.updatewritesstate.positionandstate.rotationonto the node every fixed step, after every behavior'sonUpdate. Copying the mesh's physics-driven transform back into state each step, as above, keeps state the source of truth and keeps the snapshot honest. A behavior that leaves state alone will see the node snap back to the stale position each step. - The aggregate is a behavior instance field. It is not state and it is not saved. A restored entity rebuilds it in
onMeshLoaded, which runs on restore as well as on create, from the state the snapshot carried.
The event this behavior answers is declared in the game's event map:
declare module '@skewedaspect/sage-core'
{
interface EventMap
{
'physics:applyForce' : { x : number; y : number; z : number };
}
}A push is a targeted emit from wherever the game decides to push:
crate.emit('physics:applyForce', { x: 50, y: 0, z: 0 }, crate.id);Collision detection
Use BabylonJS ActionManager for mesh-level collision callbacks, wired in onMeshLoaded:
import { ActionManager, ExecuteCodeAction } from '@babylonjs/core';
export class CollisionBehavior extends Behavior<CollisionState>
{
readonly defaults : CollisionState = { collisions: 0 };
onMeshLoaded() : void
{
const entity = this.entity as NodeEntity;
const mesh = entity.mesh;
if(mesh === undefined)
{
return;
}
if(mesh.actionManager === null)
{
mesh.actionManager = new ActionManager(mesh.getScene());
}
mesh.actionManager.registerAction(
new ExecuteCodeAction({ trigger: ActionManager.OnIntersectionEnterTrigger }, () =>
{
this.state.collisions += 1;
})
);
}
}A Babylon callback runs outside the fixed step. Writing a counter to state from it, and acting on that counter in onUpdate, keeps the reaction inside the step.
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;
playerAggregate.body.setCollisionFilteringGroups(PLAYER_GROUP, ENVIRONMENT_GROUP | ENEMY_GROUP);
projectileAggregate.body.setCollisionFilteringGroups(PROJECTILE_GROUP, ENVIRONMENT_GROUP | ENEMY_GROUP);Constraints
Physics constraints create mechanical connections between bodies:
import { DistanceConstraint } from '@babylonjs/core';
const constraint = new DistanceConstraint({
pivotA: new Vector3(0, 0, 0),
pivotB: new Vector3(0, 0, 0),
maxDistance: 5,
});
constraint.attachAll(true, pivotBody, swingingBody);Colliders from Blender
The collider property handler creates static aggregates for scene geometry marked in Blender. See the Levels reference.
Debugging physics
ColliderDebugManager at engine.debug.colliders wraps BabylonJS's PhysicsViewer to show and hide a node's collider. See the Debug Console page.
Performance
- Prefer
BOX,SPHERE, andCAPSULEoverMESHandCONVEX_HULL. - Give static objects
mass: 0. - Use collision filtering to reduce the pairs the engine checks.
- Havok sleeps bodies at rest; avoid waking them without cause.
- Large stacks of dynamic objects are expensive and can become unstable.
