State Machine and Transitions
Every AnimationController contains a built-in finite state machine. Instead of manually
calling Play and Stop in response to every game event, you define states and predicates
once. The state machine evaluates predicates every frame and transitions automatically when
conditions are met.
What the State Machine Does
The state machine has a current state. Every frame it checks the current state's transition rules, evaluates their predicate functions in priority order, and transitions to the first state whose condition is satisfied.
When a transition fires, the machine runs the exit actions of the outgoing state, then the
entry actions of the incoming state. Entry and exit actions call Play, Stop, or StopGroup
on the controller.
The machine also manages layer weights. Each state can mark layers as active or suppressed. Active layers hold their base weight. Suppressed layers lerp toward zero.
Defining States
Each state is defined with the :State(name) builder on BehaviorBuilder. States have:
- Entry actions -- what happens when this state becomes active
- Exit actions -- what happens when this state is left
- Transitions -- rules that describe when to leave this state and where to go
- Active layers -- layers this state enables at their base weight
- Suppressed layers -- layers this state sets to zero weight
:State("Walk")
:OnEntry()
:Play("Walk")
:Done()
:OnExit()
:Stop("Walk")
:Done()
:ActiveLayer("Base")
:Transition("Idle", "IsIdle"):Priority(10):Done()
:Transition("Run", "IsRunning"):Priority(11):Done()
:Done()
Entry and Exit Actions
:OnEntry() and :OnExit() each return a directive list builder with three operations:
| Method | Effect |
|---|---|
:Play(animationName) | Call Controller:Play(name) when the action fires |
:Stop(animationName, immediate?) | Call Controller:Stop(name, immediate) |
:StopGroup(groupName, immediate?) | Call Controller:StopGroup(name, immediate) |
Multiple directives can be chained on the same list:
:OnEntry()
:Play("AimIdle")
:Play("AimOverlay")
:Done()
:OnExit()
:Stop("AimIdle")
:StopGroup("WeaponActions")
:Done()
Transitions and Conditions
Each transition rule requires a target state name and a condition name. The condition name
references a predicate function registered with :Predicate().
:Transition("Run", "IsRunning"):Priority(11):Done()
When the state machine is in the state that owns this transition, it calls the "IsRunning"
predicate every frame. When it returns true, the machine transitions to "Run".
Priority resolves conflicts when multiple transitions are satisfied simultaneously. Higher numbers win. When priorities are equal, declaration order is the tiebreaker.
Predicates
Predicates are plain boolean functions. Register them on the builder with :Predicate():
:Predicate("IsIdle", function()
return humanoid.MoveDirection.Magnitude <= 0.1
end)
:Predicate("IsWalking", function()
return humanoid.MoveDirection.Magnitude > 0.1
and humanoid.MoveDirection.Magnitude < 0.8
end)
:Predicate("IsRunning", function()
return humanoid.MoveDirection.Magnitude >= 0.8
end)
Predicates are called every frame by the state machine. Keep them cheap. Avoid any operation that yields, reads from a remote, or allocates per call.
Predicates are shared across all states in a behavior. A predicate registered once can be referenced by any number of transitions in any state.
Layer Activation and Suppression
States control layer visibility through ActiveLayer and SuppressLayer.
When a state becomes active:
- Each
ActiveLayerlayer has itsTargetWeightset to itsBaseWeight - Each
SuppressLayerlayer has itsTargetWeightset to0
Layers lerp toward their target weight at their configured LerpRate each frame. A layer not
mentioned in either list is not touched by the state transition.
:State("Idle")
:ActiveLayer("Base")
:SuppressLayer("UpperBody")
:OnEntry():Play("Idle"):Done()
:OnExit():Stop("Idle"):Done()
:Transition("Walk", "IsWalking"):Priority(10):Done()
:Done()
:State("AimDownSights")
:ActiveLayer("Base")
:ActiveLayer("UpperBody")
:OnEntry():Play("ADS"):Done()
:OnExit():Stop("ADS"):Done()
:Transition("Idle", "IsIdle"):Priority(10):Done()
:Done()
In "Idle", the "UpperBody" layer is suppressed and its weight fades to zero. In
"AimDownSights", both layers are active, allowing the ADS animation and a locomotion
animation to blend together.
Initial State
Every behavior must have an initial state set with :InitialState(). This is the state the
machine starts in when the controller is created. It must match one of the defined state names.
:InitialState("Idle")
The entry actions of the initial state are not automatically run on controller creation. If
you need animations to start immediately, call Play directly after creating the controller
or wire it via an explicit call to RequestStateTransition.
Manual Transitions
Call RequestStateTransition(stateName, priority) to bypass predicates and queue a direct
transition:
-- Immediate override to a jump state, with high priority
Controller:RequestStateTransition("Jump", 100)
Manual transitions are processed before predicate transitions in the same frame. If multiple manual transitions are queued in the same frame, the highest-priority one fires and the rest are discarded.
Manual transitions to the current state are no-ops. The machine will not re-run entry actions for a state it is already in.
Terminal States
A state with no transitions defined is a terminal state. The machine stays in it indefinitely.
Predicates are not evaluated while in a terminal state. The only way out is via
RequestStateTransition.
Terminal states are useful for death animations, cinematic sequences, or any context where automatic resumption should not happen.
:State("Dead")
:OnEntry():Play("Death"):Done()
-- No transitions: terminal state
:Done()
A Complete Humanoid Example
local Motix = require(ReplicatedStorage.Motix)
local Behavior = Motix.BehaviorBuilder.new()
:Animation("Idle")
:AssetId("rbxassetid://IDLE"):Layer("Base"):Looped(true):Priority(0):FadeIn(0.2):FadeOut(0.2)
:Done()
:Animation("Walk")
:AssetId("rbxassetid://WALK"):Layer("Base"):Looped(true):Priority(1):FadeIn(0.15):FadeOut(0.15)
:Done()
:Animation("Run")
:AssetId("rbxassetid://RUN"):Layer("Base"):Looped(true):Priority(2):FadeIn(0.1):FadeOut(0.1)
:Done()
:Animation("Jump")
:AssetId("rbxassetid://JUMP"):Layer("Base"):Looped(false):Priority(5):FadeIn(0.05):FadeOut(0.1)
:Done()
:Layer("Base"):Order(0):BaseWeight(1.0):LerpRate(8.0):Done()
:State("Idle")
:OnEntry():Play("Idle"):Done()
:OnExit():Stop("Idle"):Done()
:ActiveLayer("Base")
:Transition("Walk", "IsWalking"):Priority(10):Done()
:Transition("Run", "IsRunning"):Priority(11):Done()
:Done()
:State("Walk")
:OnEntry():Play("Walk"):Done()
:OnExit():Stop("Walk"):Done()
:ActiveLayer("Base")
:Transition("Idle", "IsIdle"):Priority(10):Done()
:Transition("Run", "IsRunning"):Priority(11):Done()
:Done()
:State("Run")
:OnEntry():Play("Run"):Done()
:OnExit():Stop("Run"):Done()
:ActiveLayer("Base")
:Transition("Idle", "IsIdle"):Priority(10):Done()
:Transition("Walk", "IsWalking"):Priority(9):Done()
:Done()
:State("Jump")
:OnEntry():Play("Jump"):Done()
:OnExit():Stop("Jump"):Done()
:ActiveLayer("Base")
:Transition("Idle", "IsIdle"):Priority(10):Done()
:Done()
:Predicate("IsIdle", function() return humanoid.MoveDirection.Magnitude <= 0.1 end)
:Predicate("IsWalking", function() return humanoid.MoveDirection.Magnitude > 0.1 and humanoid.WalkSpeed < 18 end)
:Predicate("IsRunning", function() return humanoid.MoveDirection.Magnitude > 0.1 and humanoid.WalkSpeed >= 18 end)
:InitialState("Idle")
:Build()
local Config = Behavior:CreateController(
character.Name,
character.Humanoid:FindFirstChildOfClass("Animator"),
true,
Players.LocalPlayer
)
local Controller = Motix.new(Config)
-- Wire jump event to manual transition
humanoid.Jumping:Connect(function()
Controller:RequestStateTransition("Jump", 100)
end)