9.4 Timeline & Cutscenes

Phase 9 · Animation · Study time: 15–25 h

Sequencing scripted moments — cameras, animation, audio and events on a timeline for cinematics.

Earlier lessons in this chapter taught you how a single character moves: skeletal animation and skinning (9.1), blend trees and Animator state machines (9.2), and IK and root motion (9.3). All of that was about one character, controlled by the player or by AI, moment to moment. A cutscene (a short scene where the game takes over storytelling, usually with little or no player input) is a different problem: many things at once — a camera, several characters, audio, effects — all need to move together on a fixed schedule, like a small movie. This lesson covers the tool Unity gives you for that job: Timeline. You will learn how a Timeline is built from tracks and clips, how to play one from C#, how Cinemachine handles the camera, how to fire gameplay events at exact moments, how to hand control back and forth between the cutscene and the player, and how to make a cutscene skippable and localizable without turning it into a pile of special-case code.

1. What Is a Cutscene?

A cutscene (also called a cinematic) is a scripted scene where the game — not the player's input — drives what happens on screen for a while. It can be fully non-interactive (you just watch), or partly interactive (you can look around or skip, but you cannot make the character walk somewhere else). Games use cutscenes to deliver story beats that are hard to convey through normal gameplay, to introduce a boss before the fight starts, to hide a loading screen behind something worth watching, or to show off a place the player is about to explore.

What makes a cutscene hard to build by hand is that it is never just one system. A two-second cutscene of a door opening needs several things to happen in sync:

If you wrote this by hand with a pile of Invoke calls and WaitForSeconds coroutines, you would spend most of your time re-typing timing numbers every time a sound designer moved a beat by half a second, and you would have no single place to see the whole scene at a glance. What every cutscene actually needs is one shared clock that all of these systems read from, and a place to see all of them lined up against that clock at once. That shared clock is a Timeline.

2. The Timeline Idea: Tracks and Clips

A Timeline is a horizontal sequencer (a tool for arranging things in order along a time axis). Time runs left to right, in seconds. Underneath that time axis sit several tracks, stacked vertically, one for each thing that needs to change over time — a camera track, an animation track, an audio track, a VFX track, an event track. Each track holds one or more clips: a chunk of content with a start time and a length, placed somewhere along the track. A single moving marker called the playhead sweeps across all tracks at once, and whatever the playhead is currently touching on each track is what is active right now.

0s 1s 2s 3s 4s 5s |---------|---------|---------|---------|---------| Camera Track [==== Wide Shot ====][====== Close-Up ======] Anim Track [=== Draw Sword ===][========= Swing ========] Audio Track [=============== Dialogue.wav =================] VFX Track [Spark] [Flash] Marker Track ^ ^ DamageEvent CutsceneEnd ^ Playhead (director.time)

Notice what this buys you. At t = 2.5s (2.5 seconds into the Timeline) you can see, in one glance, that the camera is mid-way through the Close-Up shot, the character is mid-swing, dialogue is still playing, and a DamageEvent marker just fired. Move the DamageEvent marker half a second later, and every system that cares — the animation, the camera cut, the audio — stays exactly where it was, because they are not tied to each other, only to the same shared timeline. This separation is the entire point: instead of one script hard-coding "wait 2.3 seconds, then play the hit sound," each track owns its own content, and the Timeline owns the schedule.

Tip Keep tracks narrowly scoped — one camera track, one track per character's animation, one audio track per voice/music layer, and a separate marker track for gameplay events. A Timeline with 40 unrelated things crammed onto 3 tracks is as hard to read as a script with no functions.

3. Unity's Timeline Window and the PlayableDirector

