Skip to content

VfxSuite

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 once under a name shared by both machines. The server never spawns a particle: casting a sequence sends one packet (a sequence id and a world CFrame), and every client drives the entire timeline — spawns, emitter bursts, sounds, Dissolve plays — locally, from one shared Heartbeat stepper.

-- SHARED — required by both the server and client Bootstraps
local VfxSuite = require(ReplicatedStorage.ChloeKernel.VfxSuite)
VfxSuite.register("GroundShatter", {
{ Time = 0.0, Action = "Spawn", Id = "Crack", Offset = Vector3.new(0, 0, 5), Lifetime = 2 },
{ Time = 0.1, Action = "Emit", Id = "Crack", Emitter = "Shockwave", Count = 150 },
{ Time = 0.4, Action = "Sound", Sound = "ExplosionEcho" },
{ Time = 0.8, Action = "Dissolve", Id = "Crack", Duration = 1 },
}, { Templates = { Crack = CrackTemplate } })

This is the same authoring pattern CutsceneKit uses for whole cinematic scenes — flat timed event arrays, shape-checked at register, one broadcast packet, full client-local playback — narrowed to a single VFX sequence instead of a scene with camera shots, animations, and subtitles. CutsceneKit’s own Vfx scene action triggers a VfxSuite sequence directly, so a cutscene and a combat hit react can share the exact same shatter definition.

Registration: shape-checked at definition time, not at play time

Section titled “Registration: shape-checked at definition time, not at play time”

VfxSuite.register(name, events, options?) runs at require-time, in a module both Bootstraps load — not lazily when a sequence first plays. It:

  1. Errors immediately if name is already registered, or if the sequence would push the registry past its id cap (see the caution below).
  2. Walks every event and asserts its shape: every event needs a numeric Time >= 0 and a string Action; Spawn, Emit, and Dissolve events additionally need a string Id; Sound events need a string Sound. A malformed event — a Spawn missing its Id, a Time that’s a string — throws right there, at boot, with the sequence name and event index in the message. There is no path where a bad event array reaches a running timeline; VfxSuite.spec.luau pins this directly (registering { Time = 0, Action = "Spawn" } with no Id throws).
  3. Sorts the (validated) events by Time and assigns the sequence the next sequential wire id, starting at 1.

That last step is the load-bearing one: ids are not declared, they are assigned by call order. The first register() call gets id 1, the second gets id 2, and so on — there is no explicit numeric id argument anywhere in the API, unlike Projectiles, which pairs an explicit 1–255 id to a Definition/VisualDefinition pair. See “Sequences register on both machines” below for why this makes the shared-module discipline stricter, not looser.

Every event’s Action is dispatched in VfxSuite._execute. Four action names are reserved as built-ins; anything else falls through to a custom Handlers table.

Spawn { Id, Offset?, Lifetime? } — acquires a clone of Templates[Id] (the table passed to register’s options) from a per-template Pool, positions it at timeline.CFrame * CFrame.new(Offset or Vector3.zero), parents it to workspace, and records it under timeline.Spawned[Id]. Positioning only happens for BasePart (sets .CFrame) or Model (:PivotTo) instances — other instance types spawn unpositioned. If no template was registered for Id, the action warns once and does nothing; the timeline continues.

Pooling is one Pool per template instance (Pool.new({ Create = template:Clone, InitialSize = 1 })), keyed by the template, not by the string Id — two sequences that both use the exact same template Instance share its pool; two different template instances get two pools even if both are registered under the Id "Crack" in different sequences. Re-Spawn-ing the same Id within one running timeline releases the previous instance back to its pool first, so an Id can be recycled mid-sequence for a looping or replacing visual.

Emit { Id, Emitter?, Count? } — walks every descendant of Spawned[Id].Instance and calls :Emit(Count) (default 20) on each that IsA("ParticleEmitter") and matches Emitter by name (or on every ParticleEmitter descendant when Emitter is omitted). If Id hasn’t been spawned yet — order mistake, typo, or a Spawn that warned and no-opped — Emit silently does nothing. No warning, unlike a missing-template Spawn.

Sound { Sound, Volume? } — plays through AudioKit’s playAt(name, position, options), at timeline.CFrame.Position — the sequence’s cast position, not any spawned instance’s position. Requires an AudioKit instance to have been passed to VfxSuite.attach; without one, Sound events are a silent no-op.

Dissolve { Id, ...passthrough } — requires both Spawned[Id] to exist and a Dissolve sim to have been passed to attach; either missing is a silent no-op. What actually dissolves is the pooled instance the matching Spawn createdself.Dissolve:play(Spawned[Id].Instance, Config) — not an arbitrary target. Config is the event table cloned with Time, Action, and Id stripped, so every other key you put on the event — Duration, Reverse, Chunks, Spin, DotSize, Drift, OnDone, anything in Dissolve’s PlayConfig — rides straight through as that call’s play config.

