Physics
Physics— a Luau global you call from inside scripts and components: forces, velocity, raycasts, overlaps, sleep control, gravity, joints, wheels, collision groups, soft body reads, 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→tool_exec(return await zero.phys.<verb>(...)) does 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".
Bodies are rigid or soft. A rigid body keeps its shape; a soft body (the SoftBody component, below) squashes, wobbles, drapes and dents, and with a plasticYield keeps a dent or a crumple after the impact that made it, the way a car's sheet metal stays bent after a crash, until it is repaired.
Physics simulates during play; in edit mode the world is posed, not running (the engine guide covers modes).
It simulates at wall-clock rate, and the engine keeps stepping between your tool calls, so a body launched on one call has travelled whatever several real seconds of travel comes to by the next one. Polling a position call by call therefore reads end states rather than a trajectory. To watch the motion, sample it inside one call (read the position, task.waitFrames(n), read it again) or slow the world with engine.timeScale = 0.1 so the next call still arrives mid-flight; engine.paused = true freezes a body where it stands for a read or a capture. Otherwise verify the settled result with data: positions, rotations, raycasts and the observation reads below. The engine guide's "How fast time passes" has the whole model, including what the world does with a frame the engine lost.
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, at 1 kg per cubic metre, whatever the collider's shape: a 1 m cube weighs 1 kg, a hull around a 0.7 x 1.0 x 0.7 cylinder about 0.38 kg (a little under the cylinder's own 0.385 m^3, as the hull's flat faces cut inside its curve), and the same hull at twice the size eight times that. Each collider reports the mass it contributes in Physics.bodyState(id).colliders[i].mass. Set Physics.mass to a value greater than zero to pin the exact total mass instead (a value of zero or below, the default, keeps the volume-derived mass); a pinned mass belongs to the body, so its colliders then report 0.
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.
What has no collider surface to shade draws as lines over the colliders, in both views and in the edit-mode collider overlay. A soft body draws its elements coloured by load (blue slack, yellow half way, red at the load it tears), its free particles as small grey crosses, its pinned particles as green octahedra and the ones held to a rigid body as magenta octahedra, and its contacts with soft surfaces as orange segments with cyan normals: a soft body that sags, tangles or pins wrongly shows where. A WheelCollider wheel draws its suspension ray (green on ground, red in the air), its travel from the mount to the tyre's centre coloured by compression on the same ramp, its tyre circle in white, and where it meets the ground with the ground's normal: a vehicle whose wheels sit wrongly, bottom out or never touch the ground shows it.
local d = Physics.debugLines({ entities = { truck } }) -- the same lines, with the data behind them
for _, o in d.objects do print(o.kind, o.grounded, o.compression, o.pinned) end
local diag = Physics.softBodyState(body).diagnostics -- invertedCells, selfCrossings, maxLoad, loadedElements, pinned, attached
Physics.debugLines answers in edit mode too, so a soft body's cage is previewable as it is authored (a mesh body's cage follows its cellSize) and a vehicle draws its wheels at rest where they are mounted, following their authored mount, travel, radius and steering.
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.
Soft bodies: jelly, cushions, balloons, cloth, soft models
Something that should squash, wobble, sag or drape is a soft body: give its entity a SoftBody component instead of Physics and a collider. A soft body is a set of particles held together by elastic elements, simulated in the same step as rigid bodies, so it rests on the ground, a crate lands on it and dents it, it pushes a dynamic crate it falls on, and two soft bodies stack on each other.
local jelly = entity.spawn("jelly")
jelly.position = { 0, 2, 0 }
jelly.localScale = { 1.5, 1, 1.5 } -- the body fills the entity's unit box times its scale
jelly.component.add("SoftBody", {
shape = "box", -- box | sphere | balloon | cloth | mesh
resolution = 6, -- particles along the longest edge, 2 to 24
-- mass = 0 (the default) weighs it at 1 kg per cubic metre, like a rigid body
stiffness = 300, -- Young's modulus in pascals (the default)
damping = 0.5, -- 0 rings on, 1 settles without overshoot
})
shapepicks the generator.boxandsphereare solids filled with tetrahedra;balloonis a hollow surface held out by the volume it encloses;clothis a sheet in the entity's local XZ plane. Each fills the entity's unit box (-0.5 to 0.5 on each axis) times its world scale, so a soft body sits exactly where a default cube or sphere primitive of the same scale would.massat its default of 0 comes from the shape's volume at 1 kg per cubic metre (a cloth's from its area at 1 kg per square metre), the rule a rigid body's mass follows, so a soft body and the crates around it weigh alike. A value above zero sets the total exactly.stiffnessis the Young's modulus of a box or sphere, and softness is stiffness against weight: a 1 m box at the default mass, dropped 1 m, squashes by about a sixth and wobbles back at the default 300, by about a fourteenth at 3000, and by a few percent at 30000, past which the default solver stiffens it no further; thefemsolver (Stiff materials, below) holds a body to stiffnesses far past that. A heavier body needs a proportionally higher stiffness to look the same, and one much softer than its weight sags flat and lets what lands on it slide off. For a balloon or a cloth it scales how stiff the stretch and bending springs are.preserveVolume(default on) keeps a closed shape's enclosed volume, so a squeezed jelly bulges rather than shrinking, and a balloon stays inflated.modelpicks how a solid's cells (box, sphere, mesh) resist deforming:corotational(the default) is linear elasticity of Young's modulusstiffness, stable at any stiffness;neoHookeanis the same at small strains, resists a hard squeeze more and a long stretch less, and costs about twice as much per cell;volumeis the cheapest, a jelly of springs that keeps each cell's volume, its springs scaled bystiffnessthe way a balloon's are.poisson(0 up to 0.5, default 0.3) is how much a squeezed cell bulges sideways: near 0.5 is rubber, close to incompressible.solverpicks how the body's elasticity is solved:constraints(the default) orfem, the solver for stiff materials (below). It takes effect on the running body.inflationholds a closed shape at that share of the volume it was built with: blow a balloon up to 1.5, let it down to 0.7, live (Physics.softBodyState(id).volumeRatiofollows it). It acts throughpreserveVolume.shapeMatching(hertz, default 0) pulls the body back toward its own shape as a whole: every particle is drawn toward where the best-fit placement of the body's rest shape puts it, at that natural frequency. 0 leaves the shape to the cells and springs alone; 5 to 20 keeps a soft body from folding or sagging out of its form and brings it back quickly from a squeeze, without making it stiffer to a small poke the way a higherstiffnesswould; higher pulls harder. The rest shape it pulls toward is the one the body holds at rest, so a dent that stayed (below) stays.selfContactmakes the surface collide with itself, so a cloth folded onto itself stays two layers instead of passing through.friction,restitution,collisionMemberships,collisionFilterare the surface's, as on a collider;canSleep(default on) lets a settled body sleep, which costs almost nothing per step. A body has settled once no particle has moved faster than 0.1 m/s for half a second, read from where each particle stands from one step to the next.
A soft body is its own body and its own collision surface, so it never shares an entity with Physics or a collider component: whichever is added second is refused with an error naming the conflict. The error is logged, and the refused component stays on the entity disabled, with lifecycle = "failed" and the error as its disabledReason. The material (stiffness, damping, poisson, the plastic fields below, inflation, shapeMatching, preserveVolume, friction, restitution and the collision groups) and the solver take effect on the running body when written, waking it. The other fields (shape, resolution, cellSize, collideWith, mass, model, selfContact, canSleep) are read when the body is built, so writing one rebuilds it at the entity's current transform, and the attachments are tied again on the rebuilt body. During play, a rebuild of the same particles (a new model, mass or selfContact) keeps every particle where it is, how it moves and any dent it kept.
Dents that stay: plasticity
An elastic body springs back once the load is gone. Give it a plasticYield and it keeps what it was pushed into past that strain: a car shell keeps its crash, clay keeps a print, a metal sheet keeps a crease. A part strained further than the yield has its rest shape flow toward the shape it is held in, so when the load goes it rests in the new shape.
clay.component.add("SoftBody", {
shape = "box", stiffness = 3000,
plasticYield = 0.02, -- strain past which deformation stays; 0 (the default) is fully elastic
plasticCreep = 10, -- per second: how fast the strain past the yield sets (default 10)
plasticMax = 0.5, -- the most permanent strain a part takes (default 0.5)
})
print(Physics.softBodyState(clay).plastic) -- metres: the deepest dent that stayed (plasticMean: the mean)
clay.component.get("SoftBody"):repair() -- or Physics.repairSoftBody(clay): back to the built shape
plasticYieldis a strain: 0.02 dents like sheet metal under a firm knock, 0.1 only under a hard hit. For a balloon or a cloth it is an edge's stretch and a fold's angle in radians, so a sheet keeps its creases.plasticCreepis how fast the strain past the yield becomes permanent, per second: an impact lastingtseconds sets aboutplasticCreep * tof its excess. The default 10 keeps most of a crash's dent; 1 lets a short knock spring back and lets a steady load sink in slowly.plasticMaxbounds the permanent strain any part takes, so a body hit again and again keeps a bounded dent rather than collapsing.- A body has to hold its own weight below its yield. Its own weight strains it too, most at its bottom, and a yield below that strain lets it sag by itself, slowly, whenever it is awake. A 1 m tall block of the default mass strains about
10 / stiffnessat its base, so aplasticYieldof 0.02 wants astiffnessof 1000 or more; raisestiffnesswithmass. - The dent does not follow the tick rate.
plasticCreepis a rate per second, so the same impact leaves the same dent within a quarter at 30 or 60 ticks a second (physics.tick_rate) and at 4 or 8 substeps a tick (physics.solver_iterations): a 5 kg crate dropped on a 3 x 1 x 3 m slab ofstiffness1000 andplasticYield0.02 left a dent of 3.0 to 3.7 cm and aplasticof 2.1 to 2.7 cm across those four, and the same crate dropped from 3 m and lifted off after a second, on a slab ofplasticCreep2, a dent of 1.6 to 1.7 cm and aplasticof 0.9 to 1.1 cm. The body's elastic give does follow the substep (the tick divided by the solver iterations): at the default 1/120 s a body gives about a quarter more under a steady load than at 1/240 s or shorter, and a steady load holding a part just past its yield with a slowplasticCreep(1 or less) sets its dent about twice as fast at 1/120 s as at 1/240 s. Tune a world's plastic bodies at thephysics.tick_rateandphysics.solver_iterationsit runs at. - Repairing (
:repair(),Physics.repairSoftBody(id), or raising the component'srepairscount, which reaches every peer) restores the shape the body was built in: the body is put back in it where it stands, moving on as it moved. Leaving play for edit mode also puts every body back in its built shape, and edit mode builds bodies in that shape. - The drawn model keeps the dent. A
Modelon the body is bound to the shape it was built in, so a dent shows on the model as deep asplasticreads, and a model with more vertices shows it more finely. - A body rebuilt during play with the same particles (a new
model,massorselfContact, or its entity moved while the clock is paused) keeps the deformation that stayed. A rebuild of other particles (a newshape,resolution,cellSizeor scale) starts from the generator's shape.
While the simulation runs, the entity's position and rotation follow the body as a whole (the rigid motion that best fits its particles), so children, scripts and tools that read the entity see where the body went. A Model on the entity is drawn deformed with the body: every vertex of the model rides the body element nearest it at rest (a tetrahedral cell of a box or sphere, a surface triangle of a balloon or cloth), so a dent, a squash or a drape shows on the model itself, with its own material, and lighting, shadows and motion vectors follow the deformed surface. The model is drawn between physics ticks the way a rigid body's pose is, culled against the box its deformed vertices stay inside, and a cloth's model draws both faces of its sheet. Nothing extra is needed: add SoftBody to an entity that has a Model. The model's vertices decide how finely the deformation shows (a 24-vertex cube primitive can only move its corners), so give a soft body a model with enough vertices to carry the shape. Physics.softBodyState(id).render reads the render side back: whether the frame drew the model deformed (drawn), how many vertices and elements are involved, whether the frame uploaded anything (uploadedThisFrame; a body at rest costs no upload) and the world-space bounds it is culled against. The physics view (capture with pass = "physics") draws the collision surface itself, for comparison. Writing the entity's position or rotation during play carries the body there, shape and velocity kept; in edit mode, where nothing simulates, the body is rebuilt in its rest shape where you put it. Deactivating the entity, removing the component or despawning the entity removes the body. Editing the SoftBody component's code (a hot reload) keeps it: the reloaded component takes the body over as it stands, its dents, motion and pins included.
Stiff materials: the FEM solver
The default solver holds each elastic element as a constraint and sweeps them all with the contacts a few times a substep. That is cheap and right for jelly, cushions and crumpling shells, but it converges only as far as its sweeps reach: a stiff body (a steel or plastic beam, a chassis rail, a panel of stiffness 1e6 and up) keeps more give than its material has, a load crossing a long body takes several steps to reach its far end, and past a point the sweeps jitter and the body flies apart. solver = "fem" solves the whole body's elasticity at once each substep (implicit finite elements), so a stiff material holds the shape its stiffness gives it and a load reaches across the body in the step it lands.
-- a 2 m beam held by one end: it stands out straight on "fem" and swings from its pin on "constraints"
beam.localScale = { 2, 0.2, 0.2 }
beam.component.add("SoftBody", {
shape = "box", resolution = 16, stiffness = 1e8, mass = 8, damping = 1,
solver = "fem",
pins = { { name = "root", region = { box = { min = { -0.5, -1, -1 }, max = { -0.45, 1, 1 } } } } },
})
print(Physics.softBodyState(beam).solver) -- "fem"
That beam, measured on native and in the browser: on fem its tip hangs 3 cm below where it was built and holds still, the same on both; on constraints at the same stiffness it hangs from its pin and swings, its tip half a metre to 1.7 m down and still moving at 1 to 4 m/s five seconds on. At stiffness 1e9 the constraints beam flies apart within a few seconds and the engine switches it off, while the fem beam stands 3 mm below where it was built and holds still. Everything else works on it as on the default solver: contacts with rigid and soft bodies, attachments, pins, the plastic fields, tearing and cutting (a piece torn or cut off a fem body stays on fem), and sleep. A Joint on a region of a fem body is answered through the whole body, so the beam above held by a fixed Joint on its root face instead of the pin stands out the same way, its tip 3.7 cm down.
Choose it for the parts that must hold their shape (a beam, a frame, a stiff panel, a bumper) and keep the default for everything soft. What it costs, one awake body resting on the ground (or the beam above, held in the air by its pin), stiffness 1e6, in milliseconds: the median soft body time of 40 steps once the body settled, measured twice on a busy machine, natively and in Chrome:
| body | particles | cells | constraints, native | fem, native | constraints, browser | fem, browser |
|---|---|---|---|---|---|---|
the beam above, stiffness 1e8 | 144 | 300 | 1.0 | 0.9 | 1.1 to 1.2 | 1.1 to 1.2 |
| box, resolution 4 | 64 | 135 | 0.3 to 0.4 | 0.5 to 0.6 | 0.5 | 0.8 |
| box, resolution 6 | 216 | 625 | 1.1 to 1.5 | 4.9 to 6.3 | 1.7 to 1.8 | 6.5 to 6.8 |
| box, resolution 8 | 512 | 1715 | 2.9 to 4.6 | 34 to 36 | 4.6 to 4.8 | 38 to 41 |
A fem body costs by its particles and by what touches it: each contact and attachment on it is answered through the whole body, so the resolution-6 box resting on a face of 36 particles costs five times the beam held by its end, with only half again its particles. Hold fem bodies to resolution 6 or below and to the few parts that need it: a resolution-8 box alone takes twice the 16 ms a frame at 60 frames a second has.
Three world settings tune how far the solver goes each substep, read and written with Physics.softBodySettings like the others:
femMaxDenseDofs(default 1600, a resolution-8 box): the largest body, in degrees of freedom (three per particle), whose system is factorized directly at the start of each step. The factorization answers the contacts and attachments on the body and carries each substep's solve to convergence in a few iterations. A larger body is solved iteratively instead, which converges a stiff body only withfemMaxIterationsin the hundreds. The factorization is the cheaper step up to resolution 8 (a resolution-6 box steps in about 5 ms factorized and 13 ms iteratively, a resolution-8 box in 35 ms and 43 ms) and the dearer one past it (a resolution-10 box, 3000 degrees of freedom, in about 180 ms factorized and 140 ms iteratively).femMaxIterations(1 to 1000, default 20): the most iterations a substep's solve runs. A body factorized directly needs a few; a larger stiff body solved iteratively and cut short behaves softer than its material, the way the default solver does.femTolerance(above 0 and below 1, default 1e-5): the residual, relative to where it started, at which a solve stops.
A soft version of a model
shape = "mesh" makes the entity's own model soft: an imported character, a rubber duck, a pudding, a car body. The body is the model itself, filled with tetrahedra (its cage), and the full-detail model is drawn carried by the cage, seams and UVs intact.
-- `duck` already draws a closed model
duck.component.add("SoftBody", {
shape = "mesh",
cellSize = 0.08, -- metres; 0 (the default) is the model's longest extent / resolution
collideWith = "cage", -- or "surface": collide through the model's own surface
})
-- the body is built at the start of the next frame; once `status == "built"`:
local s = Physics.softBodyState(duck)
print(s.cellCount, s.particleCount, s.cage.cellSize, s.cage.cached, s.cage.buildMs)
-- also: s.cage.filledParts, strayParts, openParts, seams, pieces, generateMs, digest
- The model must enclose a volume. Every closed part of it (each edge bordered by exactly two triangles) is filled at the entity's world scale; vertices split for UVs or normals are joined first, and a part wound inside out is turned. A part that is open or non-manifold, or too small to hold one cell, is left out of the fill and counted (
cage.openParts,cage.strayParts), and is still drawn carried by the cells nearest it. A model with no closed part is refused, naming the edges at fault. cellSizeis the edge of the cells. Smaller follows the model's outline more closely and costs more to build and to step; the cells always contain the model, so a coarse cage is blocky but never cuts into it. Halving the cell size makes about eight times the cells, and the step cost follows the particles: a cage of a few thousand cells steps in milliseconds, one of tens of thousands takes a large part of a frame or more. Start from the default (cellSize0: the model's longest extent overresolution, 6 cells by default), readparticleCountandPhysics.stepCost().softBodyMs, and refine only as far as the look needs; the model is drawn at full detail whatever the cell size. A size that would make more than 60000 cells is refused before anything is built, naming the smallest size that fits.collideWithpicks the surface the body meets the world through:cage, the boundary of the cells (cheap, as coarse ascellSize), orsurface, the model's own closed surface carried by the cells (as fine as the model). A contact on the surface pushes the cell holding the touched triangle, or, for a triangle spread over several cells, the cell holding its nearest corner, sosurfacesuits a model whose triangles are no larger than the cells; a model of a few large flat faces meets the world more evenly throughcage. Either carries a contact skin of 0.15 of a cell (particleRadius): the drawn model of asurfacebody rests that far above what it stands on, and acagebody's at least twice that, since its cells wrap the model no closer than the same distance.- The cage is made once. Cages are kept by the model's triangles, the scale and the cell size, so a second instance of the model, a rebuild at the same scale and a switch between edit and play take the cage already made:
cage.cachedis true andcage.buildMsis 0 (cage.generateMsis what making it cost). The fill is the same on every platform:cage.digestnames the cage, equal for equal input. The cage is derived from the model and never written into the scene. - Parts that touch move as one. A model built of several closed pieces (a car body and its doors, a beam resting on two posts) is split where one surface only touches another, each piece is filled on its own, and pieces whose cages meet are sewn together with springs (
cage.seams), so they deform and move as one body. Pieces further apart than half a cell stay separate (cage.piecescounts them).
The deformation itself is read from the solver:
local s = Physics.softBodyState(jelly)
print(s.particleCount, s.cellCount, s.volumeRatio, s.sleeping) -- volume / restVolume
print(s.bounds.min.y, s.centerOfMass.y)
for _, p in Physics.softBodyState(jelly, { positions = true }).positions do
-- every particle, in world space
end
status says where the body stands. The body is built at the start of the frame after its component is added, so a read in the same call as component.add answers status = "pending" with exists = false, and waitingFor names what the build waits on: the build at the start of the next frame, a mesh body's model still loading, or the entity being active in its hierarchy. status = "refused" with refusal says why a SoftBody component was not built (a conflicting Physics, a field out of range, a model that cannot be filled), "built" means the solver holds it, and "none" that the entity carries no SoftBody. Wait on the status rather than reading exists = false as a refusal:
jelly.component.add("SoftBody", { shape = "box", resolution = 4 })
print(Physics.softBodyState(jelly).status) -- "pending": built at the start of the next frame
-- a frame later: "built"
The surface the body collides through is an ordinary collider to the rest of the physics surface: rays and overlaps hit it and name the soft body's entity, contacts on a crate resting on it names the soft body, Physics.colliderGeometry() and the physics capture view draw it deformed as simulated, and worldState().softBodies counts soft bodies on their own.
A ray or a shape cast that hits a soft body meets its surface as it is deformed now, and its hit names where on the body it landed as hit.softBody: the surface triangle (element), the particles carrying it with the hit's share on each (particles, weights), the particle nearest the hit (particle), and region, the hit as a soft region. A hit is answered at the spot it struck in one step, a dent where a shot lands:
local hit = Physics.raycast(muzzle, aim, 100)
if hit and hit.softBody then
Physics.applyImpulseAtPoint(hit.entityId, { x = 0, y = 0, z = -20 }, hit.point, { radius = 0.3 })
end
Physics.contacts and Physics.touching work with a soft body on either side, two soft bodies included, and a contact with a soft body names the particles of it that are touching (particles for the body read, otherParticles for the other side), numbered as Physics.softParticles numbers them. Physics.contacts(truck, pole) reads that one pair. A soft body resting on the ground holds a contact point per surface element touching it, so a read of every pair a large body is in costs every one of those points.
What a soft body costs, and the settings that trade quality for it
A soft body costs the solver its elements (cells, edges, folds) and the contact points on its surface, each solved once per substep, so the cost grows with the particle count: a cube at resolution 6 is 216 particles, at 8 it is 512. resolution counts the particles along the longest side; a shorter side takes round((resolution - 1) * side / longest) + 1, at least 2. The count jumps where that rounding does: a 1.8 x 0.8 x 4 m truck shell at resolution 8 is 4 x 2 x 8 = 64 particles, a 1.9 x 0.9 x 3.8 m one is 5 x 3 x 8 = 120. Physics.softBodyState(id).particleCount reads the count a body was built with. Read what each body costs from the step that just ran:
local c = Physics.stepCost()
print(c.softBodyMs, c.softBodiesAwake) -- the soft body part of the step, and how many bodies it simulated
for _, b in c.softBodies do -- costliest first
print(b.entity, b.ms, b.particles, b.cells, b.contacts, b.substeps)
end
print(Physics.softBodyState(jelly).lastStepMs) -- one body's part; 0 while it sleeps
softBodyMs is the step's soft body time: the step is timed as a whole, and softBodyMs is the part of it in proportion to the elements the soft bodies handed the solver against the rigid contacts and joints (softBodyShare). softBodies divides it between the bodies by the work each handed the solver; a body asleep is not listed and costs nothing. A body's ms is its share by work rather than a clock of its own: bodies that hand the solver the same elements, contacts and substeps (four identical trucks in a symmetric crash) read the same ms, and contacts says which body is pressed hardest. substeps is the world's solver iterations plus the extra ones a body asks for while it is hit fast or loaded hard; bodies touching each other share an island, which runs the most any of them asks for.
A frame can run more than one step (the tick is fixed, the frame rate is not), and stepCost breaks down the last of them. recent lists the last 64 steps, oldest first, each with its ordinal step and its stepMs, softBodyMs and softBodiesAwake, so a loop that reads once a frame still counts every step:
local seen, steps, total, worst = 0, 0, 0, 0
task.spawn(function()
while steps < 300 do -- ten seconds at the default 30 Hz tick
local c = Physics.stepCost()
if c then
for _, s in c.recent do
if s.step > seen then
seen = s.step
steps += 1
total += s.softBodyMs
worst = math.max(worst, s.softBodyMs)
end
end
end
task.waitFrames(1)
end
print(("soft body time: %.1f ms a step on average, %.1f ms at worst"):format(total / steps, worst))
end)
The figures are wall-clock milliseconds, so anything else on the machine competing for the core (another engine, another agent's work, a build) lands in them as a slow step that nothing in the scene caused. Compare two settings on the median and the 95th percentile of the same scene run more than once, and take a single slow step as the scene's own only when it lines up with an event in it (the step softBodies lists the most contacts on, a body built during play).
Measured cost of one awake body per step, falling and landing on ground, the cost of its elements with a few contacts (two soft bodies pressed into each other cost by their contacts as well, which softBodies lists per body) (native engine; the lowest step of 40, which is the cost without other work on the machine competing for the core; the mean on a busy machine runs 1.5 to 3 times that):
| body | particles | cells | ms per step |
|---|---|---|---|
| box, resolution 4 | 64 | 135 | 0.3 |
| box, resolution 6 (the default) | 216 | 625 | 1.0 to 1.3 |
| box, resolution 8 | 512 | 1715 | 2.5 to 3.5 |
box, resolution 6, model = "neoHookean" | 216 | 625 | 1.3 |
box, resolution 6, model = "volume" | 216 | 625 | 0.6 |
| sphere, resolution 6 | 331 | 1272 | 2.7 to 3.5 |
| balloon, resolution 6 | 162 | 0 | 0.5 |
| cloth, resolution 12 | 144 | 0 | 0.3 |
What a body rests on costs about the same whether it is built from boxes, a triangle mesh (an exact MeshCollider) or a heightfield, however finely the mesh is cut: each element of the soft surface meets the triangle closest to each of its corners and the few reaching into it between them (a bump, a ridge, a spike), so the contact points stay at a few per surface element. The sphere above (resolution 6, scaled to 0.8 m), dropped from 1.2 m into a 1536-triangle exact bowl whose triangles fan into its bottom, cost 2.1 to 2.5 ms a step on average over the 150 steps from its drop and 12.5 to 14.5 ms at its worst, its pairs holding at most 2400 to 2800 contact points; dropped into the same bowl built from 33 boxes it cost 3.1 to 4.7 ms on average and 10 to 15 ms at its worst. Woken at rest with a light Physics.applyImpulseAtPoint, it cost 4.7 to 6.0 ms a step on average over the next 90 steps in the mesh bowl and 5.3 to 5.7 ms in the box bowl (native engine, every step counted, twice each, on a busy machine).
The material options cost by what they add to each step, measured the same way in one session, twice, beside the plain bodies they change, on native and in the browser (on a busier machine than the table above, where the plain resolution-6 box read 0.7 to 1.0 on native and 0.6 in the browser):
| body | particles | cells | ms per step, native | browser | the same body without it, native | browser |
|---|---|---|---|---|---|---|
box, resolution 6, stiffness 1000, plasticYield = 0.02 | 216 | 625 | 0.7 to 0.9 | 0.6 to 0.7 | 0.9 | 0.7 |
box, resolution 6, shapeMatching = 20 | 216 | 625 | 0.8 to 1.0 | - | 0.7 to 1.0 | - |
balloon, resolution 6, inflation = 1.5 | 162 | 0 | 0.3 to 0.4 | 0.4 to 0.5 | 0.3 to 0.4 | 0.4 |
cloth, resolution 12, selfContact = true | 144 | 0 | 0.5 | 0.5 | 0.2 | 0.2 |
Plasticity and inflation add nothing measurable: the flow is worked out from what the solver already holds for each cell, and inflation only changes the volume the body is held at. Shape matching adds a pull per particle and stays within the spread of the plain box. Self contact is the one to budget for: a self-collision pass over the surface each step, about two and a half times a cloth's cost.
The tick is 1/30 s (33 ms), and the soft bodies awake at once share it with everything else a step does. The budget to hold a scene to is half the tick: about 16 ms of soft body time a step on average, which leaves the other half to the rigid bodies, the scripts and the frame. Four default boxes and a cloth all dropped at once (1008 particles) cost 5.0 to 6.6 ms of soft body time a step on average over the 60 steps after they are built on native and 9.9 to 13.1 ms in the browser, and 15 to 52 ms on the steps they land on (every step counted, twice on each, on a busy machine); once they settle they sleep and cost nothing. On the scenes measured here the browser ran at up to about 2.5 times the native soft body time, so a scene held to about 8 ms on native stays inside the budget in the browser. Lower the resolution or pick model = "volume" before anything else, and let settled bodies sleep: a sleeping body is not solved, so twenty settled boxes read softBodyMs 0 and the whole step 0.06 ms at the median (native engine, twice).
The world-wide settings trade simulation quality for cost on every soft body at once. They are world settings, saved with the world in .world_settings (physics.soft_body_*), and Physics.softBodySettings reads and writes them:
print(Physics.softBodySettings().maxExtraSubsteps) -- 4
Physics.softBodySettings({ maxExtraSubsteps = 2 }) -- cheaper hard hits
Physics.softBodySettings({ tangleDetection = false }) -- skip the tangle search
maxExtraSubsteps(0 to 16, default 4): the most extra substeps a body hit fast or loaded hard runs. Each one solves the body again, so a hard hit at 4 costs up to twice a resting step; 0 holds every body to the world's solver iterations (a 100 kg crate thrown down onto a soft box at 20 m/s has the box run 8 substeps on the hit at 4 and 4 at 0), and a fast hit then sinks deeper before the contact holds it. The extra substeps last as long as the body is hit fast or pressed hard, and so does their cost: under that crate a resolution-6 box ran 8 substeps on the hit and still 6 twenty steps later at the default, and its soft body time over the 20 steps from the hit was 92 to 189 ms at 4 against 42 to 83 ms at 0 (five rounds of each, alternated, native engine on a busy machine), and 156 to 443 ms at 4 against 149 to 227 ms at 0 in three more rounds of each on a busier one.contactStiffening(default 4): how much stiffer soft contacts are than rigid ones.resweepStrain(default 0.75, 0 turns it off): the strain past which a body's elements are solved again after each substep's contacts, so a light body under heavy ones is not torn. Each re-solve is another pass over the strained elements, so in a pile-up 0 cuts the costliest steps; watch the bodies'volumeRatiofor one crushed out of shape under a heavier one.tangleDetection(default true): each step looks for cells turned inside out and surfaces passing through themselves or each other, and lets the elasticity undo them. Off saves that search on scenes where nothing tangles; where soft bodies pile into each other (a crash), bodies left passing through each other keep every contact of the overlap, and that costs more than the search saves.overlapConstraints(default true): closed soft surfaces that overlap something are pushed apart by the volume of the overlap. Off leaves an overlap to the per-point contacts alone, which in a pile-up hold the bodies interpenetrated.recoveryPace(default 0.5): metres per second a tangle or an overlap is undone at.
A field the settings do not have, or a value out of range, is refused by name. The solver runs under a change from the next tick. Written during play, the settings land on the play shadow like any other /source write, so a discard of the play edits takes them back (and the solver with them); write them in edit mode, or keep them with vfs.promotePlayShadow("/zero/source/.world_settings"), for the world to keep them.
A body built from more cells than one body may hold is refused before anything is built: resolution past 24 names the range, a sphere filled with more than 60000 cells names the highest resolution that fits, and a mesh fill past 60000 cells names cellSize and the smallest size that fits.
Holding a soft body to a rigid body: flags, panels, car shells
A region of a soft body can be held to a rigid body: a flag's edge on its pole, a soft panel or a whole body shell on a car's chassis, a jelly glued to a moving platform. The hold is two-way: the region follows the rigid body wherever it goes, and the rigid body carries the region's weight and feels it pull, so a cloth hung from a dynamic pole drags it down and a shell on a chassis adds its mass to the car. The rest of the soft body stays soft: it sags, flutters, and dents where it is hit.
-- a flag hanging from a pole by its top edge
local pole = entity.spawn("pole")
pole.position = { 0, 4.6, 0 }
pole.component.add("Physics", { kind = "kinematic" })
pole.component.add("BoxCollider", { half = { 0.05, 0.05, 0.05 } })
local flag = entity.spawn("flag")
flag.position = { 0, 3.7, 0 }
flag.localScale = { 1.2, 1, 1.2 }
flag.rotation = { x = -0.7071, y = 0, z = 0, w = 0.7071 } -- a cloth lies in its local XZ plane; this stands it up, local +z on top
flag.component.add("SoftBody", { shape = "cloth", resolution = 6, mass = 0.5 })
-- every particle on the top face of the unit box (local z = 0.5)
Physics.attachSoftRegion(flag, pole, { box = { min = { -0.5, -1, 0.5 }, max = { 0.5, 1, 0.5 } } })
pole.position = { 2, 4.6, 0 } -- the flag goes with it
A region names part of the soft body by where its particles stand: { box = { min, max } } is every particle between the two corners, { nearest = point } the one particle nearest the point. Coordinates are in the soft body entity's unit box (-0.5 to 0.5 on each axis, before its scale), so a region stays the same part of the body wherever the body stands and however it is scaled. A generated body's outer particles stand their contact skin inside that box (the skin is what fills it out to the box), and a box counts a particle whose skin reaches into it, so a box drawn flat on a face of the unit box, min = { -0.5, -1, -1 }, max = { -0.5, 1, 1 }, holds that face's particles; space = "world" reads them in world space instead. Physics.softRegion(region) checks one and returns the engine's form, naming the key at fault. Each particle in the region is tied to the point of the rigid body it stands at when the attachment is made, so place the two where they should join first. Keep the rigid body's colliders clear of the held region's contact skin: where they overlap, the hold and the contact push against each other, and the hold's load reads that fight rather than the weight it carries (or Physics.ignoreCollision(soft, rigid) keeps the two from colliding).
Attachments live on the soft body's SoftBody component (attachments, a list of { target, region }), so they are saved with the scene, reach every peer, and can be authored there directly, a scene's build.luau included, where an entry's target names the rigid body the build placed and keeps naming it across rebuilds; Physics.attachSoftRegion(soft, rigid, region) and the component's :attach(rigid, region) add an entry and return its index, and Physics.detachSoftRegion(soft, rigid?) / :detach(rigid?) release the regions held to one rigid body, or all of them. Changing the list re-ties only the entries that changed, without rebuilding the body; a rebuilt body (a field written) is tied again. Removing either side releases the hold: a despawned rigid body lets its region fall, and a later entity with the same id is held again.
local s = Physics.softBodyState(flag)
for _, a in s.attachments do
print(a.target, a.region, a.particles, a.attached, a.force.y) -- newtons the region pulls its body with: -4.9 for the 0.5 kg flag at rest
if a.refusal then print(a.refusal) end -- why an entry holds nothing (no rigid body yet, an empty region, ...)
end
print(s.attachedParticles)
A car whose body dents is a rigid chassis carrying the wheels (see Vehicles below) and a soft shell held to it along its floor: the car drives as a rigid body and the shell deforms on impact. A plasticYield makes the shell keep its dents after the crash (see plasticity above); without one it springs back within a second.
local shell = entity.spawn("shell")
shell.position = { 0, 1.8, 0 } -- above the chassis, its floor clear of the chassis collider
shell.localScale = { 1.8, 0.8, 4 }
shell.component.add("SoftBody", {
shape = "box", resolution = 8, stiffness = 20000, mass = 40,
plasticYield = 0.02, -- crash dents stay
})
shell.component.get("SoftBody"):attach(car, { box = { min = { -0.5, -0.5, -0.5 }, max = { 0.5, -0.4, 0.5 } } })
-- after a crash:
print(Physics.softBodyState(shell).plastic) -- metres of dent the shell kept
shell.component.get("SoftBody"):repair() -- the body shop
Keep the shell from touching its own chassis, or the two push on each other where they touch: a shell crumpled down onto its chassis pushes the car along, and a shell resting on it stays awake solving every point of that contact. A generated body's box is the surface it collides through, its contact skin included (Physics.softBodyState(id).particleRadius reads the skin), so a shell whose box starts above the chassis' collider starts clear of it. Start the shell there, and keep each truck's shell and its own chassis from colliding while both still hit every other truck. Two ways do it. Collision bits are stored on the components, so a scene build can set them and they are saved with the scene; a pair of bits per truck covers 16 trucks:
-- truck i (1 to 16): its shell and its own chassis never touch
local chassisBit, shellBit = bit32.lshift(1, i - 1), bit32.lshift(1, 15 + i)
shell.component.get("SoftBody").collisionMemberships = shellBit
shell.component.get("SoftBody").collisionFilter = bit32.bnot(chassisBit)
Physics.setCollisionGroups(car, chassisBit, bit32.bnot(shellBit))
Physics.ignoreCollision(shell, car) keeps one named pair apart with no bits to hand out, so it covers any number of trucks. It holds for the play session it is called in, so call it where play sets the truck up, in a component's onLoad:
Physics.ignoreCollision(shell, car) -- this shell and this chassis never touch
Either way, Physics.contacts(shell) then lists no contact with its own chassis.
Four of these trucks, each started 14 m from the middle of a flat floor and facing it, driven head on into each other at full throttle (motorTorque = 1500 on every wheel of a 600 kg chassis with springRate = 20000, damperRate = 2000, suspensionDistance = 0.3), the drivers lifting off as they meet (the shells 64 particles each), dent every shell by 0.11 to 0.14 m. Over 185 to 240 steps from the throttle opening (they meet at about step 35, and every run went on at least 150 steps past that), counted with the loop above, at the default settings they cost 2.8 to 5.0 ms of soft body time a step on average on native and 2.4 to 8.6 ms in the browser, 5 to 34 ms at the 95th percentile, and the whole step 2.9 to 5.0 ms on average on native and 2.8 to 12.2 ms in the browser (six runs on native, four of them with the collision bits above, and five in the browser, on a busy machine): inside the budget of half the tick on average. The steps the trucks hit on cost the most, 13 to 48 ms in those runs. Two more native runs, on a machine running five times the work it has cores for, read 7.7 and 13.6 ms of soft body time a step on average, 4.2 and 3.0 ms at the median and 33 and 70 ms at the worst step: on a machine that busy the mean takes in every step the engine waited for a core, and the median is the scene's own cost. A truck parked after the crash keeps its chassis and its shell awake: about 0.4 ms of soft body time a step for a shell standing clear, and 1 to 3 ms for each shell still pressed into another, for as long as the heap stands; pull the trucks apart, or take the heap away, to get those steps back.
Pushing, hitting and pinning a soft body from code
The verbs that push a rigid body push a soft body too. Physics.applyForce, applyImpulse, setVelocity, addVelocity, getVelocity, isSleeping and wakeUp take the body as a whole: a force or an impulse is shared over its particles by mass, so it moves off the way a rigid body of its mass would. A force acts for the next physics step (call it every frame for a steady push); an impulse is the momentum the body gains.
To deform it, aim the push at part of it:
-- a punch at a point: the particles around it take the impulse, weighted from
-- the whole at the point to nothing at `radius`, and the body dents there
Physics.applyImpulseAtPoint(jelly, { 0, -3, 0 }, hit.point, { radius = 0.3 })
-- a car shell hitting a pole: the crash impulse at the contact
local contact = Physics.contacts(shell)[1]
Physics.applyImpulseAtPoint(shell, { -80, 0, 0 }, contact.points[1].point, { radius = 0.4 })
-- a blast: every particle (and every rigid body) pushed straight away from the centre
Physics.applyRadialImpulse(balloon, blast, 10, { radius = 3 })
-- a push on a region: these particles accelerate alike, the rest follow
Physics.applyForce(cloth, { 0, 0, 6 }, { box = { min = { -0.5, -1, 0.4 }, max = { 0.5, 1, 0.5 } } })
Physics.applyImpulse(jelly, { 0, 2, 0 }, { nearest = { 0, 0.5, 0 } })
applyImpulseAtPoint and applyForceAtPoint reach twice the spacing of the body's particles when no radius is given, and always reach at least the four particles nearest the point: a body dents in patches no finer than its particles are spaced, so a smaller dent takes a higher resolution, and a radius below the spacing spreads over that patch. On a rigid body they push at that point of it, spin included, and applyRadialImpulse pushes it by the distance to its centre of mass. A soft body with a plasticYield keeps the dent a hard enough hit leaves.
A pin holds a region still: its particles become kinematic, and the rest of the body hangs from them, rests on them or is dragged by them. Give a pin a target and its particles move together so their centre stands there; set the target every frame to drag it.
local left = Physics.pinSoftRegion(cloth, { nearest = { -0.5, 0, 0.5 } }) -- returns "pin1"
local right = Physics.pinSoftRegion(cloth, { nearest = { 0.5, 0, 0.5 } }, { name = "right" })
Physics.setPinTarget(cloth, right, { 2, 3, 0 }) -- drag that corner there (nil holds it where it stands)
Physics.releasePin(cloth, right) -- or Physics.releasePin(cloth) for every pin
-- a press driven down into a block: pin its top face and move the pin
local press = Physics.pinSoftRegion(block, { box = { min = { -0.5, 0.5, -0.5 }, max = { 0.5, 0.5, 0.5 } } })
Pins live on the SoftBody component (pins, a list of { name, region, target }, target empty to hold in place), so they are saved with the scene, reach every peer, are made again on a rebuilt body, and can be authored there: { shape = "cloth", pins = { { name = "top", region = { box = { ... } } } } }. The component's :pin(region, opts), :setPinTarget(name, position) and :release(name?) are the same verbs. Physics.softBodyState(id).pins reads each pin back (particles, pinned, target, center, refusal), and pinnedParticles counts them.
Physics.softParticles(id, region?) reads the particles a region selects: their indices, world positions and velocities, which are pinned, and the selection's center and meanVelocity. Read it before and after a hit to see the dent where it landed. The region is resolved at each read against where the particles stand then, a local-space region in the body's frame as it stands now, so a read after the body has moved or sagged can select other particles than the same region did earlier: compare two reads by indices, and follow what a pin holds through Physics.softBodyState(id).pins (center) or the pinned flags rather than by its region.
local top = Physics.softParticles(jelly, { box = { min = { -0.5, 0.45, -0.5 }, max = { 0.5, 0.5, 0.5 } } })
print(top.count, top.center.y, top.meanVelocity.y)
Tearing and cutting: pieces that fly off
A soft body tears where it is loaded past its threshold: tearStrain on the SoftBody component is a stretch (0.4 tears an element pulled to 1.4 times its rest length), tearForce a pull in newtons on an edge. A script cuts one with Physics.cutSoftBody(id, { point = p, normal = n }), every element the plane through p meets opening along it (or { a, b, c }, a world-space blade triangle that cuts only as far as it reaches), or tears it with Physics.tearSoftBody(id, region), opening every element that holds a particle of the region. Both apply straight after the next physics step, a plane measured against the body where that step leaves it, so they act in play mode and raise in edit mode, where nothing simulates. A cut or tear that opens nothing (a blade that misses the body) says so in the log, naming the entity. Under multiplayer the peer that simulates a body is the one that tears it (see "Physics under multiplayer").
Every piece a tear splits off becomes an entity of its own: a copy of the torn entity, without its children and standing at the root of the scene (free of whatever the torn entity hangs from), whose SoftBody (its piece field naming the piece) holds the piece's body. It is drawn with its share of the torn entity's model and with its material, and it keeps the velocity, the dents and the pins and attachments that fell on its side, so a car panel torn off a chassis flies on with its crumple and drops the bolts that held its other half. The torn entity keeps the largest piece. A piece can be found, read with Physics.softBodyState, pushed, pinned, cut again and despawned like any soft body. It is play state: left out of every save, and leaving play takes it away and gives the torn entity back its whole body.
-- a flag that rips when the wind pulls it past 1.4 times its size
flag.component.add("SoftBody", { shape = "cloth", resolution = 12, tearStrain = 0.4 })
-- hear every tear: the torn entity, the pieces' entities, where and why
Physics.onSoftBodyTear(function(e)
print(e.entity, e.kind, e.position, #e.pieces) -- kind: "load" | "cut" | "tear"
for _, piece in e.pieces do
Physics.applyImpulse(piece, { x = 0, y = 2, z = 0 }) -- send the panels flying
end
end)
-- slice a jelly in two along the plane x = 0
Physics.cutSoftBody(jelly, { point = vector.create(0, 0, 0), normal = vector.create(1, 0, 0) })
-- punch a hole where a ray hit a sheet
Physics.tearSoftBody(sheet, { nearest = hit.point, space = "world" })
Physics.softBodyTears() returns the same records for a caller that polls. A tear costs bounded work in the step it happens in: maxTearsPerStep (32 by default) tears the most loaded edges first and leaves the rest for the next step, while an edge loaded past twice its threshold tears at once. minPiece is the smallest piece a tear splits off, tearSmoothing averages the load over that many seconds before the test, and interiorToughness above 1 makes the inside of the body harder to tear than its surface, so tears start at an edge or at earlier damage.
Joints with a soft body at either end
A Joint (fixed, hinge, ball, prismatic, spring, rope) takes a soft body at either end or both: a punching bag hung from a chain link, a soft tail on a character, two jellies stuck together. A soft end with no region is anchored on the body as a whole (its frame); region (this end) and remoteRegion (the connected end) anchor it on a region of its particles, the anchor then measured from the centre of those particles.
-- a soft bag on a ball joint, hung by its top particle from a fixed hook
Physics.addJoint(bag, hook, { kind = "ball", region = { nearest = { 0, 0.5, 0 } } })
-- two jellies stuck face to face, coming apart above 200 N
Physics.addJoint(a, b, {
kind = "fixed", breakForce = 200,
region = { box = { min = { 0.4, -0.5, -0.5 }, max = { 0.5, 0.5, 0.5 } } },
remoteRegion = { box = { min = { -0.5, -0.5, -0.5 }, max = { -0.4, 0.5, 0.5 } } },
})
Motors, breakForce / breakTorque, Physics.jointReaction and Physics.onJointBreak work as they do between rigid bodies. A region end is a cluster of the soft body's particles standing in for the joint (Physics.softBodyState(id).clusters counts the body's frame plus one per such end); it goes with the joint, and the joint goes with either body.
Vehicles: a car on wheels
A drivable car, kart, truck or buggy is a dynamic chassis body with a WheelCollider on each wheel entity under it. Every wheel under one body is one wheel of that body's vehicle, which rapier's ray-cast vehicle controller simulates: each wheel casts its suspension down from where it is mounted, and the controller applies the suspension, drive, brake and tyre grip to the chassis, and the opposite of each to the dynamic body the wheel stands on, so a truck parked on a car, a deck or a seesaw weighs on it and a car driving on a floating deck pushes it back; a light body such as a small crate is pushed only as far as the tyre can follow it, and one the floor's friction holds passes the tyres' grip on to the floor. A chassis BoxCollider around the wheel mounts is fine; the wheels see past it.
local car = entity.spawn("car")
car.position = { 0, 1, 0 }
car.component.add("Physics", { kind = "dynamic", mass = 600 })
car.component.add("BoxCollider", { half = { 0.9, 0.3, 2.0 } })
for _, at in { { -0.85, -1.3 }, { 0.85, -1.3 }, { -0.85, 1.3 }, { 0.85, 1.3 } } do
local wheel = entity.spawn("wheel", { parent = car.id })
wheel.localPosition = { at[1], -0.3, at[2] } -- the suspension mount
local tyre = entity.spawn("tyre", { parent = wheel.id })
tyre.component.add("Model", { model = "cylinder" })
tyre.localRotation = { 0, 0, 0.7071068, 0.7071068 } -- the cylinder's axis along X
tyre.localScale = { 0.7, 0.25, 0.7 }
wheel.component.add("WheelCollider", {
suspensionDistance = 0.3, springRate = 20000, damperRate = 2000,
visual = tyre, -- posed at the wheel centre, steered and spun every tick
})
end
The conventions, in one place:
- The chassis' forward is its local -Z. Positive
motorTorquedrives that way;brakeTorquestops the wheel whilemotorTorqueis 0. - Positive
steerAngle(degrees) steers right. Steer the front pair (the wheels at negative local Z) and drive whichever wheels you like. - To drive it, add
VehicleControllerto the chassis:car.component.add("VehicleController", { motorTorque = 1500, topSpeed = 25 }). It reads the built-in@builtin::inputMaps.vehiclemap (throttle W/S, steer A/D, brake Space, handbrake Ctrl, boost Shift, recover R, camera zoom on the scroll wheel, each on keyboard, gamepad and touch) and writesmotorTorque,brakeTorqueandsteerAngleon the wheels: throttle pulled back brakes and then reverses, steering narrows with speed, the handbrake locks the rear pair, the chassis is held level in the air with throttle lifting the nose, and a car on its roof is set back on its wheels. Its README lists every field with its unit. A driver of your own reads the same controls throughZin.controllers.vehicle()(getThrottle,getSteer,getBrake,isHandbrakeHeld,isBoosting,consumeRecoverRequest). Thescenes/player-setupskill covers making the car the player's body. The wheel fields are synced fields, and each write to one is a property change the world syncs: write a field when its value changes rather than on every tick (VehicleControllerdoes), and keep read-outs of the car (a speedometer, a HUD) off synced fields. - The suspension ends at a bump stop. The spring rests
suspensionDistance × targetPositionbelow the mount and compresses from there by up tosuspensionDistance; a landing the springs cannot take ends on the stops, with the tyres on the ground and the chassis' own collider clear of it, rather than with the tyres sunk in.Physics.getWheelState(wheel).stopImpulsereads what the stop took that tick. - A camera that follows the car reads where the car is drawn, not
car.position: the built-in follow cameras (@builtin::controller.chaseand the others) do, and "Where a moving body is, and where it is drawn" below covers the read. Physics.vehicleState(chassisId)reads the car: its forward speed (m/s, negative in reverse), how many wheels are on the ground, and each wheel's state;Physics.getWheelState(wheelId)reads one wheel: contact, compression, spin and angle, skid, the impulses it applied and its centre.
The WheelCollider component README has every field: suspension, grip (forwardFriction, frictionSlip, sideFrictionStiffness), the most force the suspension carries, the wheel visual, and how the spring and damper are held stable for the chassis they carry.
A monster truck is the same car built big: a heavy chassis, big wheels and long travel, with maxSuspensionForce raised so the springs carry the landings (the VehicleController README has the whole truck). A 2000 kg chassis (box half 1.3 x 0.4 x 2.2 m) on four wheels of radius 0.8 m mounted at (±1.5, -0.2, ±1.7), with suspensionDistance = 0.8, springRate = 25000, damperRate = 5000, maxSuspensionForce = 40000 and VehicleController { motorTorque = 4000, topSpeed = 25 }, driven at an 18 degree ramp 12 m long with the throttle held, leaves it at about 17 m/s, rises 4.8 m above its ride height, flies about 22 m holding its nose 16 degrees up, comes down rear wheels first onto its stops and drives on on four wheels, the same on native and in the browser. Dropped from 12 m (about 15 m/s at the ground) it lands on its stops with its tyres on the floor and its box 0.2 m clear of it, and rises back to its ride height on four wheels. A wheel meets the ground along its chassis' down axis, so a wheel touching down on a chassis pitched nose up stands its tyre's lowest point a little below the floor, 0.045 m at 19 degrees. With the default maxSuspensionForce of 6000 N the springs of this truck carry little more than its weight: under full throttle it sat back 13 degrees, and its landings end on the stops. Raise it until the four wheels together carry several times the weight.
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.
A trigger holds nothing up
A collider with isTrigger = true reports what overlaps it and blocks
nothing, and every physics query treats it that way: raycast, raycastAll,
raycastScreen, hasLineOfSight and the three casts pass through it, and
overlapSphere leaves it out. So a ray down through a trigger zone reports
the floor beneath it, a WheelCollider drives through one on the road, and a
CharacterController walks into a zone and through it the way it walks
through open air. Clicking a trigger in the editor still selects it.
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.
Where a moving body is, and where it is drawn
The simulation advances in fixed ticks (physics.tick_rate, 30 a second unless the world sets it), while frames are drawn at whatever rate the display runs. So a dynamic body has two poses on any frame:
- The simulated pose, which
entity.position,entity.rotationandentity.worldMatrix()report. It is where the last tick left the body, and it only changes when a tick runs: sampled once a frame, a body moving at 10 m/s on a 30 Hz tick advances 0, 1/3 or 2/3 of a metre from one frame to the next, in whole ticks. - The drawn pose. A body whose
Physics.interpolationis on (the default) is drawn blended between its last two ticks, as far into the gap as the frame sits, so it moves smoothly on screen. Everything parented under the body (a car's wheels and tyres, a model parented to its collider) is drawn moved with it, and a camera or a light parented under it draws and shines from where it sits on the body as drawn. A soft body's frame is drawn the same way. The world settingphysics.interpolation = falsedraws every body at its simulated pose;Physics.interpolation = falsedoes it for one body. A body a script or the editor moves (settingentity.position, a respawn) is drawn where it was put on that frame, and the next tick blends on from there.
Physics.drawnPose(id) reads both for any entity:
local d = Physics.drawnPose(car)
print(d.position.z) -- where the frame draws it
print(d.simulatedPosition.z) -- where its transform puts it: the same as car.position.z
print(d.interpolated, d.interpolatedBy, d.alpha) -- blended?, by which body, how far into the tick
Anything that has to stay with a moving body on screen reads the drawn pose from its update: a camera following it, a marker or label placed over it, a measurement of how smoothly it moves. Placed from entity.position instead, it moves in whole ticks while the body moves every frame, so the body slides back and forth against it. The built-in follow cameras (@builtin::controller.chase, third_person_follow, orbital_follow, cinematic, birds_eye, isometric, side_scroller, rts, orbit, first_person) follow the drawn pose. While the gameplay clock is paused no tick runs, and a body stays drawn where it stood when the clock stopped. Game logic (distances, triggers, where to aim a force) reads the simulated pose, which is what the simulation acts on.
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 touching, 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. A pair is listed only while its surfaces touch, so every entry has at least one point (two soft bodies whose closed surfaces cross before any single point comes within reach are touching through the crossed volume, which names its particles and impulse and holds no point of its own), and a pair kept apart by Physics.ignoreCollision or by collision groups is never listed. A soft body's surface stands its particle radius out from its particles, and distances and penetrations are measured to that surface. That is the read behind "is the character grounded", "did these two interpenetrate", and "how hard did that land".
Each point carries the impulse the last step applied there, and totalImpulse is their sum. The solver holds at most four points of each contact plane as constraints, so the impulse sits on those and every other point reads zero. A body touching a mesh, a heightfield or a compound across several of its triangles, cells or children reports the points of each contact plane as the solver holds them: a point two neighbouring pieces share is listed once, and every point is placed where it lies on the piece that touches.
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, soft bodies and their particles, 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. Soft bodies are a kind of their own: softBodies counts them, colliders.softBody counts their surface colliders, and none of them appears among bodies. stepCost breaks the last step into its stages (the same figures worldState().step carries), and softBodyMs / softBodyShare name the part attributed to soft bodies, apportioned by the constraints their elements handed the solver against the rigid contacts and joints; softBodies lists each soft body's part of it (see "What a soft body costs" above), and worldState().softBodySettings the settings the solver runs every soft body under. 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.
A step runs on several threads at once: the solver works through independent groups of contacts and joints side by side, the narrow phase splits its pairs between them, and the broad phase reorganises its tree on a spare one while the rest of the step goes on. stepCost().workers is how many it ran on: the engine's data-parallel pool, sized to its share of the machine's threads. A large scene (a pile of thousands of boxes, a field of debris) therefore steps faster on a machine with more cores, natively and in a browser build with threads; a browser build without threads steps on one. The simulation comes out the same on any number of workers, so a replay or a comparison between machines is not thrown off by a different core count.
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 groups an entity's CollisionGroup component states reach its colliders as its body is built and before every step, in edit mode as well as play, a body built or rebuilt after they were set included. Physics.ignoreCollision(a, b) keeps one pair of entities apart, every collider of each (a soft body's surface too), from the next step until it is turned off with false; a pair either the groups or an ignore excludes does not collide. Vehicles have a section of their own above. 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, the peer that simulates a body measures the loads on its joints and breaks them (see "Physics under multiplayer" below). A peer holding replicas of both bodies moves them along the simulating peer's poses, so the bodies part where that peer's break sent them; no break message crosses the wire. The Joint component's breakForce and breakTorque are synced fields, so every peer holds the same threshold.
Physics under multiplayer
One peer simulates each synced body: its owner, or for an entity nobody owns, the peer that announced it into the room (an ownership claim moves the body to the claimer). Every other peer holds a replica that shows the body where the simulating peer puts it:
- A rigid body that is
dynamicmoves on a replica as a kinematic body along the poses the simulating peer sends, so it pushes the local bodies it meets and is not pushed by them.Physics.kindstill readsdynamicthere. A body handed to this peer by a claim simulates from where it stands, moving as it was moving. - A soft body keeps its elastic motion on a replica, held to the simulating peer's pose, and takes its permanent deformation from that peer: a dent the owner makes reaches every peer and a peer that joins later, and a replica never dents on its own. Tearing is the simulating peer's too: its solver tears the body, a replica tears nothing of its own, and a cut or tear a script asks of a replica is left to the simulating peer. When the body comes to rest the simulating peer states the elements its flow changed and where each of its particles settled, so every peer, and every peer that joins later, holds the shape it settled in particle for particle, and keeps it at rest. A body handed over by a claim keeps its dent and flows from there. A pinned body is carried with its pins; a body whose particles are tied to a rigid body (
attachments) is placed by that body's replica.
The pose travels on every change and once more, reliably, when the body comes to rest, so the place a body settles at is the place every peer and every later joiner holds. While a soft body flows its dent travels as the particles whose rest place moved (10 bytes each); once it comes to rest it states, once, the elements its flow changed (22 bytes a flowed cell, 6 an edge or fold) and every particle's settled place (6 bytes a particle), about 7 KB for a dented 64-particle box. Shape statements are paced to about 2 KB a second per body, and a body at rest costs nothing. Physics.softBodyState(id).replication reads which peer simulates the body (authority: "local" or "remote", and owner), when its state last crossed the wire (lastSync, seconds) and what it costs (bytesPerSecond).
A force, impulse or velocity written to a replica changes nothing the room sees: a rigid replica does not take it, and a soft replica wobbles from it on this peer alone, held back to the simulating peer's pose and keeping no dent. Push a body from the peer that simulates it, or claim it first (multiplayer.claimOwnership(id)).
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.