Skip to content

AnimKit

Plays a registered animation bank through per-rig controllers with cached tracks, exclusive-group crossfades, marker events on the Bus and a fadeable IK layer.

Realm
Shared
Require
ReplicatedStorage.ChloeKernel.AnimKit
Since
v0.3.0
Source
AnimKit.luau

AnimKit is a bank of registered animations played through one controller per rig. Register every clip once (id, priority, speed, fade, exclusive group, markers), attach a rig per model and play by name. The inverse-kinematics layer uses the same fade engine as the tracks, so a procedural look-at or reach blends in over the keyframes instead of snapping on. Everything a rig creates (tracks, IK controls, helper parts) goes away with its model. CutsceneKit plays its Anim events through AnimKit rigs, and Gait layers foot planting over whatever the Animator plays.

There are two levels of state.

The kit holds the registry (name -> AnimConfig), one shared Animation instance per registered name, the set of live rigs and the table of IK weight fades. It has no permanent loop. The first fade starts a 1/30 s job on the kernel Scheduler at Priority.Low, and the job cancels itself on the tick that finishes the last fade. An idle kit has no scheduler work and LoopHandle is nil.

A rig comes from attachRig(model). It holds the model’s loaded tracks, the clip currently playing in each exclusive group, its named IK handles and a queue of plays waiting for the Workspace. The model’s Destroying event destroys the rig. For a player character it also watches AncestryChanged, so a character that leaves the game without Destroy (a place that keeps old characters with PlayerCharacterDestroyBehavior) still releases its rig.

attachRig looks for an Animator under the model’s Humanoid, or under an AnimationController when there is no Humanoid. What happens when none exists depends on the machine:

  • On the server it creates one. A server-created Animator replicates to every client.
  • On a client it waits up to 5 s for the replicated Animator (WaitForChild), so attachRig can yield there. If none arrives it logs a warning and the rig has no Animator. It never creates one: a client-created Animator does not replicate, so other players would never see what it plays.

A rig without an Animator still attaches. The first play on it raises <model> has no Animator, so "<name>" cannot load. Models that clients create themselves (viewport previews, local props) should carry an Animator in their template.

Tracks load lazily and cache per rig. The first play("Swing") calls Animator:LoadAnimation, applies the config’s Priority and Looped overrides and connects the marker signals. Later plays reuse the track. Each marker listed in Markers publishes Anim.Marker(model, animName, markerName, param) on the kernel Bus, so combat code keys damage off an Impact marker without holding a track.

Animator:LoadAnimation throws until the Animator is inside the Workspace: a character before it is parented, a pooled model, a preview model in a ViewportFrame. A play or setParameter that needs a load before then is queued and play returns nil. One AncestryChanged connection per rig watches for the model to arrive and then runs the queue in call order. A queued call that fails is logged and the rest still run. A later call with the same key replaces the queued one (the key is the animation name for plays, and name plus parameter key for parameters), so ten plays of Idle while off-Workspace run once. stop(name) drops queued plays of that animation and stopAll drops every queued play.

A config with Group = "Locomotion" claims that group while it plays. Playing another clip in the same group stops the current one with the newcomer’s fade, so Walk crossfades into Run with no bind-pose frame between them. Clips without a group stack freely.

Playing the clip that already holds the group, while its track is playing, does not restart it. The rig calls AdjustSpeed and AdjustWeight(weight, fade) on the running track instead. Movement code can call play("Run") every time the state machine reports running without resetting the cycle to frame 0.

rig:setParameter(name, key, value) loads the track if needed and calls AnimationTrack:SetParameter, which drives animation graph inputs (a blend space, a mask, a state machine parameter). The engine keeps a value set before the graph has loaded.

rig:setLod(preferLod) writes Animator.PreferLodEnabled. false keeps the rig animating at full rate at any distance, which suits bosses and cutscene rigs. true lets the engine throttle it when it is far away or small on screen.

rig:ik(name, options) builds an IKControl named CKIK_<name> under the rig’s Humanoid, or under the AnimationController that owns its Animator. The friendly type names map onto Enum.IKControlType: LookAt, Reach (Position), Transform and Rotation. The weight fades from 0 to its target over FadeIn seconds on the kit stepper with smoothstep easing.

A Vector3 target creates an invisible, anchored, non-colliding, non-queryable 0.2 stud helper part (CKIKTarget_<name>) in workspace, and setTarget moves it, so aim points do not need a real Instance.

IKControl.Weight = 0 does not turn a control off: SmoothTime and Pole still move the limb. AnimKit sets Enabled = false whenever a weight lands on 0 (an ik call with Weight = 0, or a setWeight fade that completes at 0) and sets Enabled = true before any raise.