Anything else dispatches to Handlers[Action], called as task.spawn(Handler, event, context) where context = { CFrame, Spawned, Suite }. This is the same extension idea as Mounts’ custom Handlers table, with one difference worth being precise about: Mounts checks custom handlers before its built-ins, so a game handler can override a built-in mount type. VfxSuite checks the four built-in action names first in a fixed if/elseif chain — a Handlers.Spawn entry is simply never reached. Custom handlers only ever catch action names outside { Spawn, Emit, Sound, Dissolve }. An action that’s neither a built-in nor in Handlers warns once ("has no handler for action") and is otherwise skipped.

The stepper: one Heartbeat, every active timeline

Section titled “The stepper: one Heartbeat, every active timeline”

play(name, cframe) looks up the registered Sequence, builds a Timeline ({ Name, Sequence, CFrame, StartedAt = os.clock(), Cursor = 1, Spawned = {} }), adds it to self.Active, and lazily connects a single shared RunService.Heartbeat stepper the first time any timeline is active. Each step, for every active timeline:

  1. VfxSuite.due(events, cursor, age) — a pure cursor walk — returns the [from, to] index range of events whose Time has elapsed since the timeline started. This is the same “advance a sorted cursor by elapsed age” shape as a scheduler tick, and it’s spec-verified directly: partial elapsed time returns exactly the newly-due slice, a repeat call with no new elapsed time returns an empty range (to < from), and a big jump returns everything remaining at once.
  2. Every event in that range executes via _execute, in array order (see the caution on tied Time values below).
  3. Any Spawned entry whose Lifetime-derived ReleaseAt has passed releases back to its pool — this is the “auto-release” the module header promises for Spawn’s Lifetime field. ReleaseAt is computed as timeline.StartedAt + event.Time + event.Lifetime — the clock starts at the Spawn event’s own Time, not at zero.
  4. A timeline retires — removed from Active — once its cursor has passed every event and Spawned is empty. The stepper itself disconnects once Active is empty, so an idle VfxSuite instance costs nothing per frame.

Every dispatch, pool acquire/release, and stepper tick is engine-plain Luau and part-instance work — nothing here is server-only or client-only machinery. VfxSuite.server and VfxSuite.attach are two thin wrappers around the same play/_step/_execute core; the difference is only in how play gets invoked.

Timeline shape (what a custom Handlers entry and play’s return value both expose):

Field Type Meaning
Name string The registered sequence name
Sequence { Events, Templates, Id } The shared, already-validated registration record — never copied per-play
CFrame CFrame The world CFrame this cast played at
StartedAt number os.clock() at the moment play was called
Cursor number Index of the next event still due
Spawned { [string]: { Instance, Template, ReleaseAt } } Live spawns for this timeline, keyed by Spawn’s Id

Note what Timeline does not carry a clock of its own beyond StartedAtage is recomputed every step as os.clock() - StartedAt for every active timeline, not accumulated by dt. A long GC pause or a frame hitch doesn’t desync a running sequence from real time the way an accumulated-dt clock could; the next Heartbeat simply finds a bigger age and fires everything that fell due in between, same as due’s “big jump returns everything remaining at once” behavior the spec pins.

Why a raw Heartbeat connection instead of the kernel’s Scheduler. VfxSuite’s stepper is not scheduled work — it doesn’t compete for a frame budget or run at a priority tier, it connects RunService.Heartbeat directly, exactly like Dissolve’s own stepper. Both are pure client-cosmetic loops with no gameplay consequence if a frame runs long, so there’s nothing to prioritize against. Projectiles, by contrast, schedules its authoritative simulation through the Scheduler at kernel.Priority.Normal — that loop’s timing is gameplay-relevant. VfxSuite’s server half doesn’t even have a stepper: VfxSuite.server only ever fires a packet, so nothing runs per-frame on the server for VFX at all.

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) }. Calling it looks up the sequence (erroring loudly if name was never registered — this is a caller mistake, not a wire condition), fires one packet, and publishes one bus event:

Packet Schema Payload
CKVFX_Play NumberU8 sequence id, CFrameF32U8 cframe 1 + 15 = 16 bytes

CFrameF32U8 packs three quantized-angle bytes (Euler XYZ, U8 each — about 1.4° of resolution) plus full-precision F32 X/Y/Z, for 15 bytes total; the sequence id is a single NumberU8 byte. That’s the entire wire cost, and it does not change with the sequence’s complexity — Projectiles documents its wire table as three packets across a shot’s life (spawn/hit/expire, ~26/11/11 bytes) precisely because a projectile’s trajectory needs updates over time. VfxSuite’s timeline needs none: a ten-stage sequence with five Spawns, three Emit bursts, a Sound, and a Dissolve still costs exactly 16 bytes, because every stage’s timing is baked into the registered event array both machines already hold — the wire only has to say which sequence and where.

