AnimKit
AnimKit is a bank of registered animations played through per-rig controllers. Register every clip once at boot — id, priority, speed, fade, exclusive group, markers — then attach a rig per model and play by name. The defining decision: the inverse-kinematics layer rides the same fade engine as the tracks, so a procedural look-at or reach blends in and out over the keyframed motion like any other animation instead of snapping on. Everything a rig creates — tracks, IK controls, helper parts — dies with its model.
Mental model
Section titled “Mental model”Two levels of state:
- The kit holds the registry (
name → AnimConfig), a kit-wideAnimationinstance cache (oneAnimationper registered name, shared by every rig), the set of live rigs, and the fade table. Onattachit registers a 30Hz stepper on the kernel Scheduler atPriority.Low; that one loop advances every IK weight fade in the game. - A rig is one controller per model from
attachRig(model). It resolves anAnimator— under the model’sHumanoid(created if missing), or under anAnimationControllerfor non-humanoid rigs — and keeps per-rig state: loaded tracks (name → AnimationTrack), the currently playing clip per exclusive group, and named IK handles. The model’sDestroyingconnection auto-destroys the rig, so a despawned character or NPC cleans itself up.
Tracks load lazily and cache per rig: the first play("Swing") calls Animator:LoadAnimation once, applies the config’s Priority and Looped overrides, and connects the marker signals; every later play reuses the track. Marker events forward to the kernel Bus as Anim.Marker(model, animName, markerName, param) — combat code keys damage off the Impact marker without holding a track reference.
Exclusive groups are the anti-T-pose mechanism. A config with Group = "Locomotion" claims that group while playing; playing another clip in the same group stops the incumbent with the newcomer’s fade, so Walk crossfades into Run with no blank frame between them. Ungrouped clips stack freely.
The IK layer wraps IKControl. rig:ik(name, options) builds a control under the Humanoid, maps the friendly type names (LookAt, Reach, Transform, Rotation) onto Enum.IKControlType (Reach is Position), and fades Weight from 0 to the target over FadeIn seconds on the kit stepper — smoothstep-eased, like every other fade in the kit. A Vector3 target spawns an invisible anchored helper part in workspace that setTarget repositions, so aim points don’t need a real Instance. release() fades the weight out, then destroys the control and helper — the skeleton returns to pure keyframes.
The fade engine is keyed by the object itself, not its name (tostring(instance) returns only the Name and same-named objects would collide), with one fade per property; starting a new fade on the same object and property replaces the old one mid-flight.
Register the bank, animate a character
Section titled “Register the bank, animate a character”-- Client Bootstrap (server rigs work the same through a server Animator)local ReplicatedStorage = game:GetService("ReplicatedStorage")local Players = game:GetService("Players")
local Kernel = require(ReplicatedStorage.ChloeKernel).boot()local AnimKit = require(ReplicatedStorage.ChloeKernel.AnimKit)
local Anims = AnimKit.attach(Kernel)Anims:registerBank({ Walk = { Id = "rbxassetid://10", Group = "Locomotion" }, Run = { Id = "rbxassetid://11", Group = "Locomotion" }, Swing = { Id = "rbxassetid://12", Markers = { "Impact" }, Priority = Enum.AnimationPriority.Action }, Wave = { Id = "rbxassetid://13", Speed = 1.25, FadeIn = 0.2 },})
local Rig = nilPlayers.LocalPlayer.CharacterAdded:Connect(function(character) Rig = Anims:attachRig(character) -- the old rig died with the old model Rig:play("Walk")end)
-- Same Group: Walk crossfades out under Run — no T-pose blinkRig:play("Run", { Fade = 0.3 })
-- Ungrouped: plays on top at its configured priorityRig:play("Wave")Rig:setSpeed("Wave", 0.5)Rig:stop("Wave", 0.2)Key gameplay off markers
Section titled “Key gameplay off markers”Kernel.Bus:subscribe("Anim.Marker", function(_, model, animName, marker, param) if animName == "Swing" and marker == "Impact" then requestMeleeHit(param) -- param is the marker's parameter string endend)
Rig:play("Swing")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: fades in over 0.4s on the kit stepperlocal 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.3s
-- Reach for a world position: the Vector3 rides a movable 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 overrides for anything the options don't coverRig:ik("Lean", { Type = "Rotation", EndEffector = Waist, Properties = { SmoothTime = 0.15 },})
Aim:release(0.3) -- fade out, then the control and helper are destroyedWarm the bank behind the loading screen
Section titled “Warm the bank behind the loading screen”assetIds() returns every registered animation id — feed it to Preload so first plays don’t hitch on 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 injects a track source for specs and custom rigs; SkipLoop skips the 30Hz stepper so specs drive _step(dt) directly |
kit:register(name, config) |
Adds one AnimConfig to the bank (overwrites the name) |
kit:registerBank(bank) |
{ [name] = AnimConfig } in one call |
kit:assetIds() → { string } |
Every registered animation id — feed to Preload |
kit:attachRig(model) → Rig |
One controller per model. Resolves the Animator (Humanoid — created if missing — or AnimationController); model.Destroying auto-destroys the rig |
kit:destroy() |
Cancels the stepper, destroys every rig, destroys the cached Animation instances |
AnimConfig
Section titled “AnimConfig”| Field | Type | 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. Default 0.1 |
Looped |
boolean? |
Overrides the asset’s own flag at load |
Group |
string? |
Exclusive group: playing one clip crossfades out the group’s current clip |
Markers |
{ string }? |
Marker names forwarded to the bus as Anim.Marker |
| Member | Description |
|---|---|
rig:play(name, options?) → track |
Loads (once) and plays. options = { Fade?, Speed?, Weight? } — Fade defaults to the config’s FadeIn (then 0.1), Weight to 1, Speed to the config’s Speed (then 1). A Group clip crossfades out the group’s incumbent with the same Fade. Errors on unregistered names |
rig:stop(name, fadeSeconds?) |
Stops the track (default fade 0.1) and clears its group claim |
rig:stopAll(fadeSeconds?) |
Stops every loaded track (default fade 0.1) and clears all group claims |
rig:setSpeed(name, speed) |
AdjustSpeed on the loaded track; no-op if never played |
rig:ik(name, options) → IkHandle |
Named, fadeable IKControl. Re-declaring a name releases the existing control instantly. Requires a Humanoid; errors without one |
rig:destroy() |
Stops all tracks (fade 0), releases all IK (fade 0), disconnects marker and Destroying connections, forgets the tracks. Idempotent |
IkOptions
Section titled “IkOptions”| Field | Type | Description |
|---|---|---|
Type |
string? |
"LookAt" | "Reach" | "Transform" | "Rotation". Default "LookAt"; Reach maps to IKControlType.Position. Unknown names error |
Target |
(Instance | Vector3)? |
A Vector3 spawns an invisible anchored helper part (CKIKTarget_<name>, 0.2 studs, in workspace) that setTarget moves |
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 |
FadeIn |
number? |
Seconds to fade the weight from 0. Default 0.25; 0 snaps |
Properties |
{ [string]: any }? |
Raw IKControl property overrides (SmoothTime, Offset, Pole, …) applied after the options |
IkHandle
Section titled “IkHandle”| Member | Description |
|---|---|
handle.Control |
The raw IKControl, for anything the handle doesn’t wrap |
handle:setWeight(weight, fadeSeconds?) |
Fades the blend weight; nil/0 fade snaps |
handle:setTarget(target) |
New Instance target, or a Vector3 that reuses and moves the helper part |
handle:release(fadeSeconds?) |
Fades the weight to 0, then destroys the control and helper part. nil/0 destroys immediately |
Bus topics
Section titled “Bus topics”| Topic | Payload | Fired |
|---|---|---|
Anim.Marker |
model, animName, markerName, param |
A track reached a keyframe marker listed in the config’s Markers |