Physics
Physics
Bodies, collisions, and asking the world what is in front of you.
Part of the scripting guide · every call, as a reference
Contents
4. node — the physics body
These extra fields appear only when the node has a Rigidbody (Inspector → ◆ Rigidbody). Instead of teleporting the node, you drive its velocity and the engine integrates it (gravity, collisions, ground contact).
| Field | R/W | Meaning |
|---|---|---|
node.vx node.vy node.vz |
read/write | Velocity (m/s). Read the current value, modify it, write it back. |
node.grounded |
read | true while the body rests on a surface. Gate jumps on it. |
node.up_x node.up_y node.up_z |
read | The body's up = −gravity. [0,1,0] on a flat world, radial on a planet. |
node.height |
read/write | Capsule standing height. Write a smaller value to crouch (feet stay planted). |
node.groundNormal |
read | The floor it stands on, as a vec3 — nil while airborne. |
node.wallNormal |
read | The steepest surface it is pressed against, as a vec3 — nil when there's only floor. |
The golden rule for movement: keep the velocity's vertical (gravity/jump) part, replace the horizontal part.
local vy = node.vy
if node.grounded and input.pressed("space") then vy = params.jump end
node.vx = move_x
node.vz = move_z
node.vy = vy
Because node.up_* is the surface normal of gravity, a controller that moves along
it and jumps along it works on flat worlds and on spherical planets with no extra
code (see the character recipe below).
Slopes: don't push into what you can't walk up
A walking controller that drives straight into a steep face launches itself. Nothing is bouncing it: the solver resolves the overlap by pushing the capsule out along the surface normal, that normal points partly upward, and a controller that keeps pushing collects that push again every single frame. At a run into a 70° hillside it is tens of metres per second of free climb.
The two normals are the fix. Take the into-the-surface part out of your movement and what remains is a slide along it:
-- cos of the steepest ground you allow: 50° here
local steep = math.cos(math.rad(params.slope_limit))
local function slide(m, n) -- m = desired velocity (vec3), n = a normal or nil
if not n or n:dot(node.up) >= steep then return m end -- nothing there, or walkable
local into = m:dot(n)
if into >= 0 then return m end -- already moving away
return m - n * into -- slide along the face
end
local move = slide(slide(move, node.wallNormal), node.groundNormal)
wallNormal is the cliff you ran at; groundNormal catches a slope you are
standing on that is still ground but steeper than you want to allow. The shipped
first_person.lua and third_person.lua both do exactly this —
params.slope_limit in the Inspector.
One more line pays for itself: while grounded and not jumping, drop any upward velocity you didn't ask for. It came from being pushed out of a slope or a step, and keeping it is how a walk turns into a takeoff.
if node.grounded and not jumping and vup > 0 then vup = 0 end
The body's tunables — friction, bounciness, gravity on/off, shape/size, axis
locks — are scriptable too, via node:getcomponent("RigidBody") (see
§7).
Body modes: Dynamic, Kinematic, Static
The Rigidbody's mode dropdown replaces hand-freezing axes and disabling gravity:
| Mode | What it does | Cost |
|---|---|---|
| Dynamic | Fully simulated: gravity, velocity, collisions push it around. | normal |
| Kinematic | Transform-driven: never falls or gets pushed — your scripts/animation move the node and the body follows. Dynamic bodies collide with it (a moving platform carries and pushes the player), raycasts hit it, touch events fire. | near zero |
| Static | Baked collider in the body's shape — no body at all. The cheapest way to make something solid (walls, floors, props). | zero per tick |
A Collidable node with no Rigidbody (a wall, a building, a piece a level editor places) is baked the same way, and its collider follows the node when a script moves, turns or scales it. There is no need to destroy and spawn it again. A move shifts the collider for nothing; a turn or a resize rebuilds it, from geometry the engine reads once per model and keeps. In a level of more than about a thousand such pieces they are checked a slice a frame, so a moved piece can take a few frames to follow. Something that moves all the time belongs in Kinematic, which follows every tick.
A Collidable model also casts a distance-field shadow, which is baked from
its shape at its current turn and size. That bake costs around 100 ms, so it is
not redone every frame: while a script keeps turning or resizing the node (a
flinch, a throb, a door swinging), the shadow keeps its last shape and follows
only the node's position. Once the node holds still for about a third of a
second, the shadow is baked once at the new shape. A node that returns to a
shape it already has a bake for costs nothing. shot --timing with --frames
counts these bakes and the collider rebuilds, and run --timing counts the
collider rebuilds.
-- a moving platform: Kinematic mode + plain transform writes
defaults = { dz = 6.0, speed = 0.5 }
local from
function start(node) from = node.pos end
function update(node, dt)
local t = (math.sin(time * params.speed * math.pi * 2) + 1) * 0.5
node.pos = from:lerp(from + vec3(0, 0, params.dz), t)
end
Scripts can flip Dynamic ↔ Kinematic live (grab an object, dock a vehicle):
node:getcomponent("RigidBody").kinematic = true -- freeze + carry it
node:getcomponent("RigidBody").kinematic = false -- drop it (wakes at rest)
Every mode can also be a trigger (the Rigidbody's trigger checkbox): the
body becomes a sensor that never blocks anything but fires the
onTriggerEnter/Stay/Exit hooks on overlap — Kinematic + trigger is the
moving pickup / sweeping damage zone (see
§20 Triggers).
Static is authoring-time (it's a collider, not a body — switch it in the Inspector; the live sim rebuilds instantly). All three modes ride the scene format, so replicated/spawned nodes behave identically over the network — a server-moved Kinematic platform replicates its transform like any node, and clients keep its collision hull where the players see it.
4.1 Assemblies: assembly.* — multi-part vessels
Tick assembly on a Dynamic RigidBody and that node roots ONE compound
6-DOF rigid body built from every descendant node that carries a RigidBody:
each part becomes an oriented shape at its offset, weighted by its mass
field (the root's own shape fields are ignored). Composed mass, center of
mass and inertia are real — thrust that doesn't point through the CoM
torques the vessel, and landing on one leg tips it. Ships, rovers, cranes,
breakable structures.
-- fixedUpdate: hold thrust for this tick (world space; re-arm every tick —
-- a dropped call means the engine stops, nothing latches).
function fixedUpdate(node, dt)
local i = assembly.info(node) -- mass, com, vel, angVel (vec3 tables),
if i == nil then return end -- grounded, parts (entity ids)
local up = vec3(0, 1, 0)
if input.down("space") then
-- 20 kN straight up THROUGH an engine mounted at the base: if that point
-- is off the CoM line, the vessel honestly starts to rotate.
assembly.forceAt(node, vec3(0, 20000, 0), vec3(i.com.x + 0.4, i.com.y - 2, i.com.z))
end
assembly.torque(node, vec3(0, 0, 400)) -- reaction-wheel roll
end
-- Staging: detach parts into a NEW live vessel (a fresh root node; the part
-- nodes re-parent under it, physics momentum hands off exactly).
assembly.split(node, { boosterA, boosterB }, function(stage)
assembly.impulseAt(stage, vec3(0, -800, 0), assembly.info(stage).com) -- sep spring
end)
Splitting off something FLYABLE: pass a prefab name as the fourth argument and the detached half is rooted at a fresh instance of it — scripts and all — instead of a bare node. That's the difference between shedding debris and undocking a lander that can fly home:
assembly.split(node, moduleParts, function(craft)
save.set("handoff." .. craft.id, subBlueprint) -- its controller reads this in start()
end, "Vessel") -- prefab root: needs an assembly RigidBody
Docking, cranes, construction — assembly.merge(node, other): the inverse
of split. Two compounds become ONE rigid body carrying their combined
momentum, other's part nodes re-parent under this root with their world pose
kept, and other's root is retired. The join is perfectly inelastic: an
off-centre catch spins the pair up exactly as much as it should, and whatever
relative motion is left at the instant of the latch is felt as a jolt — so aim
for a slow, aligned closing (or add your own magnetism to make one). Absorbed
parts keep their entity ids, so per-part contact attribution
(assembly.impacts) carries straight across the join. Latching onto something
setAnchored'd pins the pair.
-- A docking latch, in full: line the two up, then weld.
if range < 0.5 and align > 0.8 and closingSpeed < 1.5 then
assembly.merge(myVessel, theirVessel) -- next tick, their parts are my children
end
Vessels collide with each other. Two assemblies, or an assembly and a Dynamic rigidbody, push each other apart part by part, through each side's mass and inertia, so a glancing hit spins both. A rigidbody standing on a deck is grounded and is carried by friction. The layer matrix decides who is pushed: each side is pushed only if its own layer collides with the other's, so a one-way row makes a one-way contact. An anchored assembly is never moved, and neither is a rigidbody under rollback, so a fighter's trajectory never depends on a crate. Two plain rigidbodies push each other the same way: crates stack and a character shoves a crate along. Layers are per assembly, not per part: a docking probe meant to slide into its drogue stops at the drogue's surface, so latch within reach of touching rather than after overlapping.
assembly.force(node, f) pushes through the CoM; assembly.impulseAt is a
one-shot kick (explosions, docking bumps). All vectors are world-space
vec3s. Forces are held per tick and applied through every physics
substep — call them from fixedUpdate for continuous thrust.
Script-assembled vessels: spawn part prefabs as children of an assembly
root (spawn(part, pos, fn, vesselNode)), then call
assembly.rebuild(vesselNode) once — the compound re-gathers from the
root's current descendants. That's the whole blueprint-spawner pattern.
Anchoring (launch clamps, latches, cranes): assembly.setAnchored(node, true) pins the vessel exactly where it stands — no gravity, no contacts,
velocities read zero, held forces are ignored — and it still rides a moving
celestial's frame. assembly.setAnchored(node, false) releases it from
rest (nothing banks up while clamped). assembly.info(node).anchored
reports the state; a rebuild preserves it.
Staying live when the camera roams: distant compounds drop out of full
physics into a cheap LOD (landed ones freeze, in-flight ones coast on analytic
Kepler rails) and wake on approach — great for hundreds of deployed craft, but
a craft you're flying from a far-off view (e.g. a map camera pulled hundreds of
metres back) would freeze under you. assembly.keepLive(node, true) exempts a
compound from that LOD so it stays in full physics — live throttle, steering
and orbital velocity — however far the camera is; assembly.keepLive(node, false) rejoins the LOD.
Placing a live assembly: the compound writeback owns the root node's
transform, so plain node.x = … writes are overwritten every frame —
assembly.teleport(node, pos) is THE way to move one (velocity untouched):
pad pinning, save restores, cutscene placement.
Assembly roots read like bodies: node.vx/vy/vz, node.up_x/y/z
(local gravity-up) and node.grounded all work on an assembly root, so
cameras and controllers written for single rigidbodies follow a vessel
unchanged.
Surface structures on orbiting worlds: a Static-bodied node parented
(at any depth) under a celestial body's node rides its orbit — the transform
hierarchy carries the visuals and the engine carries the baked collider. A
launchpad parented to its planet stays exactly as solid as the terrain.
Distant craft cost nothing: compounds far from the camera (~700 units)
leave live physics automatically — landed or slow ones freeze in their
planet's carried frame, in-flight ones coast on analytic Kepler rails
(drift-free at any warp) — and wake to full contact physics on approach.
Deploy hundreds of satellites, stages and rovers; only the neighborhood
simulates. While parked this way info.anchored reads true.
Pausing physics wholesale: physics.pause(true) skips the entire
physics step each tick while scripts, rails and terrain streaming keep
running — the tool for loading screens, cutscenes and pause menus
(physics.pause(false) resumes; physics.isPaused() reads it; queued
thrust is dropped, never banked, while paused).
Frame-stepping: physics.step([n]) freezes the whole gameplay tick and
releases exactly n (default 1), each advancing scripts, physics and
animation by one frame — enough to build a training mode's own frame stepper.
The editor does the same thing from its ⏭ Step button (F3, while paused), with
the tick counter beside it naming the frame you stopped on. Call physics.step
from update: the frame pass still runs while the tick is frozen, fixedUpdate
by definition does not.
Per-part impact attribution — assembly.impacts(node): the engine
attributes every contact a compound resolves to the PART that took it. Each
tick the call returns an array of { part, impulse, speed, x, y, z } —
part is the part node's entity id (match child.id over node:children()
or info.parts), impulse the total normal impulse that part absorbed this
tick (mass·Δv), speed the peak closing speed it hit at this tick (m/s),
x/y/z its hardest contact point in world space. Hits from other assemblies
and from rigidbodies count, on both sides. Empty between contacts. An anchored
assembly makes no contacts with the static world, but a vessel striking it
still registers on both. Poll it from fixedUpdate and
compare against per-part strength — that is a damage model in ten lines:
for _, hit in ipairs(assembly.impacts(node)) do
if hit.speed > crashToleranceOf(hit.part) then -- m/s, KSP-style
spawnEffect("Explosion", hit.x, hit.y, hit.z)
-- shear the part off as wreckage:
assembly.split(node, { childById(node, hit.part) }, function(junk) end)
end
end
Prefer speed over impulse for a crash test: the contact solver's
depenetration is BUDGETED (so a deep or fast spawn un-buries at a sane rate
instead of catapulting), which spreads a high-speed crash's impulse over many
ticks — the per-tick impulse plateaus and understates the hit. speed is
the pre-resolution normal closing velocity and is NOT capped, so it faithfully
reports how hard something struck (and it needs no mass normalization: a
40-tonne ship and a 4-tonne ship judge the same touchdown the same way). A
soft landing on legs reads as a low speed on the leg parts; a nose-first
lithobrake reads as a high speed on the nose. The solar demo's vessels break
exactly this way (solar/scripts/vessel_controller.lua).
Joints between assemblies: articulated craft, rovers and grips
Either end of a Joint can be an assembly, with its root or any of its parts standing for the whole craft, so a vessel built from several assemblies moves against itself: a hinged arm, a piston, a rotor, a docking clamp.
-- A hinge or slider motor as a servo: it drives to a goal and holds it.
arm:setJoint{ goal = 45 } -- degrees for a hinge, metres for a slider
local j = arm:getcomponent("Joint")
j.goal = 30 -- set every tick if you like; no rebuild
print(j.position, j.speed) -- where it is, and how fast it moves
A wheel joint hung from an assembly chassis turns only about its axle, grips the ground by the load its suspension carries, and parks on a slope with its motor held at zero.
assembly.grip(node, at [, target] [, breakForce]) welds a craft at a point to a
body, another assembly, or the ground under it; it rides that ground when the
ground moves. assembly.release(node) lets go, and assembly.info(node).gripped
says whether it still holds.
A part's mass is read and written as rb.mass; the assembly re-weighs itself
on the next tick. assembly.info reports the inertia the solver uses, and a
parked assembly falls asleep like a single body (node.asleep).
4.2 Two telegraph layers: draw.* (game) vs gizmo.* (debug)
They look similar but serve different masters:
draw.*is part of your GAME — always rendered in the game view, no editor toggle involved. Attach-point markers, selection outlines, range rings, orbit conics: player-facing linework. Immediate mode (re-issue every frame/tick you want it visible), world space, alpha supported.draw.line(x1,y1,z1, x2,y2,z2, r,g,b [,a])draw.polyline(points, r,g,b [,a [, closed]])— a connected line throughpoints(a flat{x1,y1,z1, x2,...}array or an array of vec3s) in one call. A 128-point orbit is one call rather than 127draw.lines, which is what a map with dozens of orbits spends its frame on otherwise.draw.conic(center, e1, e2, p, ecc, segs, r,g,b [,a [, maxR]])— the orbitr(θ) = p / (1 + ecc·cos θ)around the focuscenter, in the plane ofe1(toward periapsis) ande2. Closed forecc < 1; an open arc cut atmaxR(default10 × p) otherwise.draw.ring(cx,cy,cz, nx,ny,nz, radius, r,g,b [,a])— a circle around the normalndraw.sphere(cx,cy,cz, radius, r,g,b [,a])— three ringsdraw.box(cx,cy,cz, hx,hy,hz, yaw, r,g,b [,a])— wireframe box- Filled primitives (solid triangles, for polished gizmos & markers):
draw.tri(x1,y1,z1, x2,y2,z2, x3,y3,z3, r,g,b [,a])— one triangledraw.cone(bx,by,bz, dx,dy,dz, radius, height, r,g,b [,a])— a solid cone (base atb, apexheightalong unit dird): arrowheads, nozzles, markersdraw.disc(cx,cy,cz, nx,ny,nz, r0, r1, r,g,b [,a])— a filled annulus (innerr0, outerr1) around normaln;r0=0is a full disc. Rotation-gizmo bands, ring markers
gizmo.*is for DEBUGGING (gizmo.line/ray/sphere/point) — drawn only while the editor's viewport gizmos toggle (and its Script filter) is on, exactly like collider/light overlays. Ground-check rays, AI targets, physics probes: developer eyes only, never the player's.
20. Collision & trigger events
Define these hooks in any script on a node and the engine calls them when the node's body touches something — per gameplay tick, right after physics:
function onCollisionEnter(node, other, hit) -- the touch STARTED this tick
end
function onCollisionStay(node, other, hit) -- every tick while it lasts
end
function onCollisionExit(node, other, hit) -- the pair separated (hit = last contact)
end
otheris the other node's handle —other.name,other:hasTag("enemy"),other:getscript("health")all work.hitis{ x, y, z, nx, ny, nz }: the world contact point and the unit normal out of the surface that was hit.- Fires for body-vs-collider, body-vs-body and assemblies. Rigidbodies and
assemblies push each other apart (see §4.1); a pair whose layers do not
collide, or one passed to
physics.ignorePair, passes through. - The events fire on both nodes' scripts, and the collision matrix (Project Settings → Layers) gates them: pairs that don't collide don't event.
- A body resting on the floor reports
onCollisionStayagainst the floor node every tick — gate on tags/names rather than assuming silence.
Triggers
Tick trigger on a node's Collider component and it stops blocking: bodies (and raycasts) pass straight through, but overlap fires the trigger hooks — portals, pickup zones, checkpoints, kill planes:
function onTriggerEnter(node, other, hit) end
function onTriggerStay(node, other, hit) end
function onTriggerExit(node, other, hit) end
Triggers work on rigidbody nodes too — the trigger checkbox sits on the
Rigidbody component there, and it turns the body into a sensor: it never
blocks or gets blocked (and rays skip it), but overlap fires the hooks on both
nodes. A Kinematic + trigger rigidbody is the moving pickup / sweeping
damage zone: scripts move it, players pass through it, onTriggerEnter fires.
A Dynamic + trigger body still falls — it drops straight through solid
geometry (firing trigger events against everything it crosses), so pair
triggers with Kinematic or gravity-off for things that should stay put.
The full portal — one script, any number of portals, each with its own destination via a string param:
-- portal.lua — attach to a Collidable node with "trigger" ticked
defaults = { destination = "hub" }
function onTriggerEnter(node, other, hit)
if other:hasTag("player") then
scene.load(params.destination)
end
end
When events fire (and don't)
Events are produced where physics runs: offline everywhere, on the server
in multiplayer, and on a predicted node's owning client. Prediction replays
never re-fire events (corrections can't double-trigger a pickup). Handlers
run outside the normal update pass — their node writes apply immediately,
but params writes are frame-local there (persist state in script variables
or synced instead).