On the client, VfxSuite.attach (unless SkipListen is set) subscribes to CKVFX_Play and, on receipt, looks up SequencesById[id] and calls self:play(name, cframe) only if that lookup succeeds. play() itself also works called directly and locally, without going through the network at all — the module header calls this out explicitly for client-predicted flourish (a hit-spark you want to play the instant local input lands, not after a round-trip).

Sequences register on both machines in one shared module

Section titled “Sequences register on both machines in one shared module”

Both VfxSuite.server and VfxSuite.attach read from the same module-level Sequences/SequencesById tables — there’s no server registry and client registry, just one registry per Luau VM, populated by whatever calls VfxSuite.register(...) run in that VM. The convention (and the only correct way to use this module) is: put every VfxSuite.register call in one shared module, required once at boot by both the server Bootstrap and the client Bootstrap.

Why this matters more here than it might look: because ids are assigned by call order, not declared, the server and client don’t just need to agree on what sequence id 4 means — they need to run register() for every sequence in exactly the same order, or id 4 silently becomes two different sequences on the two machines. Projectiles has the same “shared source of truth” principle (one numeric id pairs a Definition and a VisualDefinition), but its id is an explicit argument you write down and can visually cross-check between the two def tables. VfxSuite has no such checkpoint — a register call gated behind an if RunService:IsStudio() in the shared module, or two files that both require it but in a different order relative to other registrations, desyncs the id space with no error anywhere. Put every register call unconditionally, in one file, in one order.

-- src/Shared/VfxSequences.luau — required by BOTH Bootstraps, same order, every time
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local VfxSuite = require(ReplicatedStorage.ChloeKernel.VfxSuite)
local CrackTemplate = ReplicatedStorage.VfxAssets.Crack -- a Part or Model
VfxSuite.register("GroundShatter", {
{ Time = 0.0, Action = "Spawn", Id = "Crack", Offset = Vector3.new(0, 0, 5), Lifetime = 2 },
{ Time = 0.1, Action = "Emit", Id = "Crack", Emitter = "Shockwave", Count = 150 },
{ Time = 0.4, Action = "Sound", Sound = "ExplosionEcho", Volume = 0.8 },
}, { Templates = { Crack = CrackTemplate } })
return true

The GroundShatter example above spawns Crack (auto-releasing after 2s), bursts its Shockwave emitter a tenth of a second later, and plays ExplosionEcho at the cast position 0.4s in — three stages, one cast call, one packet. Extending it with a fourth { Time = 0.8, Action = "Dissolve", Id = "Crack", Duration = 1 } stage (as in the lead example) costs nothing extra on the wire — it’s just one more line in the shared array.

Client-predicted flourish (no wire round trip)

Section titled “Client-predicted flourish (no wire round trip)”

A sequence’s attached instance also plays locally on demand — useful for a hit-spark or footstep-scuff you want the instant local input lands, not after a round trip through the server:

-- CLIENT — e.g. inside a weapon's local swing-confirm handler
Vfx:play("GroundShatter", Character:GetPivot())

This runs the exact same _execute/_step path as a wire-triggered play — it’s VfxSuite.server’s play that’s special (it broadcasts instead of running locally), not VfxSuite.attach’s. Mix the two freely: play a light flourish locally for zero latency, and let the server-broadcast version of the same or a different sequence carry the shared, everyone-sees-it version of the effect.

SkipListen = true is what the spec suite uses to drive timelines without a network channel at all — the same pattern is useful in your own TestKit specs:

local Suite = VfxSuite.attach({ Bus = Bus.new() }, { SkipListen = true })
local Timeline = Suite:play("GroundShatter", CFrame.new())
Suite:_step() -- advance the stepper manually — no Heartbeat needed in a spec
assert(Timeline.Spawned.Crack ~= nil)
Member Description
VfxSuite.register(name: string, events: { Event }, options: SequenceOptions?) Shape-checks and sorts events by Time, assigns the next sequential wire id. Errors on a duplicate name, a malformed event, or exceeding the id cap
VfxSuite.resetRegistry() Test-only seam: clears Sequences/SequencesById and resets the id counter. Not for runtime use — module state is otherwise boot-lifetime
VfxSuite.due(events, cursor, age): (cursor, from, to) Pure cursor walk: which event indices are newly due at age. to < from means nothing fired this step

SequenceOptions:

Field Type Default Meaning
Templates { [string]: Instance }? {} Pooled Spawn sources, keyed by the Id used in Spawn/Emit/Dissolve events

Event shapes ({ Time: number, Action: string, ... })

