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

PhysicsService.New(opts) -> PhysicsPlane

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:

  • GravityVector applied as acceleration to every non-anchored object (units/sec²). Defaults to Vector(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 to 1 (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 does velocity *= max(0, 1 - LinearDamping * dt). Default 0.10. Higher values make objects slow down sooner (think air resistance).
  • AngularDamping — same idea for rotation. Default 0.30 — kills tumbling fairly quickly so visually a stacked pile doesn't look like it's vibrating.
  • Dragquadratic drag coefficient. Each tick, every non-anchored body has F = -Drag * |v| * v applied as an extra force on top of the normal integration. Unlike LinearDamping (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. Default 0.0. Reasonable underwater values are around 1.04.0.
  • Buoyancy — constant upward acceleration applied per non-anchored body each tick. Positive values counteract gravity (set to about -Gravity.Y for neutral float; lower for "weighed-down sinking"; higher and things rise). Negative values pull down harder than gravity. Default 0.0.
  • ConstantForceVector, 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. Default Vector(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 by Bounciness. Default 0.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, default 4. Number of constraint-solver iterations rapier runs each tick. Higher = better convergence on dense piles, more CPU. Ignored when LoopSolver is true.
  • LoopSolver — boolean, default false. 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 of SolverIterations; pay a modest extra per-tick cost in exchange.
  • NeverSleep — boolean, default false. 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 = true disables 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.
  • DeferGpuboolean, default false. 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.
    Requires the wgpu device to be initialised (a Window has been opened). Collision in the shader is O(N²); a GPU spatial-hash broadphase isn't built yet.
PhysicsService.NewGUI(opts) -> GuiPhysicsPlane

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

plane.Gravity Vector

Acceleration applied to every non-anchored object every tick. Setting it takes effect on the next tick.

plane.Enabled boolean

When false, the plane skips integration + collision — objects freeze in place, BaseParts hold their current CFrame. Defaults to true.

plane.Threads number (read-only)

Number of rayon threads simulating this plane (the value you passed in opts.Threads, or 1 if omitted).

plane.LinearDamping number

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).

plane.AngularDamping number

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.

plane.Drag number new in 1.2

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.04.0 for underwater feel.

plane.Buoyancy number new in 1.2

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.

plane.ConstantForce Vector new in 1.2.2

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.

plane.RestThreshold number

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.

plane.SolverIterations number new in 1.1

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.

plane.NeverSleep boolean new in 1.1

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).

plane.LoopSolver boolean new in 1.1

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.

plane.DeferGpu boolean new in 1.1

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.

plane.Alive boolean (read-only)

False after :Destroy().

plane.ObjectCount number (read-only)

Live count of PhysicsObjects in this plane. Drops on :Unlink(), :Destroy(), or when a wrapped BasePart is destroyed externally.

plane:Add(part) -> PhysicsObject method

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.

plane:SetEnabled(on) method

Equivalent to setting plane.Enabled = on.

plane:Step(dt) method

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.

plane:NewController(part, opts?) -> Controller method

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.

plane:NewControlledController(part, opts?) -> Controller method

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.

plane:Destroy() method

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

.Position / .Velocity / .RotationVector

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).

.WalkSpeed / .JumpPower / .TurnSpeednumber

Top horizontal speed (studs/sec), jump launch velocity (studs/sec), and yaw blending rate (radians/sec) when LookWhereMoving is on.

.Gravity / .UsePlaneGravitynumber / boolean

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.

.OnGroundbooleanread-only

Set by the engine each tick. Tracks whether the foot capsule probe hit anything below it (rapier hit or GroundY fallback).

.LookWhereMovingboolean

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.

.Controlledbooleanread-only

True for ControlledController, false otherwise.

.PathLength / .PathIndexnumberread-only
.Jumped / .Landed / .Moved / .WaypointReached / .PathFinishedSignal

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

controller:BasePart()-> BasePartmethod

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) })
controller:WalkTo(target)method

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.

controller:ComputePath(waypoints)-> numbermethod

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)
controller:AppendWaypoint(target)method

Push another waypoint onto the back of the path.

controller:StopWalking()method

Clear path + motion intent. Horizontal velocity drags to a stop next frame.

