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
- Derive a voice key from (speakerId, nodeId, locale).
- Resolve to an Addressables key first; fall back to a Resources path for small prototypes.
- Log missing keys with (conversationId, nodeId, voiceKey) in dev builds only.
- 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
- During a quiet moment (main menu or first free-roam), call DialogueManager.PreloadMasterDatabase();
- Then call DialogueManager.PreloadDialogueUI(); to ready the visual shell and input handlers.
- 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.
- Create Addressables groups for “common UI shell” and per-chapter art/audio.
- Preload the shell at startup; stream chapter assets later.
- 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.
