Dialogue System
Patterns for building a node-based dialogue tree system: define conversations as ScriptableObject graphs, process them at runtime with a DialogueRunner, display text with a typewriter effect, and integrate with quest/state systems through condition and event nodes.
Node Architecture
Dialogue is a directed graph of nodes. Each node has a unique ID and a type that determines its behavior.
Base Node
using UnityEngine;
public enum DialogueNodeType
{
Text,
Choice,
Condition,
Event
}
[System.Serializable]
public class DialogueNode
{
public string nodeId;
public DialogueNodeType nodeType;
// Text node fields
public string speakerName;
public string speakerKey; // Localization key for speaker name
public Sprite speakerPortrait;
public string text;
public string textKey; // Localization key: "dialogue.npc_greeting.001"
public string nextNodeId;
// Choice node fields
public DialogueChoice[] choices;
// Condition node fields
public string conditionKey; // Game state variable to check
public string trueNodeId;
public string falseNodeId;
// Event node fields
public string eventName; // Event to trigger
public string eventParameter;
public string eventNextNodeId;
}
Choice Data
[System.Serializable]
public class DialogueChoice
{
public string choiceText;
public string choiceKey; // Localization key
public string nextNodeId;
// Optional: conditions for showing this choice
public string requiredConditionKey;
public bool hideIfUnavailable; // false = show grayed out; true = hide entirely
}
Dialogue Tree (ScriptableObject)
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "New Dialogue", menuName = "Dialogue/Dialogue Tree")]
public class DialogueTree : ScriptableObject
{
public string dialogueId;
public string entryNodeId;
public List<DialogueNode> nodes = new();
private Dictionary<string, DialogueNode> _lookup;
public DialogueNode GetNode(string nodeId)
{
if (_lookup == null) BuildLookup();
_lookup.TryGetValue(nodeId, out var node);
return node;
}
public DialogueNode GetEntryNode()
{
return GetNode(entryNodeId);
}
private void BuildLookup()
{
_lookup = new Dictionary<string, DialogueNode>();
foreach (var node in nodes)
{
if (string.IsNullOrEmpty(node.nodeId)) continue;
_lookup[node.nodeId] = node;
}
}
private void OnEnable()
{
_lookup = null; // Force rebuild on load
}
}
Speaker Definition
Keep speaker data (name, portrait variations, voice settings) in a separate ScriptableObject so multiple dialogues can share the same speaker.
using UnityEngine;
[CreateAssetMenu(fileName = "New Speaker", menuName = "Dialogue/Speaker")]
public class SpeakerDefinition : ScriptableObject
{
public string speakerId;
public string displayName;
public string nameLocKey;
public Sprite defaultPortrait;
public Sprite[] emotionPortraits; // Index by enum: Happy, Sad, Angry, etc.
public Color nameColor = Color.white;
[Header("Voice")]
public AudioClip talkSound; // Blip sound per character
public float talkPitch = 1f;
}
public enum SpeakerEmotion
{
Neutral,
Happy,
Sad,
Angry,
Surprised,
Thinking
}
Dialogue Runner
The runtime component that processes the dialogue tree node by node. It mediates between the data (DialogueTree) and the presentation (DialogueUI).
using System;
using System.Collections;
using UnityEngine;
public class DialogueRunner : MonoBehaviour
{
public static DialogueRunner Instance { get; private set; }
[SerializeField] private DialogueUI dialogueUI;
private DialogueTree _currentTree;
private DialogueNode _currentNode;
private bool _isRunning;
public bool IsRunning => _isRunning;
public event Action OnDialogueStarted;
public event Action OnDialogueEnded;
private void Awake()
{
Instance = this;
}
/// <summary>
/// Start a conversation from the beginning of the given tree.
/// </summary>
public void StartDialogue(DialogueTree tree)
{
if (_isRunning) return;
_currentTree = tree;
_isRunning = true;
OnDialogueStarted?.Invoke();
dialogueUI.Show();
ProcessNode(_currentTree.GetEntryNode());
}
private void ProcessNode(DialogueNode node)
{
if (node == null)
{
EndDialogue();
return;
}
_currentNode = node;
switch (node.nodeType)
{
case DialogueNodeType.Text:
ProcessTextNode(node);
break;
case DialogueNodeType.Choice:
ProcessChoiceNode(node);
break;
case DialogueNodeType.Condition:
ProcessConditionNode(node);
break;
case DialogueNodeType.Event:
ProcessEventNode(node);
break;
}
}
private void ProcessTextNode(DialogueNode node)
{
string displayText = GetLocalizedText(node.textKey, node.text);
string displayName = GetLocalizedText(node.speakerKey, node.speakerName);
dialogueUI.ShowText(displayName, displayText, node.speakerPortrait);
}
private void ProcessChoiceNode(DialogueNode node)
{
// Filter choices based on conditions
var availableChoices = new System.Collections.Generic.List<DialogueChoice>();
foreach (var choice in node.choices)
{
if (!string.IsNullOrEmpty(choice.requiredConditionKey))
{
bool conditionMet = GameStateManager.Instance.CheckCondition(choice.requiredConditionKey);
if (!conditionMet && choice.hideIfUnavailable)
continue;
}
availableChoices.Add(choice);
}
dialogueUI.ShowChoices(availableChoices, OnChoiceSelected);
}
private void ProcessConditionNode(DialogueNode node)
{
bool result = GameStateManager.Instance.CheckCondition(node.conditionKey);
string nextId = result ? node.trueNodeId : node.falseNodeId;
ProcessNode(_currentTree.GetNode(nextId));
}
private void ProcessEventNode(DialogueNode node)
{
// Fire a game event (quest update, give item, play animation, etc.)
GameEventManager.Instance.TriggerEvent(node.eventName, node.eventParameter);
ProcessNode(_currentTree.GetNode(node.eventNextNodeId));
}
/// <summary>
/// Called by UI when the player advances past a text node.
/// </summary>
public void AdvanceDialogue()
{
if (_currentNode == null || _currentNode.nodeType != DialogueNodeType.Text) return;
// If typewriter is still running, complete it instantly
if (dialogueUI.IsTyping)
{
dialogueUI.CompleteTypewriter();
return;
}
// Move to next node
string nextId = _currentNode.nextNodeId;
if (string.IsNullOrEmpty(nextId))
{
EndDialogue();
}
else
{
ProcessNode(_currentTree.GetNode(nextId));
}
}
private void OnChoiceSelected(int choiceIndex)
{
if (_currentNode == null || _currentNode.nodeType != DialogueNodeType.Choice) return;
var choice = _currentNode.choices[choiceIndex];
ProcessNode(_currentTree.GetNode(choice.nextNodeId));
}
public void EndDialogue()
{
_isRunning = false;
_currentTree = null;
_currentNode = null;
dialogueUI.Hide();
OnDialogueEnded?.Invoke();
}
private string GetLocalized