Roblox Procedural Foot Placement IK: Two-Bone Trigonometric Solvers, Hip Offsets & Surface Normal Alignment

By DopaBrain Animation & Procedural Systems Team Updated: 2026-10-01 12 min read

In modern open-world, survival, and tactical action games on Roblox—such as Deepwoken, Frontlines, and Dead Ahead—visual immersion is instantly broken when characters walk across uneven terrain. Default R15 and R6 animations assume flat, horizontal ground surfaces; on stairs, rocky inclines, or steep hillsides, one foot hovers awkwardly in mid-air while the other clips through solid rock.

To achieve lifelike locomotion, top developers implement procedural Inverse Kinematics (IK) foot placement. In this comprehensive technical guide, we engineer a production-ready two-bone analytical IK solver in Luau. We derive the law of cosines joint angles, cast precision downward raycasts from hip attachments, calculate dynamic pelvis drops to prevent leg overextension, and align ankle rotations to terrain surface normals.

1. Why Default Humanoids Hover: The Case for Procedural Foot IK

Default Roblox animation blending cannot account for runtime geometric ground variations:

2. The Mathematical Foundation: Analytical Two-Bone Law of Cosines

Two-bone leg inverse kinematics (UpperLeg -> LowerLeg -> Foot) is solved analytically using the Law of Cosines without expensive iterative FABRIK solvers:

3. Complete Procedural Foot Placement Luau Implementation

Below is a complete, modular Luau controller running on RunService.RenderStepped for buttery-smooth 60+ FPS foot adaptation:

FootPlacementIKController.luau (Procedural Leg Solver)
--!strict
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local FootIK = {}
FootIK.__index = FootIK

export type LegRig = {
    HipMotor: Motor6D,
    KneeMotor: Motor6D,
    AnkleMotor: Motor6D,
    HipAttachment: Attachment,
    ThighLength: number,
    CalfLength: number,
    CurrentFootOffset: number,
}

export type CharacterIK = {
    Character: Model,
    RootJoint: Motor6D,
    LeftLeg: LegRig,
    RightLeg: LegRig,
    RayParams: RaycastParams,
    HipDropOffset: number,
}

local function SolveTwoBone(origin: CFrame, targetPos: Vector3, l1: number, l2: number): (CFrame, CFrame)
    local localized = origin:PointToObjectSpace(targetPos)
    local dist = localized.Magnitude
    dist = math.clamp(dist, 0.05, (l1 + l2) - 0.001)

    local cosAlpha = (l1 * l1 + dist * dist - l2 * l2) / (2 * l1 * dist)
    local alpha = math.acos(math.clamp(cosAlpha, -1, 1))

    local cosBeta = (l1 * l1 + l2 * l2 - dist * dist) / (2 * l1 * l2)
    local beta = math.acos(math.clamp(cosBeta, -1, 1))

    local planeRot = CFrame.lookAt(Vector3.zero, localized)
    local hipRot = planeRot * CFrame.Angles(alpha, 0, 0)
    local kneeRot = CFrame.Angles(-(math.pi - beta), 0, 0)

    return hipRot, kneeRot
end

function FootIK.Step(ik: CharacterIK, dt: number)
    local char = ik.Character
    local rootPart = char.PrimaryPart
    if not rootPart then return end

    local rayDown = Vector3.new(0, -5, 0)
    local leftOrigin = ik.LeftLeg.HipAttachment.WorldPosition
    local rightOrigin = ik.RightLeg.HipAttachment.WorldPosition

    local leftHit = Workspace:Raycast(leftOrigin, rayDown, ik.RayParams)
    local rightHit = Workspace:Raycast(rightOrigin, rayDown, ik.RayParams)

    local leftTarget = leftHit and leftHit.Position or (leftOrigin + Vector3.new(0, -3.2, 0))
    local rightTarget = rightHit and rightHit.Position or (rightOrigin + Vector3.new(0, -3.2, 0))

    -- Calculate pelvis drop
    local leftDeficit = leftOrigin.Y - leftTarget.Y
    local rightDeficit = rightOrigin.Y - rightTarget.Y
    local maxDeficit = math.max(leftDeficit, rightDeficit)
    local targetHipDrop = math.clamp(maxDeficit - 3.2, 0, 1.8)

    ik.HipDropOffset = ik.HipDropOffset + (targetHipDrop - ik.HipDropOffset) * math.clamp(dt * 15, 0, 1)
    ik.RootJoint.Transform = CFrame.new(0, -ik.HipDropOffset, 0)

    -- Solve Left Leg
    local leftHipCF = ik.LeftLeg.HipMotor.Part0.CFrame * ik.LeftLeg.HipMotor.C0
    local lHipRot, lKneeRot = SolveTwoBone(leftHipCF, leftTarget, ik.LeftLeg.ThighLength, ik.LeftLeg.CalfLength)
    ik.LeftLeg.HipMotor.Transform = lHipRot
    ik.LeftLeg.KneeMotor.Transform = lKneeRot

    -- Solve Right Leg
    local rightHipCF = ik.RightLeg.HipMotor.Part0.CFrame * ik.RightLeg.HipMotor.C0
    local rHipRot, rKneeRot = SolveTwoBone(rightHipCF, rightTarget, ik.RightLeg.ThighLength, ik.RightLeg.CalfLength)
    ik.RightLeg.HipMotor.Transform = rHipRot
    ik.RightLeg.KneeMotor.Transform = rKneeRot
end

return FootIK

4. Pelvic Hip Drop & Ankle Surface Normal Alignment

When a character stands on a slope, solving leg IK alone causes the downhill leg to stretch to its limit while the uphill leg bends unnaturally:

5. Performance Optimization, Frustum Culling & Multiplayer LOD

Running trigonometric solvers on hundreds of player avatars simultaneously can bottleneck the CPU without smart optimization:

Frequently Asked Questions

Why use analytical two-bone trigonometry instead of Roblox's built-in IKControl?

While Roblox IKControl is convenient, custom analytical two-bone IK gives developers direct programmatic control over pelvis hip drop damping, exact ankle surface normal alignment, custom raycast filters, and distance-based frustum LOD optimizations.

What causes leg shaking or jitter on stairs?

Jitter occurs when raycasts hit alternating step edges and risers between consecutive frames. Implementing a moving average filter or exponential lerp on foot target positions completely eliminates stair stepping jitter.

How does pelvic drop affect character collision and jumping?

Because the pelvic drop is applied to the visual RootJoint Motor6D transform rather than the physical HumanoidRootPart position, physical collision capsules and jump velocities remain completely unaffected.

Explore More Interactive Tests & Guides

Discover personalized cognitive assessments, stress evaluations, and game psychology tools on DopaBrain.

Go to Portal Hub