PhysicsService
Rust-side rigid-body simulation that wraps existing
Renderable.BaseParts. Every plane runs integration,
collision, and CFrame writeback entirely in Rust on its own optional
rayon thread pool, so the only Lua-side cost per frame is reading
or assigning values you actually care about.
local PhysicsService = import("PhysicsService")
local Renderable = import("Renderable")
local Primitives = import("Primitives")
local world = PhysicsService.New({
Gravity = Primitives.Vector.new(0, -50, 0),
Threads = 4,
})
local ball = Renderable.BasePart("Sphere")
ball.CFrame = Primitives.CFrame.new(Primitives.Vector.new(0, 10, 0), Primitives.Vector.new(0, 0, 0))
local obj = world:Add(ball)
obj.Bounciness = 0.7
obj.HitBoxQuality = 4 -- finer-grained sub-stepping for fast-falling parts
obj:ApplyImpulse(Primitives.Vector.new(0, 0, 12))
PhysicsService API
Spin up a new physics plane. Every wrapped BasePart in this plane shares the plane's gravity and (optionally) its rayon thread pool. Multiple planes can coexist for things like "main world", "rigid body debris", "puzzle subsystem", etc. — they don't interact.
opts is an optional table:
Gravity—Vectorapplied as acceleration to every non-anchored object (units/sec²). Defaults toVector(0, -50, 0), roughly Earth-gravity at game scale.Threads— integer 1+. When > 1 the plane builds a rayon thread pool of this size and uses it to parallelize integration and CFrame commits across objects. Defaults to1(single-threaded inline). The cost of dispatching to the pool only pays off above ~64 objects, so leave it at 1 for small scenes.LinearDamping— per-second velocity drag. Each tick the engine doesvelocity *= max(0, 1 - LinearDamping * dt). Default0.10. Higher values make objects slow down sooner (think air resistance).AngularDamping— same idea for rotation. Default0.30— kills tumbling fairly quickly so visually a stacked pile doesn't look like it's vibrating.Drag— quadratic drag coefficient. Each tick, every non-anchored body hasF = -Drag * |v| * vapplied as an extra force on top of the normal integration. UnlikeLinearDamping(which scales with v), quadratic drag scales with v² so at low speed it barely matters but at high speed it dominates. Perfect for the "feels like underwater" pitch: small impulses barely move, big shoves still travel a long way, things never quite stop the way they do in air. Default0.0. Reasonable underwater values are around1.0–4.0.Buoyancy— constant upward acceleration applied per non-anchored body each tick. Positive values counteract gravity (set to about-Gravity.Yfor neutral float; lower for "weighed-down sinking"; higher and things rise). Negative values pull down harder than gravity. Default0.0.ConstantForce—Vector, world-space force applied to every non-anchored body in the plane each tick on top of gravity / drag / buoyancy. Force, not acceleration — a heavier body accelerates less from the same value, so a feather drifts in wind that a boulder shrugs off. Perfect for global wind, conveyor pushes, magnetic fields, etc. DefaultVector(0, 0, 0). Tween between values to ramp wind in and out.RestThreshold— velocity-along-collision-normal threshold below which a colliding object's normal-axis velocity is set to zero instead of being reflected byBounciness. Default0.5(units/sec). This is the mechanism that lets piles actually settle — without it, the tug-of-war between gravity and bounciness keeps every cube oscillating forever, and a "settled" stack looks like floating soup. Set to 0 to disable and always reflect (rare, useful for billiard-ball-style simulations where you really want every contact to bounce).SolverIterations— integer 1–64, default4. Number of constraint-solver iterations rapier runs each tick. Higher = better convergence on dense piles, more CPU. Ignored whenLoopSolveris true.LoopSolver— boolean, defaultfalse. When true, the solver is pinned at a high fixed cap (64) so any reasonable scene converges. Effectively "iterate until the body stops" with a safety bound. Skip the per-scene tuning ofSolverIterations; pay a modest extra per-tick cost in exchange.NeverSleep— boolean, defaultfalse. By default rapier puts bodies whose kinetic energy falls below a threshold to sleep — forces, contacts, and integration are skipped on them until something wakes them up.NeverSleep = truedisables that optimisation: every body keeps simulating every tick. Use it when you need every object to react instantly to scripted forces and CFrame writes without a wake-up frame of latency. Costs CPU on otherwise-stable scenes since rapier can't optimise away resting bodies.DeferGpu— boolean, defaultfalse. Picks the simulation backend. False runs rapier3d on the CPU — real rigid-body physics with OBB collision, contact manifolds, sleeping islands, Coulomb friction, stable stacks. True runs the wgpu compute shader: integration + AABB-only collision, ping-ponging between two storage buffers each frame, mapped back to CPU.- The CPU/rapier path is the accurate option. Use it for anything load-bearing — players, vehicles, structures, balanced piles. Stacks settle correctly, corners don't clip, OBB rotation is real.
- The GPU path is the fast option, but less accurate. AABB-only collision means rotated cubes still test against their axis-aligned box, contacts are single-point (no manifolds), and there's no Baumgarte stabilisation or sleeping. Corners can sink slightly into neighbours, stacks are less stable, rotation is suppressed for non-Sphere shapes. Use it when you need throughput over accuracy: particle showers, confetti, debris fields, decorative scenes with thousands of small bodies that interact but don't have to be physically perfect.
2D sibling of New. Spins up a pixel-space physics
world for GUI primitives — same idea (anchored / unanchored
bodies, gravity, damping, controllers, raycast), but every
position is a Dim, shapes come from the bound
primitive's Shape, and the simulation writes
results back to each primitive's Position /
Rotation each tick.
opts is an optional table — see
GuiPhysicsPlane below.
PhysicsPlane
Acceleration applied to every non-anchored object every tick. Setting it takes effect on the next tick.
When false, the plane skips integration + collision — objects freeze in place, BaseParts hold their current CFrame. Defaults to true.
Number of rayon threads simulating this plane (the value
you passed in opts.Threads, or 1 if omitted).
Per-second drag applied to every non-anchored object's
velocity. Default 0.10. Tune up if your
scene's objects feel too lively / float around; tune down
for low-friction air sims (snowflakes, bubbles).
Same idea for angular velocity. Default 0.30.
Mostly affects how quickly tumbling parts come to a still
orientation when they're sitting on something.
Quadratic drag coefficient applied as an extra per-body
force F = -Drag * |v| * v each tick. Stacks on
top of LinearDamping. Default 0.0.
Bump to 1.0–4.0 for underwater
feel.
Constant upward acceleration term applied per non-anchored
body each tick. Positive values float bodies up, negative
pulls them down faster than gravity, 0
disables. Default 0.0.
World-space force applied to every non-anchored body in the
plane each tick, on top of gravity / drag / buoyancy. Force
(not acceleration), so heavier bodies feel less of it.
Useful for global wind, conveyor pushes, magnetic fields.
Default Vector(0, 0, 0). Live-editable, so you
can tween or animate it directly — ramp from zero to
Vector(15, 0, 0) over a few seconds for a
"wind kicks up" beat.
Below this collision-normal speed (units/sec), a contact
zeroes the normal velocity instead of reflecting it. The
knob that controls how quickly piles settle vs. how bouncy
low-energy contacts feel. Default 0.5.
Number of constraint-solver iterations rapier runs per
tick. The solver is iterative Projected Gauss-Seidel —
one pass resolves each contact once but constraints
between many touching objects (tall stacks, dense piles)
stay under-resolved. Iterating more converges toward the
correct equilibrium. Default 4; clamped to
[1, 64]. Ignored when LoopSolver is
true.
When true, rapier's sleep optimisation is
disabled for this plane: every body keeps simulating every
tick whether it's moving or not. Default false
(let rapier sleep resting bodies for free perf).
When true, the solver is pinned to a high
fixed cap (64) so any reasonable scene
reaches the residual below which rapier's PGS solver does
negligible work per pass — effectively "iterate
until the body stops" with a hard upper bound for safety.
Trades a modest per-tick CPU cost for stack stability.
Use it when you'd rather not hand-tune
SolverIterations for the worst pile in your
scene. Default false.
Why not actually unbounded? Rapier's solver is fixed-iteration Projected Gauss-Seidel — it doesn't expose a residual-based termination hook, so "loop until converged" isn't a thing it natively supports. But its per-iteration correction decays toward zero once constraints are stable, so a high cap is functionally equivalent: extra iterations after convergence cost very little. 64 is far past the convergence point for any stack you can build with thousands of bodies.
Backend toggle. false (default) runs rapier3d on
CPU — accurate OBB physics, stable stacks, sleeping.
true runs the wgpu compute path — faster
for bulk scenes, but contacts are AABB-only and
single-point so corners can clip slightly into neighbours
and stacks are less stable. Pick CPU/rapier for
accuracy, GPU for throughput. Safe to flip at
runtime — the next tick picks up the new path.
False after :Destroy().
Live count of PhysicsObjects in this plane.
Drops on :Unlink(), :Destroy(), or
when a wrapped BasePart is destroyed externally.
Wrap a BasePart with physics. The part's CFrame becomes
lazily driven by the simulation — the heavy
PartState mutex is never locked per-tick;
instead, the physics loop writes to a small
Arc<Mutex<CFrame>> override cell on
the part, and reading part.CFrame from Lua
transparently returns the simulated value.
Initial position + rotation come from the BasePart's current
CFrame, so there's no first-tick teleport. Velocity
and AngularVelocity start at zero.
Equivalent to setting plane.Enabled = on.
Manually advance the simulation by dt seconds.
The auto-tick already runs every frame in
heart::run_loop, so this is mostly useful for
sub-stepping in custom loops or for deterministic replays.
No-op when the plane is destroyed or disabled.
Spawn a kinematic character controller on this plane. The
controller drives the BasePart's CFrame each frame -- physics
objects collide with it via the plane's rapier query
pipeline. opts is an optional table of
WalkSpeed, JumpPower,
Gravity, TurnSpeed,
MaxSlope, GroundY,
CapsuleRadius, CapsuleHalfHeight,
WaypointThreshold,
LookWhereMoving overrides. Many controllers
can coexist on a single plane — enemy hordes plus
the player share the same Plane gravity and tick.
Defaults: WalkSpeed = 16,
JumpPower = 28, TurnSpeed = 12,
LookWhereMoving = true.
Same kinematic Controller, but defaults
LookWhereMoving = false and treats the motion-
intent methods (MoveForward, etc.) as the
primary input. Use for the local player: each frame, wire
WASD / left-stick to the move methods, drive the facing
from controller.Rotation.Y (mouse / right-
stick), and the controller's velocity blends along the
facing without overriding it.
Tear down the plane. Every PhysicsObject in it
is removed and its wrapped BasePart is left frozen at its
last simulated CFrame — BaseParts are NOT
destroyed. Use this to dispose of a physics
subsystem without killing the parts themselves.
Controller
Kinematic character controller created by
plane:NewController or
plane:NewControlledController. The controller is not
a rigid body — it doesn't accept impulses from collisions.
Instead it integrates its own velocity, applies gravity, and uses
the plane's rapier query pipeline to probe ground and snap to it.
Many controllers can coexist on a single plane; the engine ticks
them all every frame.
Properties & signals
World-space pose. Reading is cheap; writing
.Position zeroes velocity, .Velocity
adds free motion, .Rotation aims the body (use
the Y component for yaw on a ControlledController so mouse-
look aims independent of the walk path).
Top horizontal speed (studs/sec), jump launch velocity (studs/sec), and yaw blending rate (radians/sec) when LookWhereMoving is on.
UsePlaneGravity = true (the default) reads
gravity from plane.Gravity.Y each tick. Setting
.Gravity flips this to false and
uses the controller's own magnitude — useful for
zero-g pockets or per-character feel.
Set by the engine each tick. Tracks whether the foot capsule probe hit anything below it (rapier hit or GroundY fallback).
True: the controller auto-yaws toward its current velocity
vector (smoothed via TurnSpeed). False:
Rotation.Y is honored directly -- use this
for mouse-aimed players. Default true for
NewController, false for
NewControlledController.
True for ControlledController, false otherwise.
Jumped() fires the frame a successful jump
launches. Landed() fires when OnGround flips
to true. Moved(vx, vy, vz) fires every frame
the body has non-zero velocity. WaypointReached(idx)
fires when each path waypoint is consumed.
PathFinished() fires once after the final
waypoint.
Methods
Returns the BasePart this controller drives. Pass it to
DynMesh.new(...) to weld decorative geometry
(arms, hats, weapons) so it rides along with the
controller's CFrame. Particles, billboards, and other
attachments work the same way.
local mesh = DynMesh.new(player:BasePart())
mesh:Weld(hat, { Offset = CFrame.new(0, 2, 0) })
Single-waypoint shortcut. target is a Vector
or CFrame. Replaces any active path; the controller drags
in toward the point and stops when its horizontal distance
falls below WaypointThreshold.
Replace the path with a list of waypoints (Vectors or
CFrames). The controller walks them in order, firing
WaypointReached(index) on each arrival and
PathFinished() after the final one. Returns
the stored waypoint count. Note: this is
a deterministic sequencer, not a nav-mesh solver -- supply
already-clear waypoints (or use a separate pathfinder).
enemy:ComputePath({
Vector.new(10, 0, 0),
Vector.new(10, 0, 20),
Vector.new(-5, 0, 20),
})
enemy.WaypointReached:Connect(function(i) print("step", i) end)
Push another waypoint onto the back of the path.
Clear path + motion intent. Horizontal velocity drags to a stop next frame.
Applies JumpPower to vertical velocity. No-op
(returns false) when OnGround is false.
Compatible with path-following: the controller jumps mid-
stride and resumes walking on landing. Returns true if the
jump launched.
Hard-set Position + Rotation, clear velocity and path.
Motion-intent inputs in local space (+z forward, +x right).
Each axis is a unit intensity in -1..1; the
controller blends magnitude through its facing.
MoveForward(0.5) sets z to 0.5 for
the next tick. Combine with a Rotation Y driven from mouse
for full first-/third-person control. Re-call each frame
from your input pump — the intent persists between
ticks until you change or clear it.
Keyboard.InputChanged:Connect(function()
local fz = (Keyboard:IsKeyDown("W") and 1 or 0)
- (Keyboard:IsKeyDown("S") and 1 or 0)
local fx = (Keyboard:IsKeyDown("D") and 1 or 0)
- (Keyboard:IsKeyDown("A") and 1 or 0)
player:SetMoveVector(fx, fz)
end)
Remove from the plane. The BasePart is left at its last CFrame; it is not destroyed.
PhysicsObject
Each plane:Add(part) returns a PhysicsObject. The
object holds simulation state (position, velocity, mass, etc.)
in Rust; field reads and writes go through one mutex lock + a
HashMap lookup, no per-property userdata allocation.
Combined pose (Position + Rotation), mirroring
BasePart.CFrame. Writing teleports the object
instantly — next tick will integrate from the new
pose. Use cf.Position / cf.Rotation
on the read side if you only care about one component.
Linear velocity (units/sec).
Angular velocity per axis (rad/sec).
Persistent per-tick acceleration applied on top of the plane's gravity. Useful for thrusters, wind, or steady-state forces. Set to zero to clear.
World-space force applied just to this object each tick, on
top of the plane's ConstantForce and the
object's ImpulseDirection. Where
ImpulseDirection is per-tick acceleration
(mass-independent), ConstantForce is a true
force, so a heavier object accelerates less from the same
value. Use for per-object wind catch (a sail catches more
than a brick), thruster jets, magnetic pull on a single
body. Default Vector(0, 0, 0).
Inverse mass scaling for :ApplyImpulse. Higher
= heavier (smaller velocity change for the same impulse).
Doesn't affect free-fall (every object accelerates equally
under gravity, as in the real world).
Material density in kg / unit³. When set
non-zero, rapier rebuilds the collider's mass and inertia
tensor as density × volume on the next tick —
density wins over Weight. Setting it back to
0 reverts to the Weight-driven path. Reading
always returns the effective density rapier is using right
now (either the explicit value, or
Weight ÷ current volume). This lets you build
"iron part next to balsa-wood part" scenes without computing
volumes by hand.
-- Heavy iron block
crate.Density = 7800 -- iron
-- ...next to a styrofoam crate
foam.Density = 30 -- styrofoam
-- The two will behave very differently in collision now.
Restitution coefficient used in collisions, clamped to
[0, 1]. 0 = absorb (objects stick), 1 = elastic
(full reflection). The collision pair uses the higher of
the two participants' values.
Coulomb friction coefficient, [0, 1]. The pair
coefficient used when two objects touch is
sqrt(Frictiona · Frictionb),
and the resulting tangential impulse is clamped to
μ · |jn| (the Coulomb cone).
Default 0.5 — wood-on-wood. Lower toward 0 for
slick / icy surfaces; raise toward 1 for grippy rubbery
surfaces. This is the knob that stops parts from
sliding around when they should be at rest.
When true, the object doesn't integrate (no gravity, no
velocity step). Other objects still collide with it —
it acts as static geometry. Setting Anchored = true
zeroes the current Velocity and AngularVelocity so the
object doesn't keep its inertia from before.
When true, this object participates in collision resolution against other CanCollide objects in the same plane. Set false for ghost / trigger / fx-only physics objects that should fall under gravity but pass through everything.
Simulation-fidelity dial for this object, clamped to
[1, 32], default 1. Higher values
cost more CPU but produce visibly tighter contacts. The
dial drives several knobs that compound as it rises:
- Per-object contact skin. The collider's
contact tolerance shrinks roughly as
0.02 / quality. At1the skin is loose (cheap, slightly gappy at corners); at32it tightens to near-zero so cube corners and edges meet faces cleanly instead of sinking in or floating off. - Plane-wide solver scaling. The plane
inspects
max(HitBoxQuality)across its objects and scales solver iterations, friction passes, internal PGS passes, and stabilization passes proportionally — up to ~2.5× more solver work at quality 32 versus quality 1. Stacks settle faster and slide less when they shouldn't. - Tighter penetration tolerance.
allowed_linear_errorandprediction_distanceshrink with quality, andcontact_damping_ratiorises — contacts are stiffer and resolve closer to true geometry. - CCD (continuous collision detection).
Enabled automatically at
HitBoxQuality > 1, so fast-moving objects don't tunnel through thin geometry. - Rotation lock for low-quality cubes.
A non-sphere with
HitBoxQuality < 2and noCenterOfMassoffset has its rotation axes locked at body creation — it translates but does not spin from contacts. Bump quality to2+or set aCenterOfMassto let it tip.
Direct rotation always works regardless of quality.
Setting obj.AngularVelocity, calling
:RotateTo, or assigning a rotated
obj.CFrame integrates through
AngularDamping as usual.
Reserve high values for the few parts that actually need
precision (the player, a bullet, a stacked crate tower);
leave static props at 1. Because the plane
scales with the maximum across all objects, even one
quality-32 part will bump fidelity for the whole world
on that frame.
Read-through to the wrapped BasePart's Size. Setting this also resizes the BasePart and fires its Changed signal (Size affects the AABB used for collision).
Body-local center-of-mass offset from the BasePart's origin.
Default Vector(0, 0, 0) — CoM coincides
with the origin, the object is balanced, and contact-driven
rotation stays suppressed (no spurious rolling). When set
non-zero, the simulator switches this
object to its real box / sphere inertia tensor, and contact
patches that don't sit under the CoM produce tipping torque
under gravity-loaded contact normals.
This is how "weld a heavy part to one side and watch it tip" works in an AABB-only simulation: gravity pulls the CoM down, but the floor pushes back up at the bottom of the AABB which is no longer aligned with the CoM horizontally — the resulting offset force pair generates a proper tipping torque around the support edge.
-- Manual: weld a 1-unit-out chunk on the +Z face
crate.CenterOfMass = Vector.new(0, 0, 1)
-- Auto: derive from the wrapped BasePart's mesh vertices.
-- Useful after a Deform / DynMesh mutation that bulges geometry
-- to one side. Reads vertices, takes the centroid, writes back.
crate:RecalculateCenterOfMass()
True until the object is :Unlinked,
:Destroyed, or its plane / wrapped BasePart is
destroyed.
Velocity-space impulse: velocity += vector / Weight.
Anchored objects absorb impulses without moving. Locked
axes (see :FieldRestraint) are skipped.
Constrain which axes the object is allowed to move / rotate around. Pass any subset of the lock flags — unspecified flags are left as-is. Useful for top-down 2.5D games (lock Y), conveyor parts (lock X+Z), doors (lock everything except one rotation axis), etc.
obj:FieldRestraint({
LockY = true, -- pin to current Y
LockRotX = true, -- no tumbling forward
LockRotZ = true, -- no rolling sideways
})
Available flags:
LockX, LockY, LockZ,
LockRotX, LockRotY, LockRotZ
— all booleans.
Drive the object's angular velocity toward target
(Euler radians) using a proportional controller. Higher
strength snaps faster (default 8.0).
Pair with LockRot* flags to clamp specific
rotation axes that you don't want fighting the controller.
Cancel a previously-started :RotateTo. Existing
angular velocity is left as-is — the controller just
stops correcting.
Pin a grab point on the object to a world-space target with
a clamped spring-damper. The grab point
(relative) is given in object-local coordinates
(centre of the body is vector.create(0, 0, 0));
target is a world CFrame whose
position pulls the grab point in and whose rotation drives a
torque to align the body's orientation with the target's.
force caps the per-step magnitude — light
objects snap to target instantly, heavy ones drag, and an
object the force can't lift won't move at all. Pass
nil to release.
-- Drag a crate towards the cursor's world hit; partial force so heavy
-- crates feel sluggish and the player can be blocked by walls.
crate:SetPin(2000, vector.create(0, 0.5, 0), CFrame.new(hit.Position))
-- VR "soft grab": pin the held object to the controller pose so it
-- still collides with the world (vs. :Attatch which teleports).
crate:SetPin(800, vector.create(0, 0, 0), Right:ToWorldSpace())
-- Release:
crate:SetPin(nil)
Walks the wrapped BasePart's mesh and writes the vertex
centroid into CenterOfMass. Prefers the
deformed mesh (if a :Deform call is active)
and falls back to the source model. Vertex positions are
stored body-local, so the centroid is the body-local CoM
under uniform vertex density. Call this after a DynMesh /
Deform mutation that asymmetrically reshapes
the part — physics tipping behaviour will then match
the new mass distribution. No-op for primitive Cube /
Sphere parts that don't carry a model; for those, set
CenterOfMass directly.
Returns the underlying Renderable.BasePart this object wraps. Useful for grabbing back the visual handle after you've kept only the PhysicsObject reference.
Detach from the plane without destroying the BasePart. The part keeps its last simulated CFrame (frozen in place) and resumes manual control. Use this when you want to hand a part back to script-driven animation after physics finishes a phase.
Detach AND destroy the wrapped BasePart. After this, both the physics object and the BasePart are gone.
GuiPhysicsPlane
A 2D physics world for GUI primitives.
Same shape as PhysicsPlane but in pixel-space:
positions are Dim, gravity is Dim,
bodies wrap GUI primitives (Square,
Circle, Triangle, Image,
Text) instead of BaseParts. Run
alongside the 3D world — they don't interact.
local GUI = import("GUI")
local Physics = import("PhysicsService")
local Dim = import("Primitives").Dim
local plane = Physics.NewGUI{
Gravity = Dim.new(0, 1200),
BoundsMin = Dim.new(0, 0),
BoundsMax = Dim.new(1280, 720),
}
local ground = GUI.Basic.Square()
ground.Size = Dim.new(1280, 40)
ground.Position = Dim.new(640, 700)
plane:Add(ground).Anchored = true
for i = 1, 20 do
local ball = GUI.Basic.Circle()
ball.Size = Dim.new(32, 32)
ball.Position = Dim.new(100 + i * 50, 100)
plane:Add(ball).Bounciness = 0.6
end
ZIndex matches. Gravity, damping, drag and
constant force always apply regardless of layer. So a HUD on
ZIndex = 100 can sit physically inert above a
gameplay layer on ZIndex = 0 even though they
share the screen — exactly like 2D z-sorted UI where higher
layers never overlap lower ones.
Plane options
All fields are optional. PascalCase keys match the existing
New API; semantics mirror their 3D counterparts.
Pixels-per-second² applied to every non-anchored body each
tick. Default Dim(0, 980) (down-positive,
matching the GUI Y axis where +Y is down). Set to
Dim(0, 0) for a zero-gravity puzzle.
Pause / resume integration without destroying anything.
Default true.
Per-second linear velocity decay. velocity *= max(0, 1
- LinearDamping * dt). Default 0.05.
Same idea for angular velocity. Default 0.10.
Quadratic drag coefficient. F = -Drag * |v| * v.
Zero by default. Useful for "underwater" UIs where light
taps drift but big throws fly far.
Force (not acceleration) added to every non-anchored body each tick on top of gravity. A heavy body accelerates less from it. Good for wind, conveyor pushes, magnetic sweep effects.
Speed below which a body's sleep timer starts ticking
(after 0.5s of low speed, the body sleeps and stops
integrating until something wakes it). Default
0.25. Higher values let piles settle
faster; 0 disables sleeping (combined with
NeverSleep for true persistent motion).
How many resolution passes per tick. Default
4 (clamped 1..32). More iterations =
stiffer stacks at the cost of CPU.
Disable sleeping entirely. Useful when you're polling
Velocity from Lua and need it to keep
integrating even at rest.
Construction-only. If both are passed, bodies are clamped
inside the rectangle and the contact reflects via
Bounciness on each side. Omit for an
unbounded playfield.
Live count.
False after :Destroy(). Operations on a dead
plane no-op.
Plane methods
Wrap any GUI primitive
(Square, Circle,
Triangle, Image,
Text) as a physics body. The body inherits
the primitive's Size, Position,
Rotation, and ZIndex at the
moment of the call. Subsequent ticks write back
to the primitive, so anything that watches
:GetPropertyChangedSignal("Position") on the
primitive sees physics updates without further wiring.
Bind a primitive as a player-driven kinematic body. The
controller responds to :Move(direction) /
:Jump() calls. Internally it adds the body
to the plane (you don't need to :Add it
first) with LockedRotation = true so the
sprite doesn't tumble while running.
opts is an optional table:
MoveSpeed— max horizontal speed in px/s. Default240.Acceleration— how quickly velocity snaps to target. Default1800.JumpStrength— upward impulse on:Jump(). Default700.WaypointRadius— "arrived" tolerance for waypoint-mode pathing. Default8.
Same controller, but configured for NPC / AI use:
:Move still works, but you'd typically
drive it via :SetWaypoints /
:MoveTo and let the controller seek along
them on its own.
Cast a ray through the plane. Returns
nil if nothing was hit; otherwise a table
with Distance (number),
Position (Dim), Normal (Dim),
Object (the PhysicsGuiObject).
Z-index does not filter raycasts — they hit any
collidable body in their path, so use it for cursor
hover / click probes across layers.
Manually advance the simulation by dt
seconds. The engine already calls this each frame from
the heart loop — you only need it for paused-game
scrubbing, fixed-step replay systems, or unit tests.
Drop every body and controller, free the plane. Bound primitives freeze at their last simulated position and are otherwise untouched.
PhysicsGuiObject
The 2D sibling of PhysicsObject. Returned
by plane:Add(primitive). Mutating its fields
affects the simulation on the next tick.
Live pixel-space position and velocity. Writing
Position wakes the body; the next
write-back propagates the value to the bound primitive.
Degrees / degrees-per-second. Set
LockedRotation = true to freeze rotation
(default for controllers).
One-shot impulse applied on the next tick, then cleared.
Equivalent to :ApplyImpulse(d).
Per-body force re-applied each tick (in addition to the
plane's ConstantForce + gravity). Good for
jetpacks, hovering platforms, magnetism toward a moving
anchor (set per frame).
Default 1. Used by impulse application and
collision response. Min 0.0001.
0..1. Coefficient of restitution applied on collision.
0 = inelastic, 1 = perfect
bounce. Walls inherit each body's own
Bounciness.
0..4. Tangential friction applied during collision resolution. Pairs take the geometric mean of both bodies' values.
Anchored bodies don't integrate, but other bodies still
collide with them. Use for floors, walls, platforms.
Setting true zeros velocity.
Skip pair resolution entirely. The body still integrates (so gravity / wind still apply) but passes through others. Useful for visual-only debris.
Multiplier on the plane's Gravity for this
body. 0 = body floats, 2 =
falls twice as fast as the rest. Per-body, so heavy
obstacles + floaty pickups in the same plane is fine.
Zero the respective component of velocity each tick. Used by controllers, side-scrollers, slot-machine reels.
Mirrored from the bound primitive each tick. Change the
primitive's Size or ZIndex and
the body picks it up next frame — collision-layer
membership and AABB extents update automatically.
True after the body has been at rest long enough to skip
integration. Wakes automatically on any contact / write
to Position / Velocity /
ImpulseDirection, or manually via
:Wake().
Add impulse / Mass to Velocity
immediately. Wakes the body.
Shorthand for assigning to
ConstantForce. Re-call each frame to
achieve a varying force; clear with
obj:ApplyForce(Dim.new(0, 0)).
Force the body out of sleep so the next tick integrates it.
Drop the body from the plane. The bound primitive is left alive at its last simulated position.
GUIController
The 2D character controller. Returned by
plane:NewController /
plane:NewUncontrolled. Mirrors the
3D Controller's shape: a wrapped body
plus convenience signals and waypoint pathing.
The underlying body. Use it to read
Velocity for animation, set
Anchored to freeze, etc.
Last value passed to :Move(). Each
component is clamped to -1..1 at
integration time; magnitude doesn't matter, direction
does.
Tune at runtime — slow-mo, sprint, jump-buff modifiers are all just field writes.
True if the controller's foot AABB overlaps any
collidable body on the same ZIndex. Updated each tick;
gates jumping (you can only jump while
OnGround).
Fires with the 1-based waypoint index every time the controller arrives at one.
Fires once after the last waypoint is reached. Calling
SetWaypoints again resets the path.
Fires each tick the controller moved, with
(dx, dy) in pixels. Good driver for
footstep audio, run animations.
Fires whenever OnGround flips. Perfect for
landing thuds / jump-anticipation poses.
Set the desired direction. Typically called every frame
from input handling: ctrl:Move(Dim.new(input.x,
0)).
Queue a jump for the next tick. Only consumed if the controller is on the ground — the request is cleared either way.
Replace the path. Array of Dims. The
controller seeks toward each one in order, fires
WaypointReached on arrival, and
PathFinished after the last.
Shorthand for :SetWaypoints({target}).
Drop the current path. The body keeps its current velocity.
Remove the controller and its body from the plane.
Patterns
Per-layer playfields stacked on one screen
-- Gameplay on layer 0, HUD on layer 100. Same plane, same gravity,
-- but the HUD never collides with falling debris.
local world = Physics.NewGUI{ Gravity = Dim.new(0, 800) }
for _, sprite in debris do
sprite.ZIndex = 0
world:Add(sprite)
end
for _, sprite in hud_pieces do
sprite.ZIndex = 100
world:Add(sprite).GravityScale = 0 -- and float
end
Cursor-drag a UI element
local Mouse = import("Mouse")
local obj = world:Add(card)
Heart:Connect(function()
if Mouse.GetButtonState("Left") then
obj.Anchored = true
obj.Position = Dim.new(Mouse.X, Mouse.Y)
else
if obj.Anchored then
obj.Anchored = false
obj:ApplyImpulse(Dim.new(Mouse.DeltaX * 60, Mouse.DeltaY * 60))
end
end
end)
NPC chasing waypoints around a HUD
local enemy = GUI.Basic.Square()
enemy.Size = Dim.new(32, 32)
local npc = plane:NewUncontrolled(enemy, { MoveSpeed = 160 })
npc:SetWaypoints{
Dim.new(100, 300),
Dim.new(800, 300),
Dim.new(800, 500),
Dim.new(100, 500),
}
npc.PathFinished:Connect(function()
npc:SetWaypoints(npc:GetWaypoints()) -- loop
end)
GravityScale = 0 + ConstantForce
if you want floaty behaviour).
Lifecycle & teardown
- BasePart destroyed externally — next physics
tick we detect
!part.alive, drop the PhysicsObject, and continue. No dangling refs. - PhysicsObject:Destroy() — removes the object from the plane and destroys the wrapped BasePart. Lua references to the BasePart raise on access after this.
- PhysicsObject:Unlink() — same as Destroy but leaves the BasePart alive at its last simulated CFrame. The PhysicsObject userdata becomes inert.
- PhysicsPlane:Destroy() — clears every physics override and drops the plane. Wrapped BaseParts freeze at their last simulated CFrame, untouched otherwise.
Multithreading
Every plane has its own optional rayon::ThreadPool
sized by opts.Threads. Inside a single tick, the work
splits into three phases:
- Bookkeeping — reap dead objects, refresh
per-object
cached_size+part_aliveflags. Single-threaded under the plane lock. - Integration + collision — sub-stepped at
max(HitBoxQuality). Integration parallelizes across objects via the rayon pool when there are ≥ 64 objects; collision resolution stays single-threaded (cheap pairwise AABB sweep). - Commit — every override cell is updated from its object's final position+rotation. Parallelizes across the rayon pool too.
Multiple planes run sequentially in the heart loop, so two planes don't share threads — if you want parallel planes, give each its own pool and they'll pipeline work onto distinct cores.
Patterns
Listening to physics-driven CFrame changes
The post-tick pass fires per-property change signals on every
wrapped BasePart, but only when listeners exist (probed via
signal._conns). So a plain
part:GetPropertyChanged("CFrame") works from any
Lua-side script without further setup:
local obj = world:Add(ball)
ball:GetPropertyChanged("CFrame"):Connect(function(cf)
print("ball at", cf.Position)
end)
When the last connection disconnects, the per-tick fire drops to a fast empty-listener probe and pays no Lua-side cost.
Mixed-mode parts (physics + manual)
Toggle obj.Anchored on a part to freeze its physics
while you animate it from script (e.g. for a pickup tween), then
flip back to false and the simulation resumes from the new
position. :Unlink() + manual control is an
alternative if you don't expect to re-physics that part.
Size. Spheres are treated as their bounding
box. For a more capable collider system (capsules, OBBs, mesh
triangles) keep an eye on future releases.