Section titled “Event shapes ({ Time: number, Action: string, ... })”
Action Required fields Optional fields Defaults
Spawn Id: string Offset: Vector3?, Lifetime: number? Offset = Vector3.zero; Lifetime = nil (never auto-releases)
Emit Id: string Emitter: string?, Count: number? Emitter = nil (every ParticleEmitter descendant fires); Count = 20
Sound Sound: string Volume: number? Requires AudioKit at attach
Dissolve Id: string any Dissolve PlayConfig field Requires Dissolve at attach; Time/Action/Id are stripped before the fields pass through
(custom) whatever your Handlers[Action] reads Dispatched via task.spawn(Handler, event, context)

Every event requires Time: number >= 0 and Action: string regardless of kind — checked unconditionally at register.

Member Description
VfxSuite.server(kernel, options?): { play } Server-side handle. Nothing spawns server-side; play only broadcasts
handle:play(name: string, cframe: CFrame) Errors if name isn’t registered. Fires CKVFX_Play and publishes Vfx.Played

options:

Field Type Default Meaning
PacketFactory any? Net.Packet Injectable packet constructor (spec seam)
Member Description
VfxSuite.attach(kernel, options?) Client-side instance. Subscribes to CKVFX_Play unless SkipListen
suite:play(name: string, cframe: CFrame) Plays a sequence locally — works standalone for client-predicted flourish, and is what the wire handler calls internally
suite:destroy() Disconnects the packet listener and stepper, releases every live spawn back to its pool, destroys every pool

AttachOptions:

Field Type Default Meaning
AudioKit any? nil Enables Sound events; omitted, they no-op silently
Dissolve any? nil Enables Dissolve events; omitted, they no-op silently
Handlers { [string]: (event, context) -> () }? {} Custom actions — only reached for action names outside the four built-ins
PacketFactory any? Net.Packet Injectable packet constructor (spec seam)
SkipListen boolean? false Don’t subscribe to CKVFX_Play — for specs and pure client-predicted use that drives :play() directly

Handler context (context argument to a custom action):

Field Description
CFrame The timeline’s cast CFrame
Spawned The timeline’s live { [Id]: { Instance, Template, ReleaseAt } } table — read another action’s spawned instance
Suite The VfxSuite instance itself
Topic Payload When
Vfx.Played name: string, cframe: CFrame Published on the server, immediately after CKVFX_Play broadcasts. There is no client-side Vfx.Started/Vfx.Ended topic — timelines are purely local state on each client
  • Spawn without Lifetime never auto-releases. A timeline only retires once every entry in Spawned has been released, and only Lifetime (or a later Spawn/manual release path) releases one. A Spawn event with no Lifetime and no matching Dissolve to hand it off keeps its timeline in Active — and the shared stepper connected — forever. If an effect’s visual should just sit there permanently outside of any sequence’s control, don’t model it as a Spawn in a VfxSuite timeline at all.
  • A Dissolve shorter-lived than its Spawn’s Lifetime is fine; the reverse is not. Dissolve doesn’t clear the instance out of Spawned — it hands the same instance to Dissolve:play(), which drives its own hide/fade over its own Duration. If the originating Spawn’s Lifetime expires before that Dissolve finishes, the pool’s release() sets the instance’s Parent to nil immediately, which yanks it out from under a still-running dissolve. Give a dissolved Spawn either no Lifetime (let the Dissolve action own the exit, as the module header’s own example does) or a Lifetime that clears comfortably after the Dissolve’s Duration.
  • Pool exhaustion doesn’t exist as a distinct failure mode. Pool has no hard cap — acquire() creates a fresh clone whenever the idle list is empty. Rapid re-triggering of the same sequence without matching releases grows that template’s pool unboundedly rather than failing; the cost shows up as memory and instance count, not an error.
  • Why the server never spawns anything. Every Spawn/Emit/Dissolve action only makes sense as something a viewer sees — there is no gameplay-affecting state in a VFX timeline. Keeping the server’s role to “broadcast one packet” means a ten-stage effect costs the server nothing beyond that one Fire, regardless of how expensive the client-side visual is.
  • Why ids are assigned by call order instead of declared. It keeps the authoring surface small — register(name, events), no id bookkeeping — at the cost of the shared-module discipline being load-bearing rather than just good practice. If that trade feels wrong for a given project (e.g. sequences registered from multiple optional modules), keep a single top-level file that unconditionally requires every module that calls register, in a fixed order, on both machines.
  • Why Emit/Dissolve fail silently instead of warning. Spawn warns on a missing template because a missing template is almost always an asset-reference bug. A missing Spawned[Id] in Emit/Dissolve is treated as more likely to be an intentional skip — a variant of a sequence that reuses the same event array but a different Templates table where one Id was deliberately left out. Author Time ordering carefully (see the tied-time caution above) since there’s no warning to catch a real ordering bug here.