Dialogue System – A Deeper Dive Into The System

The Concept

Safety Protocol’s core revolves around a narrative dialogue between the player and a survivor on the VASA space station. For this purpose, I created this dialogue system. I had never made a dialogue system before but I really enjoy games with an intuitive and immersive storyline where the player feels part of the story; I had ample experience using dialogue systems implemented in other games. Based on that experience, I had a clear vision of what I wanted my own dialogue system to be like.

Giving the player options to choose from felt the most vital to help the player feel like they’re part of the game. Further, each choice should trigger a unique response, which in turn can trigger further options and responses, or simply reroute back to the main story. With this vision in mind, I started looking for ways on how to create a dialogue system.

I looked for some tutorial videos to find inspiration. Surprisingly, I didn’t find anything that really matched my vision.

I ended up following a basic system using queues to add and remove messages. This didn’t really work for what I had in mind, so I ended up just analyzing how that system worked and used that new knowledge to make my own system from scratch. I hashed out the concept on a piece of paper and started thinking about what kind of code structure and what kind of variable types would help me achieve my goal.

Out of this 7 week project, I spent almost four weeks working on and continuously improving upon this system. Part of that was working closely with our game designers, making sure the system did what they wanted it to and having it be user friendly for them to implement their narrative story on.

In the end I feel like I made a very well written, well functioning and easy-to-use dialogue system which can be tailored to fit in many different kind of games, either as it is now or as a good structural base upon which to build even more functionality. Of course, a few things could be improved upon, though I am still quite proud of what I’ve made thus far.

The Sequence

While activity diagrams are not commonly used within game programming, the way this dialogue system is built fits very well with their purpose. (This is a general overview, some steps are skipped.)

Written version: 

After initiating the conversation, the system goes to the next dialogue index, checks if the end of the dialogue has been reached and then either initiates the game ending or otherwise prints the message stored at the index. That message then might trigger a set of options which in turn triggers specific responses. It might otherwise signal that it’s the end of the current conversation and thus pause the system until it’s reactivated through game progression. If neither of those trigger, the system simply goes to the next dialogue index instead. This sequence is repeated until either a new dialogue set is initiated, or the game ends.

How it’s implemented

To use this system [in Unity] you should apply the Dialogue Manager script to one or several Dialogue Manager objects in your game. They then implement dialogue through structs turned into scriptable objects, which store all the data in a safe way to prevent potential data loss for the game designers.

For Safety Protocol, we only needed one dialogue manager which was shared across all the consoles in the game so they all share the same dialogue. With some light code tweaking, you could also use the same system for NPCs or other settings where you would want several separate dialogues in the game. It’s made to be customizable and user friendly!

The Code

The Safety Protocol project, especially writing this dialogue system, helped me finally develop my own style of coding. It is probably a style many other people use as well but as someone quite new to the field who has written her fair share of messy incoherent code, I was thrilled to develop my coding style through this project.
 
I want my code to be as easy to read and easy to follow as possible; Every functionality should have its own designated method. Even just one sentence, for example setting a variable, should in my way of coding be its own method called something like “SetVariable”. This way, if another programmer were to look at my code, they should be able to read through it easily and get a good grasp of what’s going on, without actually needing to go through most of the more “codey” lines. It does make the script deceptively lengthy but each individual method is very easy to read.
(Feel free to click the gif on the right to see how it’s structured.)
 
This way of coding also makes adjustments and additions to the code very simple. For example, in the 5th week of our 7 week project, the game designers came to me with a request to change the way the dialogue worked when implementing the game’s endings; They wanted a more extensive ending dialogue to occur in one of the endings, while the other ending dialogue simply ended the game. They also wanted to add a new option button functionality for the new extensive ending.
 
At first I was a bit aghast with sudden changes so late in the project but with how my code was organized it was actually pretty easy to add this new functionality and make it work properly. I must hand it to our designers, the new ending functionality was brilliant and we had a lot of positive feedback from players experiencing that ending.
(Go check it out for yourselves! Can you pick the right ending? 😉 )
 
With that said… there are cases where I have to choose between extra optimization and more legible code. For instance, in this project I use structs to store the dialogues, options and responses. Technically, the dialogues and responses function in the same way and could be merged into one thing. Though for legibility’s sake, I opted for keeping them separated. They do the same thing but that doesn’t necessarily mean they Are the same thing. Thus, to help keep a clear vision while coding and to follow the code’s structure easier, I kept the separation of “dialogue” and “response”.
 
Some might disagree with this (I could keep them separated in the main script of the code while still only using one struct in the struct script, for example) and I completely understand why! I hope my reasoning still resonates with those who would do it differently.

using System.Collections.Generic;
using TMPro;
using UnityEngine.UI;

public enum UserName
{
	AI,
	Survivor,
	EmptyLine
}