controller:Jump()-> booleanmethod

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.

controller:Teleport(target)method

Hard-set Position + Rotation, clear velocity and path.

controller:SetMoveVector(x, z) / MoveForward / MoveBackward / MoveLeft / MoveRight / ClearMoveIntentmethod

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)
controller:Destroy()method

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.

obj.CFrame CFrame

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.

obj.Velocity Vector

Linear velocity (units/sec).

obj.AngularVelocity Vector

Angular velocity per axis (rad/sec).

obj.ImpulseDirection Vector

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.

obj.ConstantForce Vector new in 1.2.2

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).

obj.Weight number

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).

obj.Density number new in 1.1

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.
obj.Bounciness number

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.

obj.Friction number new in 1.1

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.

obj.Anchored boolean

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.

obj.CanCollide boolean

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.

obj.HitBoxQuality number

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. At 1 the skin is loose (cheap, slightly gappy at corners); at 32 it 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_error and prediction_distance shrink with quality, and contact_damping_ratio rises — 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 < 2 and no CenterOfMass offset has its rotation axes locked at body creation — it translates but does not spin from contacts. Bump quality to 2+ or set a CenterOfMass to 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.

obj.Size Vector

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).

obj.CenterOfMass Vector new in 1.1

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()
obj.Alive boolean (read-only)

True until the object is :Unlinked, :Destroyed, or its plane / wrapped BasePart is destroyed.

obj:ApplyImpulse(vector) method

Velocity-space impulse: velocity += vector / Weight. Anchored objects absorb impulses without moving. Locked axes (see :FieldRestraint) are skipped.

obj:FieldRestraint(opts) method

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.

obj:RotateTo(target, strength) method

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.

obj:StopRotateTo() method

Cancel a previously-started :RotateTo. Existing angular velocity is left as-is — the controller just stops correcting.

obj:SetPin(force, relative, target) method

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)
obj:RecalculateCenterOfMass() method new in 1.1

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.

obj:BasePart() -> BasePart method

Returns the underlying Renderable.BasePart this object wraps. Useful for grabbing back the visual handle after you've kept only the PhysicsObject reference.

obj:Unlink() method

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.

obj:Destroy() method

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
Z-index is the collision layer. Two bodies collide only if their bound primitive's 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.

plane.Gravity Dim

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.

plane.Enabled boolean

Pause / resume integration without destroying anything. Default true.

plane.LinearDamping number

Per-second linear velocity decay. velocity *= max(0, 1 - LinearDamping * dt). Default 0.05.

plane.AngularDamping number

Same idea for angular velocity. Default 0.10.

plane.Drag number

Quadratic drag coefficient. F = -Drag * |v| * v. Zero by default. Useful for "underwater" UIs where light taps drift but big throws fly far.

plane.ConstantForce Dim

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.

plane.RestThreshold number

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).

plane.SolverIterations number

How many resolution passes per tick. Default 4 (clamped 1..32). More iterations = stiffer stacks at the cost of CPU.

plane.NeverSleep boolean

Disable sleeping entirely. Useful when you're polling Velocity from Lua and need it to keep integrating even at rest.

opts.BoundsMin / opts.BoundsMax Dim

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.

plane.ObjectCount number read-only

Live count.

plane.Alive boolean read-only

False after :Destroy(). Operations on a dead plane no-op.

Plane methods

plane:Add(primitive) -> PhysicsGuiObject method

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.

plane:NewController(primitive, opts?) -> GUIController method

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. Default 240.
  • Acceleration — how quickly velocity snaps to target. Default 1800.
  • JumpStrength — upward impulse on :Jump(). Default 700.
  • WaypointRadius — "arrived" tolerance for waypoint-mode pathing. Default 8.
plane:NewUncontrolled(primitive, opts?) -> GUIController method

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.

plane:Raycast(from, dir, maxDistance?) -> hit? method

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.

plane:Step(dt) method

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.

plane:Destroy() method

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.

obj.Position / obj.Velocity Dim

Live pixel-space position and velocity. Writing Position wakes the body; the next write-back propagates the value to the bound primitive.

obj.Rotation / obj.AngularVelocity number

Degrees / degrees-per-second. Set LockedRotation = true to freeze rotation (default for controllers).

