Unity dialog system, RPG dialogue UI mobile

How to Build a Reusable Dialog System for Mobile RPGs in Unity

Game UI Systems & Interaction Design

By George Jones (PlayMobile.online)

You’ll get a compact, shipped-style approach that kept one runner handling overworld NPC chats, cutscenes, and radio calls without duplicating UI prefabs per scene.

Start concrete: define a DialogueNode ScriptableObject with fields id, speakerId, localizedKey, and nextIds[]. Drive a single DialogController that only renders the current node plus available choices.

Starter snippet: controller.StartConversation(startNodeId); StartConversation resolved IDs from a dictionary to avoid per-click allocations.

Expect constraints on low-end Android: minimize layout rebuilds, avoid Resources.Load stalls on first conversation, and keep batching stable (portraits and masks can break it). Watch memory, draw calls, and battery.

Beginner mistakes you’ll avoid: updating TMP every frame, allocating garbage in typewriter loops, and forgetting safe-area padding on notched phones.

The article maps this path: build the minimal runner, expand the data model (choices, conditions, versioning), harden UI lifetime across scenes, then profile on device.

Reference: see Unity Manual entry on ScriptableObject and Addressables for preload and first-conversation stutter fixes.

Build the minimal dialog runner with ScriptableObject data and a UI hook

Begin by authoring lightweight node assets and wiring a single controller to your canvas. Create DialogueNode ScriptableObjects with id, speakerId, text key, nextId, and a Choice[] array. Use stable string or int IDs so save/load works without scene references.

Author one asset per node in the inspector. Each Choice holds text, nextId, and an optional conditionKey. At load, build a Dictionary once so next-node lookup is O(1) and you never call FindObjectOfType during playback.

Wire a Canvas: speaker TMP, body TMP, one Next button, and a pooled choice-button template inside a vertical group. Pool button instances to avoid allocations. For the typewriter, use TMP_Text.maxVisibleCharacters and a cached StringBuilder. Cancel the prior CancellationTokenSource before starting the next line so text and audio never interleave.

Example (conceptual) snippet—tested on 2021+. Includes namespaces, pooled buttons, and allocation-light typewriter:

using TMPro;
using System.Text;
using System.Threading;
...
private StringBuilder sb = new StringBuilder();
private CancellationTokenSource cts;
private Dictionary<string, DialogueNode> nodeLookup;

