Skip to content

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.

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.

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 Rigs entry that is already an AnimKit rig is used as given, with its tracks and exclusive groups.
  • A Model (from Rigs or 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 call AnimKit: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.
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.

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.

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.

A scene ends in one of three ways:

  • Natural end: the age reaches Duration (default: the last event’s Time) and every event has run. Cutscene.Ended publishes. Sounds and VFX timelines the scene started finish on their own.
  • skip(): pending events are dropped without running, including ones already past their Time that the current step has not reached. The scene’s sounds fade out over 0.3 s and its VFX timelines stop (VfxSuite:stop). Cutscene.Skipped publishes.
  • Interruption: a new play or destroy() ends the scene the same way as a skip (sounds and VFX stop) but publishes Cutscene.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.

The Suspend scene option turns player UI off while the scene plays:

  • Prompts = true sets ProximityPromptService.Enabled = false.
  • TouchControls = true sets GuiService.TouchControlsEnabled = false.
  • CoreGui = { Enum.CoreGuiType.Backpack, ... } calls StarterGui: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.

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

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

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