obj.ImpulseDirection Dim

One-shot impulse applied on the next tick, then cleared. Equivalent to :ApplyImpulse(d).

obj.ConstantForce Dim

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).

obj.Mass number

Default 1. Used by impulse application and collision response. Min 0.0001.

obj.Bounciness number

0..1. Coefficient of restitution applied on collision. 0 = inelastic, 1 = perfect bounce. Walls inherit each body's own Bounciness.

obj.Friction number

0..4. Tangential friction applied during collision resolution. Pairs take the geometric mean of both bodies' values.

obj.Anchored boolean

Anchored bodies don't integrate, but other bodies still collide with them. Use for floors, walls, platforms. Setting true zeros velocity.

obj.CanCollide boolean

Skip pair resolution entirely. The body still integrates (so gravity / wind still apply) but passes through others. Useful for visual-only debris.

obj.GravityScale number

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.

obj.LockedX / obj.LockedY / obj.LockedRotation boolean

Zero the respective component of velocity each tick. Used by controllers, side-scrollers, slot-machine reels.

obj.ZIndex / obj.Size / obj.Shape read-only

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.

obj.Sleeping boolean read-only

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().

obj:ApplyImpulse(impulse) method

Add impulse / Mass to Velocity immediately. Wakes the body.

obj:ApplyForce(force) method

Shorthand for assigning to ConstantForce. Re-call each frame to achieve a varying force; clear with obj:ApplyForce(Dim.new(0, 0)).

obj:Wake() method

Force the body out of sleep so the next tick integrates it.

obj:Destroy() method

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.

ctrl.Object PhysicsGuiObject read-only

The underlying body. Use it to read Velocity for animation, set Anchored to freeze, etc.

ctrl.MoveDirection Dim read-only

Last value passed to :Move(). Each component is clamped to -1..1 at integration time; magnitude doesn't matter, direction does.

ctrl.MoveSpeed / ctrl.Acceleration / ctrl.JumpStrength number

Tune at runtime — slow-mo, sprint, jump-buff modifiers are all just field writes.

ctrl.OnGround boolean read-only

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).

ctrl.WaypointReached Signal<number> event

Fires with the 1-based waypoint index every time the controller arrives at one.

ctrl.PathFinished Signal<> event

Fires once after the last waypoint is reached. Calling SetWaypoints again resets the path.

ctrl.Moved Signal<number, number> event

Fires each tick the controller moved, with (dx, dy) in pixels. Good driver for footstep audio, run animations.

ctrl.GroundChanged Signal<boolean> event

Fires whenever OnGround flips. Perfect for landing thuds / jump-anticipation poses.

ctrl:Move(direction) method

Set the desired direction. Typically called every frame from input handling: ctrl:Move(Dim.new(input.x, 0)).

ctrl:Jump() method

Queue a jump for the next tick. Only consumed if the controller is on the ground — the request is cleared either way.

ctrl:SetWaypoints(points) method

Replace the path. Array of Dims. The controller seeks toward each one in order, fires WaypointReached on arrival, and PathFinished after the last.

ctrl:MoveTo(target) method

Shorthand for :SetWaypoints({target}).

ctrl:ClearWaypoints() method

Drop the current path. The body keeps its current velocity.

ctrl:Destroy() method

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)
Differences from 3D physics worth knowing. No rapier dependency — the 2D path is a self-contained AABB + circle solver with impulse-based resolution and a single rayon-free integration loop. That keeps startup cheap and keeps the GUI thread the only one touching primitive state. No GPU deferral, no per-plane thread pool, no buoyancy field (use GravityScale = 0 + ConstantForce if you want floaty behaviour).

Lifecycle & teardown

Multithreading

Every plane has its own optional rayon::ThreadPool sized by opts.Threads. Inside a single tick, the work splits into three phases:

  1. Bookkeeping — reap dead objects, refresh per-object cached_size + part_alive flags. Single-threaded under the plane lock.
  2. 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).
  3. 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.

Collision shapes. The current solver only handles AABB collisions derived from each BasePart's Size. Spheres are treated as their bounding box. For a more capable collider system (capsules, OBBs, mesh triangles) keep an eye on future releases.