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/
│ ├── PhysicsBodyBehavior.ts # The Havok aggregate and its operations
│ └── PlayerVehicleBehavior.ts # Tank-style driving through the body's operations
├── entities/
│ ├── GroundEntity.ts # defineEntity classes, one per entity class
│ ├── CrateEntity.ts
│ ├── BallEntity.ts
│ ├── CylinderEntity.ts
│ ├── DumbbellEntity.ts
│ ├── BulldozerEntity.ts
│ └── index.ts # The entity class table
├── input/
│ ├── actions.ts # Actions, the 'physics' context, keyboard and gamepad bindings
│ └── helpers.ts
└── game/
├── PhysicsController.ts # Spawning, actions, fall cleanup, statistics
├── PhysicsPlaygroundLevel.ts # Custom Level: enables physics, binds the simulation
├── scene.ts # Camera and lights
└── meshes.ts # The mesh source, standard plus three hand-built meshesEnable 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:
// game/PhysicsPlaygroundLevel.ts
protected async buildScene() : Promise<Scene>
{
const { sceneEngine } = this.gameEngine.engines;
const scene = sceneEngine.createScene();
sceneEngine.enablePhysics(scene, new Vector3(0, GRAVITY_Y, 0));
setupScene(scene);
this.gameEngine.rebuildSimulation(scene, this.#meshSource);
return scene;
}The level reaches the engine through this.gameEngine, which every Level gets from its context; the only thing its config carries beyond the name and class is the mesh source.
A YAML level does the same with physics: true.
The physics behavior
PhysicsBodyBehavior declares the state every physics body carries: the transform, the shape, and the three material values the sliders set.
export type PhysicsShape = 'box' | 'sphere' | 'cylinder' | 'convex' | 'compound';
export interface PhysicsBodyState
{
position : Vector3State;
rotation : QuaternionState;
shape : PhysicsShape;
parts : CompoundPart[];
mass : number;
restitution : number;
friction : number;
}A NodeEntity's mesh arrives asynchronously, and onMeshLoaded is the first hook where it exists. The behavior builds its PhysicsAggregate there and disposes it in onDestroy. The aggregate is a behavior instance field, not state, and is never saved.
The aggregate goes on entity.node, not on the mesh. NodeEntity writes state.position onto the node every fixed step; with the body on the node, that write carries the value the behavior copied out of the node a moment before. A body on the mesh, one level down, would be dragged around by the node above it.
A TransformNode has no geometry, so Babylon cannot size a collision shape from it. The behavior reads the mesh's bounding box instead, and for a convex hull hands Babylon the mesh itself:
function shapeParametersFor(shape : PhysicsShape, mesh : AbstractMesh) : ShapeParameters
{
const halfExtents = mesh.getBoundingInfo().boundingBox.extendSize;
if(shape === 'sphere')
{
return { radius: halfExtents.x };
}
if(shape === 'cylinder')
{
return {
radius: halfExtents.x,
pointA: new Vector3(0, -halfExtents.y, 0),
pointB: new Vector3(0, halfExtents.y, 0),
};
}
if(shape === 'convex')
{
return { mesh };
}
return { extents: halfExtents.scale(2) };
}onMeshLoaded() : void
{
const entity = this.entity as NodeEntity;
const mesh = entity.mesh;
if(mesh === undefined)
{
throw new Error(`PhysicsBodyBehavior: "${ entity.name }" loaded no mesh to shape a body from.`);
}
const node = entity.node;
this.#aggregate = new PhysicsAggregate(
node,
shapeTypeFor(this.state.shape),
{
mass: this.state.mass,
restitution: this.state.restitution,
friction: this.state.friction,
...shapeParametersFor(this.state.shape, mesh),
},
node.getScene()
);
}NodeEntity places the node from state.position and state.rotation at construction, pooled or fresh, so by the time onMeshLoaded runs the node is already at the spawn point and Havok builds the body there.
Every fixed step, the behavior copies the node's transform into state:
onUpdate() : void
{
if(this.#aggregate === undefined)
{
return;
}
const node = (this.entity as NodeEntity).node;
this.state.position.x = node.position.x;
this.state.position.y = node.position.y;
this.state.position.z = node.position.z;
// ... and the same for rotation
}The fall check and a snapshot read state alone; the statistics read each prop's mass from state and its velocity through an operation. A restored entity rebuilds its aggregate in onMeshLoaded from the state the snapshot held.
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. state.parts lists its children as plain JSON, each with a shape, an offset, a rotation, and a full size:
// entities/DumbbellEntity.ts
defaultState: {
shape: 'compound',
parts: [
{ shape: 'sphere', position: { x: -1, y: 0, z: 0 }, rotation: UPRIGHT, extents: WEIGHT },
{ shape: 'sphere', position: { x: 1, y: 0, z: 0 }, rotation: UPRIGHT, extents: WEIGHT },
{ shape: 'cylinder', position: { x: 0, y: 0, z: 0 }, rotation: ALONG_X, extents: { x: 0.3, y: 2, z: 0.3 } },
],
},The behavior builds one Havok shape per part and places each in a PhysicsShapeContainer, then hands the container to the aggregate in place of a shape type:
#buildCompound(scene : Scene) : PhysicsShapeContainer
{
const container = new PhysicsShapeContainer(scene);
const material = { friction: this.state.friction, restitution: this.state.restitution };
for(const part of this.state.parts)
{
const child = buildPartShape(part, scene);
child.material = material;
container.addChild(child, toVector3(part.position), toQuaternion(part.rotation));
this.#compoundShapes.push(child);
}
this.#compoundShapes.push(container);
return container;
}An aggregate does not dispose a shape it was handed ready-made, so the behavior keeps the container and its children and disposes them itself in onDestroy.
0.9's dumbbell was three child entities under a definition-level children field; authoring has no such field, and one merged mesh over one compound body describes the same object without a hierarchy.
Operations
The behavior exposes four operations, each taking or answering plain { x, y, z } objects:
static readonly ops = [
'setLinearVelocity',
'getLinearVelocity',
'setAngularVelocity',
'getAngularVelocity',
] as const;getLinearVelocity returns undefined until the body exists, which is how a caller tells a mesh still loading from a body at rest.
The bulldozer's driver is a second behavior on the same entity, and it moves the vehicle by calling those operations through this.ops. Its Requires names them, so an entity composing the driver without the body does not compile:
// behaviors/PlayerVehicleBehavior.ts
export class PlayerVehicleBehavior extends Behavior<PlayerVehicleState, OpsOf<typeof PhysicsBodyBehavior>>
{
static readonly events = [ 'action:playerMoveForward', 'action:playerMoveRight' ] as const;
onUpdate() : void
{
const velocity = this.ops.getLinearVelocity();
if(velocity === undefined)
{
return;
}
if(this.#isGrounded(velocity))
{
this.#drive(velocity);
}
else
{
this.#coast(velocity);
}
this.#keepUpright();
}
}PlayerVehicleState declares position and rotation too. Two behaviors declaring the same field share it, and PhysicsBodyBehavior sits first in the bulldozer's behavior tuple, so by the time the driver reads the heading each step, the body has already written 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, and the compiler-checked state and ops typing:
// entities/CrateEntity.ts
const behaviors = [ PhysicsBodyBehavior ] 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;
declare readonly state : StateFor<typeof behaviors>;
declare readonly ops : OpsFor<typeof behaviors>;
}A spawn supplies what differs per instance, which is where the slider values go:
simulation.spawn('CrateEntity', undefined, {
position: { x: Math.cos(angle) * radius, y: SPAWN_HEIGHT, z: Math.sin(angle) * radius },
mass: this.#mass,
restitution: this.#restitution,
friction: this.#friction,
});The ground is the same behavior with mass: 0, which makes its body static:
// entities/GroundEntity.ts
const GroundBase = defineEntity({
type: 'GroundEntity',
behaviors,
tags: [ 'physics', 'environment', 'static' ],
defaultState: {
shape: 'box',
...MATERIAL_GROUND,
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 return undefined for a class with no meshConfig, and the body build would then throw for want of a mesh.
// game/meshes.ts
export function buildPlaygroundMeshSource(assets : MeshAssetSource) : EntityMeshLoader
{
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. 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 five digital actions and two analog ones onto the engine's InputManager, in one context, with keyboard and gamepad bindings for each. 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 simulation'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:
this.#unsubscribe = simulation.bus.register(
{ id: CONTROLLER_ID, receive: (event) => this.#receive(event) },
SUBSCRIPTIONS
);Statistics and the fall check
One scan of the props a frame does both: whatever fell off the edge despawns, and the rest make the statistics, mass from state and velocity through an operation.
for(const prop of simulation.getEntitiesByTag(PROP_TAG))
{
if(!prop.hasBehavior(PhysicsBodyBehavior))
{
throw new Error(`Prop "${ prop.id }" has no physics body.`);
}
if(prop.state.position.y < FALL_THRESHOLD)
{
simulation.despawn(prop.id);
}
else
{
entityCount += 1;
totalMass += prop.state.mass;
if(isMoving(prop.ops.getLinearVelocity()))
{
activeCount += 1;
}
}
}hasBehavior narrows a GameEntity to the behavior's read-only state and its operations, so the controller reads a prop's mass and velocity without holding a reference to the behavior or the body. The bulldozer is held 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.
Next steps
- Physics: the full reference, including filtering and constraints
- Behaviors:
onMeshLoadedandonDestroy - Input System: actions, contexts, and bindings in depth
