Skip to content

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.

VfxSuite.register(name, events, options?):

  1. Errors if name is already registered, or if 255 sequences already exist.
  2. Checks every event’s shape. Each needs a numeric Time >= 0 and a string Action; Spawn, Emit and Dissolve need a string Id; Sound needs a string Sound. A malformed event throws at boot with the sequence name and event index, so a typo never reaches a running timeline.
  3. Sorts the events by Time. Events with the same Time keep their authored order: a same-time Spawn then Emit always runs in that order, so the Emit finds its spawn.
  4. 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.

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.

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:

  1. VfxSuite.due(events, cursor, age) returns the range of events due at age = 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.
  2. 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.
  3. Spawns with a Lifetime release when StartedAt + event.Time + Lifetime passes. Spawns without one release as soon as the last event has fired.
  4. 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.

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.

src/Shared/Game/VfxSequences.luau
--!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 init
function 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

GroundShatter 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.

-- Inside a local swing-confirm handler: plays now, with no round trip
local Timeline = Suite:play("GroundShatter", Character:GetPivot())
-- The swing was cancelled: drop pending events and release the spawns
Suite: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.

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 needed
assert(Timeline.Spawned.Crack ~= nil)
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.
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.
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 }.
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.
  • A spawn without Lifetime releases once the timeline’s last event fires, which removes its emitters and with them any particles still in the air. Give emitter spawns a Lifetime that outlives their bursts. Before 0.9.0 such spawns stayed forever and kept the timeline and the stepper alive.
  • A spawn released while its Dissolve still 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. acquire clones a new instance when the idle list is empty, so re-triggering a sequence faster than it releases grows the pool instead of failing.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.