animation-system
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