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
Weekly Installs
2
Installed on
codex2
claude-code2
windsurf1
opencode1
cursor1
antigravity1