[System.Serializable]
public struct Dialogue
{
	public UserName username;
	public string dialogue;
	public bool triggersOption;
	public bool endsCurrentConversation;
	public List<Option> options;
}

[System.Serializable]
public struct Option
{
	public string optionText;
	public bool triggerGoodEnding;
	public bool triggerBadEnding;
	public bool fakeOption;
	public List<Response> responses;
}

[System.Serializable]
public struct Response
{
	public UserName username;
	public string response;
	public bool triggersOption;
	public bool endsCurrentConversation;
	public List<Option> options;
}

[System.Serializable]
public class OptionButton
{
	public Button button;
	public TextMeshProUGUI text;
	public bool isFake;
}
      
      	using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;

public class ConsoleDialogue : MonoBehaviour
{
    [SerializeField, Tooltip("How many seconds to wait between displaying new messages. Default value.")]
    float _messageDelayDefault = 1.2f;
    [SerializeField, Tooltip("Additional message delay (in seconds) per letter in a message\nused for increasing the message delay based on message length.")]
    float _messageDelayAddition = 0.035f;
    [SerializeField, Tooltip("How many seconds to wait before displaying the player's selected dialogue Options.\n(Each time a selected option is printed.)")]
    float _optionDisplayDelay = 2f;
    [SerializeField, Tooltip("How many seconds delay there is between each letter being typed.")]
    float _typingDelay = 0.05f;

    [SerializeField, Tooltip("The text box where the dialogue will be printed.")]
    TextMeshProUGUI _mainDialogueText = default;

    [SerializeField, Tooltip("The panel where the option buttons will be displayed.")]
    GameObject _optionPanel = default;

    [SerializeField, Tooltip("The buttons which will display the option texts.")]
    List<OptionButton> _optionButtons = default;

    public bool ShowOptionPanel { get; set; }

    private float _currentMessageDelayDefault = 1.2f;
    private float _currentMessageDelayAddition = 0.035f;
    private float _currentOptionDisplayDelay = 2f;
    private float _currentTypingDelay = 0.05f;
    private int _optionIndex = 0;
    private bool _isResponseOption = false;
    private bool _isTypingText = false;
    private bool _goodEnding = false;
    private bool _badEnding = false;
    private string _outputText = "";
    private string _sentence = "";

    private GameObject _manager = default;
    private DialogueManager _dialogueManager = default;
    private ConsoleUI _consoleUI = default;

    private Option _currentOptionStruct = default;
    private Response _currentResponseStruct = default;
    private Dialogue _currentMainDialogue = default;

    private void Awake()
    {
        GetManagers();
        GetConsoleUI();
    }

    private void Start()
    {
        DeactivateOptionPanel();
        SetupButtonClickListener();
    }

    public void InitiateDialogue()
    {
        StartCoroutine(InitiateConversation());
    }

    private void ClearText()
    {
        _mainDialogueText.text = "";
    }
   
    private void PrintFullDialogue()
    {
        ClearText();

        for (int i = 0; i < _dialogueManager.fullDialogue.Count; i++)
        {
            PrintDialogue(_dialogueManager.fullDialogue[i]);
        }
        ConsoleUI.OnScrollChatToBottom();
    }

    private void AddToFullDialogue(string message)
    {
        _dialogueManager.fullDialogue.Add(message);
    }

    private void SetMainDialogueOutput()
    {
        _outputText = GetUserNameWithColor(_currentMainDialogue.username) + ": " + _currentMainDialogue.dialogue;
    }

    private void StoreCurrentMainDialogue()
    {
        _currentMainDialogue = _dialogueManager.currentDialogue[_dialogueManager.MainIndex];
    }

    private void PrintDialogue(string text)
    {
        _mainDialogueText.text += text + "\n\n";

        ConsoleUI.OnScrollChatToBottom();
    }
    
    private void StoreCurrentResponseStruct(int index)
    {
        _currentResponseStruct = _currentOptionStruct.responses[index];
    }

    private void SetResponseOutput(int index)
    {
        _outputText = GetUserNameWithColor(_currentOptionStruct.responses[index].username) + ": " + _currentOptionStruct.responses[index].response;
    }

    private void SetCurrentOptionStruct()
    {
        // if the button(option) you clicked is from response
        if (_isResponseOption)
        {
            _currentOptionStruct = _currentResponseStruct.options[_optionIndex];
        }
        // if the button(option) you clicked is from main dialogue
        else
        {
            _currentOptionStruct = _currentMainDialogue.options[_optionIndex];
        }
    }

    private void StartTypingSelection()
    {
        PlayTypingSound();
        StartCoroutine(TypeSelection());
    }

    private void PlayTypingSound()
    {
        AudioManager.instance.Play("keyboard");
    }

