Tutorial 15: Physics — rigid bodies, joints, and collision events
Level: Intermediate Time: 20 minutes You’ll learn: how
@joroya/physicsdrivescannon-esfrom any OroyaScene, how to wire collision events to nodes, and how to constrain bodies with joints.
Install
npm install @joroya/core @joroya/renderer-three @joroya/physics three
@joroya/physics ships its own dependency on cannon-es, so you don’t need to install it separately.
Step 1 — Build a scene with a floor and a dropping cube
RigidBody + Collider are components you attach to regular Oroya nodes. The PhysicsSystem traverses the scene each frame, materializes a cannon body for every node that has both, and writes the simulated transform back to node.transform.
import {
Scene, Node, Camera, CameraType, Light, LightType,
createBox, createPlane, Material,
RigidBody, RigidBodyType,
Collider, ColliderShape,
} from "@joroya/core";
import { ThreeRenderer } from "@joroya/renderer-three";
import { PhysicsSystem } from "@joroya/physics";
const scene = new Scene();
// Camera
const cam = new Node("camera");
cam.transform.position = { x: 6, y: 6, z: 10 };
cam.transform.lookAt({ x: 0, y: 0, z: 0 });
cam.addComponent(new Camera({ type: CameraType.Perspective, fov: 50, aspect: 16 / 9, near: 0.1, far: 100 }));
scene.add(cam);
// Lights
const sun = new Node("sun");
sun.transform.position = { x: 5, y: 10, z: 5 };
sun.addComponent(new Light({ type: LightType.Directional, intensity: 1.0, castShadow: true }));
scene.add(sun);
scene.add(Object.assign(new Node("amb"), {})).addComponent(new Light({ type: LightType.Ambient, intensity: 0.4 }));
// Floor — static body
const floor = new Node("floor");
floor.addComponent(createPlane(20, 20, 1, 1, { receiveShadow: true }));
floor.addComponent(new Material({ color: { r: 0.25, g: 0.27, b: 0.32 } }));
floor.transform.rotation = { x: -Math.SQRT1_2, y: 0, z: 0, w: Math.SQRT1_2 };
floor.addComponent(new RigidBody({ type: RigidBodyType.Static }));
floor.addComponent(new Collider({
shape: ColliderShape.Box,
halfExtents: { x: 10, y: 0.1, z: 10 },
}));
scene.add(floor);
// Dynamic cube — falls under gravity
const cube = new Node("cube");
cube.addComponent(createBox(1, 1, 1, { castShadow: true }));
cube.addComponent(new Material({ color: { r: 1, g: 0.55, b: 0.2 } }));
cube.transform.position = { x: 0, y: 5, z: 0 };
cube.transform.updateLocalMatrix();
cube.addComponent(new RigidBody({ type: RigidBodyType.Dynamic, mass: 1 }));
cube.addComponent(new Collider({
shape: ColliderShape.Box,
halfExtents: { x: 0.5, y: 0.5, z: 0.5 },
restitution: 0.4,
}));
scene.add(cube);
Step 2 — Run physics in the render loop
const renderer = new ThreeRenderer({
canvas: document.getElementById("canvas") as HTMLCanvasElement,
width: window.innerWidth,
height: window.innerHeight,
});
renderer.mount(scene);
const physics = new PhysicsSystem({ gravity: { x: 0, y: -9.82, z: 0 } });
let last = performance.now();
function frame(now: number) {
const dt = Math.min((now - last) / 1000, 0.1);
last = now;
physics.update(dt, scene); // step the world, sync transforms back
renderer.render(dt); // draw
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
That’s it — the cube falls, hits the floor, and bounces with the configured restitution.
Step 3 — React to collisions
PhysicsSystem emits collision events on the node’s existing EventEmitter (the same channel that delivers click / hover events). Subscribe with node.events.on(...):
cube.events.on("collide-begin", (e) => {
console.log(`cube hit ${e.other.name} with impulse ${e.impulse?.toFixed(2)}`);
});
cube.events.on("collide-end", (e) => {
console.log(`cube left ${e.other.name}`);
});
Six event names exist:
collide-begin/collide/collide-endfor solid contactstrigger-enter/trigger-stay/trigger-exitfor sensor colliders (setisTrigger: trueon theCollider)
Step 4 — Add a joint
A hinge joint pins two bodies along a shared axis — perfect for doors, wheels, pendulums.
const anchor = new Node("anchor");
anchor.transform.position = { x: -3, y: 4, z: 0 };
anchor.transform.updateLocalMatrix();
anchor.addComponent(new RigidBody({ type: RigidBodyType.Static }));
anchor.addComponent(new Collider({ shape: ColliderShape.Sphere, radius: 0.1 }));
scene.add(anchor);
const pendulum = new Node("pendulum");
pendulum.transform.position = { x: -3, y: 2, z: 0 };
pendulum.transform.updateLocalMatrix();
pendulum.addComponent(createBox(0.5, 2, 0.5, { castShadow: true }));
pendulum.addComponent(new Material({ color: { r: 0.6, g: 0.8, b: 1 } }));
pendulum.addComponent(new RigidBody({ type: RigidBodyType.Dynamic, mass: 1 }));
pendulum.addComponent(new Collider({ shape: ColliderShape.Box, halfExtents: { x: 0.25, y: 1, z: 0.25 } }));
scene.add(pendulum);
// Ensure both bodies exist in the world before linking them.
physics.update(1 / 60, scene);
physics.addHingeConstraint(anchor, pendulum, {
pivotA: { x: 0, y: 0, z: 0 }, // attach at anchor center
pivotB: { x: 0, y: 1, z: 0 }, // attach at pendulum top
axisA: { x: 0, y: 0, z: 1 },
});
Push the pendulum (mouse drag, an applyImpulse call, or just gravity at startup) and it swings.
Step 5 — Raycast against the physics world
The renderer’s raycast targets visual meshes. The physics raycast targets simulated bodies — useful for click-to-pick on physical objects, line-of-sight checks, or laser-pointer effects.
const hit = physics.raycast(
{ x: 0, y: 10, z: 0 }, // from
{ x: 0, y: 0, z: 0 }, // to
);
if (hit) {
console.log(`hit ${hit.node.name} at distance ${hit.distance}`);
}
raycast returns the closest hit (or null); raycastAll(from, to) returns every body along the ray.
What to try next
- Sensors: add
isTrigger: trueto a Collider — bodies pass through it buttrigger-enter/trigger-exitfire. Use for checkpoints, damage zones, proximity sensors. - Collision filtering:
collisionGroup(bitmask, default1) andcollisionMask(default-1) let you put bodies in layers that ignore each other. - Vehicles:
Vehicleis a high-level wrapper over cannon-es’sRaycastVehiclefor drivable cars. See theVehicleAPI in@joroya/physics.