Skip to content

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.

Two levels of state:

  • The kit holds the registry (name → AnimConfig), a kit-wide Animation instance cache (one Animation per registered name, shared by every rig), the set of live rigs, and the fade table. On attach it registers a 30Hz stepper on the kernel Scheduler at Priority.Low; that one loop advances every IK weight fade in the game.
  • A rig is one controller per model from attachRig(model). It resolves an Animator — under the model’s Humanoid (created if missing), or under an AnimationController for 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’s Destroying connection 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.

-- 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 = nil
Players.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 blink
Rig:play("Run", { Fade = 0.3 })
-- Ungrouped: plays on top at its configured priority
Rig:play("Wave")
Rig:setSpeed("Wave", 0.5)
Rig:stop("Wave", 0.2)
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
end
end)
Rig:play("Swing")

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: fades in over 0.4s on the kit stepper
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.3s
-- Reach for a world position: the Vector3 rides a movable 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 overrides for anything the options don't cover
Rig:ik("Lean", {
Type = "Rotation",
EndEffector = Waist,
Properties = { SmoothTime = 0.15 },
})
Aim:release(0.3) -- fade out, then the control and helper are destroyed

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()
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
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
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
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
Topic Payload Fired
Anim.Marker model, animName, markerName, param A track reached a keyframe marker listed in the config’s Markers