Skip to content

BonePhysics

BonePhysics is verlet-chain secondary motion for hair, capes, and tails. The defining decision is a fixed-timestep solver that writes poses through Bone.Transform — the animation-system slot — never WorldCFrame. Fixed steps mean no frame-rate-dependent droop (a 30 fps phone and a 240 fps desktop settle into the same sag), and Transform writes mean the skeleton is handed back exactly as it was found when a handle is destroyed.

Bones are visual only. Server-side simulation has no observable effect, so the sim attaches on the client — where the frames are.

-- CLIENT
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local BonePhysics = require(ReplicatedStorage.ChloeKernel.BonePhysics)
local Bones = BonePhysics.attach({ MaxDistance = 100 })
-- Scan a character for boned parts (layered hair, capes) AND boneless
-- accessories (classic catalog hair) and bind everything found
local Handles = Bones:bindCharacter(Players.LocalPlayer.Character, {
Damping = 0.92, -- floppiness
Stiffness = 0.1, -- snap-back-to-pose
})

A single simulation (attach returns it) steps every bound handle from one Heartbeat connection. Three binding paths share the solver:

Path Target Writes through
bind(root) Every Bone hierarchy under a BasePart Bone.Transform
bindParts(parts) A chain of plain parts (stud-style tails) BasePart.CFrame
bindAccessory(accessory) Boneless accessories, modeled as a swing-clamped pendulum The joint that holds the accessory on

bindCharacter is the bulk scan: it binds every descendant part that carries bones, then binds each remaining boneless Accessory as a pendulum. HairKit sits upstream of the first path — it gives boneless hair a real skeleton so it can sway per-vertex instead of as a rigid pendulum.

Each bound root gets one handle holding flat parallel arrays (Position, PrevPosition, RestCFrame, RestLength, AnimatedWorld, EffectiveWorld) — no per-bone objects. Nodes are ordered parents-before-children, so a single in-order pass per substep does everything:

  1. Integrate — verlet: Velocity = (Current - Previous) * Damping, then Current += Velocity + (Gravity * GravityScale + Wind * WindScale) * dt².
  2. Stiffness — lerp toward the animated pose by Stiffness (this is pose-hold strength, not springiness).
  3. Constrain — an inextensible link to the parent restores RestLength exactly. Parents solve first, so the correction propagates down the chain in the same pass.

Chain roots (Parent == 0) are pinned: they copy the animated pose every step. The rig owns chain roots; physics only swings the children. The spec verifies segment lengths hold within 0.05 studs while the chain sags.

step(dt) feeds an accumulator that drains in 1 / FixedHz slices (default 60 Hz). The accumulator is clamped to FixedStep * MaxSubsteps (default 3), so a long hitch simulates at most 3 substeps instead of spiraling. Poses are written once per frame after all substeps, not per substep.

WorldCFrame writes would permanently mutate a bone’s rest CFrame. Instead the solver computes each node’s world pose — the animated rotation swung by the shortest arc from the animated segment direction to the simulated one — and writes Transform = RestWorld:Inverse() * WorldCFrame, composed against the parent’s already-written world. Parents-first order keeps every child exact, and destroy() restores every Transform to CFrame.identity.

Eligibility is computed once per frame and reused across substeps:

  • A chain simulates when its root is within MaxDistance of the camera (or when your ShouldSimulate override says so — the override replaces the distance check entirely).
  • Past the MaxChains budget, active chains sort by distance to the camera and the nearest chains win; the rest sleep.
  • Sleeping chains freeze — no integration, no writes, holding their last pose. On wake they snap to the animated pose rather than integrating from stale positions (a chain that slept 30 seconds ago should not whip across the distance its root traveled since).

Accessory pendulums are outside the MaxChains budget — they are one bob point each, so they only distance-cull.

If a root moves more than 50 studs in one step (TeleportDistance, not configurable), the chain snaps to the animated pose instead of integrating the jump. Respawns and teleports do not whip chains across the map.

Every connection lives on the handle. Roots auto-unbind on Destroying, handle:destroy() restores bone Transforms (or the accessory’s original joint offsets), and sim:destroy() tears down everything including the Heartbeat loop. Stepping after a destroy is a no-op, not an error.

