Physics
SAGE uses Havok Physics through BabylonJS. A scene either carries a physics engine or it does not. Where it does, two things build bodies on it: HavokPhysicsBodyBehavior, for an entity, and the authored collider property, for level geometry a scene file already carries. Everything past that -- forces, contacts, joints -- is reached through the body one of those two built.
A character is neither: CharacterControllerBehavior sweeps a capsule through the scene instead of simulating a body. See Character Controller.
Enabling physics
Havok is not loaded until a scene asks for it. The first enablePhysics call in a session loads the module; every call after it, on any scene, reuses that one module. Two simultaneous asks join the same load. A load that fails is not remembered, so the next ask starts a fresh one.
import { Vector3 } from '@babylonjs/core';
const sceneEngine = engine.engines.sceneEngine;
const scene = sceneEngine.createScene();
// Default gravity (0, -9.81, 0)
await sceneEngine.enablePhysics(scene);
// Custom gravity
await 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. The module is the scene engine's to hold, so SceneEngine.dispose() releases it and forgets a load still in flight; the scenes it created belong to their levels and are left alone.
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.
A game running headless, on Babylon's NullEngine, hands Havok's WebAssembly bytes to createGameEngine as havokBinary: there is nothing to fetch them over the network from outside a browser.
Physics runs on the fixed step
One Havok step per fixed step, advanced by the simulation and never by rendering. A fixed step is every entity's update() in insertion order, then the physics step, then the bus drain, so a behavior reading a body in the next step reads the result of its own decision from this one. A frame that renders without running a fixed step draws the same physics state again.
HavokPhysicsBodyBehavior
HavokPhysicsBodyBehavior gives an entity one PhysicsAggregate -- a body, a shape, and a material -- built from its own state. It is how an entity gets physics without a game writing a body builder of its own, and it only ever does anything on a NodeEntity: it builds the body in onMeshLoaded and disposes it in onDestroy.
import { HavokPhysicsBodyBehavior, NamedBehavior, defineEntity } from '@skewedaspect/sage';
export const CrateEntity = defineEntity({
type: 'CrateEntity',
tags: [ 'prop', 'pushable' ],
defaultState: { mass: 20 },
behaviors: [ HavokPhysicsBodyBehavior, NamedBehavior ] as const,
mesh: { source: 'box', params: { size: 1 } },
});State
| Field | What it is | Default |
|---|---|---|
shape | box, sphere, cylinder, convex, mesh, or compound | box |
parts | the children of a compound shape, unused by every other shape | [] |
motion | static, dynamic, or kinematic | dynamic |
mass | in kilograms | 1 |
friction | resistance to sliding against what it touches | 0.5 |
restitution | how much of an impact's speed comes back | 0 |
position | where the body is | the entity's own |
rotation | how the body is oriented | the entity's own |
velocity | metres per second | zero |
angularVelocity | radians per second | zero |
suspended | whether the body is out of the physics world | false |
The six shape words are the type BodyShape and the three motion words are BodyMotion, both exported, so a game writing its own body configuration types it by name rather than repeating the literals:
import type { BodyMotion, BodyShape } from '@skewedaspect/sage';
export interface PropDefinition
{
shape : BodyShape;
motion : BodyMotion;
mass : number;
}shape and parts are read once, when the body is built. Every other field is computed from the body itself, once there is one: reading state.position asks the body where it is, and writing it tells the body to be somewhere else. There is nothing kept in sync, because the field and the body are the same fact read two ways. See Behaviors.
position, rotation, velocity, and angularVelocity each answer with a live view, the same object on every read, and a write to one of its components reaches the body through the write the whole-field setter makes. Adding to velocity.y and assigning velocity tell the body the same thing.
A body is never the first computation on the two transform fields it takes. Every NodeEntity computes position and rotation from its node (Entities), and the body's go over that: while the body exists, reading and writing those two reach the body. Disposing the body hands nothing back to the node, because the behavior disposes it in onDestroy and destroy() has already taken every computation off, newest first. position and rotation fall to the node's own answer on the way, and each field lands as a plain value holding what the body last answered with, so state on a destroyed entity reads as data rather than asking a body that is not there.
box, sphere, and cylinder size themselves from the entity's mesh, or from everything hanging under its node when it has none. convex and mesh read vertices, so they need a mesh. compound is sized from parts alone -- a list of boxes, spheres, and cylinders, each placed at its own offset and rotation -- and is the only shape that keeps a concave outline; every other single shape is convex.
Before the body exists
The body is built in onMeshLoaded, because it is sized from the mesh and there is none earlier. Until then velocity, angularVelocity, motion, mass, friction, and restitution are ordinary fields holding whatever composition, a snapshot, or a game's own onStateReady put there. Reading one gives that value, writing one replaces it, and a snapshot taken in that window records it.
position and rotation are the node's for that whole window, as they are for an entity with no physics at all. The entity stands where construction put its node, and a write in the window moves the node. The body is then built where the node stands and carrying the velocity the field holds, so a write during the window is a value the body starts from rather than one the body discards.
How long the window lasts is decided by the mesh. A pooled claim and an adopted node settle inside construction, so there is no window; a fresh spawn's window is however many fixed steps its mesh load takes.
A suspended body
suspended, false by default, is whether the body takes part in the physics world. A suspended body collides with nothing, no query finds it, gravity does not pull it, and it stops driving its node, so the node goes wherever its parent puts it: a carried item rides the camera on the node alone. position and rotation answer from the node meanwhile, and a write moves the node. Writing suspended back to false puts the body where the node now stands, carrying no velocity. motion written while suspended is what the body resumes as, and a body built from state that says suspended is built suspended, so a save taken mid-carry restores a carried item.
// Pick the crate up: out of the physics world, riding the camera.
crate.state.suspended = true;
crate.node.parent = camera;
crate.node.position.set(0, -0.45, 0.9);
// Set it down: the node goes where it lands, then the body catches up there.
crate.node.parent = null;
crate.node.position.set(4, 0.5, 2);
crate.state.suspended = false;Motion
A dynamic body is moved by Havok: gravity pulls it, contacts push it. A static body never moves, and is the cheapest body Havok has. A kinematic body is moved by a game writing position or rotation, and Havok carries the consequences: the body is not pushed by anything it hits, but it pushes what it hits and carries the velocity the move implies. That velocity is what a rider inherits -- a dynamic body resting on a moving platform is carried by friction, and a character controller standing on one reads it from a support query.
A behavior writes those fields the way it writes any field it shares with another behavior: through its own this.state, never through another entity's. A behavior that moves a platform declares the same fields HavokPhysicsBodyBehavior does, which is how the two meet on one entity:
import { Behavior } from '@skewedaspect/sage';
import type { PhysicsBodyState } from '@skewedaspect/sage';
type ElevatorState = Pick<PhysicsBodyState, 'motion' | 'position'>;
export class ElevatorBehavior extends Behavior<ElevatorState>
{
readonly defaults : ElevatorState = { motion: 'kinematic', position: { x: 0, y: 0, z: 0 } };
onUpdate() : void
{
this.state.position = { ...this.state.position, y: this.state.position.y + 0.01 };
}
}Writing position or rotation on a kinematic body arrives with velocity; writing them on a static or dynamic body teleports, arriving with none.
applyImpulse
applyImpulse is the one operation the behavior exposes: a shove to the entity's own body, answered immediately.
import { HavokPhysicsBodyBehavior } from '@skewedaspect/sage';
import type { NodeEntity } from '@skewedaspect/sage';
declare const crate : NodeEntity;
if(crate.hasBehavior(HavokPhysicsBodyBehavior))
{
// Through the middle -- moves without turning it
crate.ops.applyImpulse({ x: 0, y: 5, z: 0 });
// Against a corner, in world space -- turns it as well
crate.ops.applyImpulse({ x: 10, y: 0, z: 0 }, { x: 0, y: 1, z: 0.5 });
}Left out, the location is the body's own centre. A body that has not been built yet has nothing to shove, and the call answers with nothing rather than an error.
Writing your own physics behavior
A game that wants a body built differently, or paired with logic HavokPhysicsBodyBehavior does not offer, writes its own behavior against the same two hooks: build the PhysicsAggregate in onMeshLoaded, on this.entity.node, and dispose it in onDestroy.
import { PhysicsAggregate, PhysicsShapeType } from '@babylonjs/core';
import { Behavior } from '@skewedaspect/sage';
import type { NodeBehaviorHooks, NodeEntity } from '@skewedaspect/sage';
export class BouncyBallBehavior extends Behavior<object, object, NodeEntity> implements NodeBehaviorHooks
{
readonly defaults = {};
#aggregate : PhysicsAggregate | undefined;
onMeshLoaded() : void
{
const node = this.entity.node;
this.#aggregate = new PhysicsAggregate(
node,
PhysicsShapeType.SPHERE,
{ mass: 1, restitution: 0.9 },
node.getScene()
);
}
onDestroy() : void
{
this.#aggregate?.dispose();
this.#aggregate = undefined;
}
}The aggregate goes on the entity's node, never on its mesh: NodeEntity parents the mesh under the node, and the node is what state.position and state.rotation are computed from, so a body on the mesh would be dragged along by the node above it.
Sizing a shape from a hierarchy
A node imported from Blender is usually a parent with a mesh per material under it, and a box on the parent alone measures nothing. computeHierarchyBounds(node) measures the whole hierarchy and answers with a HierarchyBounds: extents, the full box size across the node's descendants, and center, the offset from the node's origin in the node's own frame. Both are what PhysicsAggregate takes in its options, so the answer goes straight in.
import { PhysicsAggregate, PhysicsShapeType } from '@babylonjs/core';
import { computeHierarchyBounds } from '@skewedaspect/sage';
const bounds = computeHierarchyBounds(node);
const aggregate = new PhysicsAggregate(
node,
PhysicsShapeType.BOX,
{ mass: 1, extents: bounds.extents, center: bounds.center },
node.getScene()
);HavokPhysicsBodyBehavior sizes box, sphere, and cylinder this way when the entity has no mesh of its own, so a behavior that measures the same way gets the same body.
Static, dynamic, and kinematic bodies
| Static | Dynamic | Kinematic | |
|---|---|---|---|
| Moved by | Nothing | Havok, by gravity and contacts | A game, by writing where it should be |
| Pushed by contacts | No | Yes | No |
| Pushes what it hits | Yes | Yes | Yes, carrying its own velocity |
| Use | Floors, walls | Players, projectiles, props | Doors, lifts, moving platforms |
| Cost | Cheapest: never integrated, never wakes | Higher | Higher |
Babylon's own enumeration spells kinematic motion ANIMATED; SAGE's own vocabulary, on both the behavior's motion field and the authored collider_motion property, says kinematic on both sides.
PhysicsAggregate directly
A body on a mesh a level built by hand -- something outside both HavokPhysicsBodyBehavior and the authored collider property -- is Babylon's own PhysicsAggregate, called after the scene's physics has been enabled.
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 |
These are Babylon's own defaults for the options it fills in, not SAGE's:
| Option | Default | Meaning |
|---|---|---|
mass | 0 | Mass in kg. 0 is static. |
restitution | 0.2 | Bounciness, 0 to 1 |
friction | 0.2 | Surface friction |
HavokPhysicsBodyBehavior defaults friction to 0.5 and restitution to 0, so a body SAGE builds does not bounce unless it is asked to. An aggregate built directly keeps Babylon's 0.2. Pass restitution: 0 to match what the behavior gives.
Forces, impulses, and velocity
import { Vector3 } from '@babylonjs/core';
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));Collision detection
Contact reporting stays Babylon's: there is no collision hook on Behavior and no physics event in the event map. Use ActionManager for mesh-level collision callbacks, wired in onMeshLoaded:
import { ActionManager, ExecuteCodeAction } from '@babylonjs/core';
import { Behavior } from '@skewedaspect/sage';
import type { NodeBehaviorHooks, NodeEntity } from '@skewedaspect/sage';
interface CollisionState
{
collisions : number;
}
export class CollisionBehavior extends Behavior<CollisionState, object, NodeEntity> implements NodeBehaviorHooks
{
readonly defaults : CollisionState = { collisions: 0 };
onMeshLoaded() : void
{
const mesh = this.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.shape.filterMembershipMask = PLAYER_GROUP;
playerAggregate.shape.filterCollideMask = ENVIRONMENT_GROUP | ENEMY_GROUP;
projectileAggregate.shape.filterMembershipMask = PROJECTILE_GROUP;
projectileAggregate.shape.filterCollideMask = ENVIRONMENT_GROUP | ENEMY_GROUP;The masks live on the shape, not the body. filterMembershipMask is the set of groups the shape belongs to; filterCollideMask is the set it collides with. Babylon runs the test both ways round, so two shapes touch only when each one's membership overlaps the other's collide mask.
Constraints
SAGE builds none, and offers nothing beyond what HavokPhysicsBodyBehavior and the authored collider already give you: attach Babylon's own constraint between the two bodies your behaviors hold.
import { DistanceConstraint } from '@babylonjs/core';
const constraint = new DistanceConstraint(5, scene);
pivotAggregate.body.addConstraint(swingingAggregate.body, constraint);Colliders from Blender
The collider property handler creates aggregates for scene geometry marked in Blender: box, sphere, mesh, or none, static unless collider_motion says otherwise. 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, ormotion: 'static'on the behavior. - 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.
