Skip to main content

Animations That Know What They're Doing

Motix is an animation controller system for Roblox. It manages multi-layer animation blending, a predicate-driven state machine, exclusive animation groups, and MotixNet: a built-in network layer for replicating animation state across client and server.


One Folder. One Require.

Drop the Motix folder into ReplicatedStorage and require it from your scripts.


The Registry Comes First

AnimationRegistry is a global singleton. Initialize it once at startup, before any AnimationController is created. Every animation config your game uses goes here.

local Motix             = require(ReplicatedStorage.Motix)
local AnimationRegistry = require(ReplicatedStorage.Motix.Modules.AnimationRegistry)

local Registry = AnimationRegistry.GetInstance()

Registry:Init({
{
Name = "Idle",
AssetId = "rbxassetid://YOUR_ANIMATION_ID",
Layer = "Base",
Looped = true,
Priority = 0,
FadeInTime = 0.2,
FadeOutTime = 0.2,
Speed = 1.0,
Weight = 1.0,
CanInterrupt = true,
Tags = {},
Additive = false,
},
})

Init validates every config at call time. Bad field types, missing required fields, and duplicate names all produce errors immediately rather than surfacing later at runtime.


Build the Behavior. Create the Controller.

BehaviorBuilder defines the full animation setup for a character type: which animations exist, which layers they belong to, and which states drive them. Build it once per character type, then use it to create as many controllers as needed.

local Behavior = Motix.BehaviorBuilder.new()
:Animation("Idle")
:AssetId("rbxassetid://IDLE_ID")
:Layer("Base")
:Looped(true)
:Priority(0)
:FadeIn(0.2)
:FadeOut(0.2)
:Done()
:Layer("Base")
:Order(0)
:BaseWeight(1.0)
:LerpRate(8.0)
:Done()
:InitialState("Idle")
:Build()

Then create a controller from the built behavior:

local Config = Behavior:CreateController(
character.Name,
character.Humanoid:FindFirstChildOfClass("Animator"),
isOwningClient,
player
)

local Controller = Motix.new(Config)

Pass the Animator only from the client. On the server the argument is accepted but ignored. Animation tracks are only loaded and played on clients.


The Controller Listens. You React.

local Controller = Motix.new(Config)

Controller.OnPlay:Connect(function(AnimationName)
print("Playing:", AnimationName)
end)

Controller.OnStop:Connect(function(AnimationName)
print("Stopped:", AnimationName)
end)

OnPlay and OnStop are controller-level signals. They fire for every animation on that controller, regardless of layer or group. Use the animation name argument to dispatch.


Play. Stop. Done.

-- Queue an animation to play on the next frame
Controller:Play("Idle")

-- Stop with a fade-out
Controller:Stop("Idle")

-- Stop immediately, no fade
Controller:Stop("Idle", true)

Play is safe to call every frame. If the animation is already playing, the request is a no-op. Requests are queued and flushed once per frame in the correct order.


Layers Let Them Coexist

Each layer has an order, a base weight, and an optional additive mode. Higher-order layers blend on top of lower ones. Additive layers compose rather than replace.

:Layer("Base")
:Order(0)
:BaseWeight(1.0)
:LerpRate(8.0)
:Done()
:Layer("UpperBody")
:Order(1)
:BaseWeight(1.0)
:Additive(true)
:LerpRate(6.0)
:Done()

LerpRate controls how quickly the layer weight interpolates toward its target each second. Set it to math.huge for an instant snap.

Animations on UpperBody play on top of Base animations simultaneously. A rifle raise on UpperBody can coexist with a run cycle on Base.


Groups Enforce Mutual Exclusion

Animations in the same group compete. Only one plays at a time. The highest-priority animation wins. Lower-priority animations are deferred or rejected.

:Animation("Fire")
:AssetId("rbxassetid://FIRE_ID")
:Layer("UpperBody")
:Group("WeaponAction")
:Priority(10)
:Looped(false)
:FadeIn(0.05)
:FadeOut(0.1)
:Done()
:Animation("Reload")
:AssetId("rbxassetid://RELOAD_ID")
:Layer("UpperBody")
:Group("WeaponAction")
:Priority(5)
:Looped(false)
:FadeIn(0.1)
:FadeOut(0.15)
:Done()

If "Reload" is playing and Controller:Play("Fire") is called, "Fire" wins because it has higher priority. "Reload" stops. If "Fire" is playing and Controller:Play("Reload") is called, the request is rejected.

You can stop all animations in a group at once:

Controller:StopGroup("WeaponAction")
Controller:StopGroup("WeaponAction", true) -- immediate

Tags for Batch Operations

Assign tags to animations and play all of them in one call:

:Animation("FootstepLeft")
:AssetId("rbxassetid://FOOTSTEP_L_ID")
:Layer("Base")
:Tag("footstep")
:Looped(false)
:FadeIn(0.05)
:FadeOut(0.05)
:Done()
:Animation("FootstepRight")
:AssetId("rbxassetid://FOOTSTEP_R_ID")
:Layer("Base")
:Tag("footstep")
:Looped(false)
:FadeIn(0.05)
:FadeOut(0.05)
:Done()
-- Plays both FootstepLeft and FootstepRight
Controller:PlayTag("footstep")

