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.
Mental model
Section titled “Mental model”Per-leg look-ahead casts
Section titled “Per-leg look-ahead casts”Each step, every leg casts down from where its foot is about to be, not where it is:
local Lead = Velocity * self.LookAheadlocal 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.
Reach IK plant and lift
Section titled “Reach IK plant and lift”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.
Body height
Section titled “Body height”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)endA 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.
Tilt from acceleration
Section titled “Tilt from acceleration”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
Section titled “The root joint”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.
Stepping on PreSimulation
Section titled “Stepping on PreSimulation”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.
Writing the offset once
Section titled “Writing the offset once”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:
- Reads the joint’s
Transformas the base. - If that value equals
LastWritten(Gait’s own post-write readback from the previous step), nothing else touched the joint, so the base becomesLastBase, the unshifted pose under the last write. - Writes
CFrame.new(0, Height, 0) * CFrame.Angles(rad(Pitch), 0, rad(Bank)) * Base. - Reads
Transformback intoLastWritten(a readback, so the comparison survives property quantization) and keepsBaseasLastBase.
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.
Lifecycle
Section titled “Lifecycle”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.
Bind a quadruped NPC on the client
Section titled “Bind a quadruped NPC on the client”--!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 CreatureControllerGait.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.
A rig whose root joint lives elsewhere
Section titled “A rig whose root joint lives elsewhere”Gait.bind(Rig, { Legs = Legs, -- The default search looks only under the PrimaryPart RootJoint = model.Body.RootJoint,})Configuration
Section titled “Configuration”Options for Gait.bind(rig, options)
Section titled “Options for Gait.bind(rig, options)”| 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. |
LegConfig
Section titled “LegConfig”| 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. |
API reference
Section titled “API reference”| 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. |