Log inGet started

Cutscenes

Updated October 7, 2026
cutscene.play({
    duration = 6,
    letterbox = 0.12,
    shots = {
        { dur = 6, pos = { { -8, 3, 12 }, { 4, 2, 5 } }, look = "Hero", fov = { 55, 38 } },
    },
    subtitles = {
        { t = 0.5, dur = 4, text = "They came at first light.", speaker = "Mira" },
    },
    cues = {
        { t = 3, run = function() entity.find("gate"):despawn() end },
    },
})

cutscene is a global — the prelude injects it, so there is nothing to require.

What playing does

One call takes over four things and gives all four back when the cutscene ends, however it ends:

  • The camera. A cutscene camera is put in the scene on the first shot, at a priority above every camera already there, and the viewport is routed to it outright for as long as the cutscene runs — so it is what the viewer sees while the world is being played and while it is being authored. It is driven along the shot path each frame, and the viewport goes back to whatever held it when the cutscene ends.
  • The image. Letterbox bands open at the start and close at the end, and the cutscene fades in from and out to a colour at its boundaries.
  • The subtitle. The line whose window holds the current moment, on screen at a fixed place, through every change of lens. It is composed for the frame it is read in: it wraps at a measure of about forty characters, and a frame too narrow to hold that measure takes the whole block down together, so a line of any length reads in full on a wide screen and on a phone held upright alike.
  • The player. Their controls are held while it runs, so a cutscene is not something they walk out of.

Nothing has to be ticked. play starts the cutscene advancing on its own and hands back a playback.

The playback

local pb = cutscene.play("mygame.scenes.intro")

pb:onFinished(function(_, reason)
    -- "completed" | "skipped" | "stopped"
    startTheGame()
end)

skip() · stop(reason?) · seek(t) · pause() · resume() · setRate(r) · onFinished(fn) · time() · getDuration() · progress() · isPlaying() · status()

onFinished is where control goes back to the game — it fires whether the cutscene played out, was skipped by the player, or was stopped by your code.

Only one cutscene plays at a time. Starting a second ends the first.

Shots

A shot moves the camera between anchors over dur seconds. The shot list is a continuous move: each shot's window is half-open, so the moment one ends is the moment the next begins.

shots = {
    { dur = 4, pos = { { 0, 3, 10 }, { 0, 2, 4 } }, look = { 0, 1, 0 }, fov = { 60, 40 } },
    { dur = 3, pos = "BalconyCam", look = "Hero", lookOffset = { 0, 1.6, 0 } },
}

An anchor is one of three things — a world position { x, y, z }, the entity to read one from (a name, an id, or a proxy), or a list of either that the shot travels along as a polyline.

Reading an anchor from an entity is what makes a cutscene work in a game rather than only in a fixed demo: it is resolved on the frame it is drawn, so a shot aimed at a walking character tracks the walk instead of the spot they stood on when you wrote it.

A polyline is the answer to a camera crossing ground that is not flat — a straight move between two points a few seconds apart cuts the chord under the terrain between them, and spends its middle underground.

KeyWhat it does
durSeconds. Defaults to 4.
posWhere the camera is.
lookWhat it faces.
fovVertical field of view — one number, or a list interpolated across the shot.
easeAny name cutscene.easings() lists, or a function. Defaults to inOutQuad.
posOffset / lookOffsetShift a resolved anchor — head height rather than foot height.

Check a path against the scene before you watch it:

local eye, at, fov = cutscene.evaluate(def.shots, 2.5)

Subtitles and cues

subtitles = {
    { t = 0.5, dur = 4, text = "They came at first light.", speaker = "Mira" },
    { t = 5.0, dur = 3, text = "And the pillars still stood." },
},
cues = {
    { t = 3.0, name = "gate", run = function(pb) openGate() end },
},

A subtitle shows for dur seconds from t (3 seconds if you do not say). A cue fires once, as the clock crosses t, and is handed the playback.

Write the line, not the layout. A subtitle wraps onto as many rows as it needs and sits on a baseline a fixed way down the frame, so the rows a long line takes go upward and the row nearest the bottom edge stays where a single row sits. Its size follows the frame: a portrait viewport, which is barely a third as wide as a landscape one at the same lens, draws the same line smaller rather than off its edges. There is no line length to keep under and no size to state.

A skip reaches every device. A skippable cutscene puts a Skip control on the player's surface while it is on screen: the keys in skipKeys, the pad buttons in skipButtons, and an on-screen button a thumb reaches. It is a control like any other. Zin.scheme.bindings() lists it, a rebind screen can show it under the name skipLabel gives it, and it leaves the surface with the cutscene. A button already held when the cutscene opens does not skip it: its press came before the skip control did, so the skip waits for a fresh press and an opening does not vanish under a finger the player had down a moment ago.

A skippable cutscene names at least one of the three. Emptying skipKeys and skipButtons with skipTouch = false is refused where the playback is built, and skippable = false is how a cutscene plays out in full.

Cues survive a skip. Skipping jumps the clock to the closing fade and fires every cue between here and there, so a cutscene that opens a door on its way out opens it whether or not the player watched. Put state changes the game depends on in cues and they will happen.

A seek does not fire the cues it passes. seek re-derives the frame that time would have produced — the camera, the fade and the subtitle — rather than running the clock through to it, so a cue behind the new time is marked as already fired and its run never happens. That is what makes a seek a view of a moment rather than a fast-forward, and it is why seeking past a cue and then looking for its effect finds nothing. To watch a cue happen, play through it or skip — a backwards seek re-arms every cue ahead of the new time, so replaying the stretch runs them again.

The bands are drawn over everything

