skills/taozhuo/game-dev-skills/animation-system

animation-system

SKILL.md

Roblox Animation Systems

When implementing animations, follow these patterns for smooth, performant character and object animations.

Animation Basics

Loading and Playing Animations

local function setupAnimations(character)
    local humanoid = character:WaitForChild("Humanoid")
    local animator = humanoid:WaitForChild("Animator")

    -- Create animation instance
    local walkAnim = Instance.new("Animation")
    walkAnim.AnimationId = "rbxassetid://123456789"

    -- Load animation track
    local walkTrack = animator:LoadAnimation(walkAnim)

    -- Configure track
    walkTrack.Priority = Enum.AnimationPriority.Movement
    walkTrack.Looped = true

    -- Play with parameters
    walkTrack:Play(
        0.1,  -- Fade in time
        1,    -- Weight (0-1)
        1     -- Speed multiplier
    )

    return walkTrack
end

Animation Priorities

-- Priority order (lowest to highest):
-- Core < Idle < Movement < Action < Action2 < Action3 < Action4

local function setAnimationPriority(track, priority)
    track.Priority = priority
end

-- Example priority usage
idleTrack.Priority = Enum.AnimationPriority.Idle
walkTrack.Priority = Enum.AnimationPriority.Movement
attackTrack.Priority = Enum.AnimationPriority.Action
-- Action always overrides Movement, Movement overrides Idle

Animation Events (Keyframe Markers)

-- Add markers in Animation Editor, then listen:
local function setupAnimationEvents(track)
    -- Listen for specific marker
    track:GetMarkerReachedSignal("Footstep"):Connect(function(paramValue)
        playFootstepSound()
    end)

    track:GetMarkerReachedSignal("DamageFrame"):Connect(function()
        applyDamage()
    end)

    track:GetMarkerReachedSignal("SpawnVFX"):Connect(function(vfxName)
        spawnEffect(vfxName)
    end)
end

-- Animation completion
track.Stopped:Connect(function()
    print("Animation stopped or completed")
end)

-- Check if playing
if track.IsPlaying then
    -- Animation is active
end

Animation Controller

State-Based Animation Controller

local AnimationController = {}
AnimationController.__index = AnimationController

function AnimationController.new(character)
    local self = setmetatable({}, AnimationController)

    self.character = character
    self.humanoid = character:WaitForChild("Humanoid")
    self.animator = self.humanoid:WaitForChild("Animator")
    self.tracks = {}
    self.currentState = "Idle"
    self.stateAnimations = {}

    return self
end

function AnimationController:loadAnimation(name, animationId, config)
    config = config or {}

    local animation = Instance.new("Animation")
    animation.AnimationId = animationId

    local track = self.animator:LoadAnimation(animation)
    track.Priority = config.priority or Enum.AnimationPriority.Movement
    track.Looped = config.looped or false

    self.tracks[name] = track
    return track
end

function AnimationController:setState(stateName, fadeTime)
    fadeTime = fadeTime or 0.1

    if self.currentState == stateName then return end

    -- Stop current state animation
    local currentTrack = self.tracks[self.currentState]
    if currentTrack and currentTrack.IsPlaying then
        currentTrack:Stop(fadeTime)
    end

    -- Play new state animation
    local newTrack = self.tracks[stateName]
    if newTrack then
        newTrack:Play(fadeTime)
    end

    self.currentState = stateName
end

function AnimationController:playOneShot(name, fadeTime, weight, speed)
    local track = self.tracks[name]
    if track then
        track:Play(fadeTime or 0.1, weight or 1, speed or 1)
    end
    return track
end

-- Usage
local controller = AnimationController.new(character)
controller:loadAnimation("Idle", "rbxassetid://idle", {looped = true, priority = Enum.AnimationPriority.Idle})
controller:loadAnimation("Walk", "rbxassetid://walk", {looped = true, priority = Enum.AnimationPriority.Movement})
controller:loadAnimation("Attack", "rbxassetid://attack", {priority = Enum.AnimationPriority.Action})

controller:setState("Idle")
-- When moving:
controller:setState("Walk")
-- Attack (plays on top):
controller:playOneShot("Attack")

Movement-Based Animation Selection

