Cutscenes
A cutscene is the moment your game takes the camera off the player: the opening that establishes where they are, the door that opens once, the ending. In Zero you write one as data — a table of…
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 at a priority above every camera already there, so it is what the viewer sees, and driven along the shot path each frame.
- 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, at a fixed size, through every change of lens.
- 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.
| Key | What it does |
|---|---|
dur | Seconds. Defaults to 4. |
pos | Where the camera is. |
look | What it faces. |
fov | Vertical field of view — one number, or a list interpolated across the shot. |
ease | Any name cutscene.easings() lists, or a function. Defaults to inOutQuad. |
posOffset / lookOffset | Shift 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.
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 "press Space to 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
| Key | Default | What it does |
|---|---|---|
duration | the shots' total | How long it runs. Taken from the shots, the last subtitle and the last cue when not stated. |
shots | none | The camera path. |
subtitles | none | { t, dur?, text, speaker? }. |
cues | none | { t, run, name? }. |
letterbox | 0 | Band height per side, as a fraction of the screen height. |
fadeIn / fadeOut | 0.6 | Seconds of fade at each boundary. |
fadeColor / barColor | black | { r, g, b } in 0..1. |
skippable | true | Whether a skip key ends it early. |
skipKeys | Escape / Space / Enter | Which keys skip it. |
takeCamera | true when it has shots | Whether it takes the viewer's camera. |
freezePlayer | true | Hold the player still for the duration. |
hidePlayer | false | Hide the player's body. |
renderLayers | scene default | Render layers the cutscene camera draws. |
onStart / onFinish | none | onStart(pb), onFinish(pb, reason). |
name | the module's identity | What 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 -- freeze the clock; the frame stays put
… edit the cutscene module through the VFS …
zero cutscene play mygame.scenes.intro -- play it again on the edit
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. Check the log; the message is said once per playback rather than once per frame.
- The camera is stuck after a cutscene —
cutscene.sweep()removes a camera, subtitle or overlay left by a playback that never got to end (a scene torn down mid-cutscene), and stops whatever is playing. - 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.