release(seconds) fades the weight to 0, then destroys the control and the helper part. A setWeight during that fade replaces the fade but inherits its cleanup, so the control and helper part are still destroyed when it ends. Destroying the kit mid-release runs the pending cleanups at once. Fade completions run after the whole tick’s writes, on their own threads, so a completion that starts a new fade cannot disturb the sweep.

rig:destroy() stops every track with fade 0, destroys the loaded tracks (each LoadAnimation creates a track that lives until destroyed, so a rig re-attached to a persistent model would otherwise pile them up), releases every IK handle at once, clears the queue and disconnects its connections. It is idempotent. kit:destroy() destroys every rig, cancels the stepper, finishes pending release fades and destroys the cached Animation instances.

Register the bank and animate the local character

Section titled “Register the bank and animate the local character”
src/Client/Game/Controllers/MotionController.luau
--!strict
-- MotionController: the local character's animation bank and rig.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local AnimKit = require(ReplicatedStorage.ChloeKernel.AnimKit)
local MotionController = {
Name = "MotionController",
}
function MotionController.init(self: any, kernel: any)
self.Kernel = kernel
self.Anims = AnimKit.attach(kernel)
-- Replace the ids with your own uploads
self.Anims:registerBank({
Idle = { Id = "rbxassetid://10", Group = "Locomotion", Looped = true },
Walk = { Id = "rbxassetid://11", Group = "Locomotion", Looped = true },
Run = { Id = "rbxassetid://12", Group = "Locomotion", Looped = true },
Swing = { Id = "rbxassetid://13", Markers = { "Impact" }, Priority = Enum.AnimationPriority.Action },
Wave = { Id = "rbxassetid://14", Speed = 1.25, FadeIn = 0.2 },
})
end
function MotionController.start(self: any)
self.Scope = self.Kernel:createScope()
local function onCharacter(character: Model)
-- The previous rig released itself when its model left.
-- attachRig can yield up to 5 s here while the server's Animator replicates.
self.Rig = self.Anims:attachRig(character)
self.Rig:play("Idle")
end
local Player = Players.LocalPlayer
self.Scope:add(Player.CharacterAdded:Connect(onCharacter))
if Player.Character then
task.spawn(onCharacter, Player.Character)
end
end
function MotionController.setMoving(self: any, speed: number)
-- Same group: Idle, Walk and Run crossfade. Repeating the current clip adjusts it in place.
if speed > 14 then
self.Rig:play("Run", { Fade = 0.3 })
elseif speed > 0.5 then
self.Rig:play("Walk", { Speed = speed / 12 })
else
self.Rig:play("Idle")
end
end
function MotionController.stop(self: any)
if self.Scope then
self.Scope:destroy()
end
self.Anims:destroy()
end
return MotionController
self.Scope:add(self.Kernel.Bus:subscribe("Anim.Marker", function(_topic, model, animName, marker, param)
if model == Players.LocalPlayer.Character and animName == "Swing" and marker == "Impact" then
self.Net.MeleeHit.fire(param) -- param is the marker's parameter string
end
end))
self.Rig:play("Swing")

Play on a model that is not in the Workspace yet

Section titled “Play on a model that is not in the Workspace yet”
local Preview = Template:Clone() -- carries its own Animator in the template
local PreviewRig = Anims:attachRig(Preview)
local Track = PreviewRig:play("Wave") -- nil: queued until the model reaches the Workspace
PreviewRig:setParameter("Wave", "Energy", 0.8) -- queued too, applied after the play
Preview.Parent = workspace.Previews -- the queue runs in call order

Drive a graph and keep a boss at full rate

Section titled “Drive a graph and keep a boss at full rate”
local BossRig = Anims:attachRig(Boss)
BossRig:setLod(false) -- never throttled, even far from the camera
BossRig:play("Locomotion")
BossRig:setParameter("Locomotion", "Speed", 12)

Blend inverse kinematics over the keyframes

