VfxSuite
Authors multi-stage effects as timed event arrays registered on both machines, and plays a whole timeline on every client from one 16-byte packet.
- Realm
- Shared
- Require
ReplicatedStorage.ChloeKernel .VfxSuite - Since
- v0.7.0
- Source
- VfxSuite.luau
VfxSuite turns a multi-stage effect (a ground cracks, bursts particles, echoes a sound, then dissolves) into data instead
of a bespoke script. A sequence is a flat array of { Time, Action, ... } events registered under one name on both
machines. The server never spawns a particle: a cast sends one packet with a sequence id and a world CFrame, and every
client runs the whole timeline locally (pooled spawns, emitter bursts, AudioKit sounds,
Dissolve plays) from one shared stepper. CutsceneKit uses the same authoring
pattern for whole scenes, and its Vfx action plays a VfxSuite sequence, so a cutscene and a combat hit can share one
definition.
Mental model
Section titled “Mental model”Registration is checked at boot
Section titled “Registration is checked at boot”VfxSuite.register(name, events, options?):
- Errors if
nameis already registered, or if 255 sequences already exist. - Checks every event’s shape. Each needs a numeric
Time >= 0and a stringAction;Spawn,EmitandDissolveneed a stringId;Soundneeds a stringSound. A malformed event throws at boot with the sequence name and event index, so a typo never reaches a running timeline. - Sorts the events by
Time. Events with the sameTimekeep their authored order: a same-timeSpawnthenEmitalways runs in that order, so theEmitfinds its spawn. - Assigns the next sequential wire id, starting at 1.
Ids come from call order; there is no id argument. The server and the client must call register for every sequence in
exactly the same order, or id 4 means two different sequences on the two machines. Put every register call in one
shared module, unconditionally, in one order.
Built-in actions
Section titled “Built-in actions”| Action | Fields | What it does |
|---|---|---|
Spawn |
Id, Offset?, Lifetime? |
Acquires a clone of Templates[Id] from a per-template Pool, places it at timeline.CFrame * CFrame.new(Offset) (a BasePart by CFrame, a Model by PivotTo) and parents it to workspace. A second Spawn of the same Id in one timeline releases the first. A missing template warns. |
Emit |
Id, Emitter?, Count? |
Calls :Emit(n) on each ParticleEmitter under the spawned Id (only the one named Emitter when given), where n = max(1, round(Count * VfxDensity)) and Count defaults to 20. Does nothing if Id was not spawned. |
Sound |
Sound, Volume? |
AudioKit:playAt(Sound, timeline.CFrame.Position, { Volume }). Needs AudioKit at attach. |
Dissolve |
Id, any Dissolve PlayConfig field |
Runs Dissolve:play on the spawned instance on its own thread, with the event’s other fields as the play config. Needs Dissolve at attach and the spawn to exist. |
| anything else | whatever the handler reads | Handlers[Action](event, context) on its own thread, with context = { CFrame, Spawned, Suite }. An unknown action with no handler warns. |
Built-ins are checked first, so a Handlers.Spawn entry is never reached.
Spawn resets LocalTransparencyModifier to 0 on the visual and on every BasePart, Decal, Texture, ParticleEmitter,
Beam, Trail and Fire under it. A pooled visual that a previous Dissolve hid comes back visible.
Emit counts are scaled by VfxDensity: the DeviceBench profile’s value on a client (read once
at attach, 0.6 at mid-range and at most 1), 1 elsewhere, or the value you pass. Author counts for a full-quality device
and let weaker devices emit fewer.
The stepper
Section titled “The stepper”play(name, cframe) builds a timeline (Name, Sequence, CFrame, StartedAt = os.clock(), Cursor, Spawned) and
connects one shared RunService.Heartbeat connection if none is live. Each step, for every active timeline:
VfxSuite.due(events, cursor, age)returns the range of events due atage = os.clock() - StartedAt. Age is recomputed from real time every step, so a hitch fires everything that fell due in one step instead of drifting.- Each due event runs in isolation. The cursor moves past an event before it runs, and the event runs inside
pcall. An event that throws logs its sequence name, event index and action, and the timeline carries on. It never replays, and other timelines keep stepping. A yielding event cannot replay either: a re-entrant step skips events already claimed. - Spawns with a
Lifetimerelease whenStartedAt + event.Time + Lifetimepasses. Spawns without one release as soon as the last event has fired. - A timeline ends once every event fired and every spawn released. The Heartbeat connection disconnects when no timeline is active.
suite:stop(timeline) ends one timeline early: pending events drop and its spawns release at once.
Slow motion and pause
Section titled “Slow motion and pause”Engine-driven effects follow the kernel Time scale. At attach the suite reads kernel.Time:getScale()
and isPaused() when a Time service exists, then follows the Time.ScaleChanged, Time.Paused and Time.Resumed Bus
topics. It sets the TimeScale of every ParticleEmitter, Fire, Smoke, Sparkles and Explosion on live and newly spawned
visuals to its authored value times the kernel scale, clamped to 0-1 (the engine cannot run them faster than 1) and 0
while paused. Authored values are read the first time a pooled visual spawns, before the suite ever writes one.
The timeline clock itself is os.clock(). Events keep firing at real-time offsets during slow motion; only the engine
effects they started slow down.
Networking: one packet drives the whole timeline
Section titled “Networking: one packet drives the whole timeline”VfxSuite.server(kernel, options?) returns { play(self, name, cframe) }. play errors for an unregistered name (a
caller bug), fires one packet and publishes Vfx.Played on the server’s Bus.
| Packet | Schema | Payload |
|---|---|---|
CKVFX_Play |
NumberU8 sequence id, CFrameF32U8 |
16 bytes: 1 for the id, 15 for the CFrame |
CFrameF32U8 stores three Euler angles as one byte each (about 1.4 degree steps) and the position as three F32s. The
payload does not grow with the sequence: a ten-stage timeline costs the same 16 bytes, because both machines already
hold the event array. Packet framing adds its own id and length bytes on top (see
Packet and wire types).
On the client, VfxSuite.attach (unless SkipListen) listens for CKVFX_Play, looks up the id and calls
self:play(name, cframe). play also works directly and locally, for client-predicted flourish that should not wait
for a round trip.
--!strict-- VfxSequences: every VFX sequence, registered in one fixed order on both machines.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local VfxSuite = require(ReplicatedStorage.ChloeKernel.VfxSuite)
local VfxSequences = {}
-- Called once per machine, from a server service's init and a client controller's initfunction VfxSequences.register() local Assets = ReplicatedStorage.VfxAssets VfxSuite.register("GroundShatter", { -- The emitter spawn outlives its bursts; a spawn with no Lifetime releases after the last event { Time = 0.0, Action = "Spawn", Id = "Crack", Offset = Vector3.new(0, 0, 5), Lifetime = 3 }, { Time = 0.0, Action = "Emit", Id = "Crack", Emitter = "Shockwave", Count = 150 }, { Time = 0.1, Action = "Impact", Intensity = 1, Radius = 60 }, { Time = 0.4, Action = "Sound", Sound = "ExplosionEcho", Volume = 0.8 }, { Time = 0.8, Action = "Dissolve", Id = "Crack", Duration = 1 }, }, { Templates = { Crack = Assets.Crack } })end
return VfxSequences--!strict-- VfxService: casts VFX sequences to every client.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local VfxSequences = require(ReplicatedStorage.Game.VfxSequences)local VfxSuite = require(ReplicatedStorage.ChloeKernel.VfxSuite)
local VfxService = { Name = "VfxService",}
function VfxService.init(self: any, kernel: any) self.Kernel = kernel VfxSequences.register() -- id 1 = GroundShatter self.Vfx = VfxSuite.server(kernel)end
function VfxService.start(self: any) self.Connection = self.Kernel.Bus:subscribe("Boss.Slammed", function(_topic, position: Vector3) self.Vfx:play("GroundShatter", CFrame.new(position)) end)end
function VfxService.stop(self: any) if self.Connection then self.Connection:disconnect() endend
return VfxService--!strict-- VfxController: plays every VFX sequence the server casts.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Dissolve = require(ReplicatedStorage.ChloeKernel.Dissolve)local Impact = require(ReplicatedStorage.ChloeKernel.Impact)local VfxSequences = require(ReplicatedStorage.Game.VfxSequences)local VfxSuite = require(ReplicatedStorage.ChloeKernel.VfxSuite)
local VfxController = { Name = "VfxController", Dependencies = { "AudioController" },}
function VfxController.init(self: any, kernel: any) self.Kernel = kernel VfxSequences.register() -- same function, same order: id 1 = GroundShatterend
function VfxController.start(self: any) local Audio = self.Kernel:getService("AudioController").Audio self.Feedback = Impact.attach(self.Kernel, { AudioKit = Audio }) self.Dissolve = Dissolve.new() self.Suite = VfxSuite.attach(self.Kernel, { AudioKit = Audio, -- enables Sound events Dissolve = self.Dissolve, -- enables Dissolve events Handlers = { Impact = function(event, context) self.Feedback:pulse(context.CFrame.Position, event) end, }, })end
function VfxController.stop(self: any) self.Suite:destroy() self.Dissolve:destroy() self.Feedback:destroy()end
return VfxControllerGroundShatter spawns Crack, bursts its Shockwave emitter in the same step (same-time events keep their order),
pulses Impact through a custom action, plays ExplosionEcho at the cast position and dissolves the
crack. The server sends one 16-byte payload per cast. Boss.Slammed is the game’s own topic.
Client-predicted flourish
Section titled “Client-predicted flourish”-- Inside a local swing-confirm handler: plays now, with no round triplocal Timeline = Suite:play("GroundShatter", Character:GetPivot())
-- The swing was cancelled: drop pending events and release the spawnsSuite:stop(Timeline)This runs the same path as a wire-triggered play. Play a light flourish locally for zero latency and let the server broadcast carry the version everyone sees.
Testing a sequence headless
Section titled “Testing a sequence headless”SkipListen = true drives timelines without a network channel, which is how the specs work and how your own
TestKit specs can:
local Suite = VfxSuite.attach({ Bus = Bus.new() }, { SkipListen = true, VfxDensity = 1 })local Timeline = Suite:play("GroundShatter", CFrame.new())Suite:_step() -- advance manually; no Heartbeat neededassert(Timeline.Spawned.Crack ~= nil)API reference
Section titled “API reference”Registration
Section titled “Registration”| Member | Description |
|---|---|
VfxSuite.register(name, events, options?) |
Checks and sorts events (stable for equal times) and assigns the next wire id. Errors on a duplicate name, a malformed event or a 256th sequence. options = { Templates? }, the Spawn sources keyed by Id. |
VfxSuite.resetRegistry() |
Test seam: clears the registry and the id counter. |
VfxSuite.due(events, cursor, age) -> (cursor, from, to) |
Pure cursor walk: the event indices newly due at age. to < from means none. |
Server half
Section titled “Server half”| Member | Description |
|---|---|
VfxSuite.server(kernel, options?) -> { play } |
Server handle. Nothing spawns on the server. options = { PacketFactory? } (spec seam). |
handle:play(name, cframe) |
Errors on an unregistered name. Fires CKVFX_Play to every client and publishes Vfx.Played. |
Client half
Section titled “Client half”| Member | Description |
|---|---|
VfxSuite.attach(kernel, options?) -> Suite |
Client instance. Listens for CKVFX_Play unless SkipListen, and follows the kernel time scale. |
suite:play(name, cframe) -> Timeline? |
Plays a sequence locally and returns its timeline. Warns and returns nil for an unknown name. |
suite:stop(timeline) |
Ends a timeline early: pending events drop and its spawns release now. |
suite:destroy() |
Disconnects the packet listener, the time-scale subscriptions and the stepper, releases every live spawn and destroys every pool. |
AttachOptions:
| Field | Type | Default | Meaning |
|---|---|---|---|
AudioKit |
any? |
nil | Enables Sound events; without it they do nothing. |
Dissolve |
any? |
nil | A Dissolve sim; enables Dissolve events. |
Handlers |
{ [string]: (event, context) -> () }? |
{} |
Custom actions, for action names outside the four built-ins. |
VfxDensity |
number? |
Client: DeviceBench profile().VfxDensity. Elsewhere: 1. |
Emit count multiplier. |
PacketFactory |
any? |
Net.Packet |
Spec seam. |
SkipListen |
boolean? |
false | Do not listen for CKVFX_Play; drive play directly. |
Timeline fields (the return value of play, and what a handler’s context draws from):
| Member | Description |
|---|---|
timeline.Name |
The sequence name. |
timeline.CFrame |
The cast CFrame. |
timeline.StartedAt |
os.clock() when play ran. |
timeline.Cursor |
Index of the next event still due. |
timeline.Spawned |
Live spawns keyed by Id: { Instance, Template, ReleaseAt }. |
Bus topics
Section titled “Bus topics”| Topic | Payload | Fired |
|---|---|---|
Vfx.Played |
name: string, cframe: CFrame |
On the server, right after CKVFX_Play is sent. Clients publish nothing; timelines are local state. |
Time.ScaleChanged, Time.Paused, Time.Resumed |
see Time | Consumed: the suite rescales engine effects. |
Pool and lifetime interactions
Section titled “Pool and lifetime interactions”- A spawn without
Lifetimereleases once the timeline’s last event fires, which removes its emitters and with them any particles still in the air. Give emitter spawns aLifetimethat outlives their bursts. Before 0.9.0 such spawns stayed forever and kept the timeline and the stepper alive. - A spawn released while its
Dissolvestill runs is safe. The dissolve already hid the visual and animates its own pooled dots, which do not depend on the released instance. - Pool has no hard cap.
acquireclones a new instance when the idle list is empty, so re-triggering a sequence faster than it releases grows the pool instead of failing.