-- CLIENT
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local BonePhysics = require(ReplicatedStorage.ChloeKernel.BonePhysics)
local Bones = BonePhysics.attach({
AmbientWind = Vector3.new(0, 0, 6), -- procedural gusting breeze, no wiring
})
local function bindCharacter(character: Model)
Bones:bindCharacter(character)
end
local Player = Players.LocalPlayer
if Player.Character then
bindCharacter(Player.Character)
end
Player.CharacterAdded:Connect(bindCharacter)

AmbientWind is direction times strength. Layered sines gust it between roughly 0.1x and 1x, so hair drifts and capes breathe with zero extra code. For authored wind, pass Wind = function() return WindVector end instead — it is sampled once per step() call and reused across that frame’s substeps.

-- A skinned cape: heavier than hair
Bones:bind(CapeMeshPart, { GravityScale = 1.4, Stiffness = 0.06 })
-- A stud-style tail from plain parts. Parts[1] is the pin — weld it to the
-- character; the rest should be anchored and non-colliding, the solver
-- writes their CFrames directly
Bones:bindParts({ TailRoot, Segment1, Segment2, Segment3 }, {
Stiffness = 0.05,
Damping = 0.85,
})

For hanging things — tails, chains, lanterns — bind with Stiffness = 0 and let gravity own them. Stiffness is pull toward the animated pose, not springiness; at Stiffness = 1 the chain pins to the pose exactly and merely tracks root motion.

local Bound = Bones:bindAccessory(HairAccessory, {
Stiffness = 0.08,
MaxSwingDegrees = 25,
})

Most catalog hair has no bones. bindAccessory (run automatically by bindCharacter for accessories whose Handle carries no bones) models the accessory as a pendulum bob hinged at its attachment joint, clamps the swing to MaxSwingDegrees, and steers whichever joint kind holds it on:

Joint kind How it is steered On destroy
Weld / Motor6D The handle-side C0/C1 offset rotates Both offsets restored exactly
RigidConstraint The handle-side Attachment.CFrame rotates — nothing is swapped or disabled; the hinge is the real attachment Attachment CFrame restored exactly
WeldConstraint No offset exists to rotate, so it is swapped for an equivalent local Weld named CKSwayWeld The weld is destroyed and the original constraint re-enabled

An accessory with no Handle or no steerable joint returns nil — refused, never half-bound. If a joint write ever throws (the joint was destroyed externally), the handle destroys itself.

The accessory is one verlet bob point hinged at the joint’s world position:

  1. The worn pose derives live from the joint’s world frame and the original handle-side offset — so the pendulum follows animation, not a cached snapshot.
  2. The rest lever is the vector from the hinge to the worn handle center. A handle centered on its joint (lever under 0.1 studs) gets a synthetic lever hung off its lower half (-UpVector * 0.75) so there is always something to swing.
  3. The bob integrates with the same verlet step as chain nodes (damping, gravity, wind, stiffness lerp to the rest bob), then re-projects to the lever length.
  4. The swing angle from rest is clamped to MaxSwingDegrees — the spec drives a bound accessory with 80-stud wind for a full second and asserts the total rotation never exceeds the clamp.
  5. The whole worn pose rotates about the hinge by the clamped swing, and the handle-side offset is written so the joint itself holds the swung pose. Nothing fights the rig; removing the write restores the accessory exactly.

GravityScale defaults to 0.4 here (versus 1 for chains) — a welded accessory should sway near its worn pose, not hang off it.

local RunService = game:GetService("RunService")
local Sim = BonePhysics.attach({ SkipLoop = true })
Sim:bind(HairPart)
RunService.PreRender:Connect(function(dt: number)
Sim:step(dt) -- your loop, your ordering
end)

SkipLoop = true skips the built-in Heartbeat connection. The spec suite drives the solver exactly this way.

