CutsceneKit
A cutscene is a flat array of { Time, Action, ... } events — camera shots, animations, sounds, VFX, and subtitle lines, each stamped with the second it fires. CutsceneKit doesn’t drive any of that itself: it walks the timeline and calls out to CameraKit, AnimKit, AudioKit, and VfxSuite at the right moments. The defining decision is networking: the server sends one packet naming a scene and an origin CFrame, and every client plays the entire scene — every event, every timing — independently and locally from that single packet. A boss intro with thirty timed beats costs exactly the same one packet as a two-beat “the door opens” scene.
Mental model
Section titled “Mental model”Scenes are validated at register, not at play time. CutsceneKit.register(name, events, options?) walks every event and asserts its shape for the built-in action types: Shot/Blend need a Shot table, Anim needs Rig and a string Anim, Sound needs a string Sound, Vfx needs a string Vfx, Line needs a string Text, and every event needs a numeric Time >= 0 and a string Action. Any of those missing throws immediately, at the register() call — a malformed scene fails at definition time, before it ever has a chance to half-play in front of a player. register() also sorts events by Time and assigns the scene a sequential U8 id (registration order, capped at 255 scenes — CKCUT_Play’s scene id is a NumberU8, which only holds 0–255, so the cap check runs before incrementing the counter and a 256th register() call throws immediately: "CutsceneKit supports at most 255 scenes (U8 wire ids)", the same fail-at-register contract VfxSuite enforces for its own registry). One caveat worth being precise about: this shape-check only covers the five built-in action kinds. A custom Handlers[Action] event only gets the universal Time/Action check at register — whatever fields your custom action reads are validated on your own time, inside the handler, the first time the event actually fires.
Rig and Target/LookAt strings resolve through a Rigs roster, not through the scene definition. A scene definition is one shared module, required identically by the server (to register ids) and by every client (to register and later play). If a Shot’s Target or an Anim’s Rig held an actual Model Instance, that Instance would have to be the same object on every machine — but rigs are per-place, sometimes per-server-instance, things (workspace.Boss, a spawned NPC, whatever a given place happens to have). So scenes reference rigs by string key (Rig = "Boss", Shot = { Target = "Boss" }), and CutsceneKit.attach(kernel, { Rigs = { Boss = bossModel } }) supplies the actual mapping locally, resolved at play time on each machine. The scene definition never holds an Instance reference; only the roster does, and the roster lives in AttachOptions, not in ReplicatedStorage.
Rig may also be a Model directly instead of a roster key — in that case CutsceneKit lazily calls AnimKit:attachRig(model) the first time that model shows up in an Anim event and caches the attached rig, so repeated Anim events against the same model don’t re-attach every time.
Every action type maps to one integration call:
| Action | Fields | What runs |
|---|---|---|
Shot |
Shot |
Camera:cut(shot) — instant. |
Blend |
Shot, Seconds? (default 1), Ease? |
Camera:blend(shot, seconds, ease) — eases from the real current pose. |
Anim |
Rig, Anim, Fade? |
Resolves Rig (roster key or Model), then Rig:play(anim, { Fade }). Tracked so scene end/skip can stop it. |
Sound |
Sound, Volume? |
AudioKit:playAt(sound, origin.Position, { Volume }) if the scene has an origin, else AudioKit:play(sound, { Volume }). |
Vfx |
Vfx, Offset? |
VfxSuite:play(vfx, (origin or CFrame.identity) * CFrame.new(offset or Vector3.zero)) — Offset is in scene-origin-local space. |
Line |
Text, Seconds? |
Publishes bus Cutscene.Line(text, seconds). |
| anything else | whatever you define | Handlers[Action](event, context), dispatched through task.spawn — a throwing handler can’t break the scene stepper. |
Shot/Blend warn every time they fire if no Camera was attached ([CutsceneKit] "<scene>" uses Shot but no Camera was attached) — this is a loud, repeated signal, not a once-only notice. Anim/Sound/Vfx are quieter: if their respective kit (AnimKit/AudioKit/VfxSuite) wasn’t attached, those actions silently no-op. Worth wiring all four integrations even in tooling/test contexts where you’d expect obvious failure.
Text on a Line event is a convention, not an enforced call. CutsceneKit does not itself call into Text — it publishes the raw Text string on Cutscene.Line exactly as written in the scene. The pairing with Text keys is on your subtitle subscriber: put a Text key (not the literal string) in Text, and have your UI resolve it per-player with Text:get(player, text) (or :format for interpolated lines) before it renders, the same way every other kit sends keys over the wire and localizes at the point of display.
One packet, then every client is on its own. CutsceneKit.server(kernel) returns a play(name, origin?) that fires CKCUT_Play — NumberU8 scene id + CFrameF32U8 origin — and that’s the entire wire cost of the cutscene, regardless of how many timed events it contains. On the client, CutsceneKit.attach listens for that one packet, resolves the id back to a scene name, and calls its own local :play(name, origin). From there each client runs a Heartbeat-driven step() against its own clock, walking due() over the sorted event list and firing whatever’s newly in range — nobody sends anything else for the rest of the scene. A cutscene with dozens of timed beats is one packet whether it has three events or three hundred; the alternative (one packet per event) would multiply wire cost with scene complexity for no reason, since every client already has the full timeline the moment it has the scene name.
skip() jumps to the end — it does not run the remaining events. Instance:skip() finishes the active scene immediately with skipped = true. This bypasses the normal step() walk entirely: any event between the current cursor and the end, including ones already past their Time that simply haven’t been executed yet this frame, is dropped without ever firing. Scene-started animations are stopped (Rig:stop(anim, 0.3), a fixed 0.3s fade, regardless of skip vs. natural end), and if the scene took the camera (any Shot/Blend fired), the camera is released with the scene’s ReleaseBlend seconds — an eased blend back, not a hard cut, so skipping mid-cutscene doesn’t snap the camera home instantly.
-- ReplicatedStorage.Cutscenes.BossIntro (ModuleScript, required by both machines)local CutsceneKit = require(game.ReplicatedStorage.ChloeKernel.CutsceneKit)
CutsceneKit.register("BossIntro", { { Time = 0.0, Action = "Shot", Shot = { Type = "Static", CFrame = CFrame.new(0, 15, 30), FOV = 40 } }, { Time = 0.5, Action = "Anim", Rig = "Boss", Anim = "Roar" }, { Time = 0.6, Action = "Sound", Sound = "BossRoar" }, { Time = 1.2, Action = "Blend", Shot = { Type = "Orbit", Target = "Boss", Distance = 35, Height = 10 }, Seconds = 2 }, { Time = 2.0, Action = "Line", Text = "Boss.Intro.Threat", Seconds = 3 },}, { Duration = 5, ReleaseBlend = 0.5 })-- ServerScriptService: trigger the scene for everyonerequire(game.ReplicatedStorage.Cutscenes.BossIntro) -- ensure it's registered
local Cutscenes = CutsceneKit.server(kernel)local BossModel = workspace.Boss
local function startBossIntro() Cutscenes:play("BossIntro", BossModel:GetPivot())end-- StarterPlayerScripts: play locally on receipt, and wire a skip buttonrequire(game.ReplicatedStorage.Cutscenes.BossIntro)
local Cam = CameraKit.attach(kernel)local Anim = AnimKit.attach(kernel)local Audio = AudioKit.attach(kernel)
local Cutscenes = CutsceneKit.attach(kernel, { Camera = Cam, AnimKit = Anim, AudioKit = Audio, Rigs = { Boss = workspace.Boss }, -- resolved locally on THIS machine})
local Bus = kernel.BusBus:subscribe("Cutscene.Line", function(_topic, text, seconds) subtitleUi:show(kernel.Text:get(game.Players.LocalPlayer, text), seconds)end)
game:GetService("ContextActionService"):BindAction("SkipCutscene", function(_name, state) if state == Enum.UserInputState.Begin and Cutscenes:active() then Cutscenes:skip() end return Enum.ContextActionResult.Sinkend, false, Enum.KeyCode.X)Configuration
Section titled “Configuration”CutsceneKit.register(name, events, options?) — SceneOptions
Section titled “CutsceneKit.register(name, events, options?) — SceneOptions”| Field | Type | Default | Description |
|---|---|---|---|
Duration |
number? |
the last event’s Time |
When the scene is considered finished if nothing else ends it first. |
ReleaseBlend |
number? |
0.6 |
Seconds the camera eases back over at scene end/skip, if the scene took it. |
CutsceneKit.attach(kernel, options?) — AttachOptions
Section titled “CutsceneKit.attach(kernel, options?) — AttachOptions”| Field | Type | Description |
|---|---|---|
Camera |
CameraKit instance? |
Enables Shot/Blend actions. |
AnimKit |
AnimKit instance? |
Enables Anim actions. |
AudioKit |
AudioKit instance? |
Enables Sound actions. |
VfxSuite |
VfxSuite instance? |
Enables Vfx actions. |
Rigs |
{ [string]: any }? |
Named rig/target roster; resolved locally at play time. |
Handlers |
{ [string]: (event, context) -> () }? |
Custom action dispatch. context = { Origin, Kit, Scene }. |
PacketFactory |
any? |
Injectable packet constructor for specs. |
Clock |
(() -> number)? |
Default os.clock. |
SkipListen |
boolean? |
Skip wiring CKCUT_Play’s OnClientEvent; specs drive :play() directly. |
SkipLoop |
boolean? |
Skip the Heartbeat stepper; specs drive :step() manually. |
Event reference
Section titled “Event reference”Event field |
Required by | Description |
|---|---|---|
Time |
all | Seconds from scene start. >= 0, checked at register. |
Action |
all | "Shot" | "Blend" | "Anim" | "Sound" | "Vfx" | "Line" | a custom key in Handlers. |
Shot |
Shot, Blend |
A CameraKit Shot table. Target/LookAt may be roster string keys. |
Seconds |
Blend |
Blend duration. Default 1. |
Ease |
Blend |
Ease style passed to Camera:blend. |
Rig |
Anim |
A Rigs roster key, or a Model (attached lazily). |
Anim |
Anim |
Animation name, string. |
Fade |
Anim |
Passed through to Rig:play. |
Sound |
Sound |
Sound name, string. |
Volume |
Sound |
Passed through to AudioKit. |
Vfx |
Vfx |
VFX name, string. |
Offset |
Vfx |
Vector3, scene-origin-local. |
Text |
Line |
A Text key, by convention. string. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
CutsceneKit.register(name: string, events: { Event }, options: SceneOptions?) |
Shape-checks built-in event fields, sorts by Time, assigns a U8 scene id. Throws on a malformed built-in event, a duplicate name, or a 256th registration (255 scenes is the cap; the check runs before the id counter increments, so it fails at register(), not at play time). |
CutsceneKit.resetRegistry() |
Test seam: clears all registered scenes and ids. |
CutsceneKit.due(events: { Event }, cursor: number, age: number) -> (number, number, number) |
Pure. Advances the cursor over time-sorted events; returns (newCursor, from, to). to < from means nothing is due. |
CutsceneKit.server(kernel, options?) -> { play } |
play(self, name: string, origin: CFrame?) fires CKCUT_Play and publishes Cutscene.Played on the server’s own bus. Nothing plays server-side. |
CutsceneKit.attach(kernel, options?) -> Instance |
The client (or local) half: listens for CKCUT_Play and steps the active scene off Heartbeat. |
Instance:play(name: string, origin: CFrame?) -> Scene? |
Plays a scene locally. A scene already running is ended (not skipped) first. Fires Time = 0 events synchronously before returning. |
Instance:skip() |
Jumps the active scene to its end. Pending events drop; played anims stop; the camera releases with a blend. |
Instance:active() -> Scene? |
The currently playing scene, or nil. |
Instance:step() |
Fires all events newly due since the last call. Runs automatically off Heartbeat unless SkipLoop. |
Instance:destroy() |
Disconnects the wire listener and stepper; force-ends the active scene if any. |
| Packet | Shape | Direction | Cost |
|---|---|---|---|
CKCUT_Play |
NumberU8 scene id, CFrameF32U8 origin |
Server → all clients | One packet per play() call, regardless of the scene’s event count. |
Bus topics
Section titled “Bus topics”| Topic | Args | When | Fired on |
|---|---|---|---|
Cutscene.Started |
name |
play() begins a scene, before its Time = 0 events run. |
The playing instance (every client, plus wherever else you attach locally). |
Cutscene.Ended |
name |
The scene reaches its Duration with all events consumed. |
Playing instance. |
Cutscene.Skipped |
name |
skip() was called. |
Playing instance. |
Cutscene.Line |
text, seconds |
A Line event fires. |
Playing instance. |
Cutscene.Played |
name, origin |
The server’s play() call fires the wire packet. |
Server-side kernel bus only — distinct from the client-side Started above; don’t confuse the two when wiring analytics. |