local function setupMovementAnimations(character)
    local humanoid = character:WaitForChild("Humanoid")
    local animator = humanoid:WaitForChild("Animator")
    local hrp = character:WaitForChild("HumanoidRootPart")

    local animations = {
        idle = loadAnimation(animator, "rbxassetid://idle"),
        walk = loadAnimation(animator, "rbxassetid://walk"),
        run = loadAnimation(animator, "rbxassetid://run"),
        jump = loadAnimation(animator, "rbxassetid://jump"),
        fall = loadAnimation(animator, "rbxassetid://fall")
    }

    -- Set looping
    animations.idle.Looped = true
    animations.walk.Looped = true
    animations.run.Looped = true
    animations.fall.Looped = true

    local currentAnim = nil

    local function updateAnimation()
        local velocity = hrp.AssemblyLinearVelocity
        local horizontalSpeed = Vector3.new(velocity.X, 0, velocity.Z).Magnitude
        local isGrounded = humanoid.FloorMaterial ~= Enum.Material.Air

        local targetAnim

        if not isGrounded then
            if velocity.Y > 1 then
                targetAnim = animations.jump
            else
                targetAnim = animations.fall
            end
        elseif horizontalSpeed < 0.5 then
            targetAnim = animations.idle
        elseif horizontalSpeed < 12 then
            targetAnim = animations.walk
            -- Adjust speed based on movement
            animations.walk:AdjustSpeed(horizontalSpeed / 8)
        else
            targetAnim = animations.run
            animations.run:AdjustSpeed(horizontalSpeed / 16)
        end

        if targetAnim ~= currentAnim then
            if currentAnim then
                currentAnim:Stop(0.2)
            end
            targetAnim:Play(0.2)
            currentAnim = targetAnim
        end
    end

    RunService.Heartbeat:Connect(updateAnimation)
end

Animation Blending

Weight-Based Blending

local BlendedAnimator = {}

function BlendedAnimator.new(animator)
    return {
        animator = animator,
        layers = {}
    }
end

function BlendedAnimator:addLayer(name, animationId, priority)
    local animation = Instance.new("Animation")
    animation.AnimationId = animationId

    local track = self.animator:LoadAnimation(animation)
    track.Priority = priority or Enum.AnimationPriority.Movement
    track.Looped = true

    self.layers[name] = {
        track = track,
        weight = 0,
        targetWeight = 0
    }

    track:Play(0, 0)  -- Start at weight 0
    return track
end

function BlendedAnimator:setLayerWeight(name, weight, blendTime)
    local layer = self.layers[name]
    if not layer then return end

    layer.targetWeight = math.clamp(weight, 0, 1)

    if blendTime and blendTime > 0 then
        -- Smooth blend
        local startWeight = layer.weight
        local startTime = os.clock()

        local conn
        conn = RunService.Heartbeat:Connect(function()
            local elapsed = os.clock() - startTime
            local t = math.min(elapsed / blendTime, 1)

            layer.weight = startWeight + (layer.targetWeight - startWeight) * t
            layer.track:AdjustWeight(layer.weight)

            if t >= 1 then
                conn:Disconnect()
            end
        end)
    else
        layer.weight = layer.targetWeight
        layer.track:AdjustWeight(layer.weight)
    end
end

-- Usage: Blend between walk and limp
local blender = BlendedAnimator.new(animator)
blender:addLayer("Walk", "rbxassetid://walk", Enum.AnimationPriority.Movement)
blender:addLayer("Limp", "rbxassetid://limp", Enum.AnimationPriority.Movement)

-- Normal walking
blender:setLayerWeight("Walk", 1, 0.3)
blender:setLayerWeight("Limp", 0, 0.3)

-- Injured (blend to limp)
blender:setLayerWeight("Walk", 0.3, 0.5)
blender:setLayerWeight("Limp", 0.7, 0.5)

Additive Animation Blending

-- Additive animations add on top of base animation
local function setupAdditiveBlending(animator)
    local baseWalk = loadAnimation(animator, "rbxassetid://walk")
    local leanLeft = loadAnimation(animator, "rbxassetid://lean_left")
    local leanRight = loadAnimation(animator, "rbxassetid://lean_right")

    baseWalk.Looped = true
    leanLeft.Looped = true
    leanRight.Looped = true

    baseWalk:Play()
    leanLeft:Play(0, 0)  -- Start at 0 weight
    leanRight:Play(0, 0)

    -- Update lean based on input
    local function updateLean(turnAmount)
        -- turnAmount: -1 (left) to 1 (right)
        if turnAmount < 0 then
            leanLeft:AdjustWeight(math.abs(turnAmount))
            leanRight:AdjustWeight(0)
        else
            leanLeft:AdjustWeight(0)
            leanRight:AdjustWeight(turnAmount)
        end
    end

    return updateLean
end

Procedural Animation

Procedural Head Look

