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.
-- CLIENTlocal 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 foundlocal Handles = Bones:bindCharacter(Players.LocalPlayer.Character, { Damping = 0.92, -- floppiness Stiffness = 0.1, -- snap-back-to-pose})Mental model
Section titled “Mental model”One solver, three writers
Section titled “One solver, three writers”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.
Flat arrays, one pass
Section titled “Flat arrays, one pass”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:
- Integrate — verlet:
Velocity = (Current - Previous) * Damping, thenCurrent += Velocity + (Gravity * GravityScale + Wind * WindScale) * dt². - Stiffness — lerp toward the animated pose by
Stiffness(this is pose-hold strength, not springiness). - Constrain — an inextensible link to the parent restores
RestLengthexactly. 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.
Fixed timestep with a hitch clamp
Section titled “Fixed timestep with a hitch clamp”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.
Writing through Transform
Section titled “Writing through Transform”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.
Culling, budget, and sleep
Section titled “Culling, budget, and sleep”Eligibility is computed once per frame and reused across substeps:
- A chain simulates when its root is within
MaxDistanceof the camera (or when yourShouldSimulateoverride says so — the override replaces the distance check entirely). - Past the
MaxChainsbudget, 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.
Teleport guard
Section titled “Teleport guard”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.
Lifecycle
Section titled “Lifecycle”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.
Bind a character
Section titled “Bind a character”-- CLIENTlocal 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.LocalPlayerif Player.Character then bindCharacter(Player.Character)endPlayer.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.
Single meshes and part chains
Section titled “Single meshes and part chains”-- A skinned cape: heavier than hairBones: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 directlyBones: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.
Boneless accessories
Section titled “Boneless accessories”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 pendulum model
Section titled “The pendulum model”The accessory is one verlet bob point hinged at the joint’s world position:
- 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.
- 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. - 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.
- 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. - 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.
Manual stepping
Section titled “Manual stepping”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 orderingend)SkipLoop = true skips the built-in Heartbeat connection. The spec suite drives the solver exactly this way.
Configuration
Section titled “Configuration”Options — BonePhysics.attach(options)
Section titled “Options — BonePhysics.attach(options)”| 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 |
AccessorySettings — per bindAccessory
Section titled “AccessorySettings — per bindAccessory”| 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 |
DeviceBench budgets
Section titled “DeviceBench budgets”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.
API reference
Section titled “API reference”| 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) |
Reading Stats
Section titled “Reading Stats”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.
Gotchas
Section titled “Gotchas”- Attach on the client. Bones are visual; a server-side sim burns CPU with no observable effect. The
MaxDistance/MaxChainsdevice-profile defaults only apply on clients anyway. ShouldSimulatereplaces the distance check — it does not combine withMaxDistance. If you override it, do your own distance math.- Wind is per-sim, scales per-chain. One sampler feeds every chain; use
WindScale(orGravityScale) inChainSettingsfor per-chain character. destroy()is exact restitution. Transforms return to identity, accessory joints return to their original offsets, swappedWeldConstraints are re-enabled. The spec asserts byte-exactC0restoration.Destroyingis deferred. Under deferred signal behavior,Stats.Bounddrops one task-step after the root is destroyed, not synchronously.