Physics and collisions
Physics runs on Rapier. It is downloaded as a separate chunk when the game starts, in parallel with your assets (the first scene waits for it), and you use it through three components:
| I want… | Use |
|---|---|
| A pickup, a trigger zone, a sensor | area() alone |
| A floor or a wall that never moves | area(), body({ isStatic: true }) |
| A crate or a ball that the physics pushes around | area(), body() |
| A platform I move from code that pushes other bodies | area(), body({ kinematic: true }) |
| A player or an NPC I control from code | area(), character() |
Here they are together: a character that walks, jumps and pushes crates, over a floor, a wall, a falling stack and a moving platform.
import threeplay from "threeplay";
threeplay({ width: 960, height: 540, background: "#a8dadc" });
scene("main", () => { // Static: a floor and a wall add([box(40, 1, 40), pos(0, -0.5, 0), color("#457b9d"), area(), body({ isStatic: true }), "floor"]); add([box(8, 3, 1), pos(0, 1.5, -8), color("#1d3557"), area(), body({ isStatic: true })]);
// Dynamic: a stack of crates that tumbles when hit for (let y = 0; y < 4; y++) { for (let x = -1; x <= 1; x++) { add([box(1), pos(x * 1.05, 0.5 + y * 1.02, -3), color("#e9c46a"), area({ friction: 0.8 }), body({ mass: 1 }), "crate"]); } }
// Kinematic: a platform moved from code const platform = add([box(4, 0.4, 4), pos(9, 1, 0), color("#2a9d8f"), area(), body({ kinematic: true })]); onUpdate(() => (platform.pos.x = 9 + Math.sin(time()) * 3));
// Sensors: coins you walk through for (let i = 0; i < 5; i++) { add([sphere(0.3), pos(-6 + i * 3, 0.8, 5), color("gold"), area(), "coin"]); }
// A character controlled from code const player = add([ capsule(0.4, 1.6), pos(0, 1, 6), rotate(), color("#f77f00"), area(), character({ stepHeight: 0.3 }), "player", ]); camFollow(player, { offset: vec3(0, 6, 10) });
onUpdate(() => { const dir = inputDir(); player.move(dir.clone().multiplyScalar(6)); if (dir.lengthSq() > 0) player.lookAt(player.worldPos().add(dir)); }); onButtonPress("jump", () => player.isGrounded() && player.jump(9));
// Collisions player.onCollide("coin", (coin) => { coin.destroy(); beep({ freq: 880, duration: 0.1 }); });
// Raycast: push the crate in front of you onKeyPress("e", () => { const hit = raycast(player.worldPos(), player.forward(), 2.5, { tag: "crate" }); if (hit) hit.obj.applyImpulse(player.forward().multiplyScalar(12)); });
add([text("WASD move, space jump, E push", { size: 16 }), pos(20, 510), fixed()]);});
go("main");area(): a collider
Section titled “area(): a collider”area() gives an object a collider. By default the collider is derived from the object’s shape:
| Shape | Collider |
|---|---|
box |
cuboid |
sphere |
ball |
capsule, cylinder, cone |
the same shape |
plane |
a thin cuboid whose top face is at the object’s origin |
mesh, model |
the bounding box |
An area() without a body() is a sensor: it detects overlaps and fires events, but nothing bounces off it, and the object moves freely with its pos. That is what you want for coins, checkpoints and trigger zones. With body() or character() the collider is solid.
area({ shape: "auto", // "auto" | "box" | "sphere" | "capsule" | "mesh" | "hull" size: vec3(2, 1, 2), // override the size (local units, multiplied by the object's scale) offset: vec3(0, 0.5, 0), sensor: true, // force sensor on or off (default: true only without a body) friction: 0.5, restitution: 0.3, // bounciness});sizemakes a box of that size, whatever the shape. A big invisiblearea({ size })is the quick way to make a trigger zone, or a generous hitbox.shape: "mesh"builds an exact triangle mesh from the geometry. It is for static objects only: a dynamic body falls back to a convex hull with a console warning.shape: "hull"is a convex hull.obj.area.sensortells you what the object ended up with.
body(): rigid bodies
Section titled “body(): rigid bodies”body() makes the object part of the simulation. It needs area().
body(); // dynamic: gravity, collisions, forcesbody({ isStatic: true }); // never moves: floors, wallsbody({ kinematic: true }); // moved by your code, pushes dynamic bodiesOptions:
| Option | Default | Meaning |
|---|---|---|
mass |
from the collider volume | Mass in kg. |
gravityScale |
1 |
Multiplies gravity for this body. |
damping |
0 |
Linear damping (air drag). |
angularDamping |
0.05 |
Angular damping. |
lockRotation |
false |
Never rotate: handy for upright “player” boxes. |
ccd |
false |
Continuous collision detection, for fast objects that might tunnel through walls. |
What a dynamic body gives you:
ball.vel; // linear velocity (m/s): a live vector, read or assignball.vel.y = 10;ball.angularVel; // degrees per secondball.applyImpulse(vec3(0, 5, 0)); // a sudden push (one shot)ball.applyForce(vec3(0, 20, 0)); // a force for the next frame: call it every frame to keep itball.applyTorqueImpulse(vec3(0, 3, 0));ball.jump(8); // sets the vertical speed (default 8)ball.isGrounded(); // something solid just below?ball.isFalling();ball.onGround(() => debug.log("landed"));ball.teleport(vec3(0, 3, 0)); // moves it and resets its velocityNotes:
- Moving a dynamic body by changing
posalso works: the engine notices it and teleports the body. move()on a dynamic body sets its horizontal velocity (and keeps the vertical one). If you stop calling it, the body keeps sliding until friction stops it. Usedamping, or callmove(0).- A static body does not follow code: changing the
posof abody({ isStatic: true })has no effect on collisions. Usekinematicfor moving platforms. isGrounded()on a body is a short ray down from the bottom of the collider. It is approximate for tumbling objects, so combinelockRotation: truewith it for player-like boxes.
character(): a character controller
Section titled “character(): a character controller”character() is a kinematic character controller: you say where you want to go and it handles the rest. It slides along walls, climbs small steps and slopes, sticks to the ground on stairs, and falls with gravity. It is almost always a better choice than a dynamic body for a player.
const player = add([capsule(0.4, 1.8), pos(0, 2, 0), area(), character(), "player"]);
onUpdate(() => player.move(inputDir().multiplyScalar(6))); // a velocity, in m/sonButtonPress("jump", () => player.isGrounded() && player.jump());| Option | Default | Meaning |
|---|---|---|
stepHeight |
0.3 |
Highest step it climbs automatically. |
maxSlope |
45 |
Steepest walkable slope, in degrees. |
snapToGround |
0.3 |
How far it snaps down to the ground on slopes and stairs. |
gravityScale |
1 |
Multiplies the world gravity. |
jumpForce |
8 |
Default speed of jump(). |
It has vel (the vertical part includes gravity), jump(force?), isGrounded(), isFalling(), onGround(fn) and teleport(pos).
- Use
move()for horizontal movement and let gravity do the vertical part.move()accumulates over a frame, so you can call it more than once. - Characters move once per rendered frame, after your
updatecode, so a camera that follows them never jitters. - Characters walk through sensors (coins, zones), but the sensors still detect them.
character()andbody()can’t be on the same object (a character already is a kinematic body); threeplay throws if you try.- A character assumes gravity points down and that it is a top-level object, or at least that its movement is in world space.
- Nothing carries a character with a moving platform: it does not inherit the platform’s motion.
Collision events
Section titled “Collision events”Collisions arrive as events on the objects involved. They need an area() on both.
player.onCollide("coin", (coin, col) => coin.destroy()); // started touching an object with that tagplayer.onCollide((other, col) => debug.log("touched", other.id)); // any objectplayer.onCollideUpdate("lava", () => player.hurt(1)); // every fixed step while touchingplayer.onCollideEnd("platform", () => debug.log("left it")); // stopped touching
player.isColliding("coin"); // touching any object with that tag?player.isColliding(somebody); // or that very objectplayer.getCollisions(); // every object touching it right nowThe col argument is { other, sensor, normal }. sensor says that one of the colliders is a sensor (there is no physical response), and normal points from the other object towards this one (null for sensors). onCollideEnd handlers only get the other object.
You can also listen globally by pair of tags. The handler receives both objects:
onCollide("player", "coin", (player, coin, col) => coin.destroy());onCollide("bullet", "enemy", (bullet, enemy) => enemy.hurt(1));onCollideUpdate("player", "lava", (player) => player.hurt(1));onCollideEnd("player", "ladder", (player) => (player.climbing = false));These are scene listeners like any other: go() removes them if you registered them in a scene. With the same tag on both sides (onCollide("crate", "crate", ...)) the handler runs once for each side of a contact.
Collisions are detected during the fixed physics step, so the handlers run at the 50 Hz step rate. When an object is destroyed while touching others, the survivors get collideEnd.
Raycasts
Section titled “Raycasts”raycast(origin, direction, maxDistance = 1000, options?) returns the first hit or null:
const hit = raycast(player.worldPos(), player.forward(), 10, { exclude: [player], tag: "enemy" });if (hit) { hit.obj; // the object that was hit hit.point; // where, in the world hit.normal; // the surface normal there hit.distance; // meters from the origin}The direction is normalized for you. Options: exclude (objects to ignore), tag (only hit objects with this tag) and sensors (include sensors, false by default). Colliders are visible to queries after the first physics step that follows their creation.
Typical uses: ground and wall checks, line of sight (raycast(enemy.worldPos(), toPlayer, dist, { exclude: [enemy] })?.obj === player) and shooting (raycast(camPos(), camera3d.getWorldDirection(vec3()), 50) fires along the camera’s exact view direction; camForward() is flattened onto the ground plane).
Gravity
Section titled “Gravity”setGravity(30); // a number pulls down (−Y) with that strengthsetGravity(vec3(0, -9.81, 0)); // or give a vectorgetGravity();threeplay({ physics: { gravity: 12 } }); // or at startupThe default is 20 m/s², not the real 9.81. Real gravity feels floaty at game scale: a jump with speed 8 reaches 1.6 m under 20 m/s² but 3.3 m under 9.81, and it takes twice as long. Most games (platformers, shooters, third-person adventures) want something between 20 and 30, so threeplay starts there. Use setGravity(9.81) when the point is realism: a physics sandbox, objects that should fall like in real life.
Gravity affects bodies and characters, and it is not reset by go(): set it at the start of each scene. gravityScale on body() or character() adjusts it per object.
Turning physics off
Section titled “Turning physics off”threeplay({ physics: false });Rapier is not even downloaded. area(), body() and character() still exist but do nothing (a single warning goes to the console), so use this for games without collisions: viewers, menus, simple movement code of your own.
Debugging colliders
Section titled “Debugging colliders”Press F1 (or start with threeplay({ debug: true }), or set debug.inspect = true) and the inspector draws every collider and contact as a wireframe over the game. An object’s inspector line shows its kind, whether it is a sensor and how many contacts it has: for example area (sensor), touching 1 for a coin or character, touching 2 for a player. See Debugging.
The Rapier escape hatch
Section titled “The Rapier escape hatch”physics (that is, k.physics) is the backend. physics.world is the RAPIER.World and physics.native is the RAPIER module itself (both are null until physics has loaded, so use them after the first scene starts).
onKeyPress("g", () => { physics.world.forEachRigidBody((rb) => rb.wakeUp());});Limitations to know about
Section titled “Limitations to know about”- The collider follows the object’s own scale. Changing
obj.scalelater (crouching, growing, squashing) rebuilds the collider at the new size on the next fixed step; the body keeps its position and velocity, and contacts that still touch carry on without a newonCollide(a scale animation does not re-fire it). Acharacter()keeps its feet in place:player.scale.y = 0.5crouches without falling and= 1stands up without sinking into the floor. Standing up under a low ceiling pushes into it, so check withraycastbefore you let the player stand. Scaling a parent does not resize its children’s colliders. - Changing the components of a live object (adding a
body()later, for instance) rebuilds its body. hiddendoes not disable colliders. Destroy the object or lose thearea()instead.- There are no collision layers or masks: decide what matters with tags in your handlers.
- There are no joints or other Rapier constraints in the API.
physics.worldgives you Rapier directly, but the engine does not expose the Rapier body behind an object, so treat it as an advanced escape hatch. - One physics world per game, stepped at the fixed rate (
fixedHz, 50 by default).