Building a Custom Dialogue Runner
"Getting Additional Control Over The Dialogue or: How I Learned To Stop Worrying And Build My Own Custom Dialogue Runner Class"
This page describes advanced usage of Yarn Spinner. Unless you really need total control over how Yarn Spinner works in your Unity project, we recommend using the built-in Dialogue Runner class rather than creating your own.
If you really do need total control, read on!
While for the most part we think the best way to use Yarn Spinner is to use the components provided and replace and supplement them as needed there are going to be times when you need more control over the dialogue itself. In these cases you will need to directly interface with the dialogue and to build your own dialogue runner. This will give you full control over the dialogue and let you control the timing and triggering of important events.
By the end of this guide we are going to have recreated the same scene and code as can be found in the Minimal Viable Dialogue sample.
⚠️ This guide is not for beginners: You should be very comfortable with Yarn Spinner and Unity before using this!
The Depths of Yarn Spinner
Before we can really start building our own dialogue system we need to take a bit of a look at what Yarn Spinner does and how it works currently. The starting point for Yarn Spinner is the Yarn dialogue files themselves, once you have your Yarn files written and ready is where Yarn Spinner takes over. These files get associated together into a Yarn Project, this project reads through and compiles the Yarn files into three pieces:
strings tables
metadata table
binary program
The strings table is a CSV that holds all the text of the Yarn, the actual written dialogue. This isn't strictly necessary and could be stored in other parts of the output of the compilation but by having it separate it allows for easier localisation and proofing. The metadata table is similar, it is also a CSV table that holds all the metadata or hashtags each line of dialogue has. This metadata is used for a variety of reasons and changes on a game-by-game basis, as with the strings table this could be embedded in other elements but by having it standalone just simplifies certain elements. The binary program is a compiled representation of the Yarn as a series of op codes as simulated on a stack machine. The binary program is the actual thing that Yarn Spinner will use to move through your dialogue, jump between nodes, create flow control, everything. Once the program has been compiled Yarn Spinner doesn't even look at the Yarn files.
These three pieces each have their own parts in what gets delivered to your game. The strings and metadata tables are used by the Line Providers to create the LocalizedLines your views use, and the compiled program is used by the Dialogue class and the Dialogue Runner, in conjunction with the variable storage, to determine the story flow.
What this means is if we are making our own runner we can still use some of the existing pieces, especially the line providers, the variable storage, and the compiler itself. Now while you could make your own interface into this compiled output from scratch, unless you are porting Yarn Spinner over to an entirely different technology stack you'd be better off using the interface into it that the existing runner uses, the Dialogue class. So that is what we will be doing in this guide.
Lines and Providers
This isn't a deep dive into how the specifics of Yarn Spinner works there is one part that does need to be covered, and that is how lines work. When you write some lines in your Yarn like the following:
You have two lines (identified by their ids ab12c
and 34de5
) that inside the strings table (simplified for this example) will be something like the following:
ID | Text |
---|---|
ab12c | Oh hi there |
34de5 | Hey |
The compiled output of the yarn has no understanding or knowledge of the text itself, it just knows that it will at some point run a line with the id of ab12c
and then after another line with the id of 34de5
. So to get the actual text this is where the line providers come in, they read the strings table and then return the actual text so that other parts of the game can use it. Even then there are further stages, substitutions need to occur and markup needs to be parsed, but the core is a line of text as far as the Yarn Spinner compiler is concerned is an id and nothing more, other pieces make it actual text. We do it this way for two main reasons, localisation and code bloat. If the text of the line was stored in the compiled program either we'd need to also store every localised variant also or have some other system to let the dialogue runner know that "no actually I know you have the line with some text but ignore it and use this text instead, thanks". Both of these approaches just makes the compiled output much much bigger and complicates the flow of the program, hence why we split them out.
Our Dialogue Runner
The first thing before we make any new code is to decide how our custom dialogue runner is going to be different from the built-in one, for if its just the same why bother making it? The question now comes "what will our runner do that is different?" The current runner works by sending Dialogue Views information when important events occur and then relying on completion callbacks to control the flow. Every view gets told about important events and once every view has run their completion callback the runner continues. This means all timing is the responsibility of the views and that might be less than ideal for your needs, so lets change that in our runner. Let's make it so that all timing is solely the responsibility of the runner, and it won't give two hoots about what any other part of the game is doing, if its told to advance it will advance. With our goal set lets lay the basis of our scene.
If you have not already done so import Yarn Spinner
Create a new scene in Unity, name it whatever you like
Create a new C# file, name it
MinimalDialogueRunner.cs
Open it and add the following:
These are the basic bits we will need for our runner, in particular the Yarn.Dialogue
is of most interest to us as it is how we will interface to the compiled version of the Yarn narrative.
Handling the main events
Yarn can be thought of as an event driven story language, you start the story by going to a particular node in the story, then you advance through the story. Every time you reach a line of dialogue you show it (somehow), and then advance onto the next piece. When you hit a command you handle it (somehow), and then advance. When you hit a set of options you get the users input on which option to go with (somehow), and then advance. Eventually you finish that node and either end the story there or jump into a new node and start the process all over again. This gives us our main events we need to handle, lets stub them out now, add the following methods to our new runner:
Hopefully these are understandable enough from their names, but the public ones are intended to allow us as the game developer control the flow, and the private ones are for the runner itself to make use of before letting other parts of the game know what has happened. In general inside each of these methods we are going to be doing some setup and configuration so that when we let other parts of the game know everything is in a manner they can consume without too much work. Now there are two more events that aren't really events but should be handled, we need to let the line provider know to expect lines and we need to have a means of querying what nodes exist in the project, so we should add them now.
Dispatch the events
Next step is to work out how to let the rest of the game know when important events have happened. While there are lots of ways we could do this, we will do this through the UnityEvent
system. We are going with Unity Events because they are super easy to create, you hook them up via the editor (which is nice), and their limitations won't bother us for this sample. We'll need six different events with various parameters so let's make them now:
Each of these basically maps to a specific method we defined above and can be thought of as the public facing side of those methods. Ok now its time to draw the rest of the owl!
Drawing the rest of the owl
Here we will be making the implementation of the methods we defined above and dispatching the events from inside of them, let's get cracking.
Replace the StartDialogue
and StopDialogue
methods with the following:
These methods are very minimal in what they do, they set some flags for state and otherwise just let the dialogue object itself know things have changed.
Replace the HandleOptions
method with the following:
In this method we do a few more steps, we go through each option in the option set and configure them so that other parts of our game can make use of them. We do this by first getting the line inside of the option from our line provider and then we do any necessary substitution of variables inside of the line, and then finally we parse any markup inside of the line. With that done the line is ready, we then make a new DialogueOption
with that line and other relevant option info, in particular the availability and ID of the option, and then add that to an array. Once we have run this process on every option in the set of options we fire off the event with the array of our configured options for the rest of the game to use.
Replace the HandleCommand
method with the following:
When it comes to commands there are two parts, if the command is wait
we will handle it, otherwise we send it off for other parts of the game to deal with. In this method we split the command up into its constituent parts, we use an existing method to do this so that situations like <<unlock "room seven">>
are split up into the elements of unlock
and room seven
, although we could do this ourselves if we want. Once that is done we check if the command is a wait command and if it is we run a coroutine to wait for the duration, otherwise we fire off our command event to let the rest of the game worry about the command.
Replace the HandleLine
method with the following:
Much like with the options, here we are getting a line from our line provider, performing any substitution, parsing markup and then sending it off. Unlike the options however we only have to do this once as lines come in one at a time.
Finally now we do the three simple events, HandleNodeStarted
, HandleNodeEnded
, HandleDialogueComplete
:
With these done we have now handled all the events that our runner needs to ensure are handled, all that remains is to implement the Continue
and SetSelectedOption
methods. These two will allow us advance the dialogue and choose options, so lets add them now:
Both of these are similar, they both advance the dialogue itself onto the next event and in the case of SetSelectedOption
chooses an option first. With that done we are finished with the events and the flow, now we need to setup the runner and dialogue so that they can actually do something with all these events we just handled.
Configuring the dialogue
Everything done so far has been about the handling and dispatching of events and flow we now need to configure the runner and dialogue class so that it can actually do something. First lets configure the various pieces of the runner itself, add the following code:
One method CreateDialogueInstance
we haven't written yet and will get to in a second, but the rest of this is setting up the pieces the runner will need. In particular we make sure we have a variable storage and line provider and that both are ready to be used, everything else happens in CreateDialogueInstance
so lets make that now:
The dialogue class being configured here is structurally very similar to our own runner, we have hooks that are run when important events happen. Its realistically the Dialogue class that does most of the heavy lifting, we are acting as a dispatching and Unity friendly window into the Dialogue. With that finished our custom dialogue runner is done, time to hook it up to something to make it dance.
Building the new Views
The existing views Yarn Spinner provides assume that you are using the callback model the runner has, ours no longer does that so they are incompatible. So we will make some new ones, based off the originals but to handle our custom runners approach instead of the original. Before going any further its worth saying we could modify the runner so that it mimics the callback model and we could reuse the existing views, we chose not to do that because it isn't really a great example of taking control over the dialogue if you end up just rebuilding the exact same (or very similar) thing.
Line View
We'll get started with the line view first, and to speed things up we are gonna be reusing most of the code from the existing LineView
.
Back inside Unity create a new C# file
Name it
MinimalLineView
Add the following code: