CutsceneKit
Plays cutscenes as flat timelines of camera, animation, sound, VFX and subtitle events, started by one server packet and played locally by every client.
- Realm
- Shared
- Require
ReplicatedStorage.ChloeKernel .CutsceneKit - Since
- v0.7.0
- Source
- CutsceneKit.luau
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 walks the timeline and calls CameraKit,
AnimKit, AudioKit and VfxSuite at the right moments. The
server sends one packet naming the scene and an optional origin, and every client plays the whole scene locally from
it. A boss intro with thirty beats costs the same single packet as a two-beat door scene.
Mental model
Section titled “Mental model”Registering scenes
Section titled “Registering scenes”CutsceneKit.register(name, events, options?) checks every built-in event’s shape when it is called: every event needs
a numeric Time >= 0 and a string Action; Shot and Blend need a Shot table, Anim needs Rig and a string
Anim, Sound a string Sound, Vfx a string Vfx and Line a string Text. A malformed scene fails at
definition, before it can half-play in front of a player. A custom action only gets the Time and Action check; its
own fields are the handler’s business.
register also sorts the events by Time. table.sort is not stable, so the kit breaks ties by authored position:
events at the same time run in the order they were written (a VFX emit never runs before the spawn it follows). Before
0.9.0 same-time events could run in any order.
Each scene gets a NumberU8 wire id in registration order, so the server and every client must register the same
scenes in the same order, from one shared module. Ids run 1 to 255; a 256th register raises
"CutsceneKit supports at most 255 scenes (U8 wire ids)" at the call instead of overflowing the id at play time.
Registering a name twice also raises.
Rigs and targets by name
Section titled “Rigs and targets by name”A scene definition is shared by the server and every client, so it cannot hold Instances. Scenes name rigs and camera
targets by string (Rig = "Boss", Shot = { Target = "Boss" }), and each client’s attach(kernel, { Rigs = ... })
maps those names to local objects at play time. The roster is read when an event runs, so a game can fill it in after
attaching, for example when the boss model streams in.
An Anim event’s Rig resolves like this:
- A
Rigsentry that is already an AnimKit rig is used as given, with its tracks and exclusive groups. - A
Model(fromRigsor given directly) reuses the live AnimKit rig the game already attached for that model, so the scene shares its tracks and groups. Only if none exists does the kit callAnimKit:attachRig(model). - A cached rig that was destroyed is never reused, and the cache entry for a model is dropped when the model is destroyed.
Actions
Section titled “Actions”| Action | Fields | What runs |
|---|---|---|
Shot |
Shot |
Camera:cut(shot). |
Blend |
Shot, Seconds? (default 1), Ease? |
Camera:blend(shot, seconds, ease), from the camera’s real pose. |
Anim |
Rig, Anim, Fade? |
rig:play(anim, { Fade = fade }) on the resolved rig. Tracked so the scene can stop it. |
Sound |
Sound, Volume? |
AudioKit:playAt(sound, origin.Position, { Volume = volume }) when the scene has an origin, else AudioKit:play(sound, { Volume = volume }). Tracked. |
Vfx |
Vfx, Offset? |
VfxSuite:play(vfx, (origin or CFrame.identity) * CFrame.new(offset or Vector3.zero)). Tracked. |
Line |
Text, Seconds? |
Publishes Cutscene.Line(text, seconds). |
| anything else | your own | Handlers[Action](event, context) on its own thread, with context = { Origin, Kit, Scene }. An action with no handler warns. |
Shot and Blend warn every time they run without an attached Camera. Sound and Vfx do nothing without their
kit, and Anim does nothing when the rig cannot play.
Line publishes the Text string as written; the kit never calls Text. Put a Text key in the event
and resolve it per player in the subtitle subscriber, the way every kit sends keys and localizes at display.
One packet, then every client plays alone
Section titled “One packet, then every client plays alone”CutsceneKit.server(kernel) returns play(name, origin?), which fires CKCUT_Play with the scene id, an origin
CFrame and a has-origin flag, and publishes Cutscene.Played on the server’s Bus. Nothing plays on the server. The
flag keeps “no origin” apart from an origin at world zero: a scene played without an origin plays its sounds in 2D.
Before 0.9.0 a missing origin reached clients as CFrame.identity, and the sounds played at (0, 0, 0).
On each client, CutsceneKit.attach listens for CKCUT_Play, maps the id back to a name and calls its local
play(name, origin). A client plays one scene at a time; a new play ends the running scene first (as an
interruption, below). While a scene plays, the kit connects RunService.Heartbeat and steps the timeline against its
own clock with the pure CutsceneKit.due. The connection is dropped when the scene finishes, so an idle kit has no
per-frame work.
Each event runs alone
Section titled “Each event runs alone”The step claims each event (advances the cursor) before running it, then runs it under pcall. An event that throws
logs a warning naming the scene, the event index and the action, and the timeline moves on. It never runs twice and it
cannot leave the camera Scriptable. If an event’s handler stops or replaces the scene, the step stops walking the old
one.
How a scene ends
Section titled “How a scene ends”A scene ends in one of three ways:
- Natural end: the age reaches
Duration(default: the last event’sTime) and every event has run.Cutscene.Endedpublishes. Sounds and VFX timelines the scene started finish on their own. skip(): pending events are dropped without running, including ones already past theirTimethat the current step has not reached. The scene’s sounds fade out over 0.3 s and its VFX timelines stop (VfxSuite:stop).Cutscene.Skippedpublishes.- Interruption: a new
playordestroy()ends the scene the same way as a skip (sounds and VFX stop) but publishesCutscene.Ended.
In all three, anims the scene started stop with a 0.3 s fade, and if the scene took the camera (any Shot or Blend
ran), the camera is released with a blend of ReleaseBlend seconds (default 0.6). Before 0.9.0, skip() left scene
sounds and VFX running.
Suspending player UI
Section titled “Suspending player UI”The Suspend scene option turns player UI off while the scene plays:
Prompts = truesetsProximityPromptService.Enabled = false.TouchControls = truesetsGuiService.TouchControlsEnabled = false.CoreGui = { Enum.CoreGuiType.Backpack, ... }callsStarterGui:SetCoreGuiEnabled(type, false)for each.
The kit records each value it finds at play and restores them in reverse order when the scene ends, is skipped, is replaced or the kit is destroyed. Something the game had already turned off stays off after the scene.
--!strict-- Cutscenes: every scene, registered in one fixed order on the server and every client.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local CutsceneKit = require(ReplicatedStorage.ChloeKernel.CutsceneKit)
local Cutscenes = {}
-- Called once per machine. Ids follow this order, so never register scenes anywhere elsefunction Cutscenes.register() 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, Suspend = { Prompts = true, TouchControls = true, CoreGui = { Enum.CoreGuiType.Backpack } }, })end
return Cutscenes--!strict-- CinemaService: starts cutscenes for everyone.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local CutsceneKit = require(ReplicatedStorage.ChloeKernel.CutsceneKit)local Cutscenes = require(ReplicatedStorage.Game.Cutscenes)
local CinemaService = { Name = "CinemaService",}
function CinemaService.init(self: any, kernel: any) Cutscenes.register() self.Cutscenes = CutsceneKit.server(kernel)end
-- One CKCUT_Play packet; every client plays the whole scenefunction CinemaService.bossIntro(self: any, boss: Model) self.Cutscenes:play("BossIntro", boss:GetPivot())end
return CinemaService--!strict-- CinemaController: plays cutscenes the server starts, with subtitles and a skip key.
local ContextActionService = game:GetService("ContextActionService")local Players = game:GetService("Players")local ReplicatedStorage = game:GetService("ReplicatedStorage")
local AnimKit = require(ReplicatedStorage.ChloeKernel.AnimKit)local AudioKit = require(ReplicatedStorage.ChloeKernel.AudioKit)local CameraKit = require(ReplicatedStorage.ChloeKernel.CameraKit)local CutsceneKit = require(ReplicatedStorage.ChloeKernel.CutsceneKit)local Cutscenes = require(ReplicatedStorage.Game.Cutscenes)
local CinemaController = { Name = "CinemaController", Rigs = {} :: { [string]: any }, -- read at play time; fill in as models stream in}
function CinemaController.init(self: any, kernel: any) self.Kernel = kernel Cutscenes.register() self.Cutscenes = CutsceneKit.attach(kernel, { Camera = CameraKit.attach(kernel), AnimKit = AnimKit.attach(kernel), AudioKit = AudioKit.attach(kernel), Rigs = self.Rigs, })end
function CinemaController.start(self: any) self.Line = self.Kernel.Bus:subscribe("Cutscene.Line", function(_topic, key, seconds) -- Resolve the key with the game's Text instance before showing it self.Kernel.Bus:publish("Hud.Subtitle", Players.LocalPlayer, key, seconds) end) ContextActionService:BindAction("SkipCutscene", function(_name, state) if state == Enum.UserInputState.Begin and self.Cutscenes:active() then self.Cutscenes:skip() return Enum.ContextActionResult.Sink end return Enum.ContextActionResult.Pass end, true, Enum.KeyCode.X, Enum.KeyCode.ButtonB)end
function CinemaController.stop(self: any) ContextActionService:UnbindAction("SkipCutscene") if self.Line then self.Line:disconnect() end if self.Cutscenes then self.Cutscenes:destroy() endend
return CinemaControllerConfiguration
Section titled “Configuration”CutsceneKit.register(name, events, options?)
Section titled “CutsceneKit.register(name, events, options?)”| Option | Type | Default | Description |
|---|---|---|---|
Duration |
number? |
the last event’s Time |
Age at which the scene ends once every event has run. |
ReleaseBlend |
number? |
0.6 |
Seconds the camera blends back at the end, if the scene took it. |
Suspend |
{ Prompts: boolean?, TouchControls: boolean?, CoreGui: { Enum.CoreGuiType }? }? |
nil |
Player UI and prompts to turn off while the scene plays. Restored to the values found at play. |
CutsceneKit.attach(kernel, options?)
Section titled “CutsceneKit.attach(kernel, options?)”| Option | Type | Description |
|---|---|---|
Camera |
CameraKit instance | Enables Shot and Blend. |
AnimKit |
AnimKit instance | Enables Anim on Models, and lets Models reuse the game’s live rigs. |
AudioKit |
AudioKit instance | Enables Sound. |
VfxSuite |
VfxSuite instance | Enables Vfx. |
Rigs |
{ [string]: any }? |
Name to Model, AnimKit rig or camera target. Read at play time. |
Handlers |
{ [string]: (event, context) -> () }? |
Custom actions. context = { Origin, Kit, Scene }. |
PacketFactory |
any? |
Test injection point for the packet constructor. |
Clock |
(() -> number)? |
Time source. Default os.clock. |
SkipListen |
boolean? |
Do not listen for CKCUT_Play. Specs call play directly. |
SkipLoop |
boolean? |
Never connect Heartbeat. Specs call step by hand. |
Event reference
Section titled “Event reference”| Field | Used by | Description |
|---|---|---|
Time |
all | Seconds from scene start, >= 0. Checked at register. |
Action |
all | "Shot", "Blend", "Anim", "Sound", "Vfx", "Line" or a key in Handlers. |
Shot |
Shot, Blend |
A CameraKit shot table. Target and LookAt may be Rigs names. |
Seconds |
Blend, Line |
Blend duration (default 1), or how long a line shows. |
Ease |
Blend |
Ease style for Camera:blend. |
Rig |
Anim |
A Rigs name, or a Model. |
Anim |
Anim |
Animation name. |
Fade |
Anim |
Passed to rig:play. |
Sound |
Sound |
Sound name. |
Volume |
Sound |
Passed to AudioKit. |
Vfx |
Vfx |
VFX name. |
Offset |
Vfx |
Vector3 in the scene origin’s space. |
Text |
Line |
By convention a Text key. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
CutsceneKit.register(name, events, options?) |
Shape-checks built-in events, sorts by time (ties in authored order) and assigns the next U8 id. Raises on a malformed built-in event, a duplicate name or a 256th scene. |
CutsceneKit.resetRegistry() |
Test seam: clears every registered scene and id. |
CutsceneKit.due(events, cursor, age) -> (number, number, number) |
Pure. Returns newCursor, from, to for the events due at age. to < from means none. |
CutsceneKit.server(kernel, options?) -> { play } |
Server half. play(self, name, origin?) fires CKCUT_Play and publishes Cutscene.Played. Raises on an unknown scene. |
CutsceneKit.attach(kernel, options?) -> kit |
Client half. Listens for CKCUT_Play. |
kit:play(name, origin?) -> Scene? |
Plays a scene locally, ending any running scene first. Runs Time = 0 events before returning. Warns and returns nil for an unknown scene. |
kit:skip() |
Ends the active scene now: pending events drop, sounds fade, VFX timelines stop, anims stop, the camera blends back. |
kit:active() -> Scene? |
The playing scene, or nil. scene.Stop(skipped?) ends it from a handler. |
kit:step() |
Runs every event now due. The Heartbeat connection calls it while a scene plays. |
kit:destroy() |
Disconnects the packet listener and the stepper, ends the active scene as an interruption and drops cached rigs. |
| Packet | Shape | Direction | Cost |
|---|---|---|---|
CKCUT_Play |
NumberU8 scene id, CFrameF32U8 origin, Boolean8 has-origin |
Server to all clients | One packet per play, whatever the scene’s length. |
Bus topics
Section titled “Bus topics”| Topic | Payload | When | Machine |
|---|---|---|---|
Cutscene.Started |
name |
play begins a scene, before its Time = 0 events. |
Every playing client. |
Cutscene.Ended |
name |
The scene ended naturally, or was replaced or destroyed. | Every playing client. |
Cutscene.Skipped |
name |
skip() ended the scene. |
The client that skipped. |
Cutscene.Line |
text, seconds |
A Line event ran. |
Every playing client. |
Cutscene.Played |
name, origin |
The server fired CKCUT_Play. |
The server only. |