    private void StopTypingSound()
    {
        AudioManager.instance.Stop("keyboard");
    }

    private void ActivateOptionPanel()
    {
        if (ShowOptionPanel == true)
        {
            _optionPanel.SetActive(true);
        }
    }

    private void DeactivateOptionPanel()
    {
        _optionPanel.SetActive(false);
    }

    private void DisplayOptions(Dialogue mainDialogue)
    {
        _isResponseOption = false;

        ShowOptionPanel = true;

        ActivateOptionPanel();

        DeactivateOptionButtons();

        HandleOptionButtons(mainDialogue.options);
    }

    private void DisplayOptions(Response response)
    {
        _isResponseOption = true;

        ShowOptionPanel = true;

        ActivateOptionPanel();

        DeactivateOptionButtons();

        HandleOptionButtons(response.options);
    }

    private void ActivateOptionButtons(int index)
    {
        _optionButtons[index].button.gameObject.SetActive(true);
    }

    private void DeactivateOptionButtons()
    {
        for (int i = 0; i < _optionButtons.Count; i++)
        {
            _optionButtons[i].button.gameObject.SetActive(false);
        }
    }

    private void HandleOptionButtons(List<Option> options)
    {
        for (int i = 0; i < options.Count; i++)
        {
            ActivateOptionButtons(i);
            SetOptionButtonText(options, i);

            ResetButtonBools(i);

            FakeOptionCheck(options, i);
        }
    }

    private void SetOptionButtonText(List<Option> options, int index)
    {
        _optionButtons[index].text.text = options[index].optionText;
    }

    private void FakeOptionCheck(List<Option> options, int index)
    {
        if (options[index].fakeOption == true)
        {
            _optionButtons[index].isFake = true;
        }
    }

    private bool IsButtonFake(OptionButton optionButton)
    {
        if (optionButton.isFake == true)
        {
            optionButton.button.interactable = false;
            return true;
        }

        return false;
    }

    private void ResetButtonBools(int index)
    {
        _optionButtons[index].button.interactable = true;
        _optionButtons[index].isFake = false;
    }

    private void SetupButtonClickListener()
    {
        for (int i = 0; i < _optionButtons.Count; i++)
        {
            if (_optionButtons == null)
            {
                Debug.LogWarning($"Please assign option buttons. Missing: OptionButton{i}");
                continue;
            }

            int j = i;  // variable capturing.
            _optionButtons[i].button.onClick.AddListener(delegate { ClickedButton(j); });
        }
    }

    private void ClickedButton(int buttonIndex)
    {
        _optionIndex = buttonIndex;

        if (IsButtonFake(_optionButtons[_optionIndex]) == false)
        {
            SetCurrentOptionStruct();

            DeactivateOptionPanel();

            StartTypingSelection();

            CheckEndingBool();
        }
    }

    private void CheckEndingBool()
    {
        if (_currentOptionStruct.triggerGoodEnding == true)
        {
            _goodEnding = true;
            InitiateEndingDialogue();
        }
        else if (_currentOptionStruct.triggerBadEnding == true)
        {
            _badEnding = true;
            InitiateEndingDialogue();
        }
    }

    private void InitiateEndingDialogue()
    {
        if (_goodEnding == true)
        {
            //reserved for code to initiate good ending dialogue if needed.
        }
        if (_badEnding == true)
        {
            _dialogueManager.SetCurrentDialogue(_dialogueManager.badEndingDialogue.dialogues);
            EndingHandlerConsole.OnStartGlitch(false);
            EndingHandlerConsole.OnStartPoisoning();
        }
    }

    private void InitiateGameEnding()
    {
        if (_goodEnding == true)
        {
            //good ending
            EndingHandlerConsole.OnStartGoodEndingSequence();

        }
        else if (_badEnding == true)
        {
            //bad ending
            EndingHandlerConsole.OnStartBadEndingSequence();
        }
        else
        {
            // when the dialogue ends in good ending scene.
            EndingHandlerConsole.OnStartTriggerCreditInGoodEndingScene();
        }
    }

    private void SetConversationInProgress(bool inProgress)
    {
        _dialogueManager.ConversationInProgress = inProgress;
    }

    private float SetMessageDelay(string sentence)
    {
        float _tempDelay = 0;

        foreach (char letter in sentence.ToCharArray())
        {
            _tempDelay += _currentMessageDelayAddition;
        }

        return _currentMessageDelayDefault + _tempDelay;
    }

    private string GetUserNameWithColor(UserName username)
    {
        string name = "";
        string hexadecimalColor = "";
      
        if(username == UserName.AI)
        {
            name = _dialogueManager.AiName;
            hexadecimalColor = _dialogueManager.AiNameColor;
        }
        if (username == UserName.Survivor)
        {
            name = _dialogueManager.SurvivorName;
            hexadecimalColor = _dialogueManager.SurvivorNameColor;
        }

        return $"<color=#{hexadecimalColor}>{name}</color>";
    }