The letterbox and the fade are a post-process pass, last in the chain — which is what makes a fade reach solid colour over the graded image, and what makes a band the edge of the frame rather than a rectangle inside it. It also means they are painted over the UI: a screen registered with ui.registerScreen draws underneath, so anything within letterbox × screenHeight of the top or bottom edge is covered every frame while looking perfectly correct in the widget tree.

Put a skip hint, a chapter title, or any HUD a cutscene shows clear of the bands — inset by more than letterbox of the screen height — or give it to the cutscene's own subtitles, which ride the camera inside the frame.

Playing one from a scene

A cutscene definition in its own module is content: it can be played by name from anywhere, it hot-reloads while you watch it, and the cutscene toolbox can drive it.

-- mygame/scenes/intro.module/init.luau
return {
    duration = 12,
    letterbox = 0.12,
    shots = { … },
    subtitles = { … },
}
-- the scene's entrypoint — the game opens on the cutscene
function onLoad()
    cutscene.play("mygame.scenes.intro"):onFinished(function()
        beginPlay()
    end)
end

The name is the module's identity — what asset.list("module") lists — and it resolves the same from a scene entrypoint, a component, a tool or an execute.

Playing one when something happens

For a cutscene that fires at a place or a moment rather than at load, put a CutsceneTrigger on an entity instead of writing the wiring:

entity.find("GateArea").component.add("CutsceneTrigger", {
    cutscene = "mygame.scenes.gate_opens",
    trigger = "onEnter",
    radius = 4,
})

trigger is "onPlay" (as the scene enters play — the game's opening), "onEnter" (when the local player comes within radius), or "manual" (only when your code calls play() on the component). once decides whether it can fire again; delay waits before starting.

A moment over live gameplay

A cutscene with no shots takes no camera. The player keeps looking through their own, and the cutscene is the letterbox, the fade, the lines and the cues over whatever is happening:

cutscene.play({
    takeCamera = false,
    freezePlayer = false,
    letterbox = 0.08,
    subtitles = { { t = 0, dur = 4, text = "Something moved in the trees." } },
})

The definition

KeyDefaultWhat it does
durationthe shots' totalHow long it runs. Taken from the shots, the last subtitle and the last cue when not stated.
shotsnoneThe camera path.
subtitlesnone{ t, dur?, text, speaker? }.
cuesnone{ t, run, name? }.
letterbox0Band height per side, as a fraction of the screen height.
fadeIn / fadeOut0.6Seconds of fade at each boundary.
fadeColor / barColorblack{ r, g, b } in 0..1.
skippabletrueWhether the player can end it early.
skipKeysEscape / Space / EnterKeyboard codes that skip it. Empty declines the keyboard.
skipButtonsstart / south / eastGamepad buttons that skip it. Empty declines the pad.
skipTouchtrueWhether the on-screen skip button is drawn.
skipLabelSkipWhat the on-screen button and a rebind screen call it.
takeCameratrue when it has shotsWhether it takes the viewer's camera.
freezePlayertrueHold the player still for the duration.
hidePlayerfalseHide the player's body.
renderLayersscene defaultRender layers the cutscene camera draws.
onStart / onFinishnoneonStart(pb), onFinish(pb, reason).
namethe module's identityWhat the status and the tools call it.

cutscene.play(source, opts) merges opts over the definition for that play alone, so one cutscene can be skippable in one place and not in another.

While you are authoring one

A cutscene is written to be watched from its start, which is exactly what you do not want to do forty times while working on the shot at 0:48. The cutscene toolbox drives the one on screen:

zero cutscene play   mygame.scenes.intro   -- put it on screen
zero cutscene seek   48                    -- land on the shot you are working on
zero cutscene pause                        -- stop the clock and the scene; the frame stays put
… edit the cutscene module through the VFS …
zero cutscene play   mygame.scenes.intro   -- play it again on the edit

pause stops the scene along with the clock — components stop updating, physics stops stepping, animation stops advancing — so the shot you are working on stays on screen for as many captures and edits as the pass needs, and repeating the call holds the same frame rather than stepping it. resume starts both again.

seek re-derives the frame rather than fast-forwarding to it, so where it lands is exactly the frame that time would have produced. rate 0.25 runs a fast move slowly enough to read; rate -1 runs it backwards.

These reach the cutscene you are watching, whichever scope started it — a scene entrypoint, a CutsceneTrigger, an execute.

Multiplayer

A world is always multiplayer, and a cutscene is something each player watches for themselves. Everything a cutscene puts in the world is local to the peer playing it: the camera, the subtitle lines and the overlay are not replicated, and freezing the player freezes the one player watching.

That is why CutsceneTrigger.played is local to each peer — one player having seen the opening is not a reason the next one does not.

What a cutscene's cues do is another matter: a cue that despawns a gate changes the world everyone is in. Write cues the way you write any other gameplay code that runs on one peer.

When something looks wrong

  • The cutscene plays but the camera does not move — a shot whose anchor names an entity that is not in the scene is refused, and the error names the shot's own field. The logs toolbox's search tool finds it; the message is said once per playback rather than once per frame.
  • The camera is stuck after a cutscene — cutscene.stop() gives the viewport back from any scope, including one that holds no playback at all: a cutscene whose handle went away with the code that started it is stopped by that call like any other. cutscene.sweep() does the same and reports how many entities it removed.
  • Nothing is playing but the toolbox says otherwise — cutscene.status() reads the live clock from any scope; { playing = false } is the idle answer.

Where the pieces are

cutscene composes three submodules, each independently requirable:

  • @builtin::modules.cutscene.path — shot evaluation and look rotation
  • @builtin::modules.cutscene.stage — what a cutscene takes over and gives back
  • @builtin::modules.cutscene.runtime — one playback's clock

Reach for them directly when you are building something a cutscene is a part of rather than playing one — a timeline editor, a replay camera, a title sequence with its own clock.

  • documentation
  • guide