Build a Dialogue System in Unity 6 with ScriptableObjects (Typewriter Text, Portraits and Branching)
Build a branching Unity 6 dialogue system with ScriptableObjects: typewriter text, speaker portraits, choice branching and an NPC trigger, step by step.
A dialogue system built on ScriptableObjects looks like overkill right up until you’ve got thirty conversations written and need to change one line of text without touching a script. This walks through building one in Unity 6: the data assets, the runner that plays them, typewriter text, portraits, and branching choices. I’m building this same kind of system for Crown Code Chronicles, my pixel-art JRPG, because JRPGs are like 90% dialogue (I’m exageratting, but not too far off), you definitely want to get it right.
What you’ll have at the end
A dialogue panel that types text out character by character, shows a speaker name and portrait, waits for a click or key press to advance, and can branch into player choices that lead to different dialogue nodes. Conversations live as ScriptableObject assets you edit in the Inspector, not as strings buried in code. You’ll also have a basic NPC you can walk up to and talk to.
Engine version, prerequisites and project setup
This targets Unity 6000.0 LTS (Unity 6). The same approach works with only minor changes in 2021 or 2022 LTS since TextMeshPro’s API hasn’t moved.
You’ll need:
- TextMeshPro installed. If you haven’t used a TMP component in this project before, Unity will prompt you to import the TMP Essential Resources the first time you add one. Do that now so it’s not interrupting you mid-tutorial.
- A basic comfort with creating ScriptableObjects and Prefabs in the Inspector. If ScriptableObjects are new to you, the short version is: they’re assets that hold data and settings, live in your project folder like any other asset, and don’t need a scene to exist. Perfect for dialogue, because a conversation isn’t behavior, it’s data.
- A scene with a player object tagged
Playerand a 2D collider on it, if you want to follow the NPC trigger step exactly. If you’re working in 3D, swap the 2D collision calls for their 3D equivalents; the dialogue logic itself doesn’t care.
Step 1: Create the DialogueLine and DialogueNode ScriptableObjects
A DialogueLine is one line of spoken text: who’s talking, their portrait, and what they say.
using UnityEngine;
[CreateAssetMenu(fileName = "NewDialogueLine", menuName = "Dialogue/Dialogue Line")]
public class DialogueLine : ScriptableObject
{
public string speakerName;
public Sprite portrait;
[TextArea(3, 10)]
public string text;
}
A DialogueNode is a step in the conversation. It holds an ordered list of lines to play, then either moves on to another node automatically or offers the player a set of choices.
using UnityEngine;
[System.Serializable]
public class DialogueChoice
{
public string choiceText;
public DialogueNode nextNode;
}
[CreateAssetMenu(fileName = "NewDialogueNode", menuName = "Dialogue/Dialogue Node")]
public class DialogueNode : ScriptableObject
{
public DialogueLine[] lines;
public DialogueNode nextNode;
public DialogueChoice[] choices;
}
DialogueChoice isn’t a ScriptableObject of its own. It’s a plain serializable class that just holds a label and a reference to whichever node it leads to. Making it a full asset would mean creating a tiny file for every single choice in the game, which is a lot of clutter for something that’s really just “text plus a pointer.”
Once both scripts compile, right-click in your Project window and you’ll see Dialogue > Dialogue Line and Dialogue > Dialogue Node under Create. Make a couple of test lines and a node that references them to get a feel for the workflow before wiring up any code.
Step 2: Write the DialogueRunner that walks the nodes
This is the MonoBehaviour that actually plays a conversation: showing lines in order, typing them out, and handling branches.
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public class DialogueRunner : MonoBehaviour
{
[Header("UI References")]
public GameObject dialoguePanel;
public TMP_Text speakerNameText;
public TMP_Text bodyText;
public Image portraitImage;
public Transform choiceContainer;
public Button choiceButtonPrefab;
[Header("Typewriter")]
public float secondsPerCharacter = 0.02f;
private DialogueNode currentNode;
private int lineIndex;
private Coroutine typingCoroutine;
private bool isTyping;
private bool waitingForChoice;
private readonly List<GameObject> spawnedChoiceButtons = new List<GameObject>();
public bool IsRunning => dialoguePanel != null && dialoguePanel.activeSelf;
public void StartDialogue(DialogueNode node)
{
currentNode = node;
lineIndex = 0;
waitingForChoice = false;
dialoguePanel.SetActive(true);
PlayCurrentLine();
}
private void Update()
{
if (!IsRunning || waitingForChoice) return;
if (Input.GetKeyDown(KeyCode.Space) || Input.GetMouseButtonDown(0))
{
Advance();
}
}
private void Advance()
{
if (isTyping)
{
StopCoroutine(typingCoroutine);
bodyText.text = currentNode.lines[lineIndex].text;
isTyping = false;
return;
}
lineIndex++;
if (lineIndex < currentNode.lines.Length)
{
PlayCurrentLine();
}
else if (currentNode.choices != null && currentNode.choices.Length > 0)
{
ShowChoices();
}
else if (currentNode.nextNode != null)
{
currentNode = currentNode.nextNode;
lineIndex = 0;
PlayCurrentLine();
}
else
{
EndDialogue();
}
}
private void PlayCurrentLine()
{
DialogueLine line = currentNode.lines[lineIndex];
speakerNameText.text = line.speakerName;
portraitImage.sprite = line.portrait;
portraitImage.enabled = line.portrait != null;
if (typingCoroutine != null) StopCoroutine(typingCoroutine);
typingCoroutine = StartCoroutine(TypeLine(line.text));
}
private IEnumerator TypeLine(string fullText)
{
isTyping = true;
bodyText.text = "";
foreach (char c in fullText)
{
bodyText.text += c;
yield return new WaitForSeconds(secondsPerCharacter);
}
isTyping = false;
}
private void ShowChoices()
{
waitingForChoice = true;
foreach (DialogueChoice choice in currentNode.choices)
{
Button button = Instantiate(choiceButtonPrefab, choiceContainer);
button.GetComponentInChildren<TMP_Text>().text = choice.choiceText;
button.onClick.AddListener(() => OnChoiceSelected(choice));
spawnedChoiceButtons.Add(button.gameObject);
}
}
private void OnChoiceSelected(DialogueChoice choice)
{
foreach (GameObject go in spawnedChoiceButtons) Destroy(go);
spawnedChoiceButtons.Clear();
waitingForChoice = false;
if (choice.nextNode != null)
{
currentNode = choice.nextNode;
lineIndex = 0;
PlayCurrentLine();
}
else
{
EndDialogue();
}
}
private void EndDialogue()
{
dialoguePanel.SetActive(false);
}
}
The waitingForChoice flag matters more than it looks like it should. Without it, clicking a choice button also counts as a mouse click for Advance(), and you get a race between “select this choice” and “skip to the next line” happening on the same frame. Blocking Update() while choices are on screen fixes it cleanly.
Step 3: Build the UI panel with TextMeshPro
In your Canvas, build this hierarchy:
DialoguePanel(Image, for the background box), inactive by defaultSpeakerName(TextMeshPro - Text UI)BodyText(TextMeshPro - Text UI)Portrait(Image)ChoiceContainer(empty object with a Vertical Layout Group)
Make a ChoiceButtonPrefab separately: a UI Button with a TMP text child, saved as a prefab in your project (not left in the scene). Drag all of these into the matching fields on your DialogueRunner component.
Set ChoiceContainer’s Vertical Layout Group to control child size and spacing, otherwise your choice buttons will stack directly on top of each other the first time you spawn more than one.
Step 4: Add the typewriter effect (and let players skip it)
The typewriter effect is already in the TypeLine coroutine above: it appends one character at a time with a short wait between each. The part people forget is the skip. Nobody wants to sit through a slow reveal on their third playthrough, which is why Advance() checks isTyping first and, if the line is still typing, just fills in the rest of the text and stops the coroutine instead of moving to the next line. One click either finishes the current line or moves forward, never both.
If your game pauses with Time.timeScale = 0 (for a menu, say), WaitForSeconds will freeze along with everything else, including dialogue that was mid-type. Swap it for WaitForSecondsRealtime if you want dialogue to keep typing through a pause, though most games are fine leaving it paused too.
Step 5: Hook up speaker names and portrait sprites
This part’s already wired into PlayCurrentLine(): it reads speakerName and portrait straight off the current DialogueLine and pushes them into the UI. The only real decision left is where the portrait art comes from.
For a pixel-art game, a small set of expression portraits (neutral, happy, annoyed) drawn in Aseprite and built off a palette from Lospec will look far more consistent than mixing art styles as you go. If you’re still deciding on a pixel art tool, there’s a full comparison of Aseprite, LibreSprite, Pyxel Edit, Krita and Pixquare worth reading before you commit hours to portrait sets in one program.
Step 6: Add branching choices with response buttons
Branching is already handled by DialogueChoice and ShowChoices(). The design side is simpler than the code: build a node whose lines array holds the question, leave nextNode empty, and fill in choices with a label and a target node for each option. When the runner reaches the end of that node’s lines and finds choices instead of a nextNode, it spawns a button per choice and waits.
One thing worth deciding early: what happens when a choice’s nextNode is left empty. In the code above it ends the conversation, which works fine for “goodbye” options but will surprise you if you leave a field blank by accident and the whole conversation just closes with no warning.
Step 7: Trigger a conversation from an NPC in the scene
using UnityEngine;
public class NPCInteractable : MonoBehaviour
{
public DialogueNode startingNode;
public DialogueRunner dialogueRunner;
private bool playerInRange;
private void OnTriggerEnter2D(Collider2D other)
{
if (other.CompareTag("Player")) playerInRange = true;
}
private void OnTriggerExit2D(Collider2D other)
{
if (other.CompareTag("Player")) playerInRange = false;
}
private void Update()
{
if (playerInRange && !dialogueRunner.IsRunning && Input.GetKeyDown(KeyCode.E))
{
dialogueRunner.StartDialogue(startingNode);
}
}
}
Add this to an NPC with a Collider2D set to Is Trigger, drag in a DialogueNode asset as the starting point, and point dialogueRunner at the single DialogueRunner living on your dialogue Canvas. Walk into the trigger, press E, and the conversation starts.
Common pitfalls and the errors you’ll actually see
NullReferenceException on speakerNameText or bodyText. Almost always means one of the Inspector fields on DialogueRunner wasn’t dragged in. Unity won’t stop you from leaving them empty, it’ll just throw the moment PlayCurrentLine() runs.
Text won’t type, it just appears instantly. Check that you didn’t accidentally leave secondsPerCharacter at 0, and that TypeLine is actually being called rather than the text being set directly somewhere else in a leftover test script.
Old text bleeds into the new line for a frame. This happens if you forget to stop the previous coroutine before starting a new one, which the if (typingCoroutine != null) StopCoroutine(typingCoroutine); line in PlayCurrentLine() exists specifically to prevent. If you strip that line out while refactoring, this comes back.
Choice buttons stack on top of each other. The Vertical Layout Group on ChoiceContainer either isn’t there or has its child controls turned off. Double check Control Child Size and Child Force Expand are set the way you expect.
Pressing E does nothing on the NPC. Usually the player object isn’t actually tagged Player, or the NPC’s collider isn’t set to Is Trigger. Both fail silently, no console error, the interaction just never fires.
TMP Essentials prompt appears mid-tutorial. Not a bug, just Unity asking to import font assets the first time a TMP component touches the scene. Say yes and move on.
How to extend this: localization keys, voice barks, saving what’s been read
The system above is deliberately minimal. A few directions worth taking it once it’s working:
- Localization. Swap the raw
textfield onDialogueLinefor alocalizationKeystring, and look the actual text up in a table keyed by the player’s language instead of hardcoding English into the asset. It’s a small change to the data structure and a much bigger one to your workflow, so it’s worth deciding early if you’ll need it. - Voice blips. A short audio clip per line, played alongside the typewriter effect, adds a lot of character for very little code, an
AudioClipfield onDialogueLineand oneAudioSource.PlayOneShot()call. If you need source material and don’t have a voice actor lined up, there’s a rundown of free SFX libraries and editing tools that covers where to find and edit short clips like these. - Tracking what’s been read. Once players can revisit NPCs, you’ll want to know which nodes they’ve already seen, whether that’s to skip repeat dialogue or to gate a quest behind a specific conversation. A
HashSet<string>of node names, keyed by eachDialogueNode’s asset name or a manual ID field, persisted alongside the rest of your save data covers most cases. If you haven’t built a save system yet, the Unity 6 JSON save system tutorial walks through exactly the kind of save and load setup this would plug into.
None of this needs to happen on day one. Get lines typing, portraits showing, and one branch working end to end first. Everything else is an addition to a structure that already holds up, not a rebuild.
Registration card
Register to receive one focused post every weekday — engine tutorials, marketing tactics, and design craft. Free, unsubscribe anytime.
No spam. We never share your address. No purchase necessary — it's free.