local function setupHeadLook(character, target)
    local neck = character:FindFirstChild("Neck", true)
    if not neck then return end

    local originalC0 = neck.C0

    RunService.RenderStepped:Connect(function()
        if not target then
            neck.C0 = originalC0
            return
        end

        local headPos = neck.Part1.Position
        local targetPos = target.Position
        local direction = (targetPos - headPos).Unit

        -- Convert to local space
        local torsoLook = neck.Part0.CFrame.LookVector
        local torsoCFrame = neck.Part0.CFrame

        local localDirection = torsoCFrame:VectorToObjectSpace(direction)

        -- Calculate angles
        local yaw = math.atan2(localDirection.X, -localDirection.Z)
        local pitch = math.asin(localDirection.Y)

        -- Clamp to prevent unnatural rotation
        yaw = math.clamp(yaw, math.rad(-70), math.rad(70))
        pitch = math.clamp(pitch, math.rad(-40), math.rad(40))

        -- Apply rotation
        local lookCFrame = CFrame.Angles(pitch, yaw, 0)
        neck.C0 = originalC0 * lookCFrame
    end)
end

Procedural Breathing

local function setupBreathing(character)
    local torso = character:FindFirstChild("UpperTorso") or character:FindFirstChild("Torso")
    if not torso then return end

    local waist = character:FindFirstChild("Waist", true)
    if not waist then return end

    local originalC0 = waist.C0
    local breathSpeed = 2  -- Cycles per second
    local breathIntensity = 0.02

    local time = 0

    RunService.RenderStepped:Connect(function(dt)
        time = time + dt

        local breathOffset = math.sin(time * breathSpeed * math.pi * 2) * breathIntensity

        waist.C0 = originalC0 * CFrame.new(0, breathOffset, 0)
    end)
end

Procedural Tail/Cape Physics

local function setupProceduralChain(parts, config)
    config = config or {}
    local stiffness = config.stiffness or 0.5
    local damping = config.damping or 0.3
    local gravity = config.gravity or Vector3.new(0, -10, 0)

    local velocities = {}
    local restOffsets = {}

    -- Store rest positions
    for i, part in ipairs(parts) do
        velocities[i] = Vector3.new()
        if i > 1 then
            restOffsets[i] = parts[i-1].CFrame:ToObjectSpace(part.CFrame)
        end
    end

    RunService.Heartbeat:Connect(function(dt)
        for i = 2, #parts do
            local part = parts[i]
            local parent = parts[i-1]

            -- Target position (relative to parent)
            local targetCFrame = parent.CFrame * restOffsets[i]
            local targetPos = targetCFrame.Position

            -- Current position
            local currentPos = part.Position

            -- Spring force toward target
            local displacement = targetPos - currentPos
            local springForce = displacement * stiffness

            -- Apply gravity
            local totalForce = springForce + gravity

            -- Update velocity with damping
            velocities[i] = velocities[i] * (1 - damping) + totalForce * dt

            -- Update position
            local newPos = currentPos + velocities[i]

            -- Maintain distance constraint
            local toParent = parent.Position - newPos
            local distance = toParent.Magnitude
            local restDistance = restOffsets[i].Position.Magnitude

            if distance > restDistance then
                newPos = parent.Position - toParent.Unit * restDistance
            end

            -- Apply
            part.CFrame = CFrame.new(newPos) * (targetCFrame - targetCFrame.Position)
        end
    end)
end

Inverse Kinematics (IK)

Two-Bone IK (Arms/Legs)

local function solveTwoBoneIK(upperBone, lowerBone, target, pole)
    local upperLength = (lowerBone.Position - upperBone.Position).Magnitude
    local lowerLength = (target - lowerBone.Position).Magnitude

    local origin = upperBone.Position
    local targetPos = target
    local polePos = pole or (origin + Vector3.new(0, 0, 1))

    -- Calculate distance to target
    local targetDistance = (targetPos - origin).Magnitude
    local totalLength = upperLength + lowerLength

    -- Clamp target to reachable distance
    if targetDistance > totalLength * 0.999 then
        targetDistance = totalLength * 0.999
    end

    -- Law of cosines to find angles
    local a = upperLength
    local b = lowerLength
    local c = targetDistance

    -- Angle at upper joint
    local upperAngle = math.acos(
        math.clamp((a*a + c*c - b*b) / (2*a*c), -1, 1)
    )

    -- Angle at lower joint (elbow/knee)
    local lowerAngle = math.acos(
        math.clamp((a*a + b*b - c*c) / (2*a*b), -1, 1)
    )

    -- Direction to target
    local directionToTarget = (targetPos - origin).Unit

    -- Calculate pole plane
    local poleDirection = (polePos - origin).Unit
    local cross = directionToTarget:Cross(poleDirection)
    local normal = cross:Cross(directionToTarget).Unit

    -- Apply rotations
    local upperRotation = CFrame.fromAxisAngle(cross, -upperAngle)
    local elbowPosition = origin + upperRotation:VectorToWorldSpace(directionToTarget) * upperLength

    return elbowPosition, lowerAngle
end

