VFX and media
Recipes for bone physics, runtime hair rigging, voxel dissolves, ragdolls, audio, animation with IK, outlines under the Highlight cap, preloading and device-scaled quality.
What the player sees and hears. These systems run on the client, where the frames are, and follow the same rules: one stepper per system that sleeps when idle, pooled instances, and budgets from DeviceBench. Client examples are controllers in src/Client/Game/Controllers/ (see Services and controllers).
Swing hair, capes and tails
Section titled “Swing hair, capes and tails”--!strict-- BonesController: physics sway for the local character's accessories.
local Players = game:GetService("Players")local ReplicatedStorage = game:GetService("ReplicatedStorage")
local BonePhysics = require(ReplicatedStorage.ChloeKernel.BonePhysics)
local ChainSettings = { Damping = 0.92, Stiffness = 0.1 } -- boned chains: floppiness against pose holdlocal AccessorySettings = { MaxSwingDegrees = 25 } -- boneless accessories swing as pendulums
local BonesController = { Name = "BonesController",}
function BonesController.init(self: any, kernel: any) self.Scope = kernel:createScope()end
function BonesController.start(self: any) local Bones = self.Scope:add(BonePhysics.attach({ AmbientWind = Vector3.new(4, 0, 2) }), "destroy")
local function bind(character: Model) Bones:bindCharacter(character, ChainSettings, AccessorySettings) -- Accessories stream in after CharacterAdded; binding again is a no-op for bound ones self.Scope:add(character.ChildAdded:Connect(function(child) if child:IsA("Accessory") then Bones:bindCharacter(character, ChainSettings, AccessorySettings) end end)) end local LocalPlayer = Players.LocalPlayer if LocalPlayer.Character then bind(LocalPlayer.Character) end self.Scope:add(LocalPlayer.CharacterAdded:Connect(bind))end
function BonesController.stop(self: any) self.Scope:destroy()end
return BonesControllerFor a single boned mesh use Bones:bind(capeMeshPart, { GravityScale = 1.4 }), and for a chain of plain parts Bones:bindParts({ TailRoot, Seg1, Seg2, Seg3 }, { Stiffness = 0.05 }).
BonePhysics is a verlet solver with a fixed timestep (60 Hz, at most 3 substeps), so droop does not depend on frame rate. Chains are culled past MaxDistance and snap back to their pose on wake, a teleport guard stops respawns from whipping chains across the map, and poses are written through Bone.Transform, the animation slot, so destroy() hands the skeleton back as it found it. Chain roots stay rig-owned; a root bone that a track animates pins to its drawn pose, so a wagging tail’s children trail the animated root. On the client, MaxDistance and MaxChains default to the DeviceBench profile’s BoneDistance and BoneChains. The stepper runs only while something is bound, and roots unbind on Destroying.
Most catalog hair has no bones. bindCharacter models each boneless accessory as a swing-clamped pendulum hinged at its joint, using its third argument (defaults: Damping 0.9, Stiffness 0.08, GravityScale 0.4, MaxSwingDegrees 25). It steers whichever joint holds the accessory on: Weld and Motor6D offsets rotate, RigidConstraint accessories steer the handle-side Attachment, and a WeldConstraint is swapped for an equivalent Weld that is restored on release. It leaves layered clothing (a Handle with a WrapLayer) alone and by default swings only Hair, Hat, Neck, Shoulder, Front, Back, Waist and Unknown accessories; pass a match(accessory) predicate as the fourth argument to choose differently. Binding something already bound returns its existing handle. For hanging things (tails, chains), bind with Stiffness = 0 and let gravity own them.
Go deeper: BonePhysics.
Rig hair automatically and make it sway
Section titled “Rig hair automatically and make it sway”HairKit gives boneless hair a skeleton at runtime: EditableMesh in, skinned mesh out.
function HairController.start(self: any) local Bones = self.Scope:add(BonePhysics.attach(), "destroy") -- Rigs every character's hair once; sway runs on the shared BonePhysics sim self.Hair = HairKit.attach(self.Kernel, { BonePhysics = Bones })end-- Or rig on the server, so the boned mesh replicates to everyone once (clients still run their own sway)HairKit.server(kernel)plan() splits the mesh vertices into radial sectors and depth bands and emits a bone chain per sector (3 sectors of 3 segments plus a root: 10 bones by default), weighting each vertex to at most 2 bones (MaxInfluences, clamped to 4). rig() writes the skeleton through EditableMesh:AddBone, SetVertexBones and SetVertexBoneWeights, registers the content with AssetService:CreateDataModelContentAsync, bakes through CreateMeshPartAsync, swaps the skinned result into the MeshPart and creates the Bone instances the mesh links to by name. The GPU deforms it, and sway is BonePhysics driving those bones, with the same stepper, culling and chain budget and no per-frame network traffic. Player.CharacterAppearanceLoaded fires only on the server, so the client manager watches CharacterAdded and the accessories that stream in afterwards, and rigs 0.25 seconds after the last one arrives; HairKit.server uses CharacterAppearanceLoaded. The client manager publishes Hair.Rigged(character, meshPart, boneNames).
Go deeper: HairKit.
Dissolve things into drifting voxels
Section titled “Dissolve things into drifting voxels”Dissolve samples a target into voxel dots and scatters them through a noise field. It runs on each client, and the server sends the trigger:
Banished = { Kind = "State", Schema = { "Instance" } }, -- the model to dissolvefunction DissolveController.start(self: any) local Vfx = self.Scope:add(Dissolve.new(), "destroy")
-- The server broadcasts the trigger; every client plays it locally self.Net.Banished.on(function(model: Model) Vfx:play(model, { Duration = 1.5, Drift = Vector3.new(0, 8, 0), Spin = true }) end)
-- Manual mode: drive erosion yourself (a ward that opens as a player approaches) self.Ward = Vfx:play(workspace.Ward, {})end
-- Called with the local player's distance to the wardfunction DissolveController.setWardOpen(self: any, amount: number) self.Ward:setProgress(amount) -- 0.5: half the surface eroded, in noise orderendMeshParts sample EditableMesh vertex positions; plain parts and denied meshes sample a surface shell, and balls and cylinders keep their true silhouette. Points snap to a DotSize grid, and each dot drifts through a 3D Perlin field (ScatterSpeed plus Drift) while fading. Reverse = true assembles the target from dots instead. Home positions follow the target’s anchor part, so a player who walks off mid-effect reforms where they are. Dots above the erosion threshold ease back and resolidify (ReturnSpeed), so a manual controller restores the target when its driver recedes. A manual controller at rest for HibernateSeconds (2) releases its dots, shows the real target and leaves the stepper, so an idle ward costs nothing until someone approaches. One Heartbeat stepper serves every active effect, dots are pooled and moved with workspace:BulkMoveTo, and MaxPoints scales from 500 to 5000 with DeviceBench quality when you do not set it. A device whose editable-mesh memory is spent samples the surface shell for that play and tries vertices again next time.
Vfx:morph(source, destination, config) flies every dot of one shape into another (pairs assigned bottom-up, launches staggered by Stagger, flights through Turbulence, colors blended), then reveals the destination. Targets hide their Decals, Textures, emitters, Beams, Trails and Fire with their parts, so faces and a torch’s flame vanish cleanly. Avatar meshes cannot be editable-loaded, so their sampling falls back to boxes; Chunks = true on play or morph clones every source part as a full-detail flying chunk that shrinks into the dust instead. play() hides the target locally only, which is why the server broadcasts the trigger.
Go deeper: Dissolve.
Ragdoll bodies that replicate for free
Section titled “Ragdoll bodies that replicate for free”function RagdollService.init(self: any, kernel: any) -- AutoDeath ragdolls player characters on death and sets BreakJointsOnDeath = false self.Ragdolls = Ragdoll.attach(kernel, { MaxActive = 16, AutoDeath = true })end
-- Knockdowns from combat codefunction RagdollService.knockDown(self: any, character: Model, direction: Vector3) self.Ragdolls:enable(character, { Duration = 4, Impulse = direction.Unit * 40 })end
function RagdollService.stop(self: any) self.Ragdolls:destroy()endfunction RagdollController.start(self: any) -- Player characters need the owner-side half: humanoid state is client-authoritative self.Listener = RagdollClient.listen(NetClient.new())endClassic rigs swap each enabled Motor6D for a BallSocketConstraint with per-joint swing and twist limits (necks stay necks; shoulders get 120-degree cones, hips 90; Limits overrides any joint). New-format rigs (AnimationConstraint joints) cut the animated joint and tune the rig’s own sockets: FrictionTorque (10) while down, and a flat 80-degree cone with 45 degrees of twist only for a socket that had no limits. Both come off on release. The cut joints first take the playing tracks’ joint velocities (Animator:ApplyJointVelocities), so a swing carries into the fall. Impulse is applied once per assembly by the machine that owns the body. Components build once per model and toggle on later ragdolls, so nothing new replicates. Limbs join a self-blind collision group (CKRagdollLimbs) plus per-pair no-collides; the group is shared, so two ragdolls pass through each other, and when the world already holds its 32 groups the limbs keep their own group. The root joint is never cut: the HumanoidRootPart is the camera subject, and an orphaned ghost root would take the camera through the floor. Duration is a Scheduler deadline that release() and destroy() cancel.
On the owning client, RagdollClient disables every humanoid state except Physics and Dead (the engine honors that for its own transitions too), turns AutoRotate off, stops the Animate script and applies the impulse. Release waits for every cut joint’s re-enable to replicate (up to 0.5 seconds), clears momentum, stands the root upright with its yaw kept, and restores exactly the states and AutoRotate it found. NPC rigs have their limbs pinned to server ownership while down. MaxActive releases the oldest ragdoll when a pile-up would cost too much. A model that leaves the DataModel without Destroy() is released and torn down. Bus: Ragdoll.Started(model), Ragdoll.Ended(model).
Go deeper: Ragdoll.
Run the whole soundstage
Section titled “Run the whole soundstage”function AudioController.start(self: any) local Audio = self.Scope:add(AudioKit.attach(self.Kernel), "destroy") self.Audio = Audio Audio:registerBank({ Shot = { Id = "rbxassetid://1", Bus = "SFX", Priority = 3, Pooled = true }, CaveTheme = { Id = "rbxassetid://2", Bus = "Music", Looped = true, Cues = { Drop = 32.5 } }, GuideLine1 = { Id = "rbxassetid://3", Bus = "Voice", Subtitle = "Head for the summit." }, })
Audio:playMusic("CaveTheme", { Crossfade = 3 }) Audio:reverbZone(workspace.Zones.Cave, Enum.ReverbType.Cave) Audio:soundscape({ Bed = "WindLoop", Chirps = { "Bird1", "Bird2" }, Interval = { 4, 12 } }) -- self.Prefs is a SettingsClient (see Save player settings and keybinds) Audio:bindSettings(self.Prefs, { Music = "MusicVolume", SFX = "SfxVolume" }) Audio:listen(NetClient.new()) -- server-triggered soundsendAudio:play("Shot") -- 2DAudio:playAt("Shot", muzzlePosition, { Occlusion = "Path", Alert = { Range = 70 } }) -- 3DAudio:speak("GuideLine1") -- ducks Music and SFX, queues lines, publishes Audio.Subtitle
local Layers = Audio:playLayers({ Calm = "rbxassetid://4", Combat = "rbxassetid://5" }, { StartLayer = "Calm" })Layers:setWeights({ Calm = 1, Combat = 0.7 }, 2) -- stems mix on one timelineBus SoundGroups wire themselves. One Scheduler stepper drives every fade, crossfade and duck, and it sleeps whenever nothing fades, no handle has cue points or occlusion, and the listener is outside every reverb zone. Sounds and 3D emitter parts pool, and channel pressure evicts by priority, so a grenade never loses its sound to thirty footsteps; eviction skips handles already fading out. Alert rings NPC ears, Occlusion = "Path" muffles by the pathfinding route around geometry, and cue points fire loop-aware Audio.Cue events. playLayers starts every stem together once all report IsLoaded (a stem still loading after LoadTimeout, 10 seconds, joins late with a warning). Reverb zones write SoundService.AmbientReverb only while the listener is inside one and restore the value they found. The default occlusion ray respects CanCollide, so trigger volumes never muffle. speak() of an unregistered line warns and moves on. Volumes persist through Player settings. On the server, AudioKit.server(kernel) returns broadcast, playFor and broadcastAt, which clients receive through listen. A place with SoundService.RespectFilteringEnabled off gets one warning, because every local Play() would reach other players.
Go deeper: AudioKit.
Animate rigs and blend inverse kinematics
Section titled “Animate rigs and blend inverse kinematics”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 },})
local Rig = Anims:attachRig(character)Rig:play("Walk")Rig:play("Run", { Fade = 0.3 }) -- same Group: Walk crossfades outkernel.Bus:subscribe("Anim.Marker", function(_topic, model, animName, marker, param) end)
-- IK blends over the keyframeslocal Aim = Rig:ik("Aim", { Type = "LookAt", ChainRoot = waist, EndEffector = head, Target = enemyHead, FadeIn = 0.4 })Aim:setWeight(0.5, 0.3)Rig:ik("Grab", { Type = "Reach", EndEffector = rightHand, Target = Vector3.new(3, 4, 2) }) -- a Vector3 creates a movable helperTracks cache per rig, a group keeps one clip alive, markers forward to the Bus, and replaying the grouped clip that is already playing adjusts its speed and weight instead of restarting it. Animator:LoadAnimation needs the Animator in the Workspace, so a play on a rig that is not there yet (a character inside a server CharacterAdded handler, a pooled NPC) waits for it and returns nil meanwhile. attachRig creates a missing Animator only on the server; on a client it waits up to 5 seconds for the replicated one, because a client-created Animator stops that rig’s animations replicating. IK wraps IKControl: weights fade instead of snapping on AnimKit’s own stepper, which runs only while a fade is in flight, and a control whose weight reaches 0 is disabled (Enabled = false, since weight 0 alone still moves the limb). rig:setParameter(name, key, value) drives animation graph parameters, and rig:setLod(false) keeps a boss rig at full rate at any distance. Everything a rig created dies with its model.
Highlight targets without hitting the cap
Section titled “Highlight targets without hitting the cap”A client draws at most 255 Highlight instances and silently ignores the rest, and a disabled Highlight still takes a slot. Highlights owns them: one manager per client, shared by every system that outlines things.
--!strict-- HighlightController: outlines loot chests and bosses, under the engine's Highlight limit.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Highlights = require(ReplicatedStorage.ChloeKernel.Highlights)local TagWatch = require(ReplicatedStorage.ChloeKernel.TagWatch)
local HighlightController = { Name = "HighlightController",}
function HighlightController.init(self: any, kernel: any) self.Kernel = kernel self.Scope = kernel:createScope() self.Marks = self.Scope:add(Highlights.new(), "destroy") -- Budget 200 leaves headroom for othersend
function HighlightController.start(self: any) -- TagWatch follows tagged instances inside Workspace only, so stored templates never light up self.Scope:add(TagWatch.watch("Chest", function(chest) self.Marks:add(chest, { OutlineColor = Color3.fromRGB(255, 220, 90), FillTransparency = 1 }) self:_rerankWhileCrowded() end, function(chest) self.Marks:remove(chest) end)) self.Scope:add(TagWatch.watch("Boss", function(boss) self.Marks:add(boss, { FillColor = Color3.new(1, 0, 0), DepthMode = Enum.HighlightDepthMode.AlwaysOnTop, Priority = 10, -- keeps its slot over any chest }) end, function(boss) self.Marks:remove(boss) end))end
-- Distance changes as the camera moves: re-rank once a second, only while some requests waitfunction HighlightController._rerankWhileCrowded(self: any) local Shown, Requested = self.Marks:count() if self.Rerank or Requested <= Shown then return end self.Rerank = self.Scope:add(self.Kernel.Scheduler:every(1, function() local NowShown, NowRequested = self.Marks:count() if NowRequested <= NowShown then self.Scope:remove(self.Rerank) self.Rerank:cancel() self.Rerank = nil return end self.Marks:refresh() end))end
function HighlightController.stop(self: any) self.Scope:destroy()end
return HighlightControllerA Highlight exists only while its request holds a slot: it is created on demand, parented under the adornee with Adornee set, and destroyed (never disabled) when the request ends or loses its slot. Requests are keyed by adornee, so pass the Model rather than each of its parts, and adding the same adornee again restyles it in place. Past the budget, requests rank by Priority (higher first), then distance from the camera, then request order; the rest wait and take the next free slot as soon as one opens. add returns whether the outline shows now, and count() returns (shown, requested). Destroying the adornee ends its request, and so does another script destroying the Highlight. Ranking runs on add, remove and destruction; refresh() re-ranks by the current camera position. An adornee that streams out without Destroy keeps its request until you remove it, which the TagWatch removal above does.
Go deeper: Highlights, Tag watch.
Preload assets behind a loading screen
Section titled “Preload assets behind a loading screen”local Warmup = Preload.run(kernel, { Banks = { Audio, Anims }, -- anything with :assetIds() Assets = { "rbxassetid://999", workspace.Map }, -- extra ids or whole Instances})kernel.Bus:subscribe("Preload.Progress", function(_topic, loaded, total, _assetId, _ok, _status) LoadingBar.Size = UDim2.fromScale(loaded / total, 1)end)kernel.Bus:subscribe("Preload.Done", function(_topic, _loaded, _total, failed) LoadingScreen.Enabled = falseend)Warmup:await() -- or block a flow until warmThe banks already know every asset they registered, so one call warms them. Ids load in batches of 16, with up to Concurrency (4) batches in flight, so one slow asset holds back only its own batch. Progress publishes Preload.Progress(loaded, total, assetId, ok, status?) and never exceeds the total. An id that times out retries once after every batch returned, and an id the callback never reported asks ContentProvider:GetAssetFetchStatus before it counts as failed. Failures are collected in Failed without stopping the pass. SurfaceAppearance and MaterialVariant textures stream in at runtime and do not preload.
Go deeper: Preload.
Scale effects to the device
Section titled “Scale effects to the device”-- During the loading screen: Preload uses the network while the bench uses the idle CPUlocal Bench = DeviceBench.run({ BudgetMs = 90, Bus = kernel.Bus })print(Bench.Quality) -- continuous 0.25 to 4 (1 is the mid-range reference)print(Bench.Axes) -- for example { Compute = 2.1, Churn = 3.4, Resume = 1.6 }
local Quality = DeviceBench.profile() -- budgets computed from the benched axesEmitter.Rate = BaseRate * Quality.VfxDensityLighting.GlobalShadows = Quality.ShadowsEnabled
local ViewDistance = DeviceBench.scale(80, 500) -- log-mapped onto your own rangelocal MaxRagdolls = DeviceBench.pick({ Low = 4, High = 16 }) -- coarse tiers remain
-- Hold 60 fps: quality steps down on sustained misses and back up with headroom, never past the benchself.Governor = DeviceBench.governor({ TargetFps = 60, Bus = kernel.Bus, RespectSavedQuality = true, -- honor a low graphics level picked in the Roblox menu OnChange = function(quality, profile) applyQuality(profile) end,})The bench runs three time-boxed Luau workloads (compute, allocation churn and coroutine resumes), each scored as a continuous 0.25 to 4 multiplier against a mid-range reference; their geometric mean is Quality. Time-boxed means a weak device runs fewer iterations in the same 90 ms instead of hitching longer. The result caches (Force re-measures) and optionally publishes Device.Benchmarked. profile() computes each budget from the axis that pays its cost, so a device strong on churn but modest on compute gets large particle budgets and modest bone chains; it also accepts a quality number (profile(1.35)) or a tier name. Framework client modules read the profile when you leave their options unset: BonePhysics (BoneDistance, BoneChains), AudioKit (AudioChannels), ProjectileClient (ProjectileVisuals), VfxSuite (VfxDensity) and Dissolve. Projectile caps only decide who gets the full visual: overflow draws a minimal tracer, and a shot on course to pass within ThreatRadius (15) of the local character always draws in full.
The governor watches p95 frame times against TargetFps, multiplies the effective quality by 0.8 after about 3 seconds of sustained misses and by 1.15 after about 10 seconds of headroom, never above the bench, and keeps the device’s axis ratios. A frame-rate cap is not overload: frames that arrive evenly below the target (a player’s frame limit, a 30 fps power mode) keep their quality, while real overload jitters and steps down. Sampling pauses while the window is unfocused. With RespectSavedQuality, a manual graphics level of 1, 2 or 3 caps quality at 0.25, 0.35 or 0.5 at once. Changes arrive through OnChange(quality, profile) and Device.QualityChanged; Governor:stop() ends it. All of this is a client measurement: use it for cosmetics, never for authority.
Go deeper: DeviceBench.