VFX & Media
Everything the player sees and hears: physics-swung accessories, runtime-rigged hair, voxel dissolves and morphs, replicating ragdolls, the audio engine, animation with blended IK, asset warm-up, and quality that scales to the device running it. All of it follows the same performance rules — one stepper per system, pooled instances, budgets from DeviceBench.
Swing hair, capes, and tails
Section titled “Swing hair, capes, and tails”-- CLIENT: bones are visual, so the sim runs where the frames arelocal Bones = BonePhysics.attach({ MaxDistance = 100 })
-- Scan a character for boned accessories (layered hair, capes) and bind themlocal Handles = Bones:bindCharacter(Players.LocalPlayer.Character, { Damping = 0.92, Stiffness = 0.1, -- floppiness vs snap-back-to-pose})
-- Or a single mesh, or a plain part chain for stud-style tailsBones:bind(capeMeshPart, { GravityScale = 1.4 })Bones:bindParts({ TailRoot, Seg1, Seg2, Seg3 }, { Stiffness = 0.05 })Why it works: a lean verlet bone simulator built for what SmartBone-style modules get wrong — fixed-timestep solving (no frame-rate-dependent droop), flat arrays instead of per-bone objects, camera-distance culling that snaps back to the pose on wake, a teleport guard so respawns don’t whip chains across the map, and a leak-free lifecycle: roots auto-unbind on Destroying, and poses are written through Bone.Transform (the animation slot), never WorldCFrame, so destroy() hands the skeleton back exactly as it found it. Chain roots stay rig-owned; physics only swings the children.
Boneless accessories move too. Most catalog hair has no bones — bindAccessory (run automatically by bindCharacter) models the accessory as a swing-clamped pendulum hinged at its joint and steers whichever joint kind holds it on: Weld/Motor6D offsets rotate, RigidConstraint accessories steer the handle-side Attachment CFrame (nothing swapped or disabled), and WeldConstraints swap for an equivalent local Weld restored exactly on release. AmbientWind = Vector3 gives the whole sim a procedural gusting breeze with zero wiring — hair drifts, capes breathe. For hanging things (tails, chains), bind with Stiffness = 0 and let gravity own them; stiffness is pose-hold strength, not springiness.
See BonePhysics for the solver internals; the cull distance and chain budget default to the DeviceBench profile on the client.
Rig hair automatically and make it sway
Section titled “Rig hair automatically and make it sway”HairKit gives boneless catalog hair a real skeleton at runtime — EditableMesh in, hardware-skinned mesh out:
-- Client bulk manager: rigs every character's hair once, sway rides BonePhysicslocal Bones = BonePhysics.attach()HairKit.attach(kernel, { BonePhysics = Bones })
-- Or rig server-side so the boned mesh replicates to everyone onceHairKit.server(kernel)Why it works: the pipeline is write-once, read-many. plan() partitions the mesh vertices into radial sectors and depth bands and emits a bone chain per sector (defaults: 3 sectors x 3 segments + root = 10 bones), every vertex weighted to at most 2 bones (MaxInfluences, clamped to 4); rig() injects the skeleton through EditableMesh:AddBone/SetVertexBones/SetVertexBoneWeights, registers the content into the DataModel with AssetService:CreateDataModelContentAsync(Content.fromObject(mesh)), bakes through CreateMeshPartAsync, swaps the skinned result into the MeshPart in place, and creates the Bone instance skeleton the mesh links to by name — the GPU deforms it natively and the baked editable stays rooted but is never mutated again. Sway is BonePhysics driving those Bone instances: one fixed-timestep stepper for every character, camera-distance culling, and the DeviceBench MaxChains budget, with zero per-accessory scripts and zero per-frame network traffic in either mode. Bus: Hair.Rigged(character, meshPart, boneNames).
See HairKit for the planning math and per-accessory options.
Dissolve things into drifting voxels
Section titled “Dissolve things into drifting voxels”Dissolve is a vertex-sampled voxelizer with noise-advected scatter (client VFX):
local Vfx = Dissolve.new()Vfx:play(banishedNpc.Model, { Duration = 1.5, Drift = Vector3.new(0, 8, 0), Spin = true })
-- Manual mode: drive erosion yourself (proximity dissolves, breaking wards)local Controller = Vfx:play(wardWall, {})Controller:setProgress(0.5) -- half the surface eroded, noise-thresholdedWhy it works: MeshParts sample EditableMesh vertex positions (plain parts and denied meshes sample a surface shell — Ball and Cylinder filter to their true silhouette and interior cells drop, so nothing dissolves as a filled box), points snap to a DotSize voxel grid and dedupe per cell, and each dot advects through a 3D Perlin field (noiseVelocity = field sample * ScatterSpeed + Drift) while fading out. Reverse = true assembles the target out of drifting voxels instead. Home positions ride the target’s anchor part (PrimaryPart or the part itself), so reassembly and morph landings follow a moving target — a player who walks off mid-effect reforms where they are, not where they stood. Dots above the erosion threshold ease back to their surface cell and resolidify (ReturnSpeed), so a manual controller restructures the target when its driver recedes — walk away from the proximity ward and it knits itself shut. Performance follows the framework rules: one Heartbeat stepper for every active effect, pooled dots written through workspace:BulkMoveTo, and a MaxPoints budget that scales 500..5000 by DeviceBench quality when unconfigured. play() hides the target locally only — broadcast the trigger through a state channel and play on every client for shared VFX.
Morphs turn one thing into another through the same particles — avatars included:
-- Disintegrate an avatar and reform it. Chunks keep the original detailVfx:play(character, { Duration = 1.6, Spin = true, DotSize = 0.3, Chunks = true, OnDone = reform })
-- Morph an avatar into a statue, then back, then reform the statueVfx:morph(character, golemStatue, { Duration = 1.6, OnDone = function() Vfx:morph(golemStatue, character, { OnDone = function() Vfx:play(golemStatue, { Reverse = true }) -- the statue was the return flight's source end })end })morph(source, destination) voxelizes the source and flies every dot into the destination’s sampled shape: pairs assign bottom-up (pairPoints, every destination cell used), launches stagger by dot key (Stagger), flights arc through Perlin turbulence (Turbulence) and lerp source color to destination color, then the destination reveals (Reveal) and the source stays hidden. Character targets hide Decals and Textures with their parts, so faces and clothing vanish cleanly. Positions sample once at call time.
Avatars are catalog meshes the experience cannot editable-load, so their voxel sampling falls back to bounding-box grids — blocky silhouettes. Chunks = true fixes that on both play() and morph(): every source part clones as a full-detail flying chunk (meshes, textures, faces, SurfaceAppearance survive; joints, welds, sounds, and emitters strip) that rides the same noise field or morph flight while shrinking into the dust. Detail stays perfect at launch and melts into particulate instead of starting squared. Chunks respect the erosion threshold, so partial dissolves and restructures work on them too. On meshes the experience owns, vertex sampling already yields detailed voxel shells without chunks.
See Dissolve for the sampling pipeline and every play/morph option.
Ragdoll bodies that replicate for free
Section titled “Ragdoll bodies that replicate for free”-- serverlocal Ragdolls = Ragdoll.attach(Kernel, { MaxActive = 16, AutoDeath = true })Ragdolls:enable(character, { Duration = 4, Impulse = hitDirection * 40 })Ragdolls:release(character) -- restores every joint, socket tune, collision flag, and humanoid state
-- client bootstrap (player characters need the owner-side half)RagdollClient.listen(netClient)Why it works: rig-agnostic and build-once. Classic rigs swap every enabled Motor6D for a BallSocketConstraint with per-joint swing/twist limits (necks stay necks, shoulders flop); NEW-format rigs (AnimationConstraint joints) just cut the animated joint and tune the rig’s own sockets — friction (FrictionTorque, default 10: loose enough to drape, damped enough to settle) and WIDE anatomical cones (shoulders 120, hips 90 — tight limits read as a stiff mannequin; Options.Limits overrides any joint’s {Upper, Twist} per game) go on for the ride and come off on release, because a frictionless free-spinning joint reads as a seizure, not a body. No automatic force: the body crumples on its own the moment the joints cut; pass Impulse for hit flings (applied per part by the owning machine). Components build ONCE per model and toggle on re-ragdolls (nothing new replicates), and limbs ride an auto-registered self-blind collision group (CKRagdollLimbs collides with the world, never with itself — jointed-pair no-collides alone miss hand-vs-hip and arm-vs-leg contacts, whose depenetration jitter is the classic residual spazz) plus per-pair NoCollisionConstraints. Limbs gain world collision while the root goes ghost — but the root joint itself never gets cut: the HumanoidRootPart is the camera subject, and orphaning a massless non-colliding ghost sends it (and your camera) through the floor; left attached, it rides the torso, so the camera lies down with the body. release() puts back exactly what it found.
Player characters get the one thing property replication cannot do: humanoid state is client-authoritative, so the server pushes the CKRagdoll state channel and RagdollClient.listen(netClient) answers on the owning machine — it enters Physics and latches it (auto-getup off plus a StateChanged re-force: ground impacts knock the state machine into Landed/GettingUp, whose standing forces wrestling the sockets are the seizure), silences the Animate script (frozen tracks aren’t enough; it keeps starting new ones), and applies any explicit Impulse fling per part — with rigid joints cut every limb is its own assembly, so an impulse on the massless ghost root would move nothing. Release waits for the server’s joint re-enables to replicate (standing up while joints are still cut is where broken recoveries come from), kills all momentum, stands the root upright with its yaw kept, re-enables Animate fresh, and gets up — no slanted-walk hangover, no snap. Server-simulated rigs get the same momentum-kill and upright on release. Replication costs nothing extra: the swap is server-side and the engine streams the resulting physics like any assembly (the kernel never takes ownership of player characters). MaxActive releases the oldest when a pile-up would hurt the server. Bus: Ragdoll.Started/Ended(model).
See Ragdoll for the joint-limit table and the client latch protocol.
Run the whole soundstage
Section titled “Run the whole soundstage”-- CLIENT (server-attached AudioKit also works: world sounds replicate)local Audio = AudioKit.attach(Kernel)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:play("Shot") -- 2DAudio:playAt("Shot", muzzlePosition, { Occlusion = "Path", Alert = { Range = 70 } }) -- 3DAudio:playMusic("CaveTheme", { Crossfade = 3 })Audio:speak("GuideLine1") -- ducks Music/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 locked timeline
Audio:reverbZone(workspace.CaveVolume, Enum.ReverbType.Cave)Audio:soundscape({ Bed = "WindLoop", Chirps = { "Bird1", "Bird2" }, Interval = { 4, 12 } })Audio:bindSettings(Prefs, { Music = "MusicVolume", SFX = "SfxVolume" }) -- persisted volumesWhy it works: everything BetterSound-style modules do, rebuilt on kernel primitives — bus SoundGroups auto-wire, ONE scheduler stepper drives every fade/crossfade/duck (no TweenService churn, no per-sound Heartbeat connections — and the whole engine is deterministic under spec), sounds and 3D emitter parts pool, and channel pressure evicts by priority so a grenade never loses its bang to thirty footsteps. The kernel-only tricks: Alert rings NPCKit ears (players’ sounds are NPC intel), Occlusion = "Path" muffles by the pathfinding route around geometry (the next corridor sounds far, not crystal-clear through the wall), cue points fire loop-aware Audio.Cue bus events, and volumes persist through the validated Settings service instead of a raw DataStore. Server triggering: AudioKit.server(kernel) gives broadcast/playFor/broadcastAt, and clients audio:listen(netClient).
See AudioKit for the channel model and occlusion internals; volume persistence rides Player Settings.
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 out, no T-pose blinkKernel.Bus:subscribe("Anim.Marker", function(_, model, animName, marker, param) ... end)
-- IK blends over the keyframes: look at a target, reach for a handlelocal 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) }) -- Vector3 spawns a movable helperWhy it works: tracks cache per rig, exclusive groups keep one locomotion clip alive, markers forward to the bus, and the IK layer wraps IKControl with the same fade stepper as AudioKit — weights blend in/out instead of snapping, release() restores the skeleton, and everything a rig created dies with its model. Properties passes raw IKControl overrides for anything the options don’t cover, and assetIds() feeds the preloader.
See AnimKit for groups, markers, and the IK option table.
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(_, loaded, total, assetId) LoadingBar.Size = UDim2.fromScale(loaded / total, 1)end)Kernel.Bus:subscribe("Preload.Done", function(_, loaded, total, failed) LoadingGui:Destroy() end)Warmup:await() -- or block a flow until warmWhy it works: the framework already knows every asset the banks registered — one call warms them in batches, narrates progress on the bus (Preload.Started/Progress/Done) for whatever loading UI you feed it, and collects failures without aborting the pass. run() returns a handle immediately; await() blocks a loading flow until warm.
See Pool & Preload for batching and the injectable preloader seam.
Scale effects to the device
Section titled “Scale effects to the device”-- CLIENT, during the loading screen: Preload warms assets on the network,-- the bench uses the idle CPUlocal Bench = DeviceBench.run({ BudgetMs = 90, Bus = Kernel.Bus })print(Bench.Quality) -- continuous 0.25..4, decimals allowed (1 = mid-range reference)print(Bench.Axes) -- e.g. { Compute = 2.1, Churn = 3.4, Resume = 1.6 }
-- Budgets are funded PER AXIS: the workload a device is good at pays for the-- systems that stress it. Strong Churn buys particles and vfx density; strong-- Compute buys bone chains and projectile visuals; strong Resume buys channels.local Quality = DeviceBench.profile() -- computed from the benched axesEmitter.Rate = BaseRate * Quality.VfxDensityLighting.GlobalShadows = Quality.ShadowsEnabled
-- Map the continuous quality onto your own knobs (log-mapped: every doubling-- of device quality buys the same slice of the range) — or ask for any exact-- quality point, decimals includedlocal ViewDistance = DeviceBench.scale(80, 500)local Handcrafted = DeviceBench.profile(1.35)local MaxRagdolls = DeviceBench.pick({ Low = 4, High = 16 }) -- coarse tiers remain a convenience
-- Stability outranks beauty: hold 60fps, nudging the effective quality down-- ~20% on sustained misses and gently back up with headroom, never past the benchDeviceBench.governor({ TargetFps = 60, Bus = Kernel.Bus, OnChange = function(quality, profile) applyQuality(profile)end })Why it works: a one-shot mini benchmark answers “how fast is this device” so quality stops being one-size — three time-boxed Luau workloads (compute, allocation churn, and coroutine resumes — how quick the client’s threads actually run) each become a continuous 0.25..4 axis multiplier against a mid-range reference, decimals fully allowed, and their geometric mean is the overall Quality. Time-boxed means a weak device runs fewer iterations in the same ~90ms instead of hitching longer. The result caches (Force re-measures), optionally publishes Device.Benchmarked, and includes Platform plus an optional median FrameMs.
DeviceBench.profile() computes granular budgets from the axes — no tier cliffs, and each knob scales by the axis that pays its cost, so a device that benched 3.4x on churn but 1.2x on compute gets near-max particle budgets while bone chains stay modest. It accepts a Result, any quality number (profile(1.35) is a real point, not a rounded tier), or a tier name for coarse use; Low/Medium/High/Ultra remain as a convenience over the continuous scale for pick(). Framework client modules consume the profile when you don’t configure them: BonePhysics defaults its cull MaxDistance to BoneDistance and its simultaneous chain budget MaxChains to BoneChains, AudioKit its MaxChannels to AudioChannels, ProjectileClient its MaxVisuals cap to ProjectileVisuals — every default is an option you can override, and the caps are purely cosmetic (a capped chain freezes at its pose). Projectiles obey a fairness rule on top: the cap only decides who gets the FULL visual (trails, particles) — overflow renders as a minimal pooled tracer instead of nothing, and a shot on course to pass within ThreatRadius (default 15) of the local character always renders full, cap or no cap. No quality level ever hides an incoming round from its target.
The governor keeps it honest at runtime: it watches p95 frame times against TargetFps (default 60 — hold that before beauty), multiplies the effective quality by 0.8 after ~3s of sustained misses and by 1.15 after ~10s of clear headroom — granular nudges, not tier jumps — never past what the bench measured, and its profiles preserve the device’s axis ratios, so a churn-strong device dialed down still favors its particles. Changes arrive via OnChange(quality, profile) and Device.QualityChanged. All of it is a client measurement: perfect for cosmetics, never authority.
See DeviceBench for the workload details and the full profile knob list.