Skip to content

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 },
},
})

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.

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

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

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

Each 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) * HeightBlend

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

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, Pitch
end

This 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) * TiltBlend
self.Pitch += (Pitch - self.Pitch) * TiltBlend

TiltRate defaults to 4 (1/s), separate from HeightRate’s 6 — tilt eases in slightly slower than height corrects.

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.Transform
if self.LastWritten and Base == self.LastWritten then
Base = self.LastBase
end
RootJoint.Transform = CFrame.new(0, self.Height, 0)
* CFrame.Angles(math.rad(self.Pitch), 0, math.rad(self.Bank))
* Base
self.LastWritten = RootJoint.Transform
self.LastBase = Base

Read this in order, once per frame:

  1. Read the joint’s live Transform into Base.
  2. Strip, conditionally. Compare Base against LastWritten — 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, so Base is swapped for LastBase: 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 (an Animator driving a track onto this joint, most commonly) wrote a different value in between; Base is left as the freshly-read value, because there’s nothing of Gait’s own left in it to strip.
  3. Compose this frame’s height/tilt offset onto Base — the stripped base if step 2 fired, or the untouched live value otherwise.
  4. Write the result to RootJoint.Transform.
  5. Record. LastWritten is set by reading RootJoint.Transform back 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. LastBase is set to the Base used 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.

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.

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.

-- CLIENT
local 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 Walker
end

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

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
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
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
  • Needs a PrimaryPart. bind() asserts Model.PrimaryPart or Model:FindFirstChildWhichIsA("BasePart"); a model with neither errors at bind time.
  • RaycastParams fully 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).
  • NeutralY is captured once, at bind(). If the rig’s rest pose changes after binding (a re-rig, a scale change), the height baseline doesn’t recompute — rebind instead.