Option Type Default Meaning
Gravity Vector3 (0, -30, 0) World gravity applied to every chain
Wind () -> Vector3 nil Wind sampler, called once per step()
AmbientWind Vector3 nil Procedural gusting breeze used when Wind is nil. Vector = direction * strength; gusts between ~0.1x and 1x
FixedHz number 60 Solver rate
MaxSubsteps number 3 Hitch clamp — most substeps per frame
MaxDistance number device profile / 100 Camera cull distance in studs
MaxChains number device profile / 20 Simultaneous chain budget. Nearest chains win
ShouldSimulate (root: BasePart) -> boolean nil Cull override — replaces the distance check
SkipLoop boolean false Skip the Heartbeat loop; call step() yourself

ChainSettings — per bind / bindParts / bindCharacter

Section titled “ChainSettings — per bind / bindParts / bindCharacter”
Setting Default Meaning
Damping 0.92 Velocity kept per step. Lower = floppier settling
Stiffness 0.1 0..1 pull toward the animated pose per step. 0 = gravity owns it, 1 = pinned to the pose
GravityScale 1 Multiplier on sim gravity
WindScale 1 Multiplier on sim wind
Setting Default Meaning
Damping 0.9 Velocity kept per step
Stiffness 0.08 Pull back to the worn pose
GravityScale 0.4 Scaled down so welded accessories sway near the worn pose instead of hanging off it
WindScale 1 Multiplier on sim wind
MaxSwingDegrees 25 Rotation clamp preventing full flips

On clients, unconfigured MaxDistance and MaxChains default from the DeviceBench device profile — the bench runs once and both knobs are funded by the Compute axis:

Knob Profile field Mid-range base Scaling
MaxChains BoneChains 10 max(round(10 * Compute), 1) — roughly 3 on a floor-quality device to 40 on a top one
MaxDistance BoneDistance 80 studs round(80 * Compute) — roughly 20 to 320 studs

Off-client (or if DeviceBench is unavailable) the static defaults are MaxChains = 20, MaxDistance = 100. Past the budget, chains beyond the nearest MaxChains sleep frozen at their last pose and wake with a snap to the animated pose — the spec verifies that raising MaxChains at runtime wakes sleeping chains on the next step.

Member Description
BonePhysics.attach(options: Options?): BonePhysics Creates a simulation. Connects a Heartbeat stepper unless SkipLoop
sim:bind(root: BasePart, settings: ChainSettings?): Handle? Simulates every Bone hierarchy under root. Returns nil when the part carries no bones — a no-op, not a dead handle
sim:bindParts(parts: { BasePart }, settings: ChainSettings?): Handle Chain of parts. parts[1] is the pin; asserts at least a pin and one link
sim:bindAccessory(accessory: Accessory, settings: AccessorySettings?): Handle? Pendulum sway for a boneless accessory. nil when there is no Handle or steerable joint
sim:bindCharacter(character: Model, settings: ChainSettings?, accessorySettings: AccessorySettings?): { Handle } Binds every boned part plus every boneless accessory. Returns all handles
sim:step(dt: number) Advances the accumulator and simulates due substeps. Called for you unless SkipLoop
sim:destroy() Destroys every handle and disconnects the loop
sim.Stats { Bound: number, Simulated: number } — bound handles, and handle-steps simulated last frame
handle:destroy() Unbinds. Restores bone Transforms to CFrame.identity (bone chains) or the original joint state (accessories)

Stats.Bound counts live handles of every kind. Stats.Simulated counts handle-steps in the last frame — one chain stepping three substeps after a hitch counts 3, and a culled frame counts 0. Watch it when tuning MaxChains: a steady value near MaxChains * (FixedHz / frame rate) means the budget is saturated and distant chains are sleeping.

  • Attach on the client. Bones are visual; a server-side sim burns CPU with no observable effect. The MaxDistance/MaxChains device-profile defaults only apply on clients anyway.
  • ShouldSimulate replaces the distance check — it does not combine with MaxDistance. If you override it, do your own distance math.
  • Wind is per-sim, scales per-chain. One sampler feeds every chain; use WindScale (or GravityScale) in ChainSettings for per-chain character.
  • destroy() is exact restitution. Transforms return to identity, accessory joints return to their original offsets, swapped WeldConstraints are re-enabled. The spec asserts byte-exact C0 restoration.
  • Destroying is deferred. Under deferred signal behavior, Stats.Bound drops one task-step after the root is destroyed, not synchronously.