-- Foot IK for terrain
local function setupFootIK(character)
    local humanoid = character:WaitForChild("Humanoid")
    local hrp = character:WaitForChild("HumanoidRootPart")

    local leftFoot = character:FindFirstChild("LeftFoot")
    local rightFoot = character:FindFirstChild("RightFoot")
    local leftLeg = character:FindFirstChild("LeftLowerLeg")
    local rightLeg = character:FindFirstChild("RightLowerLeg")

    local rayParams = RaycastParams.new()
    rayParams.FilterDescendantsInstances = {character}

    RunService.RenderStepped:Connect(function()
        if humanoid.FloorMaterial == Enum.Material.Air then return end

        -- Raycast for each foot
        for _, footData in ipairs({{leftFoot, leftLeg}, {rightFoot, rightLeg}}) do
            local foot, lowerLeg = footData[1], footData[2]

            local result = workspace:Raycast(
                foot.Position + Vector3.new(0, 1, 0),
                Vector3.new(0, -2, 0),
                rayParams
            )

            if result then
                local targetY = result.Position.Y
                local offset = targetY - foot.Position.Y + 0.1

                -- Apply IK offset (simplified)
                -- In practice, you'd solve the full IK chain
            end
        end
    end)
end

Custom Rigs

Motor6D Setup for Custom Rigs

local function createCustomRig(model)
    local root = model.PrimaryPart
    local parts = {}

    for _, part in ipairs(model:GetDescendants()) do
        if part:IsA("BasePart") and part ~= root then
            table.insert(parts, part)
        end
    end

    -- Create Motor6Ds
    local motors = {}

    for _, part in ipairs(parts) do
        local motor = Instance.new("Motor6D")
        motor.Name = part.Name

        -- Find parent part (closest connected part toward root)
        local parentPart = findParentPart(part, root, parts)

        motor.Part0 = parentPart
        motor.Part1 = part

        -- Calculate C0 and C1 (joint positions)
        local jointPos = (parentPart.Position + part.Position) / 2
        motor.C0 = parentPart.CFrame:ToObjectSpace(CFrame.new(jointPos))
        motor.C1 = part.CFrame:ToObjectSpace(CFrame.new(jointPos))

        motor.Parent = parentPart
        motors[part.Name] = motor
    end

    return motors
end

-- Animate custom rig
local function animateCustomRig(motors, animationData)
    -- animationData: {motorName = {CFrame sequence}}

    local time = 0
    local duration = animationData.duration or 1

    RunService.RenderStepped:Connect(function(dt)
        time = (time + dt) % duration
        local t = time / duration

        for motorName, keyframes in pairs(animationData.motors or {}) do
            local motor = motors[motorName]
            if motor then
                -- Interpolate between keyframes
                local transform = interpolateKeyframes(keyframes, t)
                motor.Transform = transform
            end
        end
    end)
end

Humanoid Description for NPCs

local function applyHumanoidDescription(character, description)
    local humanoid = character:FindFirstChildOfClass("Humanoid")
    if not humanoid then return end

    -- Create or modify description
    local desc = description or Instance.new("HumanoidDescription")

    -- Body parts
    desc.Head = 123456789  -- Asset ID
    desc.Torso = 123456789
    desc.LeftArm = 123456789
    desc.RightArm = 123456789
    desc.LeftLeg = 123456789
    desc.RightLeg = 123456789

    -- Animations
    desc.IdleAnimation = 123456789
    desc.WalkAnimation = 123456789
    desc.RunAnimation = 123456789
    desc.JumpAnimation = 123456789
    desc.FallAnimation = 123456789

    -- Body scales
    desc.HeadScale = 1
    desc.BodyTypeScale = 0.5
    desc.ProportionScale = 1
    desc.WidthScale = 1
    desc.HeightScale = 1
    desc.DepthScale = 1

    humanoid:ApplyDescription(desc)
end

Animation Performance

Animation Caching

local AnimationCache = {}
AnimationCache.cache = {}

function AnimationCache.load(animator, animationId)
    local cacheKey = tostring(animator) .. "_" .. animationId

    if AnimationCache.cache[cacheKey] then
        return AnimationCache.cache[cacheKey]
    end

    local animation = Instance.new("Animation")
    animation.AnimationId = animationId

    local track = animator:LoadAnimation(animation)
    AnimationCache.cache[cacheKey] = track

    return track
end