async UniTask TypeLine(TMP_Text body, string full, float cps) {
  cts?.Cancel(); cts = new CancellationTokenSource();
  body.maxVisibleCharacters = 0;
  int total = full.Length;
  for (int i=1; i

Input: first tap cancels the typewriter (reveals full line), second tap advances the node. Map the same taps to keyboard/controller for editor testing. This keeps behavior uniform across devices and avoids per-frame text rewriting that caused TMP layout spikes on older phones.

Define what “reusable” means for an RPG dialogue UI mobile setup

Aim for a single traversal engine that handles branching, ambient lines, and quest hooks. Make reuse concrete: you should swap packs, languages, or skins without changing traversal code. The same runner must play choices, barks, and short scenes.

Constraints to bake in early

Branching must be explicit: multi-choice nodes with conditional nextIds. Choices need condition checks against quest state and runtime variables.

Ambient barks should be lightweight one-liners that the runner plays without loading full portraits or voice each time.

Localization must use keys, not raw text. Persist node-level state so you can save and later resume at the exact line.

Choose an authoring format that fits production

Use formats that match your team and pipeline. Prototype fast with ScriptableObjects. Give external writers JSON/CSV exports. Adopt Ink/Yarn for branching-heavy narrative work. Use Chat Mapper when you need a simulator and validation tools.

  • Production trick: store a stable node ID plus a human-readable slug for debugging.
  • Beginner mistake: using scene object references for next nodes; that breaks on refactors and addressable splits.
  • Done criteria: choices render correctly, back/skip works, and the app restores the current node after suspend/resume.
Format Speed to prototype Writer-friendly Validation
ScriptableObjects High Medium (requires editor) Low (custom tools)
JSON / CSV Medium High Medium (schema checks)
Ink / Yarn Low (learning curve) High High (built-in testing)
Chat Mapper Medium High High (simulator & export)

Data model that won’t paint you into a corner

Start with an immutable ID policy and a version field on each node and graph. You should never rename shipped IDs. When you ship updates, use a small mapping table to migrate old saves to new versions.

Keep removed content as tombstones. A tombstone redirects restores to a safe fallback node. That avoids crashes and preserves player progression when you prune or refactor content.

Make Choice a first-class object: id, textKey, nextId, plus optional conditions[], costs[], and results[]. Store effects in the choice results, not in button handlers. This makes testing, replay, and save/load deterministic.

Evaluate conditions as pure functions over a GameState snapshot. The renderer only reads results; it does not compute story logic. That enforces the named practice of data-driven UI and content-authoring separation.

This model also lets you support procedural dialogue and addon content. Generated nodes can be injected at runtime if schemas and validation prevent bad next pointers. That keeps your dialogue system robust as you add addon generative tools.

Policy What to store How evaluated Update strategy
ID immutability + version Node id, version, tombstone flag Pure GameState functions Mapping table + tombstones
Choice as object id, textKey, nextId, conditions, costs, results Deterministic checks at render time Schema validation for generated content
Addon-ready Stable schema, validators, safe fallbacks Same traversal interface for runtime injection Preflight validation before merge

Unity UI architecture for dialog that survives scene changes

Keep your conversation layer alive across loads by treating the UI as a persistent service, not a scene asset. Make clear boundaries: scene objects trigger conversations by ID, and a single persistent manager owns rendering and input.

Canvas strategy for mobile

Overlay Canvas is the simplest for standard overlays. It keeps pixel alignment stable and input handling predictable on small screens.

Camera-space Canvas works when post-processing or camera-relative effects must apply. Use it sparingly—it adds complexity and cost on low-end devices.

World-space bubbles sit with NPC objects and are great for short barks that track characters. Keep these lightweight and avoid heavy masks to preserve batching.

Object lifetime and manager pattern

Use one root Canvas with a dialog layer child to prevent one-canvas-per-feature chaos. This keeps draw order and batching predictable.

Promote the main conversation manager to DontDestroyOnLoad. Scene-local triggers only send start requests by stable ID. That prevents null references during async scene loads.

  • Gotcha: scene-local UI can null out when a late event starts a conversation during a load. Route calls through the persistent manager API instead.
  • Addongrid elements (portraits, frames) should live under the same root and reuse materials to cut draw calls.
  • Test by forcing a scene change mid-conversation to confirm no duplicate managers and correct input routing after end.
Mode Performance When to use Notes
Screen Space – Overlay Low cost Standard HUD and conversation windows Stable pixel alignment; easiest input handling
Screen Space – Camera Medium cost When camera effects must affect UI Use only if post-processing is required
World Space Variable, can be high NPC-barks and in-world captions Keep sprites small; avoid masks to keep batching
Persistent Manager Negligible runtime cost Survives scene loads, centralizes input Use API routing to avoid broken scene object refs

Unity dialog system event flow you can test and extend

Make every conversation a replayable sequence by defining a strict event contract. This keeps behavior predictable and lets you write unit tests that assert a single input stream produces the same node sequence.

Event contract and replayability

Define explicit callbacks: OnConversationStart(conversationId), OnLineStarted(nodeId), OnChoicesPresented(nodeId, choiceIds), OnChoiceSelected(choiceId), OnConversationEnd(conversationId, reason).

Emit events in order on the main thread and log them. That log is your replay file for tests and analytics.

Determinism and testing

Keep state transitions driven only by input and saved GameState. Avoid timers for branching. In tests, feed the same taps and expect identical event sequences.

Hooking gameplay and quest machine events

When a conversation starts, disable player input, lock combat, and optionally bump camera priority. Restore all states on end, even if the player skips.

Radio calls run in a separate mode: they do not pause movement but lock other overlays and use a different skin.

Raise quest machine events only on confirmed choice selection, not on line display. Firing effects too early is a common bug that breaks saves and causes duplicate rewards.

Callback Intent When to fire Test strategy
OnConversationStart Begin conversation mode First stable frame after request Assert input locked and log entry
OnLineStarted Render line, start audio Main thread, deterministic order Confirm text/audio keys match log
OnChoicesPresented Show options After line fully shown or on skip Verify available choiceIds
OnChoiceSelected Commit game effects User selection confirmed Assert quest machine events fired once
OnConversationEnd Restore gameplay After final node or cancel Verify input restored and cleanup

Typewriter, skip, and audio playback without jank

A smooth typewriter and reliable voice playback start with clear ownership and cancellation rules. You keep text and sound from fighting by assigning each actor its own AudioSource and by sequencing playback with the text reveal.

Audio placement and 3D/2D rules

Place the AudioSource on the speaking actor so 3D panning matches position. Force 2D for radio-style lines by toggling spatialBlend. This preserves immersion and keeps post tonyli patterns predictable.

Step-by-step voice lookup

  1. Derive a voice key from (speakerId, nodeId, locale).
  2. Resolve to an Addressables key first; fall back to a Resources path for small prototypes.
  3. Log missing keys with (conversationId, nodeId, voiceKey) in dev builds only.
  4. Follow the AudioWait vs Audio pattern: wait when you must block, play-and-continue otherwise.

For no-jank playback: start the typewriter, start the clip. First tap cancels typewriter (reveals text). Second tap advances and stops the clip. Centralize audio state so StopConversation explicitly stops and releases the current clip to avoid the replay bug.

Lookup When to use Startup cost
Addressables Shipping builds, large banks Low (with preload)
Resources Small prototypes Higher first-load stalls
Local cache Hotlines / repeated barks Minimal

Mobile performance checklist for RPG dialogue UI mobile

Test three moments on device: first panel open, choice menu show, and portrait swap. Measure each step on a low-end phone so you catch spikes that never show in the editor.

Memory

Pack portraits into sprite atlases and reuse TMP font asset variants. Don’t ship one giant glyph atlas for every language; generate variants per locale.

Avoid instantiating the full dialog prefab per scene. Keep a single persistent prefab and reuse it.

Draw calls

Minimize masks and avoid portrait frames that use different materials. Those break batching and add draw calls quickly.

Prefer one canvas layer for conversations to reduce canvas rebuilds and sorting cost.

Battery and update cost

  • Stop update loops while the panel is inactive.
  • Avoid per-character tweens; use TMP maxVisibleCharacters for typewriter effects.
  • Cap animator/tween refresh rates during long exchanges.

Screen size and layout

Honor safe areas on iOS and Android. Test the smallest common resolutions.

Handle many responses with a scroll rect or paging so choices never clip off-screen.

Concern Action Cost Saved How to Verify
Memory Sprite atlases, TMP variants, single prefab Reduce RAM and GC Profile memory on device start and during convo
Draw calls Minimize masks, unify materials, single canvas Fewer batches, faster frames Render doc and frame debugger on device
Battery Pause updates, limit animators, avoid per-glyph tweens Lower CPU and power Measure battery drain during long playtest
Layout Stage text changes, force one layout pass, then animate Avoid expensive rebuild spikes Profiler markers for Layout.Rebuild calls

On PlayMobile.online you learned the “fine in Editor” trap. Only device profiling revealed layout rebuild and battery costs. Make device tests part of your routine.

Prevent first-conversation stutter by preloading the right assets

The first in-game exchange often stalls because the runtime lazily loads content on demand. Tony Li at Pixel Crushers found the lazy load pushed cost to the first tap, creating a visible hitch on phones. You can stop that by priming the database and the shell earlier.

Exact preload sequence to run early

  1. During a quiet moment (main menu or first free-roam), call DialogueManager.PreloadMasterDatabase();
  2. Then call DialogueManager.PreloadDialogueUI(); to ready the visual shell and input handlers.
  3. If a hitch remains, add the warm-up: DialogueManager.ShowAlert(string.Empty); at the end of Start().

Tony Li’s warm-up forces the same setup steps the player would trigger. That isolates whether the pause came from content load or UI init.

  • DIY option: instantiate the prefab off-screen, set an empty TMP string, force one layout pass, then disable it.
  • Guardrail: don’t preload huge portrait sets on the main thread—stream art after the shell is ready.
  • Measure: time “tap → first char appears” before and after warm-up on a mid-tier device.
What to preload When Cost
Master database Non-interactive start Small upfront
UI shell Main menu One-time layout cost
Portraits (common set) Early streaming Controlled memory

Note: Pixel Crushers tooling and certified developerpixel crushers articles and developerpixel crushers notes and load posts (often back to oct 2013) document this lazy-load behavior and fixes. Test and measure to confirm preload coverage.

Reference point: when Pixel Crushers Dialogue System is the right call

When you must ship narrative features fast, a packaged solution can save time and avoid painful rewrites later. Use buy when tooling, localization, and save behavior matter more than a tiny amount of bespoke code.

What you get out of the box

The package delivers sequencer commands for staged actions, a Lua runtime for variables and conditions, and built-in save/load that spares you a custom serialization layer.

It also offers quest hooks, localization plumbing, and event callbacks so you can hook into your gameplay flow without rewriting core logic.

Integration targets to plan early

  • Quest Machine — route choice confirmations into quest progression and rewards.
  • Your broader save systems — decide whether third-party saves or your engine owns snapshots.
  • UMA characters — plan how actor visuals resolve at runtime if you need dynamic portraits.
Decision When to buy When to build
Time to market Need cutscenes, localization, and save behavior now Small scope or heavy engine constraints
Extensibility Extend via events and wrappers to avoid forked source Deep custom features that packages can’t expose
Integration Plan Quest Machine, save systems, UMA characters early When you control every asset pipeline and load strategy

In practice, certified developerpixel and unity certified developerpixel tags signal production-grade tools and supported integrations. If you buy, prefer packages that let you extend with event hooks and wrappers so upgrades stay simple.

Final warning: even with a full tool, you must design asset loading (Addressables vs Resources) and run the first-use warm-up to prevent the hitch that kills polish on low-end devices.

Switching dialogue UIs at runtime for cutscenes vs overworld vs radio calls

You’ll often need different conversation shells for cutscenes, overworld chat, and radio calls. Cutscenes use letterbox frames and large portraits, overworld lines use a compact panel, and radio calls need a minimal overlay that does not pause movement.

Direct swap method (simple and deterministic)

Before you start a cinematic conversation, set the active presentation: assign DialogueManager.dialogueUI = cutsceneUI; then call StartConversation. After the cutscene ends, restore the previous assignment. Do this only at conversation start/end to avoid race conditions with scene loads.

Actor-based override method (persistent radio actor)

Create a persistent “radio” GameObject under the Dialogue Manager. Add an Override Dialogue UI component and a Dialogue Actor configured for your radio characters. Route radio lines through that actor so the special overlay persists across scene changes and avoids prefab duplication.

When What to set Benefit
Cutscene DialogueManager.dialogueUI = cutsceneUI Cinematic framing, portraits
Overworld Default compact UI Low cost, quick responses
Radio Actor-based override under manager Persistent, no pause in gameplay
  • Scene persistence: keep these objects under the persistent manager and use DontDestroyOnLoad to avoid duplicated prefabs.
  • Testing: start a radio call mid-combat to confirm the right shell shows and input locks match the mode.
  • Pitfall: swapping mid-conversation can strand menus; guard transitions to start/end boundaries.

Sequencing cutscene actions during dialogue without writing extra glue code

Make cutscene beats data-driven so designers can stage scenes without engineering help. Use a single Sequence field on nodes so writers trigger camera swaps, actor moves, and fades from content only.

Keep commands simple: SetEnabled(CutsceneCamera,true), MoveTo(window,NPC)@0.5, SetEnabled(CombatHUD,false). The @ syntax offsets start times so moves and fades do not race your panels. That prevents the next line appearing before a fade begins.

Timing and fade patterns

Use Fade(stay,1) when you need the screen black while you reposition actors. Example sequence: Fade(stay,1); MoveTo(position2,Orpheus)@1; Fade(in,1)@1. This avoids the one-frame flash that happens when you use simple Fade(out).

Why this helps and extensibility

The goal is to let authors trigger common beats—move, toggle, fade—without a C# script per cutscene. If you later add a system addon generative cutscene layer, extend the command set and keep existing sequences working.

Command Intent Notes
SetEnabled(X,true) Toggle cameras/props Quick on/off for the scene shell
MoveTo(target,Actor)@t Stage actor motion Starts after t seconds; avoids racing UI
Fade(stay,in/out,sec) Screen cover control Use Fade(stay) to prevent flicker

Reading custom fields and variables for dynamic RPG logic

Use two data paths so your conversation logic stays fast and predictable. Runtime variables control branching and choice availability. Design-time fields provide static metadata for staging and analytics.

Runtime state access

Read runtime values with DialogueLua.GetVariable(“reputation”).AsInt or DialogueLua.GetActorField(actorId, “mood”).AsString. Keep these checks side-effect free so rendering doesn’t change game state.

Design-time metadata lookup

For static tags, query the master database:

var conv = DialogueManager.MasterDatabase.GetConversation(convName);
string ambient = conv.LookupValue("Ambient");

Use that to pick ambient placement, camera presets, or journal tags.

Safe current node ID for analytics

Retrieve the active node id with DialogueManager.CurrentConversationState.subtitle.dialogueEntry.id. Log it in a small ring buffer for debug builds only so you can reproduce branches without bloating release logs or draining battery.

Access type Typical call When to use
Runtime variables DialogueLua.GetVariable(…) / GetActorField(…) Conditions, choice enablement, runtime checks
Design-time fields conv.LookupValue(“Ambient”) Static tags, staging, scene placement
Current node ID DialogueManager.CurrentConversationState… Analytics, journals, quest triggers

Avoid running heavy gameplay effects in UI callbacks. Commit effects only after choice confirmation to prevent duplicate triggers and inconsistent journals.

Saving and restoring dialog state on mobile without bloating files

Keep your save footprint tiny by choosing compact formats and stable IDs before you ship. You want players to resume conversations quickly and reliably on low-end phones.

What to persist

Persist only the minimal, resolvable data needed to resume play:

  • active conversation ID and last node ID
  • selected choice history (optional, compacted)
  • runtime variables snapshot — only keys owned by this system
  • seen-lines flags for barks and one-time lines
  • quest stages and completed objectives stored separately

File-size control and strategy

Keep binary size low by encoding booleans as bitsets keyed to stable numeric IDs. Do not write localized strings into the save; store keys only. Save systems that stream large assets should persist pointers, not full blobs.

When to write saves

On app backgrounding and after significant events (choice confirmed). Avoid per-character or per-frame writes. This reduces I/O and battery drain on handheld devices.

Beginner mistake and recovery

A common error is saving references to scene objects or ScriptableObject instance IDs that change across builds. Fix this by saving stable content IDs and resolving them against your content database at load.

If a node ID no longer exists, redirect players to a safe fallback node and log a telemetry event so you can patch content and avoid corrupting player progress.

Persisted Item Storage Form Size Tip
Last node ID Stable numeric/string ID 4–16 bytes
Seen-lines Bitset indexed by ID Compact, fast to serialize
Runtime vars Key → value (only owned keys) Exclude large objects

Unity documentation references you should actually use while implementing

When performance or load behavior surprises you, go to the official manuals first. The vendor docs explain the underlying rules that cause layout rebuilds, draw-call spikes, and asset-load stalls.

Key pages to read and what to look for

  • Canvas and batching: learn why splitting canvases increases rebuild cost and how masking and material changes break batching.
  • Layout rebuilding: identify which API calls trigger full rebuilds and how to stage text changes to avoid spikes.
  • Addressables: follow the Addressables guide for grouping portraits, voice clips, and localized tables to control cold vs warm load behavior.
  1. Create Addressables groups for “common UI shell” and per-chapter art/audio.
  2. Preload the shell at startup; stream chapter assets later.
  3. Profile cold start vs warm start with the Profiler and Frame Debugger to confirm fewer draw calls.
Doc Focus Why it matters
Canvas & UI Manual Batching, masks, layout Explains rebuild triggers and draw-call sources
Addressables Guide Groups, preload, dependency Helps avoid first-use stalls and manage memory
Profiler / Frame Debugger Verify reductions Confirms doc fixes apply in real builds

Common beginner mistake: using Resources for everything. The docs show why Resources can cause unpredictable stalls on low-end devices and how Addressables provide safer loading patterns.

Common beginner mistakes in a Unity dialog system and how you avoid them

Small mistakes in rendering and asset loading often cause the biggest performance headaches on handheld devices. Below are concrete failures you will see, why they happen, and the exact fixes that stop the regressions.

Layout rebuild spikes

Problem: updating TMP text or toggling layout components inside Update() triggers full rebuilds every frame. That makes the first panel open and choice menus jitter on low-end phones.

Fix: only assign text when the node or choice set changes. Group updates, then call one forced layout pass. Use SetLayoutDirty sparingly and avoid enabling/disabling Layout components per frame.

Garbage from typewriter and input code

Problem: substring, string concat, LINQ, and new closures in typewriter or input handlers cause GC spikes and frame drops.

Fix: use maxVisibleCharacters, a cached StringBuilder, pooled buttons, and pre-allocated token sources. Cancel and reuse rather than allocate on every key press.

Resources overload and preload stalls

Problem: stuffing voices and portraits into Resources causes big, unpredictable loads on first use and bloated memory.

Fix: migrate heavy packs to Addressables, preload only the common shell and frequently used portraits, and stream large banks async.

Safe area misses

Problem: action buttons or text sit under notches or home indicators on some phones.

Fix: add a Safe Area component that adjusts panel padding at runtime. Test both landscape and portrait on the smallest screened devices.

Add-on chaos and addongrid controller pitfalls

Problem: multiple add-ons each mutate the same state and you end up with hatedialogue system addon behavior—conflicting updates and race conditions.

Fix: designate a single UI state owner and expose a small extension API. If you use a dialogue addongrid, make its addongrid controller event-driven. Avoid polling loops and prevent per-emote material instantiation.

Mistake Symptom Concrete fix
Frequent text updates Layout rebuild spikes Update on state change; batch layout pass
Allocation-heavy typewriter GC hitches maxVisibleCharacters + pooled buffers
Resources overload First-use stutter, memory bloat Addressables + targeted preload
Unsafe padding Buttons under notch Safe Area driven padding

What to test

  • Smallest device model you support.
  • Longest localized strings and overflow handling.
  • Max choices rendered at once and scrolling behavior.
  • Asset load while in airplane mode or on slow networks.

Debugging and profiling your dialogue on device

Start by reproducing the hitch on device so you can validate fixes under real conditions. Use a short, repeatable script and gather both profiler traces and a QA video of the event.

Repro the first-time stutter and confirm preload coverage

Cold start the app, wait three seconds, then trigger the first conversation while recording frame times. Repeat after adding your preload or warm-up to confirm improvement.

Verify that the UI prefab, fonts, and the first portrait and voice clip are resident before the player can tap. If any asset loads on the show frame, add it to the preload set.

Use profiler markers to catch rebuilds and audio decode spikes

Measure Canvas.BuildBatch, layout rebuilds, TMP mesh rebuilds, and audio clip decode time. Capture device traces and compare them to the Editor to spot platform-specific spikes.

Logging strategy and QA workflow

Log conversation start, line, and end under a development guard (#if DEVELOPMENT_BUILD || UNITY_EDITOR). Use a ring buffer and a QA toggle to dump the last N events on demand.

Add a small on-screen label that shows current conversation ID and node ID so QA videos link visual context to your traces. This reduces back-and-forth and speeds support posts.

Check What to measure How to run Expected action
First-conversation stutter Frame-time spike on first tap Cold start → wait 3s → trigger conversation Preload shell/assets; retest
Layout rebuilds Canvas.BuildBatch & Layout.Rebuild Start conversation, show choices Batch updates, reduce text churn
Audio decode hitch Audio load/decode on show frame Play voice, observe audio load timing Pre-decode or warm cache
Log replayability Ring-buffered start/line/end traces Reproduce bug, dump buffer, attach video Faster triage and better support

Conclusion

In summary, you built a minimal ScriptableObject-driven runner, a stable ID data model with first-class choices, a UI architecture that survives scene loads, a deterministic event flow, and a typewriter/audio setup that avoids jank.

Prioritize preload to prevent first-use stutter, keep batching stable, reduce layout rebuilds, and stop needless update loops that drain battery. Add localization keys and move portraits/voice into Addressables, then run an on-device profiler focusing on first tap and response-menu open times.

Avoid these mistakes: don’t update text every frame, don’t allocate in tight loops, don’t rely on Resources for large banks, and don’t ignore safe areas. If your needs include quests, save/load, sequencing, and runtime shell swaps, a packaged product like Pixel Crushers can speed work; otherwise your DIY architecture follows the same good separation.

At the end you should have a reusable unity dialogue system you can skin for overworld, cutscene, and radio modes that stays smooth on mid-tier hardware.

Leave a Reply

Your email address will not be published. Required fields are marked *