Whether in games or more traditional media, it’s hard to tell a story without any verbal or written interaction. Oxford Languages defines a conversation as “a talk […] between two or more people.” To decompose this into something more technical: A conversation is a sequence of exchanged words between two or more participants.
Unity provides a Timeline feature, but this always plays back at the same speed. I have yet to find a way to properly interrupt playback (timescale changes are not a valid solution). Furthermore, it’s almost impossible to find the balance between not giving the player enough time to read and leaving text on screen for so long the player is bored, especially when the player is faced with important decisions. As such, I decided to write my own dialogue system to meet the following criteria:
- Purely data-driven: implementing narrative should need minimal technical knowledge, shouldn’t need to open/modify code files
- Dialogue goes as fast as player wants: wait for input before continuing, and allow fast-forwarding
- Can be triggered easily within code
- Refactorability from both programmers’ and narrative designers’ perspectives
By that technical definition, at the very least we need a Conversation object to describe a sequence of events. This could be something as simple as a long text file with one line per event. From the technical side, I decided to use coroutines with CustomYieldInstructions since I can run them as fast or slow as needed, but the downside is they can’t be serialized.
The first prototype was for a collectathon driven by sequential quests. Since these are location-based, it makes the most sense to implement the data storage as a Component. Furthermore, this allows them to have in-world effects, although we only used that feature for allowing the player to leave their house once they had completed the “tutorial.”
However, since any coroutine could be submitted, this system became less specifically dialogue and more of an interruptible sequencer. When implementing doors we found that just teleporting instantly was too jarring, so I instead had it queue a fade-to-black, a scene-change, and then a fade-from-black.
The second prototype was for a game driven by narrative between the player and a rogue starship AI. However, since the AI is omnipresent throughout the ship, it makes less sense to implement these as location-based and more sense for them to be ScriptableObjects. This also saves time later if we want to quickly swap two conversations, or trigger the same conversation from multiple points/conditions. To minimize mistakes while rewriting/renaming it would be best to also have a Speaker object, containing information like name, text style, and portrait.
While this does use a custom editor, the “Test Playback” button was written by LotteMakesStuff. The project was called InspectorButtons and unfortunately is no longer publicly available. The gist was that you could add an attribute like
[TestButton("Recalculate navigation markers", "_MyPrivateFunction")] without the headache of writing and maintaining custom editors.
Due to many of the team members having played it, we based the dialogue UI heavily based on Supergiant’s Hades. Normal text is reserved for main story content that demands full player attention, so it blocks movement as well. Flavor text is for off-hand comments and quips that add depth of character but aren’t key to the narrative.
I was later asked to add messages such as locking all doors and switching turrets to hostile, and re-add fade-to-black and scene changes. I implemented them once again using the dialogue system as a sequencer. This further abstracts our definition: A conversation is a sequence of events between two or more participants, to be played back (mostly) at the player’s desired pace.
Unfortunately, this introduced a lot of complexity into the code for DialogueItem, largely resulting from different events requiring new fields not shared between all items, and different playback behaviours. This could have been achieved through polymorphism, but Unity only supports polymorphic objects as Components or separate SerializedObjects, which would create chaos in the filesystem and make editing unnecessarily difficult. Note in hindsight: SerializeReference solves this problem, especially when combined with something like SubclassSelector.
Plans for future iterations
As it stands right now, the two main problems with this system are complex maintenance and not saving full state while running. To simplify the codebase, I would definitely like to re-implement the DialogueItem system using polymorphism for both members and display. If each subclass also implements state data, it would allow for full persistence.