function AnimationCache.clear(animator)
    local prefix = tostring(animator) .. "_"

    for key, track in pairs(AnimationCache.cache) do
        if string.sub(key, 1, #prefix) == prefix then
            track:Stop()
            track:Destroy()
            AnimationCache.cache[key] = nil
        end
    end
end

LOD for Animations

local AnimationLOD = {}

function AnimationLOD.setup(character, camera)
    local animator = character:WaitForChild("Humanoid"):WaitForChild("Animator")
    local hrp = character:WaitForChild("HumanoidRootPart")

    local LOD_DISTANCES = {50, 100, 200}
    local UPDATE_RATES = {1, 0.5, 0.25, 0.1}  -- Animation update rate

    local lastUpdate = 0
    local currentLOD = 1

    RunService.Heartbeat:Connect(function()
        local distance = (hrp.Position - camera.CFrame.Position).Magnitude

        -- Determine LOD level
        local lodLevel = 1
        for i, threshold in ipairs(LOD_DISTANCES) do
            if distance > threshold then
                lodLevel = i + 1
            end
        end

        -- Update animation rate based on LOD
        if lodLevel ~= currentLOD then
            currentLOD = lodLevel

            -- Adjust all playing animations
            for _, track in ipairs(animator:GetPlayingAnimationTracks()) do
                -- Distant characters: slower animation updates
                -- This is a simplified approach; Roblox handles this internally
            end
        end
    end)
end

Pooled Animation Tracks

local TrackPool = {}
TrackPool.pools = {}

function TrackPool.getTrack(animator, animationId)
    local poolKey = animationId

    if not TrackPool.pools[poolKey] then
        TrackPool.pools[poolKey] = {
            available = {},
            inUse = {}
        }
    end

    local pool = TrackPool.pools[poolKey]

    -- Check for available track
    local track = table.remove(pool.available)

    if not track then
        -- Create new track
        local animation = Instance.new("Animation")
        animation.AnimationId = animationId
        track = animator:LoadAnimation(animation)
    end

    table.insert(pool.inUse, track)
    return track
end

function TrackPool.releaseTrack(animationId, track)
    local pool = TrackPool.pools[animationId]
    if not pool then return end

    track:Stop(0)

    local index = table.find(pool.inUse, track)
    if index then
        table.remove(pool.inUse, index)
    end

    table.insert(pool.available, track)
end

Animation Tools

Animation Recording

local AnimationRecorder = {}

function AnimationRecorder.record(character, duration)
    local humanoid = character:FindFirstChildOfClass("Humanoid")
    local motors = {}

    -- Find all Motor6Ds
    for _, motor in ipairs(character:GetDescendants()) do
        if motor:IsA("Motor6D") then
            table.insert(motors, motor)
        end
    end

    local keyframes = {}
    local startTime = os.clock()
    local recording = true

    -- Record at 30 fps
    local frameTime = 1/30
    local lastFrame = 0

    local conn
    conn = RunService.Heartbeat:Connect(function()
        local elapsed = os.clock() - startTime

        if elapsed >= duration then
            recording = false
            conn:Disconnect()
            return
        end

        if elapsed - lastFrame >= frameTime then
            lastFrame = elapsed

            local frame = {
                time = elapsed,
                poses = {}
            }

            for _, motor in ipairs(motors) do
                frame.poses[motor.Name] = {
                    C0 = motor.C0,
                    C1 = motor.C1,
                    Transform = motor.Transform
                }
            end

            table.insert(keyframes, frame)
        end
    end)

    -- Return promise-like
    return {
        getKeyframes = function()
            while recording do
                task.wait()
            end
            return keyframes
        end
    }
end

Animation Playback from Data

local function playRecordedAnimation(character, keyframes)
    local motors = {}

    for _, motor in ipairs(character:GetDescendants()) do
        if motor:IsA("Motor6D") then
            motors[motor.Name] = motor
        end
    end

    local duration = keyframes[#keyframes].time
    local startTime = os.clock()

    local conn
    conn = RunService.Heartbeat:Connect(function()
        local elapsed = os.clock() - startTime

        if elapsed >= duration then
            conn:Disconnect()
            return
        end

        -- Find surrounding keyframes
        local prevFrame, nextFrame
        for i, frame in ipairs(keyframes) do
            if frame.time <= elapsed then
                prevFrame = frame
                nextFrame = keyframes[i + 1]
            end
        end

        if not prevFrame or not nextFrame then return end

        -- Interpolate
        local t = (elapsed - prevFrame.time) / (nextFrame.time - prevFrame.time)

        for motorName, motor in pairs(motors) do
            local prevPose = prevFrame.poses[motorName]
            local nextPose = nextFrame.poses[motorName]

            if prevPose and nextPose then
                motor.Transform = prevPose.Transform:Lerp(nextPose.Transform, t)
            end
        end
    end)

    return conn
end

Procedural KeyframeSequence Generation

This section covers creating animations entirely from code without using the Animation Editor.

KeyframeSequence Structure

KeyframeSequences contain Keyframes, which contain hierarchical Poses matching the rig structure.

--[[
    KeyframeSequence Structure:

    KeyframeSequence
    ├── Keyframe (Time = 0.0)
    │   └── Pose "HumanoidRootPart"
    │       └── Pose "LowerTorso"
    │           ├── Pose "UpperTorso"
    │           │   ├── Pose "Head"
    │           │   ├── Pose "LeftUpperArm"
    │           │   │   └── Pose "LeftLowerArm"
    │           │   │       └── Pose "LeftHand"
    │           │   └── Pose "RightUpperArm"
    │           │       └── Pose "RightLowerArm"
    │           │           └── Pose "RightHand"
    │           ├── Pose "LeftUpperLeg"
    │           │   └── Pose "LeftLowerLeg"
    │           │       └── Pose "LeftFoot"
    │           └── Pose "RightUpperLeg"
    │               └── Pose "RightLowerLeg"
    │                   └── Pose "RightFoot"
    ├── Keyframe (Time = 0.5)
    │   └── ... (same hierarchy)
    └── Keyframe (Time = 1.0)
        └── ... (same hierarchy)
]]

R15 Rig Hierarchy

The pose hierarchy MUST match the R15 rig structure for animations to work:

local R15_HIERARCHY = {
    HumanoidRootPart = {
        LowerTorso = {
            UpperTorso = {
                Head = {},
                LeftUpperArm = { LeftLowerArm = { LeftHand = {} } },
                RightUpperArm = { RightLowerArm = { RightHand = {} } },
            },
            LeftUpperLeg = { LeftLowerLeg = { LeftFoot = {} } },
            RightUpperLeg = { RightLowerLeg = { RightFoot = {} } },
        }
    }
}

Motor6D Locations in R15

Motor6Ds are stored in the CHILD part, not the parent:

Motor6D Name Located In Connects
Root LowerTorso HumanoidRootPart → LowerTorso
Waist UpperTorso LowerTorso → UpperTorso
Neck Head UpperTorso → Head
LeftShoulder LeftUpperArm UpperTorso → LeftUpperArm
RightShoulder RightUpperArm UpperTorso → RightUpperArm
LeftElbow LeftLowerArm LeftUpperArm → LeftLowerArm
RightElbow RightLowerArm RightUpperArm → RightLowerArm
LeftWrist LeftHand LeftLowerArm → LeftHand
RightWrist RightHand RightLowerArm → RightHand
LeftHip LeftUpperLeg LowerTorso → LeftUpperLeg
RightHip RightUpperLeg LowerTorso → RightUpperLeg
LeftKnee LeftLowerLeg LeftUpperLeg → LeftLowerLeg
RightKnee RightLowerLeg RightUpperLeg → RightLowerLeg
LeftAnkle LeftFoot LeftLowerLeg → LeftFoot
RightAnkle RightFoot RightLowerLeg → RightFoot

R15 Motor6D Rotation Directions (Verified)

CRITICAL: These rotation directions were verified through actual testing. All rotations are in radians.

RightShoulder (in RightUpperArm)

  • X+: Forward/down (arm rotates forward toward chest)
  • X-: Back/up (arm rotates backward)
  • Y+: Twist arm inward (palm faces down)
  • Y-: Twist arm outward (palm faces up)
  • Z+: Raise arm sideways (abduction)
  • Z-: Lower arm (adduction)

LeftShoulder (in LeftUpperArm)

  • X+: Forward/down
  • X-: Back/up
  • Y+: Twist outward
  • Y-: Twist inward
  • Z+: Lower arm
  • Z-: Raise arm sideways

RightElbow (in RightLowerArm)

  • X+: Bend elbow (forearm toward bicep)
  • X-: Extend elbow (straighten arm)

LeftElbow (in LeftLowerArm)

  • X+: Bend elbow
  • X-: Extend elbow

Waist (in UpperTorso)

  • X+: Lean forward (bow)
  • X-: Lean backward
  • Y+: Twist left
  • Y-: Twist right
  • Z+: Tilt left
  • Z-: Tilt right

RightHip (in RightUpperLeg)

  • X+: Leg backward (behind body)
  • X-: Leg forward (kick forward)
  • Z+: Leg outward (spread)
  • Z-: Leg inward

LeftHip (in LeftUpperLeg)

  • X+: Leg backward
  • X-: Leg forward
  • Z+: Leg inward
  • Z-: Leg outward

RightKnee (in RightLowerLeg)

  • X+: Bend knee (foot toward buttocks)
  • X-: Extend knee (straighten leg)

LeftKnee (in LeftLowerLeg)

  • X+: Bend knee
  • X-: Extend knee

Neck (in Head)

  • X+: Look down
  • X-: Look up
  • Y+: Turn left
  • Y-: Turn right
  • Z+: Tilt head left
  • Z-: Tilt head right

Building KeyframeSequence Programmatically

local function rad(d) return math.rad(d) end

local function buildPoseHierarchy(hierarchy, rotations, parentPose)
    for jointName, children in pairs(hierarchy) do
        local pose = Instance.new("Pose")
        pose.Name = jointName
        pose.Weight = 1

        local rot = rotations[jointName]
        if rot then
            -- rot = {X, Y, Z} in degrees
            pose.CFrame = CFrame.Angles(rad(rot[1]), rad(rot[2]), rad(rot[3]))
        else
            pose.CFrame = CFrame.new()
        end

        pose.Parent = parentPose

        if children and next(children) then
            buildPoseHierarchy(children, rotations, pose)
        end
    end
end

local function createKeyframeSequence(name, priority, keyframes)
    local sequence = Instance.new("KeyframeSequence")
    sequence.Name = name
    sequence.Loop = false
    sequence.Priority = priority or Enum.AnimationPriority.Action

    for _, kfData in ipairs(keyframes) do
        local keyframe = Instance.new("Keyframe")
        keyframe.Time = kfData.time
        buildPoseHierarchy(R15_HIERARCHY, kfData.poses, keyframe)
        sequence:AddKeyframe(keyframe)
    end

    return sequence
end

Example: Sword Slash Animation

local slashSequence = createKeyframeSequence("SwordSlash", Enum.AnimationPriority.Action, {
    -- Wind up: rotate torso left, arm back
    { time = 0.0, poses = {
        UpperTorso = {0, -30, 0},      -- Twist right (wind up)
        RightUpperArm = {-90, -45, -90}, -- Arm back and raised
        RightLowerArm = {-45, 0, 0},     -- Elbow bent
    }},
    -- Mid swing
    { time = 0.15, poses = {
        UpperTorso = {0, 45, 0},       -- Twist left (swing through)
        RightUpperArm = {-45, 90, -90}, -- Arm coming down
        RightLowerArm = {0, 0, 0},      -- Elbow extending
    }},
    -- Follow through
    { time = 0.3, poses = {
        UpperTorso = {0, 60, 0},       -- Full twist left
        RightUpperArm = {30, 90, -45},  -- Arm across body
        RightLowerArm = {0, 0, 0},
    }},
    -- Return to neutral
    { time = 0.5, poses = {
        UpperTorso = {0, 0, 0},
        RightUpperArm = {0, 0, 0},
        RightLowerArm = {0, 0, 0},
    }},
})

Playing Animations Without Publishing (Studio Testing)

Use KeyframeSequenceProvider:RegisterKeyframeSequence() to create temporary animation IDs for testing in Studio without needing to publish:

local KeyframeSequenceProvider = game:GetService("KeyframeSequenceProvider")

local function playProceduralAnimation(animator, sequence)
    -- Register returns a temporary hash ID like "rbxassetid://12345"
    local hashId = KeyframeSequenceProvider:RegisterKeyframeSequence(sequence)

    local animation = Instance.new("Animation")
    animation.AnimationId = hashId

    local track = animator:LoadAnimation(animation)
    track:Play()

    return track
end

-- Usage
local character = player.Character
local humanoid = character:WaitForChild("Humanoid")
local animator = humanoid:FindFirstChildOfClass("Animator")
if not animator then
    animator = Instance.new("Animator")
    animator.Parent = humanoid
end

local track = playProceduralAnimation(animator, slashSequence)

Complete Tool Animation Example

A complete example that creates tools and plays animations on activation:

--[[
    Complete Animation Tool - Place in StarterPlayerScripts as LocalScript
]]

local Players = game:GetService("Players")
local KeyframeSequenceProvider = game:GetService("KeyframeSequenceProvider")

local player = Players.LocalPlayer

local function rad(d) return math.rad(d) end

local R15_HIERARCHY = {
    HumanoidRootPart = {
        LowerTorso = {
            UpperTorso = {
                Head = {},
                LeftUpperArm = { LeftLowerArm = { LeftHand = {} } },
                RightUpperArm = { RightLowerArm = { RightHand = {} } },
            },
            LeftUpperLeg = { LeftLowerLeg = { LeftFoot = {} } },
            RightUpperLeg = { RightLowerLeg = { RightFoot = {} } },
        }
    }
}

local function buildPoseHierarchy(hierarchy, rotations, parentPose)
    for jointName, children in pairs(hierarchy) do
        local pose = Instance.new("Pose")
        pose.Name = jointName
        pose.Weight = 1
        local rot = rotations[jointName]
        if rot then
            pose.CFrame = CFrame.Angles(rad(rot[1]), rad(rot[2]), rad(rot[3]))
        else
            pose.CFrame = CFrame.new()
        end
        pose.Parent = parentPose
        if children and next(children) then
            buildPoseHierarchy(children, rotations, pose)
        end
    end
end

local function createAnimation(name, priority, keyframes)
    local sequence = Instance.new("KeyframeSequence")
    sequence.Name = name
    sequence.Loop = false
    sequence.Priority = priority
    for _, kf in ipairs(keyframes) do
        local keyframe = Instance.new("Keyframe")
        keyframe.Time = kf.time
        buildPoseHierarchy(R15_HIERARCHY, kf.poses, keyframe)
        sequence:AddKeyframe(keyframe)
    end
    return sequence
end

-- Define animations with verified rotations
local punchSequence = createAnimation("Punch", Enum.AnimationPriority.Action, {
    { time = 0.0, poses = {
        UpperTorso = {0, -20, 0},
        RightUpperArm = {-45, -30, -45},
        RightLowerArm = {-90, 0, 0}
    }},
    { time = 0.1, poses = {
        UpperTorso = {0, 30, 0},
        RightUpperArm = {-90, 60, -90},
        RightLowerArm = {-10, 0, 0}
    }},
    { time = 0.2, poses = {
        UpperTorso = {0, 45, 0},
        RightUpperArm = {-90, 90, -90},
        RightLowerArm = {0, 0, 0}
    }},
    { time = 0.4, poses = {
        UpperTorso = {0, 0, 0},
        RightUpperArm = {0, 0, 0},
        RightLowerArm = {0, 0, 0}
    }},
})

local function setup()
    local character = player.Character or player.CharacterAdded:Wait()
    local humanoid = character:WaitForChild("Humanoid")
    local animator = humanoid:FindFirstChildOfClass("Animator")
    if not animator then
        animator = Instance.new("Animator")
        animator.Parent = humanoid
    end

    -- Register and load animation
    local hashId = KeyframeSequenceProvider:RegisterKeyframeSequence(punchSequence)
    local animation = Instance.new("Animation")
    animation.AnimationId = hashId
    local track = animator:LoadAnimation(animation)

    -- Create tool
    local tool = Instance.new("Tool")
    tool.Name = "Punch"
    tool.RequiresHandle = true
    tool.CanBeDropped = false

    local handle = Instance.new("Part")
    handle.Name = "Handle"
    handle.Size = Vector3.new(1, 1, 1)
    handle.Transparency = 1
    handle.Parent = tool

    tool.Parent = player:WaitForChild("Backpack")

    local debounce = false
    tool.Activated:Connect(function()
        if debounce then return end
        debounce = true
        track:Play()
        task.wait(0.5)
        debounce = false
    end)
end

player.CharacterAdded:Connect(function() task.wait(1) setup() end)
if player.Character then setup() end

Smooth Animation with Many Keyframes

For fluid animations, use 20-30 keyframes with small incremental changes:

-- Generates a smooth punch animation with 26 frames
local function generateSmoothPunch()
    local frames = {}
    local totalTime = 0.7

    -- Phase 1: Wind up (0.0 - 0.15s)
    for i = 0, 5 do
        local t = i / 5 * 0.15
        local progress = i / 5
        table.insert(frames, {
            time = t,
            poses = {
                UpperTorso = {0, -20 * progress, 0},
                RightUpperArm = {-45 * progress, -30 * progress, -45 * progress},
                RightLowerArm = {-90 * progress, 0, 0}
            }
        })
    end

    -- Phase 2: Strike (0.15 - 0.35s)
    for i = 1, 10 do
        local t = 0.15 + (i / 10 * 0.2)
        local progress = i / 10
        table.insert(frames, {
            time = t,
            poses = {
                UpperTorso = {0, -20 + 65 * progress, 0},
                RightUpperArm = {-45 - 45 * progress, -30 + 120 * progress, -45 - 45 * progress},
                RightLowerArm = {-90 + 90 * progress, 0, 0}
            }
        })
    end

    -- Phase 3: Recovery (0.35 - 0.7s)
    for i = 1, 10 do
        local t = 0.35 + (i / 10 * 0.35)
        local progress = i / 10
        table.insert(frames, {
            time = t,
            poses = {
                UpperTorso = {0, 45 * (1 - progress), 0},
                RightUpperArm = {-90 * (1 - progress), 90 * (1 - progress), -90 * (1 - progress)},
                RightLowerArm = {0, 0, 0}
            }
        })
    end

    return createAnimation("SmoothPunch", Enum.AnimationPriority.Action, frames)
end

Animation Easing Functions

Apply easing for natural movement:

local Easing = {}

function Easing.easeInOut(t)
    return t < 0.5
        and 2 * t * t
        or 1 - (-2 * t + 2)^2 / 2
end

function Easing.easeOut(t)
    return 1 - (1 - t)^3
end

function Easing.easeIn(t)
    return t * t * t
end

-- Apply to keyframe generation
local function interpolateWithEasing(startValue, endValue, t, easingFunc)
    local easedT = easingFunc(t)
    return startValue + (endValue - startValue) * easedT
end
Weekly Installs
3
First Seen
Jan 25, 2026
Installed on
cursor2
codex2
claude-code2
windsurf1
opencode1
antigravity1