Gait
Gait keeps large or non-humanoid rigs — quadrupeds, creatures, anything whose feet need to actually touch ground rather than glide through a flat animation cycle — from skating and clipping over uneven terrain. A flat-rate walk cycle assumes flat ground; Roblox’s engine IK doesn’t reach across multi-legged, non-humanoid chains on its own. Gait conforms the rig to the ground it’s actually standing on: each leg down-casts from a look-ahead point and drives an AnimKit Reach IK onto the hit, while the root joint’s Transform carries a smoothed body-height offset and a velocity-derived tilt on top of the keyframed pose. Everything is client cosmetic — no server involvement, no wire cost.
local Walker = Gait.bind(animRig, { Legs = { { ChainRoot = model.LeftHip, EndEffector = model.LeftFoot }, { ChainRoot = model.RightHip, EndEffector = model.RightFoot }, },})Mental model
Section titled “Mental model”Per-leg look-ahead casts
Section titled “Per-leg look-ahead casts”Each frame, every leg casts downward not from where its foot is, but from where the foot is about to be:
local Lead = Velocity * self.LookAhead...local Origin = Effector.Position + Lead + Vector3.new(0, RayHeight, 0)local Hit = workspace:Raycast(Origin, Vector3.new(0, -(RayLength + RayHeight), 0), self.Params)Velocity is the rig root’s AssemblyLinearVelocity; LookAhead (default 0.15 seconds) scales it into a lead offset added to the foot’s current position. The ray then drops from RayHeight studs (default 4) above that lead point down to RayLength studs (default 12) below it — 16 studs of total cast depth by default. This matters specifically because of latency between deciding to plant and actually planting: a leg reaching for ground at its current position is always reaching for where the foot already was by the time the IK solves and the animation advances. At speed, that’s the difference between a foot planting mid-stride versus planting behind it, dragging or skating. Projecting the cast ahead by the rig’s own velocity means the ground sample is already where the foot is heading, so the plant lands in the stride instead of behind it.
Reach IK plant and lift
Section titled “Reach IK plant and lift”Each leg owns one AnimKit Reach-type IK control (Type = "Reach", which maps to IKControlType.Position), built once at bind() with ChainRoot/EndEffector from its LegConfig and an initial Weight = 0. Every step, a hit repositions the control’s target attachment (Leg.Target.WorldPosition = Hit.Position) and, on the transition into a hit from no hit, sets the control’s weight to 1 — the animated pose stops driving that limb and the IK takes over, reaching for the actual ground. On the transition out (no hit where there was one), the weight goes back to 0 and the animation regains the limb rather than the leg stretching indefinitely toward a stale hit point:
elseif Leg.Planted then -- Airborne: the animation owns the limb again Leg.Planted = false Leg.Control:setWeight(0, self.PlantSmoothing)endBoth setWeight calls now pass self.PlantSmoothing as the fade-seconds argument, so — per AnimKit’s fade engine — the weight change blends over that many seconds instead of snapping. The PlantSmoothing option (default 0.1) does double duty: it’s still passed as the FadeIn for the control’s one-time creation in bind() (a no-op there, since creation always targets Weight = 0 and fading from 0 to 0 has no visible effect), but it now also drives the real fade on every plant and lift transition thereafter. That’s the option’s actual point of effect — tune it there, not at creation.
An airborne leg (no ground within the cast) does not stretch or snap to a distant point — because the weight drops to 0, the animated keyframes simply resume driving that limb until the next hit.
Body height: smoothed, not springy
Section titled “Body height: smoothed, not springy”heightOffset is a pure average-and-clamp over each currently-planted leg’s vertical plant delta:
function Gait.heightOffset(plantDeltas: { number }, maxOffset: number): number if #plantDeltas == 0 then return 0 end local Sum = 0 for _, Delta in plantDeltas do Sum += Delta end return math.clamp(Sum / #plantDeltas, -maxOffset, maxOffset)endEach leg’s delta is (Hit.Position.Y - Root.Position.Y) - Leg.NeutralY, where NeutralY is that leg’s rest-pose foot height relative to the root, captured once at bind(). So a delta of zero means “this foot is exactly where the neutral pose expects it,” and a positive or negative delta means the ground under that foot is higher or lower than the rest pose assumes. Averaging across every planted leg (not just one) means one foot catching a rock doesn’t yank the whole body — it’s the group’s mean that moves the hips, clamped to MaxHeightOffset (default 6 studs) either direction.
The smoothing itself is a simple per-frame exponential approach toward that target, not a spring — no overshoot, no ringing:
local HeightBlend = math.min(dt * self.HeightRate, 1)self.Height += (TargetHeight - self.Height) * HeightBlendHeightRate (default 6, in 1/s) sets how fast Height chases TargetHeight; a higher rate closes the gap faster. The body dips or rises smoothly across a bump rather than snapping to each frame’s raw average.
Tilt: anticipatory, not reactive
Section titled “Tilt: anticipatory, not reactive”tiltFor derives bank (lateral lean) and pitch (fore/aft lean) from this frame’s acceleration — the velocity delta divided by dt — not from the rig’s heading or position path:
function Gait.tiltFor(velocity, lastVelocity, facing, dt, bankPer, pitchPer, max): (number, number) if dt <= 0 then return 0, 0 end local Acceleration = (velocity - lastVelocity) / dt local Flat = Vector3.new(facing.X, 0, facing.Z) if Flat.Magnitude < 1e-4 then return 0, 0 end local Forward = Flat.Unit local Right = Forward:Cross(Vector3.yAxis) local Lateral = Acceleration:Dot(Right) local Ahead = Acceleration:Dot(Forward) local Bank = math.clamp(Lateral * bankPer, -max, max) local Pitch = math.clamp(-Ahead * pitchPer, -max, max) return Bank, PitchendThis is the precise sense in which the changelog’s “the dragon leans into the pivot before the track shifts” holds: acceleration is the cause of a heading or speed change, and position/heading only bend as a consequence of velocity having already changed. Because tiltFor reads acceleration directly every frame rather than deriving lean from how the root’s translation has already curved, the lean appears the instant velocity starts changing — the same frame a turn is initiated — rather than lagging behind until the body’s actual path visibly bends. It is not literally predictive (it never samples a future velocity or position); it’s anticipatory in that it keys off the derivative that precedes the visible motion, not the motion itself. Right and Forward come from the root’s current flat facing (Root.CFrame.LookVector, XZ-flattened), so a rig with no meaningful facing (Flat.Magnitude < 1e-4) reports zero tilt rather than dividing by a near-zero vector.
Like height, the raw Bank/Pitch from tiltFor are smoothed toward, not applied directly:
local TiltBlend = math.min(dt * self.TiltRate, 1)self.Bank += (Bank - self.Bank) * TiltBlendself.Pitch += (Pitch - self.Pitch) * TiltBlendTiltRate defaults to 4 (1/s), separate from HeightRate’s 6 — tilt eases in slightly slower than height corrects.
Composing onto the root joint
Section titled “Composing onto the root joint”Both effects land on one joint’s Transform — but not by composing directly onto whatever the joint currently holds. The stepper first strips its own prior contribution back out, so the new offset always composes onto the same base rather than piling onto last frame’s already-offset result:
local Base = RootJoint.Transformif self.LastWritten and Base == self.LastWritten then Base = self.LastBaseendRootJoint.Transform = CFrame.new(0, self.Height, 0) * CFrame.Angles(math.rad(self.Pitch), 0, math.rad(self.Bank)) * Baseself.LastWritten = RootJoint.Transformself.LastBase = BaseRead this in order, once per frame:
- Read the joint’s live
TransformintoBase. - Strip, conditionally. Compare
BaseagainstLastWritten— the value Gait itself read back immediately after its own write on the previous frame (see step 5). If they’re equal, nothing else touched the joint in between, soBaseis swapped forLastBase: the pre-offset base Gait composed onto last frame, before its own height/tilt was added. That’s the strip — it discards Gait’s own prior contribution rather than building on top of it. If they’re not equal, something else (anAnimatordriving a track onto this joint, most commonly) wrote a different value in between;Baseis left as the freshly-read value, because there’s nothing of Gait’s own left in it to strip. - Compose this frame’s height/tilt offset onto
Base— the stripped base if step 2 fired, or the untouched live value otherwise. - Write the result to
RootJoint.Transform. - Record.
LastWrittenis set by readingRootJoint.Transformback after the write — a genuine post-write readback, not just the computed value, so the equality check in step 2 survives Roblox’s own CFrame quantization.LastBaseis set to theBaseused this frame, for next frame’s strip.
The net effect: whether or not an Animator is actively rewriting RootJoint.Transform every frame, Gait’s offset applies exactly once per frame on top of whatever base is actually in play — its own untouched base when nothing else is writing the joint, or the Animator’s live keyframe value when something is. Drift can no longer accumulate from Gait reading and re-composing onto its own last output.
This assumes at most one other system also fully replaces RootJoint.Transform each frame (an Animator does). Binding a second system that itself reads-composes-writes the same joint using this same self-referential pattern isn’t a scenario the spec covers, and isn’t recommended — Gait still expects to be the only thing composing an incremental offset onto the slot.
One stepper for every gait
Section titled “One stepper for every gait”Gait.bind registers the driver into a module-level ActiveGaits set and lazily connects a single RunService.PreRender listener (ensureStepper) shared by every bound gait — see Scheduler for what the PreRender phase means more generally. The stepper iterates every active driver each frame and disconnects itself once the set is empty, reconnecting on the next bind(). There is exactly one connection regardless of how many rigs are walking.
Lifecycle
Section titled “Lifecycle”Gait.step self-destroys if the rig’s root part has been unparented (if not Root.Parent then self:destroy() return end). destroy() removes the driver from ActiveGaits, release()s every leg’s IK control (destroying it and its helper attachment), and destroys each leg’s target Attachment.
Bind a quadruped
Section titled “Bind a quadruped”-- CLIENTlocal ReplicatedStorage = game:GetService("ReplicatedStorage")local Kernel = require(ReplicatedStorage.ChloeKernel).boot()local AnimKit = require(ReplicatedStorage.ChloeKernel.AnimKit)local Gait = require(ReplicatedStorage.ChloeKernel.Gait)
local Anims = AnimKit.attach(Kernel)
local function bindDragon(model: Model) local Rig = Anims:attachRig(model) Rig:play("Walk")
local Walker = Gait.bind(Rig, { Legs = { { ChainRoot = model.FrontLeftHip, EndEffector = model.FrontLeftFoot }, { ChainRoot = model.FrontRightHip, EndEffector = model.FrontRightFoot }, { ChainRoot = model.BackLeftHip, EndEffector = model.BackLeftFoot }, { ChainRoot = model.BackRightHip, EndEffector = model.BackRightFoot }, }, LookAhead = 0.15, HeightRate = 6, MaxHeightOffset = 8, -- a big rig can tolerate a deeper dip Tilt = { Bank = 0.15, Pitch = 0.1, Max = 15, Rate = 4 }, })
model.Destroying:Connect(function() Walker:destroy() end)
return WalkerendGait.bind needs an AnimKit rig (AnimKit:attachRig(model)), not the raw model — the IK controls it creates for each leg ride that rig’s IKControl layer.
Configuration
Section titled “Configuration”Options — Gait.bind(rig, options)
Section titled “Options — Gait.bind(rig, options)”| Option | Type | Default | Meaning |
|---|---|---|---|
Legs |
{ LegConfig } |
required | At least one. Per-leg chain root, effector, and cast tuning |
RootJoint |
Motor6D? |
The rig model’s PrimaryPart’s first Motor6D |
Joint whose Transform carries height and tilt |
LookAhead |
number? |
0.15 |
Seconds of velocity leading the foot casts |
PlantSmoothing |
number? |
0.1 |
Fade seconds for the plant/unplant setWeight transitions; also passed as the leg IK controls’ creation FadeIn (a no-op there, since creation always starts at Weight = 0) |
HeightRate |
number? |
6 |
Body-height blend rate, 1/s |
MaxHeightOffset |
number? |
6 |
Clamp on body drop/rise, studs |
Tilt.Bank |
number? |
0.15 |
Degrees of bank per stud/s² of lateral acceleration |
Tilt.Pitch |
number? |
0.1 |
Degrees of pitch per stud/s² of forward acceleration |
Tilt.Max |
number? |
12 |
Clamp on bank and pitch, degrees |
Tilt.Rate |
number? |
4 |
Tilt blend rate, 1/s |
RaycastParams |
RaycastParams? |
Excludes the rig’s Model |
Passed to every leg’s down-cast |
LegConfig — per entry in Legs
Section titled “LegConfig — per entry in Legs”| Field | Type | Default | Meaning |
|---|---|---|---|
ChainRoot |
Instance |
required | First joint of the limb chain (the IK control’s ChainRoot) |
EndEffector |
Instance |
required | The foot (the IK control’s EndEffector) |
RayHeight |
number? |
4 |
Studs the cast starts above the look-ahead point |
RayLength |
number? |
12 |
Studs the cast reaches below the look-ahead point |
API reference
Section titled “API reference”| Member | Description |
|---|---|
Gait.bind(rig: any, options: Options): Driver |
Binds a gait onto an AnimKit rig. Asserts at least one leg and a PrimaryPart. Returns the driver |
driver:step(dt: number) |
Advances leg casts, height, and tilt. Called for you by the shared PreRender stepper |
driver:destroy() |
Unbinds, releases every leg’s IK control, destroys target attachments |
Gait.tiltFor(velocity, lastVelocity, facing, dt, bankPer, pitchPer, max): (number, number) |
Pure. Bank/pitch degrees from this frame’s acceleration, clamped. Exposed standalone |
Gait.heightOffset(plantDeltas: { number }, maxOffset: number): number |
Pure. Average of the plant deltas, clamped. Exposed standalone |
Gotchas
Section titled “Gotchas”- Needs a
PrimaryPart.bind()assertsModel.PrimaryPart or Model:FindFirstChildWhichIsA("BasePart"); a model with neither errors at bind time. RaycastParamsfully replaces the default exclude list, same convention as elsewhere in the framework — pass your own if you need more than the rig excluded (e.g. other rigs’ feet).NeutralYis captured once, atbind(). If the rig’s rest pose changes after binding (a re-rig, a scale change), the height baseline doesn’t recompute — rebind instead.