    private void GetManagers()
    {
        _manager = GameObject.FindGameObjectWithTag("Manager");
        if (_manager == null)
        {
            Debug.LogWarning("Can't find a gameobject that has 'Manager' tag.");
        }

        _dialogueManager = _manager.GetComponentInChildren<DialogueManager>();
        if (_dialogueManager == null)
        {
            Debug.LogWarning("Can't find DialogueManager.");
        }
    }

    private void GetConsoleUI()
    {
        _consoleUI = _manager.GetComponentInChildren<ConsoleUI>();
        if (_consoleUI == null)
        {
            Debug.LogWarning("Can't find ConsoleUI.");
        }
        else
        {
            _mainDialogueText = _consoleUI.MainDialogueText;
        }
    }

    IEnumerator InitiateConversation()
    {
        PrintFullDialogue();

        if (_dialogueManager.conversationPaused == false)
        {
            SetConversationInProgress(true);

            yield return new WaitForSeconds(_currentOptionDisplayDelay);
            PrintDialogue("--------- New Message ---------");
            StartCoroutine(HandleNextMainDialogue());
            StartCoroutine(BlinkingCursor());
        }
    }

    IEnumerator TypeSelection()
    {
        _consoleUI.OptionOutputText.text = "";

        _sentence = _optionButtons[_optionIndex].text.text;

        _isTypingText = true;
        foreach (char letter in _sentence.ToCharArray())
        {
            _consoleUI.OptionOutputText.text += letter;
            yield return new WaitForSeconds(_currentTypingDelay);
        }
        _isTypingText = false;

        StopTypingSound();

        _consoleUI.OptionOutputText.text = "";

        PrintDialogue("> " + _sentence);
        AddToFullDialogue("> " + _sentence);

        StartCoroutine(HandleResponse());
    }

    IEnumerator HandleResponse()
    {
        yield return new WaitForSeconds(SetMessageDelay(_sentence));

        for (int i = 0; i < _currentOptionStruct.responses.Count; i++)
        {
            SetConversationInProgress(true);

            SetResponseOutput(i);
            PrintDialogue(_outputText);
            AddToFullDialogue(_outputText);

            if (_currentOptionStruct.responses[i].triggersOption == true)
            {
                StoreCurrentResponseStruct(i);
                yield return new WaitForSeconds(_currentOptionDisplayDelay);
                DisplayOptions(_currentResponseStruct);
                yield break;
            }

            if (_currentOptionStruct.responses[i].endsCurrentConversation)
            {
                DialogueManager.OnPause();
                SetConversationInProgress(false);
                yield break;
            }
            yield return new WaitForSeconds(SetMessageDelay(_outputText));
        }

        // after all the responses are printed
        StartCoroutine(HandleNextMainDialogue());
    }

    IEnumerator HandleNextMainDialogue()
    {
        while (true)
        {
            _dialogueManager.MainIndex++;

            if (_dialogueManager.currentDialogue.Count == _dialogueManager.MainIndex)
            {
                InitiateGameEnding();
            }

            if (_dialogueManager.currentDialogue.Count > _dialogueManager.MainIndex)
            {
                StoreCurrentMainDialogue();
                SetMainDialogueOutput();
                PrintDialogue(_outputText);
                AddToFullDialogue(_outputText);

                if (_currentMainDialogue.triggersOption == true)
                {
                    yield return new WaitForSeconds(_currentOptionDisplayDelay);
                    DisplayOptions(_currentMainDialogue);
                    yield break;
                }
                if (_currentMainDialogue.endsCurrentConversation)
                {
                    DialogueManager.OnPause();
                    SetConversationInProgress(false);
                    yield break;
                }
                yield return new WaitForSeconds(SetMessageDelay(_outputText));
            }
            else
            {
                yield break;
            }
        }
    }

    IEnumerator BlinkingCursor()
    {
        bool show = false;

        while (_dialogueManager.ConversationInProgress)
        {
            if (!_isTypingText)
            {
                show = !show;
                if (show)
                {
                    _consoleUI.OptionOutputText.text = "|";
                }
                else
                {
                    _consoleUI.OptionOutputText.text = "";
                }
            }
            yield return new WaitForSeconds(.5f);
        }

        _consoleUI.OptionOutputText.text = "";
    }
}
      
    

The Future Expansion

For this game it wasn’t really needed but there is one functionality I would love to expand this system with in the future, namely to connect past  options with future dialogues and events. For example, store the option selected by the player somewhere and then revisit it at a different point in time, further into the game. Tying together game events in this way would make for an even more immersive and dynamic player experience. This is definitely something I want to explore in the future.