Skip to content

Gait

Plants the feet of big and non-humanoid rigs on uneven ground with look-ahead raycasts and AnimKit Reach IK, and layers a smoothed height offset and tilt onto the animated root joint.

Realm
Client
Require
ReplicatedStorage.ChloeKernel.Gait
Since
v0.7.0
Source
Gait.luau

Gait keeps large and non-humanoid rigs (quadrupeds, creatures, anything whose feet must touch the ground) from skating and clipping over uneven terrain. A walk cycle assumes flat ground, and engine IK alone does not handle multi-legged non-humanoid chains. Gait conforms the rig to the ground under it: each leg casts down 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 animated pose. It is client cosmetic: joint Transform slots and IK targets never reach physics, the server or the wire.

Each step, every leg casts down from where its foot is about to be, not where it is:

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 root part’s AssemblyLinearVelocity and LookAhead (default 0.15 s) turns it into a lead offset. The ray starts RayHeight studs (default 4) above that point and reaches RayLength studs (default 12) below it, 16 studs in total. A leg that samples the ground at its current position is always reaching for where the foot was by the time the IK solves; at speed that is the difference between planting in the stride and dragging behind it.

The default RaycastParams excludes the rig’s own model and sets RespectCanCollide = true, so feet stand on what collides and pass through trigger volumes, zones and hazard parts. A RaycastParams you pass replaces the default entirely.

Each leg owns one AnimKit IK control of type Reach (IKControlType.Position), created at bind() through rig:ik("GaitLeg<n>", ...) with the leg’s ChainRoot and EndEffector and a weight of 0, so AnimKit creates it disabled. Its target is an Attachment (GaitPlant<n>) under workspace.Terrain.

On a hit, the step moves the attachment to the hit position. On the transition into a hit, it calls setWeight(1, PlantSmoothing): AnimKit enables the control and fades it in, and the IK takes the limb from the animation. On the transition out (no ground within the cast), it calls setWeight(0, PlantSmoothing): the weight fades out, the control is disabled when it lands on 0, and the animation owns the limb again instead of stretching toward a stale hit. PlantSmoothing (default 0.1 s) is the fade time for both transitions.

Gait.heightOffset(plantDeltas, maxOffset) averages the planted legs’ vertical deltas and clamps the result:

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

A leg’s delta is (hit Y - root Y) - NeutralY, where NeutralY is the foot’s rest height relative to the root, captured once at bind(). Zero means the foot is where the rest pose expects it. Averaging over every planted leg means one foot on a rock does not yank the body; the mean moves the hips, clamped to MaxHeightOffset (default 6 studs).

The height then approaches that target exponentially, Height += (Target - Height) * min(dt * HeightRate, 1) with HeightRate 6 per second by default, so the body eases over a bump with no overshoot.

Gait.tiltFor derives bank (sideways lean) and pitch (fore and aft lean) from this frame’s acceleration, the velocity change divided by dt, projected onto the root’s flat facing. Acceleration comes before the path visibly bends, so the lean starts the frame a turn begins: the dragon banks into the pivot before the track shifts. It leans into lateral acceleration and against forward acceleration, clamped to Tilt.Max (default 12 degrees), and returns zero for a rig with no flat facing. Bank and pitch are smoothed like height, at Tilt.Rate (default 4 per second).

The root joint is the joint whose Transform carries height and tilt. By default Gait looks under the rig’s PrimaryPart (or its first BasePart) for an AnimationConstraint first, then a Motor6D. Avatar Joint Upgrade rigs, the default since May 2026, use AnimationConstraint joints; older rigs use Motor6D. RootJoint accepts either. A rig with no root joint still plants its feet.

One module-level RunService.PreSimulation connection steps every bound gait. It connects with the first bind and disconnects on the first frame with no gaits left.

The phase matters. The Animator writes Transform after PreAnimation, and the engine applies joint transforms in a batch after PreSimulation, so a root offset written on PreSimulation layers on the frame’s animated pose. Before 0.9.0 Gait stepped on PreRender, where the Animator overwrote the offset before it applied, so on animated root joints the height and tilt never showed.

The Animator rewrites the root joint’s Transform every frame a track drives it. A joint no track drives keeps whatever was written last, so composing onto the live value would stack Gait’s offset frame after frame. Each step:

  1. Reads the joint’s Transform as the base.
  2. If that value equals LastWritten (Gait’s own post-write readback from the previous step), nothing else touched the joint, so the base becomes LastBase, the unshifted pose under the last write.
  3. Writes CFrame.new(0, Height, 0) * CFrame.Angles(rad(Pitch), 0, rad(Bank)) * Base.
  4. Reads Transform back into LastWritten (a readback, so the comparison survives property quantization) and keeps Base as LastBase.

