---
title: "Cutscenes"
description: "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."
section: "Topics"
slug: "topics-cutscenes"
canonical: "https://origozero.ai/docs/topics-cutscenes"
updated: "2026-10-07T15:56:23.531863992+00:00"
tags: ["documentation", "guide"]
---

# Cutscenes

```luau
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

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

```luau
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:

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

## Subtitles and cues

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

```luau
-- mygame/scenes/intro.module/init.luau
return {
    duration = 12,
    letterbox = 0.12,
    shots = { … },
    subtitles = { … },
}
```

```luau
-- 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:

```luau
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:

```luau
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 the player can end it early. |
| `skipKeys` | Escape / Space / Enter | Keyboard codes that skip it. Empty declines the keyboard. |
| `skipButtons` | start / south / east | Gamepad buttons that skip it. Empty declines the pad. |
| `skipTouch` | `true` | Whether the on-screen skip button is drawn. |
| `skipLabel` | `Skip` | What the on-screen button and a rebind screen call 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                        -- 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.
