Building a Modular Cutscene System in Unity
Just as a preface, I want to say that I'm still developing my skills as a Unity developer. So while I think the approaches I discuss are valid, there might be better ones out there. I mainly want to document my own findings and solutions to things I didn't consider when expanding my cutscene system to a larger, more modular scale solution. If you're planning to build something similar or are already working on one, hopefully you can use this post as a starting point and reference frame to think about things you may need to add.
And if you read this post and think there are things I could do to improve, please email me! I want to learn better solutions too!
My App, Witch Task
For the past few years, I've been working on a Unity based app called Witch Task, a gamified productivity manager where users play as a witch, brewing potions for mushroom customers. These potions represent the tasks users need to complete in their day to day life. For example, if a user logs laundry in the morning, a customer requesting a laundry potion will appear later, requesting it to be worked on. The user can then complete the task in real life, submit the potion to the customer, and earn rewards!
With Witch Task, our main objective is to make doing tasks fun and motivating. We want session interactions to have some unique emotional value that encourages users to come back and stay productive. As a result, one of the features we came up with to add variability to the day was dialogue-driven story events based on specific dates or character interactions.
When you break down this feature, the requirements are to be able to define a "cutscene", allow the player to make choices, and also have interesting visuals appear during the scene.
Our first solution was pretty straightforward. We defined each cutscene in a JSON file made up of dialogue nodes (JSON objects) that specify what happens at each point
in the scene. Each node defines the speaker, dialogue text, player choices, the corresponding
branching choice paths, and any optional cutscene actions represented as a string that maps to a snippet of code to run. They are stored in a Dictionary
with the node ID being the key.
Here's an example dialogue node:
// JSON
{
"id": 6,
"speaker": "DanDan",
"dialogueText": "It's nice to meet you.",
"actionKey": ["DanDanAddContact"]
"choices": [
"ShuShu mentioned you",
"Thank's for stopping by!"
]
"dialogueBranches": [7,8]
}
In this example, the action key is "DanDanAddContact". Our CutsceneManager.cs script that would parse the JSON file and handle displaying would run a function called
DialogueAction(string actionKey) and map it to code that needed to be executed with a switch statement. Here's a simplified version:
// C#
public void DialogueAction(string actionKey)
{
switch (actionKey)
{
case "DanDanAddContact":
Telephone.Instance.AddContact("DanDan");
break;
case "HireDanDan":
PartTimer.Instance.callFunctionAfterDelay(
() => PartTimer.Instance.DanDanWalkIn(0.5f), 2.0f
);
StartCoroutine(PartTimer.Instance.HireDanDan(3.7f));
break;
default:
break;
}
}
I wonder if more experienced programmers can already point out the multiple things that can go wrong when using this for a production-level system. Here are the problems and things we didn't account for:
1) Growing dependencies between scripts
By defining our cutscene action keys as strings and having CutsceneManager contain references to other scripts, such
as PartTimer and Telephone due toDialogueAction(), we created a dependency between our
dialogue system and the scripts responsible for individual game features. This meant that every time we introduced a new custom action,
we had to modify CutsceneManager to account for it. As new actions are created, the script would become increasingly difficult
to maintain and expand due to too many responsibilities.
2) Managing objects and interactions during cutscenes
Interesting cutscenes have more than just text. For example, we might want to spawn a new visual or trigger an interaction during a cutscene, Without a system for tracking these changes, spawned objects could remain in the scene after the cutscene ended causing temporary changes to not be cleaned up properly. We needed a way to manage these runtime instantiated objects throughout the cutscene's lifecycle.
3) Managing the cutscene lifecycle
Playing a single cutscene is straightforward, but production systems need to manage the entire cutscene lifecycle. For example, what if two cutscenes trigger at once? Without a system managing, multiple cutscenes could attempt to play simultaneously, interrupting eachother or leaving the app in an nexpected state. We needed a system that accounts for this properly.
4) Handling persistence and incomplete cutscenes
Our original system didn't account for what happened when a player exited the app during mid-cutscene. If the player quit halfway, our app needs to remember to replay it next session. We also needed to make sure that player modifying data from cutscenes only applied when the cutscene sucessfully completes rather than being lost or accidentally granted multiple times if a cutscene was replayed.
5) Supporting localization and future expansion
Long-term goals and future app expansion need to be considered. Localization is one of the easiest ways to expand into new markets, but it should be considered early because adding multiple languages to a dialogue-heavy app can be difficult to overhaul if the system wasn't designed for it from the start. Planning for this early on would make it easier for us in the future.
6) Managing runtime assets on mobile
It's easy to build projects without considering persistence, but saving and managing data should be thoughtfully considered for production-level system. On mobile, dialogue JSON files and runtime assets like speaker portraits also need to be managed carefully to avoid loading lag and excessive storage usage.
So yeah! As you can see, our previous, simple system wouldn't hold up well as we focused on designing a more full-fledged production system with localization and persistent data.
New System Improvements
As I thought through these problems, I was really running in circles trying to find the perfect and cleanest solution. And I'm definitely the type worry about the wrong choice prevent me from taking action. But sometimes, when your system is complex, it can be difficult to think of a perfectly optimized way to structure it.
I think the key is really to try and then iterate. Just like how an essay needs many iterations, you rarely get something right on the first try!
Designing the Cutscene System to be Extensible
As mentioned earlier, our CutsceneManager system was doing too much. It parsed the JSON, displayed the UI, and triggered gameplay
logic. As a result, it had many one-off connections to other system scripts. This really
beefed up the script and made adding new cutscene actions messy.
TLDR: It was way too coupled!
Good coding means each script should ideally have one main responsibility, following the Single Responsibility Principle. And so, I broke this script into two parts:
DialogueUI.cs: Only parses the JSON file and displays the dialogue node data to the user.
EventActionRegistry.cs: A separate registry that DialogueUI can reference when it needs to execute a custom cutscene
action. Other systems can register their own cutscene actions with the registry on Start(), allowing DialogueUI to communicate with
a single layer instead of directly referencing every script that is needed for a cutscene action.
Decoupling via Action Delegates and a Middle Party Registry
With our new EventActionRegistry.cs, PartTimer, Telephone, and other systems
can register their own cutscene actions.
The registry uses a Dictionary to map string keys to functions. In C#, there's a handy built-in delegate called Action
that allows us to pass functions as parameters without running them immediately. This creates a cleaner separation of responsibilities because DialogueUI.cs
can look up and execute the corresponding function without needing to know which system implements it.
Here's some sample code. EventActionRegistry.cs exposes its Register() method for other systems:
// C#
public void Register(string actionKey, Action action)
{
if (_actions.ContainsKey(actionKey)) {
Debug.LogWarning( $"EventActionRegistry: overwriting existing action '{actionKey}'");
}
_actions[actionKey] = action;
}
Now the PartTimersystem and others can register its cutscene action like this:
// C#
private void RegisterDialogueActions()
{
EventActionRegistry r = EventActionRegistry.Instance;
r.Register("HiredDanDanFirstTime", () =>
{
DestroyWalkClone();
StartCoroutine(HireDanDan(0.0f));
});
r.Register("DanDanWalkOutFirstTime", () =>
{
DestroyWalkClone();
DanDanWalkOut();
});
r.Register("HireDanDan", () =>
{
// ...
});
}
One important thing to note is that EventActionRegistry must be initialized before any other script that registers a
cutscene action. One possible solution to make sure this happens first (because the order in which scripts initialize isn't guaranteed by default) is to adjust the script execution order through Unity's project settings.
The solution I went with was having all other related scripts register their actions in Start() rather than Awake(), so the EventActionRegistry singleton loads first
before anything references it.
Adding Runtime Insantiated Interactive Visuals to Cutscenes
To make cutscenes more engaging, we also wanted to include custom visuals and interactions that could be managed and cleaned up when the cutscene ended. For example, in our birthday cutscene, a cake should spawn with a candle that can be "blown" out.
Initially, I thought I'd need to keep track of every GameObject spawned during a cutscene and manually clean them up afterward. But there's actually a way easier solution!
We can create an empty GameObject in the hierarchy to act as a parent for all temporary cutscene objects, or "props." In
my code, I call this container _propContainer. Whenever we instantiate a new prop, we make it a child of this container.
Then, when the cutscene ends, we can destroy its children to clean up all the spawned objects at once, without having to
track each one individually.
// C#
// Destroys every child spawned under _propContainer
public void ClearSpawnedProps()
{
for (int i = _propSpawnContainer.childCount - 1; i >= 0; i--)
Destroy(_propSpawnContainer.GetChild(i).gameObject);
}
Preventing Cutscene Overlap Using a Priority Queue
Production applications need to care about the entire lifecycle of a cutscene. When one starts, what happens if another is playing, when it should finish, when player data modifying effects should be applied, what happens to spawned objects, and what happens if the one is interrupted are all important stages that the overall system needs to track.
Because Witch Task has one main home screen where all the gameplay happens, we need a queue system to manage cutscenes in case multiple were triggered.
Because we wanted to be able to add cutscenes to both the front and back of the queue for more flexible control, the first data structure we used was a deque, implemented using a linked list (yay, O(1) operations!).
However we could not rely on cutscenes being added in a consistent order. As I mentioned earlier, Unity's script initialization order isn't guaranteed. Even if we added a bunch of cutscenes to the queue during startup, we could end up with a different ordering every time. This messes up content flow. For example, our Sunday weekly check-in should have priority over a daily random NPC interaction.
Therefore, we decided to explicitly define cutscene priorities, making our deque a priority queue! In a separate script, we define each cutscene with a priority value that get's checked when they get added into the queue. Lower priority values are processed first, so player-initiated cutscenes can be given the highest priority, followed by special cutscenes, weekly information, and then lower-priority NPC interactions. If two cutscenes have the same priority, then it defaults to the cutscene that was added first.
// C#
//Cutscene strings with their priority value
internal static readonly Dictionary _map = new()
{
{ "Player_OrderStartNow", -1 },
{ "Player_BirthdayY1", 0 },
{ "weeklySummary", 100 },
{ "DanDan_WalkIn", 202 }
}
This gives us a more deterministic solution!
To manage the cutscenes in the queue, we also define a custom struct called QueuedEvent. This lets us group the information
we need for each cutscene together, including its ID, the Action that needs to run, and whether it should persist when the
app quits. PriorityQueue stores these structs and manages which cutscene plays next whenever the currently one
notifies the queue that it has finished.
// C#private PriorityQueue_queue = new(); private struct QueuedEvent { public string id; public Action action; public bool persistOnQuit; }
Making the Cutscene Queue Persistent Across Sessions
I think persistence is not talked about enough in development since the focus is usually on building features. Saving and restoring state can also affect how a system is designed, especially in the case of our cutscene queue, which stores both an Action delegate and a string ID for the cutscene. Because an Action is a runtime delegate, so it can't be directly written into our JSON player save data.
Instead, we save the cutscene string ID.
For example, if the "DanDan_FirstMeet" cutscene hasn't finished when the app closes, we can write it and the ones queued into a list to be reconstructed on next app launch.
// C#
// Save the IDs of events that should persist between sessions
private void SaveQueueState()
{
List toSave = new();
// Save the currently active event if it should persist
if (_activeEventPersists && !string.IsNullOrEmpty(_activeEventId)) {
toSave.Add(_activeEventId);
}
// Save queued events with property persistOnQuit = True
foreach (var (evt, _) in _queue.UnorderedItems)
{
if (evt.persistOnQuit && !string.IsNullOrEmpty(evt.id)) {
toSave.Add(evt.id);
}
}
PlayerData.InstancePendingEventQueue = toSave;
}
// C#
// Rebuild the queue from saved IDs
private void RestoreQueueFromSave()
{
var pending = PlayerData.Instance.PendingEventQueue;
if (pending == null || pending.Count == 0) {
return;
}
foreach (string id in pending)
{
Enqueue(id, () => DialogueUI.Instance.StartDialogue(id));
}
// Clear the saved IDs after restoring them
pending.Clear();
}
Not all cutscenes require persistence though. That's why having the extra persistOnQuit() bool is useful.
We only want to persist cutscenes that affect player progress and are mandatory!
Unintended Consequences of Persistence
As I was coding this out, I actually realized another problem! If cutscenes can be interrupted halfway through, what happens to the actions that directly modify player data?
For example, if the user needs to spend money as part of cutscene, we should subtract it after its completion. Otherwise, they could quit halfway through, get stuck in a state where the cutscene keeps reloading, calling the spend money function, causing a bug in their player data.
This means we also need to be careful about when those gameplay effects are applied. An important design decision is to apply effects that modify player data only after the cutscene successfully finishes.
Now the cutscene flow has been corrected to look like this:
Supporting Localization in a JSON-Driven Dialogue System
Localization for apps should definitely be considered early on! Otherwise, especially for text-heavy apps like Witch Task, large system rewrites would need to occur if done near the end.
The often discussed solution is Unity's Localization Tables. It's really helpful for UI elements because they associate stable keys with localized strings and retrieve the appropriate text for the currently selected locale. However, our cutscene system is driven by JSON files, so I wanted a similar workflow that could be able to handle these files as well.
Creating a Proper Localization Workflow System
A key part of any localization workflow is being able to export text for translators to work with, then import the translations back into the project. The most common way is to use a table format and export, the text, the stable key, and locale columns (just like Unity Localization Tables). Inspired by this approach, I wrote two Python scripts and a custom Unity Editor GUI Tool to automate the process the many JSON files we have.
JSONToXLSX.py: Exports dialogue text and choices from JSON files into an XLSX spreadsheet for
translation, preserving existing translations by matching stable translation keys.
XLSXToJSON.py: Imports translated text from XLSX spreadsheets back into the corresponding JSON dialogue
nodes, updating localized fields while preserving other properties used by the cutscene system.
To generate stable keys, we combine the JSON filename, node ID, and field type. For example, the DanDan_FirstMeet file might have a key like DanDan_FirstMeet_2_dt to represent the dialogue text for
node 2, and DanDan_FirstMeet_2_c0 and DanDan_FirstMeet_2_c1 for its first and second choices. These unique keys
allow us keep track of each text translation!
With this method, we need to ensure existing translations are preserved when source dialogue is updated. For example, if we add a new dialogue line,
I want to make sure the previous ones still exist. In JSONToXLSX.py, I read in the previous spreadsheet and match
existing translations with their stable key. If they match, then the translation is brought to the newly generated spreadsheet. If the
key doesn't exist anymore in the JSON file, it gets flagged for me to review and edit manually.
XLSXToJson.py does the reverse by reading the translated spreadsheet and using the stable keys to insert them back
into the JSON files.
Batch Automation and Validation
Instead of needing to run these scripts manually on every file, I built a custom Unity Editor window that will run these Python scripts in editor. I can select which JSON files to export, validate, and import translated spreadsheets back into the project. The tool also supports optional backups before importing and separate spreadsheets for other localized content, such as achievements!
The main thing I wanted to check in validation was to catch common data issues before exporting. This includes missing or duplicate node IDs, dialogue paths that point outside the file's valid range, and mismatches between the number of choices and their corresponding dialogue paths.
With this new localization workflow, adding and updating new cutscene content much easier to manage during development!
Asset Loading
Now for how I actually store cutscene dialogues and associated assets. When it comes to managing assets in Unity these are the most common options:
Resources: A built-in system for loading assets at runtime by path. Assets must live inside the Resources folder.
Addressables: An asset management system that loads assets by address or label. It supports asynchronous loading, asset lifetime management, and remote content delivery.
At first, I was loading assets through Unity's Resources system. It's convenient because you can load assets
by path at runtime without needing to reference them directly in the Inspector.
// C#
TextAsset jsonFile = Resources.Load("Cutscenes/DanDan_FirstMeet");
But I started wondering whether this was the best approach long-term, especially since Addressables seemed to be the
more commonly recommended solution online.
From what I read online, Resources isn't necessarily bad for smaller project. However, assets inside a Resources
folder are included in the build, even if they're never loaded at runtime. As a
project grows, this can contribute to a larger build and make asset management harder to maintain.
To address some of these limitations, Unity has Addressables. They let you organize assets into groups, load them using
addresses or labels, and release them when they're no longer needed. They also support remote content delivery, such as hosting assets on a CDN (Content Delivery Network). This can be
useful when delivering new content without requiring players to download an entirely new app version.
All of that sounds great, except that Addressables also introduce more things to manage, such as asynchronous loading,
operation handles, and deciding when an asset should be released.
For example, loading a cutscene dialogue JSON file through Addressables requires handling the asynchronous operation before
accessing its contents:
// C#
Addressables.LoadAssetAsync("CharacterDialogue") .Completed += handle => {
if (handle.Status == AsyncOperationStatus.Succeeded)
{
string json = handle.Result.text;
}
};
This method didn't make a lot of sense since cutscenes can be triggered at any time it would be overcomplicated to lable and load a singular cutscene asset at a time. More importantly, our dialogue JSON files are only around 10 KB each, so even 100 files would amount to roughly 1 MB of raw text.
Since Resources can load assets by path, and our cutscene string identifiers correspond to the names of our cutscene dialogue JSON files, it can
handle this without requiring a separate asset management system. Sometimes the most simple solution is the best!
// C#
public TextAsset GetCutsceneDialogue(string cutsceneID)
{
return Resources.Load($"Cutscenes/{cutsceneID}");
}
Considerations I Still Have
Although I've improved on my modular cutscene system a lot, I'm still thinking about what might need a refactor later on! Here are some points that will need to be addressed sooner or later:
Event Action String Key Fragility
Something that can break easily is using plain strings to register event actions in the registry. For example:
"action": "HireNPP"
// vs
"action": "HireNPC"
If it were misspelled, it would fail at runtime. Although I have a debug statement that prints a warning if the event action doesn't exist, it's still not a very safe method. Probably the better next step would be a constants file or generated class that provides compile-time safety.
// C#
public static class DialogueActions
{
public const string HIRE_NPC = "HireNPC";
public const string PAY_NPC = "PayNPC";
}
Cutscene Dialogue File Format for Localization
Our localization files are currently formatted so that every dialogue text and choice has a dictionary containing the locale and its translation.
//JSON
"dialogueText": {
"en": "It's nice to meet you.",
"zh-Hans": "很高兴认识你。",
"ja": "はじめまして。"
}
Although it's easy right now, this is not a good strategy because if our app were to have 10+ languages and lots of cutscene files, it would be unnecessary to load all other languages.
In this case, Addressables would be a great solution since it would allow us
to load only the language currently selected by the player and keep build sizes smaller!
End Result and Walkthrough of How Things Work
And now! With our production orientated cutscene system completed, here's how a system cycle plays out:
Building this cutscene system was a lesson in was a process of breaking things down into cleaner smaller pieces and accounting data state mangagmeent. There's still room for improvement, but having these systems decoupled makes it easier for future expansion. Not bad for now!
If you have any thoughts, let me know! I'd love to learn or discuss what could be done better! Thank you for reading and I hope this post was able to provide some insight in designing a production-ready modular cutscene system in Unity!