States Drive Everything

Define states with entry/exit actions and predicate-driven transitions. The state machine evaluates predicates every frame and switches states automatically when conditions are met.

local Behavior = Motix.BehaviorBuilder.new()
:Animation("Idle")
:AssetId("rbxassetid://IDLE_ID"):Layer("Base"):Looped(true):Priority(0):FadeIn(0.2):FadeOut(0.2)
:Done()
:Animation("Walk")
:AssetId("rbxassetid://WALK_ID"):Layer("Base"):Looped(true):Priority(1):FadeIn(0.15):FadeOut(0.15)
: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()
:Done()
:State("Walk")
:OnEntry():Play("Walk"):Done()
:OnExit():Stop("Walk"):Done()
:ActiveLayer("Base")
:Transition("Idle", "IsIdle"):Priority(10):Done()
:Done()

:Predicate("IsWalking", function()
return humanoid.MoveDirection.Magnitude > 0.1
end)
:Predicate("IsIdle", function()
return humanoid.MoveDirection.Magnitude <= 0.1
end)

:InitialState("Idle")
:Build()

When the state machine transitions, it runs exit actions on the outgoing state, then entry actions on the incoming state. Layers listed as ActiveLayer have their weight set to their base value. Layers listed as SuppressLayer have their weight set to zero.


States Can Suppress Layers

Use SuppressLayer to shut down a layer when it is not relevant in a state:

:State("AimDownSights")
:OnEntry():Play("ADS"):Done()
:OnExit():Stop("ADS"):Done()
:ActiveLayer("UpperBody")
:SuppressLayer("Base")
:Done()

While "AimDownSights" is active, the "Base" layer weight lerps toward zero. Exiting the state restores the layer to its base weight.


Force a Transition

RequestStateTransition bypasses predicates and queues a direct state change. Use it for events that should override the normal evaluation flow:

humanoid.Jumping:Connect(function()
Controller:RequestStateTransition("Jump", 100)
end)

The second argument is priority. If multiple transitions are requested in the same frame, the highest priority wins.


Start From a Preset

BehaviorBuilder.Humanoid() returns a pre-built behavior with Idle, Walk, and Run animations on Base and UpperBody layers, complete with state machine and predicates. Swap in your own asset IDs and extend from there.

local Behavior = Motix.BehaviorBuilder.Humanoid()
:Animation("Sprint")
:AssetId("rbxassetid://SPRINT_ID")
:Layer("Base")
:Looped(true)
:Priority(3)
:FadeIn(0.1)
:FadeOut(0.1)
:Done()
:Build()
tip

The Humanoid preset uses "rbxassetid://0" as placeholder asset IDs. Replace them with your actual animation IDs before shipping.


Compose Behaviors With Clone and Merge

Clone produces an independent copy of a builder. Merge applies one or more built behaviors onto a clone without mutating any of them:

local Base = Motix.BehaviorBuilder.Humanoid()

-- Two independent behaviors sharing the same base
local ArmedBehavior = Base:Clone()
:Animation("FireRifle")
:AssetId("rbxassetid://FIRE_ID")
:Layer("UpperBody")
:Looped(false)
:Priority(5)
:Done()
:Build()

local UnarmedBehavior = Base:Clone():Build()

The Network Layer You Didn't Have to Write

MotixNet replicates animation intents across server and clients over a single RemoteEvent. The owning client sends intents automatically when animations play or stop. Other clients receive and replay them automatically.

Shared HashRegistry (require from both sides):

-- SharedAnimations.lua
local Motix = require(ReplicatedStorage.Motix)

local HashReg = Motix.MotixNet.HashRegistry.new()
HashReg:Register("Idle")
HashReg:Register("Walk")
HashReg:Register("Run")
HashReg:Register("Fire")
HashReg:Register("Reload")
return HashReg
Registration order

Both server and client must register animation names in the same order. Registration order determines the hash value. If the orders diverge, all intents are rejected as UnknownAnimHash. Enforce this by requiring the same shared ModuleScript on both sides.

Server:

local Net = Motix.MotixNet.new(ServerController, SharedHashReg, {
TokensPerSecond = 30,
BurstLimit = 60,
ReplicateState = true,
})

Net.OnIntentAccepted:Connect(function(player, animName, action)
-- action is "PLAY" or "STOP"
print(player.Name, action, animName)
end)

Net.OnIntentRejected:Connect(function(player, reason)
warn(player.Name, "intent rejected:", reason)
end)

Client:

local Net = Motix.MotixNet.new(ClientController, SharedHashReg)
-- No extra setup needed for standard replication.
-- The owning client sends intents automatically.
-- Other clients receive and replay them automatically.

Inspect the Controller

Attach a debug inspector to get a live view of active animations, layer weights, and state machine status:

local Inspector = Controller:AttachInspector()

Use this during development to verify that layers, groups, and state transitions are behaving as expected.