In Unity, a Timeline exists in two forms. The Timeline window is the editor tool where you drag in tracks and clips and arrange them by eye. What you actually build there is saved as a TimelineAsset (an asset file, like a prefab or a material, that stores the tracks, clips, and their timings). To actually play a TimelineAsset in your game, you attach a PlayableDirector component to a GameObject and assign the TimelineAsset to it. The PlayableDirector is the "player" for the Timeline, the same way an AudioSource is the player for an AudioClip — the asset is just data, the component is what actually drives it forward frame by frame.


using UnityEngine;
using UnityEngine.Playables;

public class IntroCutscene : MonoBehaviour
{
    public PlayableDirector director;   // drag the GameObject holding the
                                         // Timeline asset here in the Inspector

    void Start()
    {
        director.Play();   // starts playing the Timeline from time 0
    }
}

What happens: there is no console output here — the result shows up in the Game view, not in a print statement. At time 0 the director begins evaluating every track at once: the Camera track jumps the Game view to the first shot, the Animation track poses the character at its first frame, and the Audio track schedules the dialogue clip to start. From then on director.time (a double, measured in seconds) advances on its own, roughly once per frame, and every track keeps re-evaluating itself against that number.

A few PlayableDirector members you will use constantly: director.time (the current playback position, which you can also set directly), director.duration (the Timeline's total length in seconds), director.state (an enum telling you whether it is Playing or Paused), and director.Stop()/director.Pause(). You will use most of these later in this lesson.

Tip The PlayableDirector has a "Play On Awake" checkbox in the Inspector. Turn it off for any cutscene you plan to start from code (as above), so it does not also try to auto-play the instant the scene loads — otherwise you can end up with two competing calls to start the same Timeline.

4. Cinemachine: Camera Work Made of Virtual Cameras

Hand-writing camera movement for every cutscene — lerping position, slerping rotation, timing cuts — does not scale past your second or third scene. Unity's answer is Cinemachine, a camera package built around virtual cameras (often called vcams): invisible objects that each describe one way of framing a shot (a position, a target to look at, a lens field of view, follow/look-at rules). None of them render anything themselves. A single component called CinemachineBrain, sitting on your one real Camera, watches all the vcams in the scene and copies whichever one currently "wins" onto the real camera every frame.

A vcam wins by having the highest Priority (a plain integer). When the currently-winning vcam changes, CinemachineBrain does not just snap — it blends smoothly from the old vcam's framing to the new one's, over a configurable blend time (a blend time of 0 seconds is simply a hard cut).

BEFORE CutToCloseUp() AFTER CutToCloseUp() wideShot.Priority = 10 wideShot.Priority = 10 closeUp.Priority = 0 closeUp.Priority = 20 <-- now highest CinemachineBrain follows: CinemachineBrain follows: wideShot closeUp (blends over its Blend Time)

using UnityEngine;
using Cinemachine;

public class SwitchToCloseUp : MonoBehaviour
{
    public CinemachineVirtualCamera wideShot;   // Priority starts at 10
    public CinemachineVirtualCamera closeUp;    // Priority starts at 0

    public void CutToCloseUp()
    {
        // CinemachineBrain always follows whichever virtual camera
        // currently has the highest Priority. Raising closeUp's
        // priority above wideShot's makes the Brain blend to it.
        closeUp.Priority = 20;
        wideShot.Priority = 10;
    }
}

What happens: before CutToCloseUp runs, wideShot (priority 10) beats closeUp (priority 0), so the real camera shows the wide shot. The moment CutToCloseUp runs, closeUp's priority (20) becomes the highest, and on the very next frame CinemachineBrain starts blending the real camera toward closeUp's framing — with no lerp code written by you. Inside a Timeline, this priority-swapping is normally done for you by a dedicated Cinemachine track, where each clip on the track simply references a different vcam; the Timeline changes priorities on your behalf as the playhead crosses clip boundaries.

Common mistake Forgetting to add a CinemachineBrain component to your Main Camera. Without it, virtual cameras exist in the scene and change their own priorities correctly, but nothing ever copies their framing onto the real camera, so nothing appears to happen at all.

(Note on versions: newer Cinemachine packages, from Cinemachine 3.0 onward, rename the class to CinemachineCamera and move Priority behind a small settings struct. The idea — highest priority wins, Brain blends automatically — is unchanged.)

5. Keyframing Camera Moves and Cuts

Not every camera moment is a hard cut between two fixed shots — often a single shot itself needs to move, like a slow push-in toward a character's face. This is done with keyframes: a value pinned at a specific time, on a curve, so the engine can compute every in-between value for you. In the Timeline window this usually shows up as an Animation track bound to a virtual camera's Transform or lens settings, with small diamond markers (keyframes) placed at the times where you pin down a position, rotation, or field-of-view value.

Under the hood, both Timeline's animation tracks and Unity's regular Animator use the same building block for this: AnimationCurve, a list of time/value keyframe pairs plus rules for how to interpolate between them. You can see exactly what a keyframed camera move is doing with a tiny standalone example:


using UnityEngine;

public class ZoomCurveDemo : MonoBehaviour
{
    void Start()
    {
        // Two keyframes with straight-line (linear) interpolation:
        // at 0s the Field of View is 40, at 2s it is 65.
        AnimationCurve zoom = AnimationCurve.Linear(0f, 40f, 2f, 65f);

        float fovAtHalfSecond = zoom.Evaluate(0.5f);
        Debug.Log(fovAtHalfSecond);
    }
}

Expected output: 46.25

Worked trace: 0.5 seconds is a quarter of the way from the first keyframe (0s) to the second (2s), since 0.5 / 2.0 = 0.25. Linear interpolation walks that same fraction of the way from the first value to the second: 40 + (65 - 40) * 0.25 = 40 + 6.25 = 46.25. A real keyframed camera move in the Timeline window works the same way, just with a curve editor instead of code, and usually with eased tangents (curved acceleration in and out) instead of a straight line, so the move does not start and stop abruptly.

Field of View 65 | * (t=2.0s, FOV=65) | * | * | * 46.25| * <-- zoom.Evaluate(0.5) = 46.25 | * 40 | * (t=0.0s, FOV=40) +----+----+----+----+----> time (s) 0.5 1.0 1.5 2.0

A camera cut is a different thing entirely — it is not a curve at all. A cut is the boundary between two separate clips (two separate shots, possibly two separate vcams), with a blend time of zero. A camera move stays inside one shot and changes smoothly; a cut ends one shot and instantly starts a new one. Good cutscenes use both deliberately: moves to build tension slowly, cuts to change energy sharply.

6. Triggering Gameplay Events: Signals and Markers

A cutscene is not just for show — it often needs to affect the actual game: unlock a door, grant an item, start a fight, save the game. Timeline's built-in tool for "do something at this exact moment" is a marker (a small icon pinned to an exact time on a track, similar in spirit to the DamageEvent marker in the diagram in Section 2). The simplest built-in kind of marker is a Signal: you place a SignalEmitter marker on a track at the time you want, pointing at a SignalAsset (an asset that is just an identity — a named "channel," carrying no data of its own). Somewhere in the scene, a SignalReceiver component maps each SignalAsset it cares about to a response, using Unity's Inspector-driven UnityEvent — entirely without code, similar to wiring up a Button's OnClick.

Marker Track --------[SignalEmitter: "DoorUnlock"]-------- | | PlayableDirector notices | the playhead crossed it v SignalReceiver (on the Door GameObject) | | its Inspector list maps | "DoorUnlock" -> Door.Unlock() v door.Unlock() runs

Signals are a good fit for simple, one-off triggers — a screen shake, a UI popup, an achievement unlock — that a designer can wire up entirely in the Inspector. Their limit is that a SignalAsset carries no custom data: it can only say "this named thing happened," not "this happened, and here is a string/number that goes with it." For that you need to write your own marker type, which is the subject of the next section.

7. Writing a Custom Marker and Notification Receiver in C#

When a marker needs to carry its own data — which VFX to spawn, which checkpoint ID to save — you write a small custom class instead of using a plain Signal. Under the hood, both Signals and custom markers work through the same two-part mechanism: a class implementing INotification (the thing that happened) and a class implementing INotificationReceiver (something that reacts to it). A marker that also implements INotification can be dropped onto a track exactly like a built-in Signal:


using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

// A custom marker that carries its own data -- which VFX to spawn.
// Because it derives from Marker and implements INotification, it
// can be dropped onto any Timeline track, just like a built-in Signal.
[System.Serializable]
public class SpawnVfxMarker : Marker, INotification
{
    public string vfxId = "Spark";

    // INotification only requires an id. Unity uses it so a
    // receiver can tell which notification just fired.
    public PropertyName id => new PropertyName(vfxId);
}

using UnityEngine;
using UnityEngine.Playables;

public class VfxNotificationReceiver : MonoBehaviour, INotificationReceiver
{
    public ParticleSystem sparkVfx;

    // The PlayableDirector calls this automatically every time
    // playback crosses a marker on a track bound to this receiver,
    // both when playing forward and when the playhead is dragged.
    public void OnNotify(Playable origin, INotification notification, object context)
    {
        if (notification is SpawnVfxMarker marker && marker.vfxId == "Spark")
        {
            sparkVfx.Play();
        }
    }
}

What happens: in the Timeline window, you drop a SpawnVfxMarker onto a track at the moment the sword should spark, and set vfxId in the Inspector. You then drag the GameObject that carries VfxNotificationReceiver onto that track's binding slot in the PlayableDirector's inspector, the same way you bind an animation track to the character it animates. From then on, every time the playhead crosses that marker, Unity calls OnNotify on every INotificationReceiver bound to that track, and your code decides what to do with it. This is the exact same pattern you will reuse in Section 11 for subtitles, and again in Exercise 2 for a save-checkpoint marker — one small mechanism, reused for anything that needs to happen "at this exact moment."

8. Taking and Returning Player Control

While a cutscene plays, the player's normal input usually has to stop mattering — you do not want the player walking off during a scripted camera move. Just as importantly, control has to come back automatically and exactly once, whether the cutscene finishes on its own or gets skipped. The wrong way to do this is a coroutine that guesses the cutscene's length with WaitForSeconds; if the Timeline's length ever changes, or the cutscene gets skipped, that guess is now wrong. The right way is to let the PlayableDirector tell you when it actually stops, using its stopped event (a plain C# event, so you subscribe with += and unsubscribe with -=, exactly like any other C# event).


using UnityEngine;
using UnityEngine.Playables;

public class CutsceneController : MonoBehaviour
{
    public PlayableDirector director;
    public PlayerController player;   // your own movement/input script

    void OnEnable()  { director.stopped += OnCutsceneStopped; }
    void OnDisable() { director.stopped -= OnCutsceneStopped; }

    public void PlayCutscene()
    {
        player.SetInputEnabled(false);   // take control away from the player
        director.Play();
    }

    void OnCutsceneStopped(PlayableDirector stoppedDirector)
    {
        if (stoppedDirector != director) return;
        player.SetInputEnabled(true);    // hand control back
    }

    void Update()
    {
        bool isPlaying = director.state == PlayState.Playing;
        if (isPlaying && Input.GetButtonDown("Cancel"))
        {
            director.time = director.duration;   // jump to the last frame
            director.Evaluate();                 // apply that frame immediately
            director.Stop();                      // this fires "stopped"
        }
    }
}

What happens: PlayCutscene disables the player's input and starts the Timeline. Every frame, Update checks for a skip button press. If the player skips, three things happen in order: director.time jumps straight to the end, director.Evaluate() forces every track to immediately apply whatever state belongs at that final moment (so the screen does not flash through half-applied poses), and director.Stop() stops playback — which fires the exact same stopped event as a cutscene that finished naturally. Because both paths (finish normally, or skip) end up going through OnCutsceneStopped, control always comes back through one place, and you cannot accidentally forget to re-enable input on one of the two paths.

SetInputEnabled is a method you would add to your own PlayerController — typically it just sets a bool that your existing Update checks at the top (if (!inputEnabled) return;) before reading any input.

GAMEPLAY -- player has full control | | player walks into a trigger volume / talks to an NPC v CutsceneController.PlayCutscene() | | player.SetInputEnabled(false) | director.Play() v CUTSCENE PLAYING -- Timeline drives camera, animation, audio, VFX | | | player presses "skip" | Timeline reaches its end v v director.time = duration PlayableDirector fires director.Evaluate() the "stopped" event director.Stop() | | | +------------------+-------------------+ | v CutsceneController.OnCutsceneStopped() player.SetInputEnabled(true) | v GAMEPLAY -- player has control again

The same pattern extends naturally: hide the gameplay HUD when PlayCutscene runs and show it again in OnCutsceneStopped, pause nearby AI, or snap the player to a fixed mark so the next gameplay camera does not start from a strange position. All of it hangs off the same two functions.

9. Skippable Cutscenes and Data-Driven Design

Letting players skip a cutscene matters for more than convenience. On a second playthrough, a five-minute unskippable cutscene turns a returning player into a frustrated one; on many console platforms, a skip option (or at minimum a fast-forward) is required to pass first-party certification at all. The skip logic itself is not the hard part — you saw it already in Section 8. The hard part is making sure skipping cannot silently break the game's state.

Common mistake Relying only on Timeline markers to change permanent game state (grant an item, set a story flag, save a checkpoint) and assuming a skip will always fire every marker along the way. Treat presentation markers (VFX, camera shake, a one-off sound) as safe to lose if a player skips past them — nobody minds missing a spark effect. Treat state-changing markers (inventory, flags, saves) as something your skip code should apply directly and deliberately, not something you merely hope fires correctly while jumping the playhead across a large span of time.

This points at a bigger idea: build cutscene playback data-driven (behavior controlled by data — asset files, Inspector fields, ScriptableObjects — rather than hardcoded per scene in C#). Chapter 6 used data-oriented thinking for performance; here the motivation is different. A data-driven cutscene setup means:

This is also why the CutsceneController from Section 8 never mentions what any specific cutscene is about — it only knows about a generic PlayableDirector. That genericness is the entire benefit: it works for the intro cutscene, the boss reveal, and the ending, unmodified.

10. Interactive In-Engine Cinematics vs. Pre-Rendered Movies

There are two fundamentally different ways to deliver a cutscene, and big games often use both. A pre-rendered cutscene is a video file (commonly an .mp4), produced once, outside the running game — sometimes with a completely different, much more expensive offline renderer — then played back in-engine with Unity's VideoPlayer component. An in-engine cinematic (also called interactive or real-time) is exactly what this lesson has been building: a Timeline driving the same camera, characters, materials, and lighting the player sees during normal gameplay, rendered live, frame by frame, by Unity itself.

Pre-rendered: offline renderer --> baked video file (.mp4) --> Unity VideoPlayer component --> screen In-engine / interactive cinematic: Timeline + PlayableDirector --> drives the SAME camera, characters, and materials used during normal gameplay --> rendered live, every frame, by Unity itself --> screen

Each approach trades away something the other one has:

A common real compromise: use a pre-rendered movie for a publisher logo or an unskippable opening no one interacts with, and use in-engine Timeline cinematics for everything that needs to reflect the player's actual game state — which, for most story beats, is most of the game.

11. Localization and Subtitle Timing

Subtitles need their own timing, synced to the same clock as everything else — but the timing (numbers: when a line starts, when it ends) and the content (the actual sentence, in whatever language the player picked) should never live in the same place. If the English sentence is baked directly into your subtitle timing data, a translator cannot change the Thai or Japanese text without a programmer re-exporting the whole file. Instead, the timing data should store a localization key (a short, language-independent identifier, like cutscene_intro_03), and a separate lookup table maps that key to the actual text in whichever language is active.


using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.UI;

[System.Serializable]
public struct SubtitleLine
{
    public double startTime;
    public double endTime;
    public string localizationKey;   // e.g. "cutscene_intro_03"
}

public class SubtitleDriver : MonoBehaviour
{
    public PlayableDirector director;
    public SubtitleLine[] lines;
    public Text subtitleLabel;       // a UI Text or TMP_Text component

    void Update()
    {
        double t = director.time;
        string textToShow = "";

        foreach (SubtitleLine line in lines)
        {
            if (t >= line.startTime && t <= line.endTime)
            {
                textToShow = Localization.Get(line.localizationKey);
                break;
            }
        }

        subtitleLabel.text = textToShow;
    }
}

Localization.Get(key) is a stand-in for whichever localization system a project actually uses — Unity's Localization package, or a hand-rolled lookup table — the important part is only that SubtitleLine never stores an actual sentence, only a key and two numbers. What happens: as director.time advances, each frame checks which line's start/end window contains the current time and shows that line's translated text, falling back to an empty string in the gaps between lines.

Common mistake Setting a subtitle's endTime by copying the length of the English voice line. Translated text is often longer than the English original — Thai and German text in particular frequently need noticeably more horizontal space than English for the same sentence — so a duration tuned for English audio can cut off a longer translation before a player finishes reading it. A safer approach computes a minimum display duration from the translated text's character or word count (a simple reading-speed estimate, such as a fixed number of characters per second), and uses whichever is longer: that computed minimum, or the original audio-matched duration.

The same custom-marker mechanism from Section 7 works for subtitles too — a ShowSubtitleMarker carrying a localizationKey, fired through INotificationReceiver, is more precise than polling an array every frame and avoids missing a line if the game briefly drops frames. Section 11's polling version is simpler to read for a first pass, and Exercise 3 asks you to make it more efficient without switching to markers.

12. Putting It All Together

A complete cutscene trigger uses almost everything from this lesson at once, each piece doing one job:

Player enters trigger volume --> CutsceneController.PlayCutscene() [Section 8] --> player.SetInputEnabled(false) --> director.Play() Every frame, PlayableDirector evaluates the Timeline: Camera Track --> CinemachineBrain blends/cuts vcams [Section 4] Animation Track --> character plays keyframed poses [Section 5] Audio Track --> dialogue and music clips play Marker Track --> SpawnVfxMarker fires --> VFX plays [Section 7] Marker Track --> SaveCheckpointMarker fires --> game saved SubtitleDriver polls director.time [Section 11] --> shows the correctly localized line, right on time Cutscene ends, or the player presses skip [Section 9] --> director.Stop() fires the "stopped" event --> CutsceneController.OnCutsceneStopped() --> player.SetInputEnabled(true) --> back to GAMEPLAY

Notice that no single script here knows the whole scene. CutsceneController only knows about starting, stopping, and input. VfxNotificationReceiver only knows how to react to one marker type. SubtitleDriver only knows how to show text for a time window. The Timeline asset itself is the only thing that actually knows the full shot list, and a designer can change that asset without a single one of these scripts changing. That is the same goal this whole lesson has been building toward: many small, focused systems, all reading from one shared, data-driven clock.

13. Glossary

14. Exercises

Exercise 1 — Guard the Skip The CutsceneController from Section 8 lets the player press "Cancel" at any moment, even during the very first frame — which can let a player skip straight through an establishing shot before they even realize a cutscene started. Modify its Update() method so the skip is ignored until the cutscene has been playing for at least 1 real second of director.time.
Show answer

void Update()
{
    bool isPlaying = director.state == PlayState.Playing;
    bool longEnough = director.time >= 1.0;

    if (isPlaying && longEnough && Input.GetButtonDown("Cancel"))
    {
        director.time = director.duration;
        director.Evaluate();
        director.Stop();
    }
}

Only one condition was added: longEnough compares director.time (a double, in seconds) against 1.0. Everything else — the jump to duration, the forced Evaluate(), and the Stop() call that fires stopped — is unchanged, because the fix only needed to change when a skip is allowed, not what a skip actually does.

Exercise 2 — A Save-Checkpoint Marker Using the same Marker + INotificationReceiver pattern as SpawnVfxMarker in Section 7, write a SaveCheckpointMarker that stores a string checkpointId, and a SaveCheckpointReceiver MonoBehaviour that calls a (imaginary) SaveSystem.SaveCheckpoint(string id) when the marker fires.
Show answer

using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

[System.Serializable]
public class SaveCheckpointMarker : Marker, INotification
{
    public string checkpointId = "AfterIntroCutscene";

    public PropertyName id => new PropertyName(checkpointId);
}

public class SaveCheckpointReceiver : MonoBehaviour, INotificationReceiver
{
    public void OnNotify(Playable origin, INotification notification, object context)
    {
        if (notification is SaveCheckpointMarker marker)
        {
            SaveSystem.SaveCheckpoint(marker.checkpointId);
        }
    }
}

This is the exact same shape as SpawnVfxMarker / VfxNotificationReceiver — only the data the marker carries (checkpointId instead of vfxId) and what the receiver does with it changed. That reusable shape is the actual point of the pattern: once you know it, adding a new kind of timed gameplay event never requires inventing a new mechanism, only a new marker class and a small OnNotify body.

Exercise 3 — Make the Subtitle Search Cheaper The SubtitleDriver in Section 11 scans its entire lines array with a linear search, every single frame — for a cutscene with 200 subtitle lines, that is 200 comparisons roughly 60 times a second, almost all of them wasted on lines nowhere near the current time. Rewrite Update() to use a forward-moving cursor index that only advances, instead of rescanning from the start every frame. In your write-up, explain why the cursor must be allowed to reset back to 0 in some situation, and when that situation happens.
Show answer

public class SubtitleDriver : MonoBehaviour
{
    public PlayableDirector director;
    public SubtitleLine[] lines;    // must be sorted by startTime
    public Text subtitleLabel;

    int cursor = 0;

    void Update()
    {
        double t = director.time;

        // If time jumped backward (scrubbing in the Editor, replaying
        // the cutscene, or the player rewinding), the cursor can no
        // longer assume it is already ahead of the playhead -- reset it.
        if (cursor > 0 && t < lines[cursor - 1].startTime)
        {
            cursor = 0;
        }

        // Advance forward only -- never re-scan lines already passed.
        while (cursor < lines.Length && t > lines[cursor].endTime)
        {
            cursor++;
        }

        if (cursor < lines.Length &&
            t >= lines[cursor].startTime && t <= lines[cursor].endTime)
        {
            subtitleLabel.text = Localization.Get(lines[cursor].localizationKey);
        }
        else
        {
            subtitleLabel.text = "";
        }
    }
}

During normal forward playback, director.time only increases, so cursor only ever moves forward too — across the whole cutscene it advances through all 200 lines a total of 200 times, not 200 times per frame, which is the same kind of "do the work once, not every frame" saving you already saw with caching GetComponent results in earlier chapters. The reset check exists because that forward-only assumption breaks the moment time moves backward: a designer scrubbing the Timeline playhead left in the editor, or a game that lets a player replay/rewind a cutscene, would otherwise leave cursor stuck past lines that need to show again. The guard compares against the previous line's startTime so a cursor sitting exactly on a line boundary is not reset on every frame by accident.

← Back to all chapters