Physics
Physics— a Luau global you call from inside scripts and components: forces, velocity, raycasts, overlaps, sleep control, gravity, joints, wheels, collision groups, and the observation reads that report what the solver holds for a body and why it is not moving it. This is the code surface.phys— a toolbox, not a Luau global. Its verbs compose an entity + model + body + collider in one call, so you can stand up physical objects without writing a script. Drive it from any tool surface:zero physin the shell lists the verbs andzero phys <verb> --helpgives one verb's arguments,search_tools→use_tooldoes the same over MCP, andtools.use("phys", "<verb>", ...)does it from Luau. The bare name is not a global — callingphys.spawnStatic(...)in a script fails with "attempt to index nil".
Physics simulates during play; in edit mode the world is posed, not running (the engine guide covers modes).
Making something physical (in code)
A body is static (immovable — floors, walls), dynamic (moved by forces and gravity), or kinematic (moved by your code, pushes dynamics). Build one from the component primitives: give an entity a collider for its shape and a Physics component for its body.
local crate = entity.spawn("crate")
crate.position = { 0, 8, 0 }
crate.component.add("BoxCollider", {}) -- sized by the entity's mesh and scale
crate.component.add("Physics", { kind = "dynamic" }) -- static | dynamic | kinematic
A collider alone (no Physics) is a static collider fixed in world space — the right thing for ground and walls:
local ground = entity.spawn("ground")
ground.localScale = { 40, 1, 40 }
ground.component.add("BoxCollider", {})
A dynamic body's mass comes from its collider volume by default; set Physics.mass to a value greater than zero to pin the exact total mass instead (a value of zero or below keeps the volume-derived mass).
Choosing a collider
The shape is the component. Each one carries only the geometry that shape has, so there is nothing to set that means nothing.
| Component | Geometry | Reach for it when |
|---|---|---|
BoxCollider | half | Crates, walls, ground, most props |
SphereCollider | radius | Balls, blast radii, proximity triggers |
CapsuleCollider | radius, height | Characters — a rounded bottom slides over small steps |
MeshCollider | mesh, fit | The shape has to follow the model |
CompoundCollider | children | Many boxes sharing one material — a voxel chunk |
HeightfieldCollider | rows, cols, extent, heights | Terrain |
Every one also takes isTrigger, friction and restitution.
Leave a dimension unset and it comes from the entity's mesh. A bare BoxCollider fits what is rendered, at any scale. Set the dimension when you want something other than the mesh:
entity.find("crate").component.add("BoxCollider", {}) -- fits the mesh
entity.find("wall").component.add("BoxCollider", { half = { 2, 4, 0.2 } }) -- authored
Mesh colliders and the two fits
MeshCollider collides against mesh geometry, and fit chooses how that geometry is approximated:
"hull"(the default) wraps the mesh in a convex hull. Cheap, and valid on a dynamic body because a hull has an interior and therefore mass. Concavities fill in: a doorway becomes a slab."exact"uses the mesh's own triangles. It follows concavities, and it is for static bodies — a triangle mesh is a surface with no interior, so a dynamic body built from one has no mass and other bodies pass through it. Asking for"exact"on a dynamic body raises an error naming"hull".
mesh names the geometry. Left unset the collider tracks whatever the entity renders; set it to collide against a cheaper proxy than the one on screen:
entity.find("rock").component.add("MeshCollider", {}) -- what you see
entity.find("level").component.add("MeshCollider", { fit = "exact" }) -- concave, static
entity.find("rock").component.add("MeshCollider", { mesh = "rock_collision" }) -- cheap proxy
Identical geometry is built once and shared, so a field of a hundred of the same rock pays for one hull.
Asking whether something has a collider
The shape is the component, so there is no one component name to check for. Ask for the job instead — every collider component declares the collider role, including the superseded Collider, so this answers correctly for content authored against either surface:
if e.component.hasRole("collider") then ... end
local c = e.component.byRole("collider") -- the collider, or nil
for _, c in ipairs(e.component.allByRole("collider")) do ... end
Physics.colliderOn(id) answers the same question with the component's name, and Physics.removeCollider(id) removes whichever collider an entity carries.
Looking at what the simulation collides with
The simulation collides with colliders, not with what is drawn, and the two part company silently — a collider several times its mesh, one missing from a single tile in a row, a trigger sized as though its numbers were already world-space. None of that shows in a normal render, and reading the numbers off each entity finds only the mistakes you already suspect.
capture(pass = "physics") draws the collider set as solid shaded geometry on black, so the collision becomes a picture. Hue is what each collider takes part in — blue static, orange dynamic, green kinematic, magenta and translucent for a sensor — and lightness separates the individual objects inside a family, so two neighbouring crates never read as one shape.
capture { pass = "physics" } -- the colliders alone
capture { pass = "physics_context" } -- the colliders over the scene's meshes in dim grey
Take physics when the question is what the collision actually is: a gap in an otherwise continuous run of blue is the tile something falls through, and two colliders drawn deeply inside each other are bodies the solver will fire apart on the first frame of play. Take physics_context when the question is whether a collider sits where its model does — a collider entirely inside a mesh reads as hidden there, which is itself the answer.
Physics.colliderManifest() is the same reading as data: every collider in the world with its entity, shape type, role, and whether its geometry is the shape's true surface. Physics.colliderGeometry(opts) returns those colliders as drawable triangles for a view of your own. For the state behind the picture — mass, sleep, contacts, and why a body is not moving — see Asking what the solver holds below.
Composing several shapes
Colliders compose. A collider on a child entity attaches to the nearest ancestor carrying a Physics component, and each keeps its own material and trigger flag:
local body = entity.spawn("player")
body.component.add("Physics", { kind = "dynamic" })
body.component.add("CapsuleCollider", { radius = 0.3, height = 1.6 })
local head = entity.spawn("head", { parent = body.id })
head.localPosition = { 0, 1, 0 }
head.component.add("SphereCollider", { radius = 0.2, isTrigger = true })
Queries report whichever shape they hit, so the head stays distinguishable from the torso.
Reach for CompoundCollider instead when a body carries many children that share one material. Separate components cost one broadphase entry each; a compound is one entry for the whole set, with contacts still resolved per child. That crossover is what makes a compound right for a voxel chunk and wrong for a character's head.
Driving and querying (in code)
Physics.applyImpulse(id, { 0, 10, 0 }) -- one-shot push
Physics.setVelocity(id, 0, 5, 0) -- direct velocity (numeric args), or setVelocity(id, {0,5,0})
Physics.setGravity({ 0, -9.81, 0 }) -- world gravity
local hit = Physics.raycast({ 0, 5, 0 }, { 0, -1, 0 }, 100) -- origin, direction, maxDist
if hit then print(hit.entityId, hit.distance) end
local near = Physics.overlapSphere({ 0, 0, 0 }, 10) -- entity ids whose colliders overlap
A ray answers from colliders, and what to ask when the surface has none
Every physics query — raycast, raycastAll, raycastScreen, boxCast,
sphereCast, capsuleCast, overlapSphere, hasLineOfSight — answers from
the physics world, which holds colliders. A mesh that renders and carries no
collider is not in it, so a ray fired through a ground mesh, a terrain, or
anything a generator cut reports the same nil a ray through open air does.
renderer.raycast answers the same ray against the geometry the renderer
DRAWS — the surface itself, triangle by triangle, whether or not anything gave
it a body. That is the query for the height of the ground under a camera
station, a prop, a sound source or a scatter.
local hit = renderer.raycast({ x, 100, z }, { 0, -1, 0 }, 200)
if hit then eye.position = { x, hit.point.y + 1.7, z } end
A nil from a physics ray means the ray met no COLLIDER.
Physics.colliderCount() separates that from a world holding none for it to
meet, in one read off the collider set:
local hit = Physics.raycast(eye, down, 200)
if hit == nil and Physics.colliderCount() == 0 then
hit = renderer.raycast(eye, down, 200) -- nothing here is solid; ask what is drawn
end
A body at rest sleeps to save simulation cost. applyImpulse, setVelocity, and setAngularVelocity wake it for you; call Physics.wakeUp(id) to wake one yourself, and Physics.isSleeping(id) to check.
Moving a kinematic body
A kinematic body goes where you put it and carries the dynamic bodies it meets. Drive it either way — write its transform each frame, or give it a velocity:
paddle.position = { 0, 1, z } -- pose it per frame
Physics.setVelocity(paddle.id, 0, 0, 0.4) -- or hand it a velocity and let it run
While play is running, both are motion: the body travels from where it was to where you put it, and whatever stands in that span is pushed along at the speed the move implies. So a per-frame nudge carries a stack of crates the way a real platform would, and a single write across the level sweeps that whole distance in one step — position a kinematic body before starting play, or in small steps, when you want it somewhere without shoving what is in between.
Physics.getVelocity(id) reads back the speed the body is actually travelling at, which is what neighbouring bodies feel.
A velocity is a world-space vector at both ends: the body travels along the world axes the vector names — whatever its own rotation, and whatever its parent's — and getVelocity answers in the same frame, so setVelocity(id, v) followed by getVelocity(id) reads back v. To drive a body along its own facing, read that facing as a world direction first (entity(id).transform.forward) and scale it by the speed you want.
Asking what the solver holds, and why something is not moving
The Physics component carries the numbers content wrote. The simulation carries the numbers it kept, and the two part company whenever a write is refused, clamped, or aimed at a body that was never built — a mass set on a static body, a collider that resolved to nothing, a body type flipped behind the component's back. Physics.observe reads the second set.
local o = Physics.observe() -- every body in the world
for _, body in o.bodies do -- an array; each entry names its own entity
print(body.entity, body.bodyType, body.sleeping, body.stillness)
end
local b = Physics.bodyState(id) -- one body
print(b.bodyType, b.mass, b.gravityScale, b.sleeping, b.inIsland)
A body state carries what the solver resolved: body type, mass and inverse mass, centre of mass, principal inertia, gravity scale, linear and angular damping, all six lock flags, CCD, each collider with its shape and collision-group masks, its sleep timers, its velocities, the force and torque queued for the next step, its joints and their motors, and its transform constraints. Physics.setMass(id, -50) followed by Physics.bodyState(id).mass is how you find out what the solver did with the number.
Why is it not moving
local why, detail = Physics.whyStill(id)
-- "resting", "held by 4 touching contact(s), the deepest against ground at 0.0012m of penetration"
whyStill answers nil when the solver IS advancing the body, and otherwise names the nearest cause from a closed set — the thing to change, rather than a consequence of it:
| reason | what it means |
|---|---|
noBody | the entity carries no rigid body; nothing was built for it |
simulationNotStepping | the engine is not running the gameplay simulation — it is paused, or the world is still bootstrapping |
disabled | the body is disabled in the solver |
static | its body type is static, which the solver never integrates |
kinematic | its body type is kinematic and nothing is driving it |
infiniteMass | its inverse mass is zero, so no force accelerates it |
translationLocked | every axis the acceleration reaching it works along is locked — all three, or just the one gravity pulls along |
gravityDisabled | world gravity times its gravity scale is zero, and nothing is pushing it |
asleep | the solver put it to sleep, and none of the three causes above is what settled it |
outsideIsland | awake, but not in the solver's active island set on the last step |
resting | the colliders it touches are holding it up |
aboutToMove | nothing holds it and a net force reaches it — the next step accelerates it |
The three configured causes — infiniteMass, translationLocked, gravityDisabled — are read ahead of asleep, because each of them is what puts a body to sleep in the first place and each stays true once it is asleep. So a body that has been sitting still for minutes still answers with the thing to change rather than with the sleep that followed from it.
Physics.stillnessReasons() returns that list in the order the engine considers it. A body reading aboutToMove on sample after sample is having its velocity or pose written from outside the solver every frame.
Contacts
for _, c in Physics.contacts(id) do
print(c.other, c.touching, c.deepestPenetration, c.totalImpulse)
end
local touching, depth = Physics.touching(crate, ground)
contacts reports every pair one body's colliders are in, each with the entity on the other side, the normal, how deeply the two interpenetrate, the impulse the last step applied, and the individual contact points. That is the read behind "is the character grounded", "did these two interpenetrate", and "how hard did that land".
The world, and what a step costs
local w = Physics.worldState()
print(w.bodies.awake .. "/" .. w.bodies.total, w.contacts.touchingPairs, w.joints.impulse)
local c = Physics.stepCost()
if c then print(c.stepMs, c.narrowPhaseMs, c.solverResolutionMs) end
worldState counts what the simulation holds — bodies by type, how many are asleep, colliders, joints, contact pairs and points, and the bodies the last step integrated — so a body that failed to build is missing here while its component still exists. stepCost breaks the last step into its stages — the same figures worldState().step carries. Each is that one step, not a running window, and it is nil on a frame where nothing stepped. Consecutive steps over the same resting scene vary by tens of percent, so average several samples before quoting what a step costs.
Two counts read oddly until you know which pool they cover. bodies.awake and bodies.sleeping split every body between them, static ones included — the solver never sleeps a static body, so a scene that has completely settled reads awake equal to its static count rather than zero. And colliders.total counts more than bodies.total whenever the scene holds a collider with no Physics component: those are colliders.standalone, the static colliders fixed in world space.
Both answer in edit mode as well as play mode, and neither needs a screenshot, a recording, or a probe entity.
Joints, wheels, groups
Joint motors, wheel colliders, and collision-group filtering live on Physics (Physics.setJointMotor, Physics.addWheelCollider, Physics.setCollisionGroups). The phys toolbox composes the common constraints (fixed, revolute, prismatic, spherical, spring, rope) from the tool surface.
Rope and chain behavior is a joint, not a script: kind = "rope" with maxDistance gives a hard maximum-distance limit solved inside the physics step — slack is free, taut resists extension, and an orbiting body on a taut rope keeps its speed. A constraint solved per-frame from Luau reads state one step late and writes one step later; for a steadily rotating configuration that lag feeds energy in every solve, so a hand-rolled rope pumps an orbiting body faster and faster. Reel a rope in or out by writing maxDistance on the live joint.
Breakable joints
A joint given breakForce or breakTorque releases the moment the reaction it is carrying exceeds either. The reaction is what the constraint solver actually applied over the step that broke it, in real units: a fixed joint holding a hanging 10 kg mass reads 98.1 N, a 20 kg mass 196.2 N, a 40 kg mass 392.4 N.
The two thresholds are independent, and they read two different things. breakForce is the joint's linear reaction — the force the constraint applies at its anchor points to hold them together. That is where weight lands, including weight hung off to one side: a 100 kg mass on a 10 m arm reads 981 N of linear reaction and an angular reaction of 0, because the force at the anchor is already what holds the body up and holds its orientation. Its bending moment is that force times the arm, so size a cantilever with breakForce.
breakTorque is the angular row of the same reaction — the couple the constraint resolves when something twists the two frames apart, which is what a motor or Physics.applyTorque drives into a joint. Measured against an applied couple the reading comes back proportional to it rather than equal to it, so calibrate breakTorque against a jointReaction reading taken under the twist you intend the joint to survive rather than from the torque you apply. A joint given only one threshold can only break that way.
phys.addJoint("crate", { target = "wall", kind = "fixed", breakForce = 1200, breakTorque = 800 })
Physics.onJointBreak(function(e)
-- e.entityId, e.connectedEntityId, e.kind
-- e.force / e.torque — what broke it
-- e.impulse / e.angularImpulse — the reaction over the breaking step
-- e.position — where the joint's anchor was
spawnDebris(e.position, e.force)
end)
A joint reports once: the constraint is already released by the time the record arrives, and it stays released until something re-arms it. The Joint component carries the same event per joint (joint:onBreak(fn), joint:isBroken(), joint:breakEvent()) and joint:reattach() puts a broken one back — at the anchors it was authored with, so move the bodies back to where those anchors coincide before reattaching. A joint given neither threshold is never measured.
Breaks reach a listener from the tick of an enabled Joint component. A joint written straight into the ECS as ecs.PhysicsJoint has no such tick behind it: poll Physics.jointBreaks() or call Physics.pumpJointBreaks() each frame to deliver its breaks.
Physics.jointBreaks() is the polling form for a script that would rather read than register; it returns everything that has broken since its own last call.
Size a threshold by measuring rather than guessing: Physics.jointReaction(id) reads the load a live joint is carrying right now, against the same quantities the thresholds are compared to. Build the assembly, read the joint at rest, and set the threshold above that — then whatever you drop on it decides. A sleeping body reports the last step that solved it and carries nothing new; Physics.wakeUp(id) puts it back under load.
One thing will surprise a threshold set by eye. A joint whose anchors do not line up at the moment it is created is violated, and the solver pulls the two bodies together with whatever it takes — a corrective load orders of magnitude above the resting one, which breaks any sane threshold on the first step. Place localAnchor and remoteAnchor so the two joint frames coincide where the bodies already stand.
Under multiplayer, every peer runs its own simulation, so each peer breaks the joint from the load it computed and no break message crosses the wire. The Joint component's breakForce and breakTorque are synced fields, so peers agree on the threshold; whether they agree on the instant depends on their simulations having stayed in step.
The full surface
lsp.methods("Physics") lists every Physics method with its signature; zero phys (or search_tools) surfaces the phys toolbox verbs. The model to hold: phys composes from the tool surface, Physics controls from code, and simulation happens in play.