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.
Mental model
Section titled “Mental model”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.
Where the Animator comes from
Section titled “Where the Animator comes from”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), soattachRigcan 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 and plays
Section titled “Tracks and plays”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.
Exclusive groups
Section titled “Exclusive groups”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.
Graph parameters and LOD
Section titled “Graph parameters and LOD”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.
The IK layer
Section titled “The IK layer”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.
Teardown
Section titled “Teardown”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”--!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) endend
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") endend
function MotionController.stop(self: any) if self.Scope then self.Scope:destroy() end self.Anims:destroy()end
return MotionControllerKey gameplay off markers
Section titled “Key gameplay off markers”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 endend))
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 templatelocal PreviewRig = Anims:attachRig(Preview)
local Track = PreviewRig:play("Wave") -- nil: queued until the model reaches the WorkspacePreviewRig:setParameter("Wave", "Energy", 0.8) -- queued too, applied after the play
Preview.Parent = workspace.Previews -- the queue runs in call orderDrive 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 cameraBossRig: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.Characterlocal Head = Character:WaitForChild("Head")local Waist = Character:WaitForChild("UpperTorso")local RightHand = Character:WaitForChild("RightHand")
-- Look at a target, fading in over 0.4 slocal 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 partlocal 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 coverRig:ik("Lean", { Type = "Rotation", EndEffector = Waist, Properties = { SmoothTime = 0.15 },})
Aim:setWeight(0, 0.2) -- lands on 0, so the control is disabledGrab:release(0.3) -- fades out, then the control and helper part are destroyedWarm the bank behind the loading screen
Section titled “Warm the bank behind the loading screen”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()API reference
Section titled “API reference”AnimKit
Section titled “AnimKit”| 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. |
AnimConfig
Section titled “AnimConfig”| 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. |
IkOptions
Section titled “IkOptions”| 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. |
IkHandle
Section titled “IkHandle”| 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. |
Bus topics
Section titled “Bus topics”| Topic | Payload | Fired |
|---|---|---|
Anim.Marker |
model, animName, markerName, param |
A track reached a keyframe marker listed in its config’s Markers. |