Section titled “Blend inverse kinematics over the keyframes”
local Character = Players.LocalPlayer.Character
local Head = Character:WaitForChild("Head")
local Waist = Character:WaitForChild("UpperTorso")
local RightHand = Character:WaitForChild("RightHand")
-- Look at a target, fading in over 0.4 s
local Aim = Rig:ik("Aim", {
Type = "LookAt",
ChainRoot = Waist,
EndEffector = Head,
Target = EnemyHead,
FadeIn = 0.4,
})
Aim:setWeight(0.5, 0.3) -- half-strength glance, blended over 0.3 s
-- Reach for a world position: the Vector3 rides a helper part
local Grab = Rig:ik("Grab", {
Type = "Reach",
EndEffector = RightHand,
Target = Vector3.new(3, 4, 2),
})
Grab:setTarget(Vector3.new(3, 5, 2)) -- same helper part, new position
-- Raw IKControl properties for anything the options do not cover
Rig:ik("Lean", {
Type = "Rotation",
EndEffector = Waist,
Properties = { SmoothTime = 0.15 },
})
Aim:setWeight(0, 0.2) -- lands on 0, so the control is disabled
Grab:release(0.3) -- fades out, then the control and helper part are destroyed

assetIds() returns every registered id. Feed the kit to Preload so first plays do not wait on a download:

local Preload = require(ReplicatedStorage.ChloeKernel.Preload)
local Warmup = Preload.run(kernel, { Banks = { Anims } })
Warmup:await()
Member Description
AnimKit.attach(kernel, options?) -> AnimKit options = { LoadTrack?, SkipLoop? }. LoadTrack(rig, name, config) -> track replaces the loader for specs and custom rigs (an injected loader skips the Workspace wait). SkipLoop never starts the stepper so specs call _step(dt).
kit:register(name, config) Adds one AnimConfig, replacing any config with that name.
kit:registerBank(bank) Registers { [name] = AnimConfig } in one call.
kit:assetIds() -> { string } Every registered animation id, for Preload.
kit:attachRig(model) -> Rig One controller per model. Finds the Animator; creates it on the server, waits up to 5 s for it on a client.
kit:destroy() Destroys every rig, cancels the stepper, finishes release fades and destroys the cached Animation instances.
Member Description
Id: string The animation asset id. Required.
Priority: Enum.AnimationPriority? Applied to the track at load.
Speed: number? Playback speed. Default 1.
FadeIn: number? Default fade for play, in seconds. Default 0.1.
Looped: boolean? Overrides the asset’s own flag at load.
Group: string? Exclusive group. Playing a clip stops the group’s current clip with the same fade.
Markers: { string }? Marker names forwarded to the Bus as Anim.Marker.
Member Description
rig:play(name, options?) -> AnimationTrack? Loads once and plays. options = { Fade?, Speed?, Weight? }: Fade defaults to the config’s FadeIn then 0.1, Speed to the config’s Speed then 1, Weight to 1. Returns nil while the play waits for the Workspace. Re-playing the running clip of a group adjusts it. Errors on an unregistered name.
rig:stop(name, fadeSeconds?) Stops the track (default fade 0.1), clears its group claim and drops its queued plays.
rig:stopAll(fadeSeconds?) Stops every loaded track (default fade 0.1), clears every group claim and drops every queued play.
rig:setSpeed(name, speed) AdjustSpeed on the loaded track. Does nothing if the clip never loaded.
rig:setParameter(name, key, value) AnimationTrack:SetParameter on the clip’s track, loading it if needed. Queued while the model is outside the Workspace. Errors on an unregistered name.
rig:setLod(preferLod) Writes Animator.PreferLodEnabled. false keeps the rig at full rate at any distance.
rig:ik(name, options) -> IkHandle A named, fadeable IKControl. Re-declaring a name releases the old control at once. Errors without a Humanoid or an AnimationController with an Animator.
rig:destroy() Stops and destroys every track, releases every IK handle at once, clears the queue and disconnects. Idempotent.
Member Description
Type: string? "LookAt", "Reach", "Transform" or "Rotation". Default "LookAt". Reach maps to IKControlType.Position. Unknown names error.
Target: (Instance | Vector3)? A Vector3 creates the helper part CKIKTarget_<name> in workspace.
ChainRoot: Instance? First joint of the solved chain.
EndEffector: Instance? The limb end the solver drives.
Weight: number? Blend weight against the keyframes. Default 1. A weight of 0 creates the control disabled.
FadeIn: number? Seconds to fade the weight in from 0. Default 0.25; 0 snaps.
Properties: { [string]: any }? Raw IKControl properties (SmoothTime, Offset, Pole) applied after the options.
Member Description
handle.Control The raw IKControl.
handle:setWeight(weight, fadeSeconds?) Fades the weight; nil or 0 seconds snaps. Enables the control before a raise and disables it when the weight lands on 0.
handle:setTarget(target) A new Instance target, or a Vector3 that moves the helper part.
handle:release(fadeSeconds?) Fades the weight to 0, then destroys the control and helper part. nil or 0 destroys them at once.
Topic Payload Fired
Anim.Marker model, animName, markerName, param A track reached a keyframe marker listed in its config’s Markers.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.