The offset applies exactly once per frame whether or not a track drives the joint.

While the rig’s Animator reports EvaluationThrottled (the engine is reusing a pose instead of evaluating the frame), the root write is skipped: the reused pose already carries last frame’s offset. On an engine without that property the gate turns itself off.

A step whose root part has no parent destroys the driver. destroy() removes it from the stepper, puts the root joint back to its unshifted Transform when the joint still holds Gait’s last write (with no track driving it, the offset would otherwise stay baked in), releases every leg’s IK control and destroys the target attachments.

src/Client/Game/Controllers/CreatureController.luau
--!strict
-- CreatureController: plants the feet of every tagged creature the client can see.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local AnimKit = require(ReplicatedStorage.ChloeKernel.AnimKit)
local Gait = require(ReplicatedStorage.ChloeKernel.Gait)
local TagWatch = require(ReplicatedStorage.ChloeKernel.TagWatch)
local CreatureController = {
Name = "CreatureController",
}
function CreatureController.init(self: any, kernel: any)
self.Kernel = kernel
self.Anims = AnimKit.attach(kernel)
self.Walkers = {} :: { [Instance]: any }
end
local function bindDragon(self: any, model: Model)
-- attachRig can wait up to 5 s for the server-created Animator to replicate
local Rig = self.Anims:attachRig(model)
if not model:IsDescendantOf(workspace) then
return -- streamed out while we waited
end
self.Walkers[model] = 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 },
},
MaxHeightOffset = 8, -- a big rig can take a deeper dip
Tilt = { Bank = 0.15, Pitch = 0.1, Max = 15, Rate = 4 },
})
end
function CreatureController.start(self: any)
self.StopWatch = TagWatch.watch("Dragon", function(model)
task.spawn(bindDragon, self, model :: Model)
end, function(model)
local Walker = self.Walkers[model]
if Walker then
self.Walkers[model] = nil
Walker:destroy()
end
end)
end
function CreatureController.stop(self: any)
if self.StopWatch then
self.StopWatch()
end
for _, Walker in self.Walkers do
Walker:destroy()
end
table.clear(self.Walkers)
self.Anims:destroy()
end
return CreatureController

Gait.bind takes an AnimKit rig (AnimKit:attachRig(model)), not the raw model: the leg IK controls ride the rig’s IK layer, and the rig’s Animator feeds the EvaluationThrottled gate.

Gait.bind(Rig, {
Legs = Legs,
-- The default search looks only under the PrimaryPart
RootJoint = model.Body.RootJoint,
})
Option Type Default Meaning
Legs { LegConfig } required At least one. Per-leg chain, effector and cast tuning.
RootJoint Motor6D | AnimationConstraint The AnimationConstraint, else the Motor6D, under the PrimaryPart 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 plant and lift weight changes.
HeightRate number 6 Body-height blend rate, per second.
MaxHeightOffset number 6 Clamp on body drop or rise, studs.
Tilt.Bank number 0.15 Degrees of bank per stud/s^2 of lateral acceleration.
Tilt.Pitch number 0.1 Degrees of pitch per stud/s^2 of forward acceleration.
Tilt.Max number 12 Clamp on bank and pitch, degrees.
Tilt.Rate number 4 Tilt blend rate, per second.
RaycastParams RaycastParams Excludes the rig model, RespectCanCollide = true Replaces the default for every leg’s cast.
Field Type Default Meaning
ChainRoot Instance required First joint of the limb chain.
EndEffector Instance required The foot.
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, options) -> Driver Binds a gait onto an AnimKit rig. Asserts at least one leg and a root part. Joins the shared PreSimulation stepper.
driver:step(dt) Casts the legs, plants or lifts them, and writes height and tilt onto the root joint (skipped while the Animator is throttled). Called by the stepper.
driver:destroy() Leaves the stepper, restores the unshifted root Transform if no track rewrote it, releases the leg IK and destroys the targets.
Gait.tiltFor(velocity, lastVelocity, facing, dt, bankPer, pitchPer, max) -> (number, number) Pure. Bank and pitch degrees from this frame’s acceleration, clamped.
Gait.heightOffset(plantDeltas, maxOffset) -> number Pure. The average plant delta, clamped.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.