Dialogue is one of those systems that seems small until you build it: a text box, a name, a few lines, then suddenly you need typewriter text, choices, branching, conditions, events and localization. This Unity dialogue system tutorial builds a clean, data-driven system with ScriptableObjects and TextMeshPro: linear conversations, a typewriter effect with skip, player choices and branching, triggering dialogue from NPCs, and firing game events from lines. It ends with when to reach for tools like Ink or Yarn Spinner instead.
How the System Fits Together
Keep three things separate:
- Data: who says what, and what choices lead where. Stored in assets, not code.
- Runner: steps through the data, handles input, waits for choices.
- View: the UI that displays a line or a set of choices.
This lets writers add conversations without touching scripts, and lets you restyle the UI without breaking logic.
Dialogue Data With ScriptableObjects
Model a conversation as a set of nodes. Each node has a speaker, some text, and either a single “next” node or a list of choices:
using System;
using UnityEngine;
[CreateAssetMenu(menuName = "Dialogue/Conversation")]
public class Conversation : ScriptableObject
{
public DialogueNode[] nodes;
}
[Serializable]
public class DialogueNode
{
public string id;
public string speaker;
public Sprite portrait;
[TextArea(2, 5)] public string text;
public string next; // empty = end of conversation
public Choice[] choices; // if any, overrides next
public string eventToFire; // optional, e.g. "GiveKey"
}
[Serializable]
public class Choice
{
public string text;
public string next;
public string requiresFlag; // optional condition
}
Create one with Create > Dialogue > Conversation and fill in nodes in the Inspector. Linking by string id is simple and readable. The first node in the array is the start. ScriptableObjects are a great fit for this kind of authored data (see our ScriptableObjects guide), but remember never to write runtime state into them.
The Dialogue Box UI
Build a panel anchored to the bottom of a Screen Space canvas:
- DialoguePanel (Image background, hidden by default)
- Portrait (Image)
- SpeakerName (TextMeshPro)
- BodyText (TextMeshPro)
- ChoiceContainer (Vertical Layout Group)
- ContinueIndicator (a small bouncing arrow)
Make a ChoiceButton prefab: a Button with a TextMeshPro child. If text scaling is new to you, our TextMeshPro tutorial and UI scaling guide cover the setup.
The Dialogue Runner and Typewriter Effect
The runner looks up nodes, displays them with a typewriter effect, and advances on input. Pressing the button while text is typing completes it instantly, which players expect:
using System;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public class DialogueRunner : MonoBehaviour
{
public static DialogueRunner Instance { get; private set; }
public static event Action<string> EventFired;
public bool IsRunning { get; private set; }
[SerializeField] GameObject panel, continueIndicator;
[SerializeField] TMP_Text speakerText, bodyText;
[SerializeField] Image portrait;
[SerializeField] Transform choiceContainer;
[SerializeField] Button choiceButtonPrefab;
[SerializeField] float charsPerSecond = 40f;
Dictionary<string, DialogueNode> lookup;
DialogueNode current;
bool typing, advanceRequested;
void Awake() { Instance = this; panel.SetActive(false); }
public void StartConversation(Conversation convo)
{
if (IsRunning) return;
lookup = new Dictionary<string, DialogueNode>();
foreach (var n in convo.nodes) lookup[n.id] = n;
IsRunning = true;
panel.SetActive(true);
StartCoroutine(Run(convo.nodes[0]));
}
public void RequestAdvance() => advanceRequested = true; // hook to your Submit input
IEnumerator Run(DialogueNode node)
{
while (node != null)
{
current = node;
if (!string.IsNullOrEmpty(node.eventToFire)) EventFired?.Invoke(node.eventToFire);
yield return Show(node);
if (node.choices != null && node.choices.Length > 0)
{
string picked = null;
ShowChoices(node.choices, id => picked = id);
yield return new WaitUntil(() => picked != null);
ClearChoices();
node = Find(picked);
}
else
{
continueIndicator.SetActive(true);
advanceRequested = false;
yield return new WaitUntil(() => advanceRequested);
continueIndicator.SetActive(false);
node = Find(node.next);
}
}
panel.SetActive(false);
IsRunning = false;
}
IEnumerator Show(DialogueNode node)
{
speakerText.text = node.speaker;
portrait.sprite = node.portrait;
portrait.enabled = node.portrait != null;
bodyText.text = node.text;
bodyText.maxVisibleCharacters = 0;
bodyText.ForceMeshUpdate();
int total = bodyText.textInfo.characterCount;
typing = true;
advanceRequested = false;
float shown = 0f;
while (bodyText.maxVisibleCharacters < total)
{
if (advanceRequested) { bodyText.maxVisibleCharacters = total; break; }
shown += charsPerSecond * Time.deltaTime;
bodyText.maxVisibleCharacters = Mathf.Min(total, (int)shown);
yield return null;
}
typing = false;
advanceRequested = false;
}
DialogueNode Find(string id) =>
string.IsNullOrEmpty(id) ? null : lookup.TryGetValue(id, out var n) ? n : null;
void ShowChoices(Choice[] choices, Action<string> onPick)
{
foreach (var c in choices)
{
if (!string.IsNullOrEmpty(c.requiresFlag) && !GameFlags.Has(c.requiresFlag)) continue;
var btn = Instantiate(choiceButtonPrefab, choiceContainer);
btn.GetComponentInChildren<TMP_Text>().text = c.text;
string target = c.next;
btn.onClick.AddListener(() => onPick(target));
}
var first = choiceContainer.GetComponentInChildren<Button>();
if (first) first.Select(); // gamepad/keyboard navigation
}
void ClearChoices()
{
foreach (Transform child in choiceContainer) Destroy(child.gameObject);
}
}
Using maxVisibleCharacters instead of building the string letter by letter keeps rich-text tags (<b>, <color>) intact and doesn’t allocate a new string each frame. Hook RequestAdvance to your Submit action, or to a full-screen invisible button for mobile.
Choices and Branching
A branching conversation is just nodes whose choices point at different ids:
| id | speaker | text | next / choices |
|---|---|---|---|
| start | Guard | Halt! State your business. | → “I’m a merchant.” (merchant), “None of yours.” (rude), “[Show pass]” (pass, requires HasPass) |
| merchant | Guard | The market’s that way. Behave. | end |
| rude | Guard | Then you’ll wait out here. | end |
| pass | Guard | Apologies, go right in. | end, event: OpenGate |
Multiple choices can point at the same node to merge branches back together, which keeps the number of nodes manageable. A missing id ends the conversation, so check for typos with a validation button (an editor script that warns about unknown ids is a 20-line job).
Starting Dialogue From an NPC
public class NPCDialogue : MonoBehaviour
{
[SerializeField] Conversation conversation;
[SerializeField] GameObject prompt; // "Press E to talk"
bool playerNear;
void OnTriggerEnter(Collider other) { if (other.CompareTag("Player")) { playerNear = true; prompt.SetActive(true); } }
void OnTriggerExit(Collider other) { if (other.CompareTag("Player")) { playerNear = false; prompt.SetActive(false); } }
public void OnInteract() // called by the player's Interact action
{
if (playerNear && !DialogueRunner.Instance.IsRunning)
DialogueRunner.Instance.StartConversation(conversation);
}
}
If the trigger never fires, our OnTriggerEnter not working guide has the checklist. While dialogue runs, disable player movement, for example by switching the Input System to a “UI” action map, so the interact key doesn’t also make the player jump.
Events, Conditions and Flags
Dialogue often needs to change the game (give an item, open a gate) and react to it (only show a choice if you have a pass). A simple global flag set covers most indie needs:
public static class GameFlags
{
static readonly HashSet<string> flags = new();
public static bool Has(string f) => flags.Contains(f);
public static void Set(string f) => flags.Add(f);
public static IEnumerable<string> All => flags; // for saving
}
// Somewhere in your game:
void OnEnable() => DialogueRunner.EventFired += Handle;
void OnDisable() => DialogueRunner.EventFired -= Handle;
void Handle(string e)
{
if (e == "OpenGate") gate.Open();
if (e == "GivePass") GameFlags.Set("HasPass");
}
Save the flags with the rest of your game state (see our JSON save system guide) so choices persist between sessions.
✍️ Writing for games
Great dialogue is as much craft as code. A couple of the books on our reading list are excellent on narrative design and interactive storytelling.
Ink, Yarn Spinner and Other Tools
Hand-made systems are perfect for a few dozen conversations. For narrative-heavy games, use a scripting language designed for it:
- Ink (by Inkle): writers work in plain text with a dedicated editor (Inky). There’s a free Unity integration, and it’s excellent for heavy branching.
- Yarn Spinner: screenplay-like syntax with nodes, commands and variables, plus a strong Unity package.
- Asset Store dialogue systems: full editors with quests, localization and save integration. See our Asset Store tools roundup.
Whatever you choose, plan for localization from the start: store string keys rather than raw text, or use Unity’s Localization package (see our localization tutorial).
Troubleshooting
Pressing the button skips two lines at once
The press that finished the typewriter is also read as “advance”. Reset advanceRequested after typing completes, as the runner does, and read input with WasPressedThisFrame, not IsPressed.
Typewriter shows rich-text tags letter by letter
You’re building the string with Substring. Set the full text and animate maxVisibleCharacters instead.
Choice buttons can’t be selected with a gamepad
Nothing is selected when the buttons appear. Call Select() on the first button, and check the EventSystem uses the Input System UI Input Module.
Player moves or jumps during dialogue
Gameplay input is still enabled. Switch action maps or disable the player controller while IsRunning.
Dialogue changes persist after leaving Play mode
Runtime code is writing into the ScriptableObject asset. Keep runtime state in the runner or flag system, never in the Conversation.
FAQ
How do I make a dialogue system in Unity?
Store conversations as data (ScriptableObjects or Ink/Yarn files), write a runner that steps through nodes and waits for input or choices, and a UI view that displays lines and choice buttons.
Should I use Ink or Yarn Spinner?
Both are excellent. Ink suits heavily branching prose. Yarn Spinner’s node and screenplay style suits character-driven conversations with lots of commands.
How do I make a typewriter text effect?
Set the full TextMeshPro text, then increase maxVisibleCharacters over time. Let a button press reveal everything instantly.
How do dialogue choices affect the game?
Fire events from nodes and store results in flags or variables that later conditions check. Save those flags with your game data.
Can I use JSON instead of ScriptableObjects?
Yes. JSON or CSV works well if writers use spreadsheets. Load it with JsonUtility or a CSV parser into the same node classes.
Reader questions
Asked by readers of this article and answered by AI. Behavior can differ between Unity versions, so test in yours.




Leave a Reply