Visual Novel / Storyboard
TL;DR: Dialog nodes are an inspector-edited array of DialogNode structs (speaker, text, default next node, two choice strings + goto indexes). The script reveals text character-by-character, then shows either a continue button or a two-choice panel depending on whether the current node defines choices. Pure local, no networking.
Context: World. Purely scene-based; no storage or networking required.
Scene setup
Section titled “Scene setup”- A world Canvas with:
speakerLabel(TMP) — who’s talking.textLabel(TMP) — dialog text (revealed over time).continuePanel— a button hostingAdvance(onClick→OnAdvanceClicked).choicePanel— two buttons with labelschoiceALabel,choiceBLabel, wired toOnChoiceAClickedandOnChoiceBClicked.
- A
VisualNovelcomponent on the world root. In itsnodes[]array, lay out the story:- Each entry has
speaker,text, and eithernextDefault(for linear continuation) orchoiceA/choiceAGoto/choiceB/choiceBGoto(for a branch).
- Each entry has
[Serializable]public struct DialogNode{ public string speaker; public string text; public int nextDefault; public string choiceA; public int choiceAGoto; public string choiceB; public int choiceBGoto;}Example nodes (you’d fill these out in the Unity inspector):
[0] speaker: "Narrator" text: "You stand at a crossroads." nextDefault: 1[1] speaker: "Guide" text: "Which way?" choiceA: "Left path" choiceAGoto: 2 choiceB: "Right path" choiceBGoto: 5[2] speaker: "Narrator" text: "You head left." nextDefault: 3...Code highlights
Section titled “Code highlights”void Update(){ if (revealed || nodes == null || nodes.Length == 0) return; string full = nodes[currentIndex].text ?? ""; int chars = Mathf.Clamp(Mathf.FloorToInt((Time.time - revealStartTime) * charsPerSecond), 0, full.Length); textLabel.text = full.Substring(0, chars); if (chars >= full.Length) EndReveal();}OnAdvanceClicked snaps to the full text if mid-reveal, otherwise moves to nextDefault. OnChoiceAClicked / OnChoiceBClicked jump to the goto index.
Source: examples/world/VisualNovel.cs.
- Pre-load all dialog in the inspector as a single
DialogNode[]. No runtime parsing, no file reads per node. - Use
Time.timedeltas for the reveal animation — it’s cheap and the drift across a session doesn’t matter for text pacing. - Store
currentIndexas[WasmSerialized]so inspector edits carry through and replaying from test mode resumes. - Validate
nextDefault/choiceAGoto/choiceBGotoagainstnodes.Lengthbefore jumping. Invalid indexes silently do nothing.
Don’ts
Section titled “Don’ts”- Don’t parse dialog from
FileStorageat runtime. Keep it in-inspector. You’d pay a per-nodeFileStorage.ReadFileand the sandbox’s string marshaling — wasteful for what’s essentially a static asset. - Don’t allocate
new string(...)every frame.Substringallocates — that’s acceptable once per frame during a reveal. Don’t spam it beyond that. - Don’t use Unity’s
IEnumeratorcoroutines for reveal timing. UseUpdate+ a storedrevealStartTime. Coroutines aren’t fully bound and async patterns run single-threaded. - Don’t branch on
Player.GetUsername()— username isn’t persistent identity. If you want a named save, usePlayer.GetUserId()(+AccessUserIdentitypermission) and write toFileStorage.
Validator
Section titled “Validator”Paste VisualNovel.cs into the validator. Expect:
err 0,warn 0under World context.inforeportingUnity events detected: Start, Update.- No game events (dialog is fully local).
Extensions
Section titled “Extensions”- Audio per line — add an
AudioClipfield toDialogNodeand play inShowNode. - Portraits — a
Spritefield + a dedicatedUnityEngine.UI.Imageswap. - Save progress — combine with the Idle save pattern to remember the last-seen node across sessions.
- Flag-based branching — add a
bool[] flagsstate and conditionalgotologic inTakeChoice. - Rich text — since TMP supports inline tags, embed
<color>/<b>in node text to style individual words.
Full source
Section titled “Full source”using System;using UnityEngine;using TMPro;using WasmScripting;
public partial class VisualNovel : WasmBehaviour{ [Serializable] public struct DialogNode { public string speaker; public string text; public int nextDefault; public string choiceA; public int choiceAGoto; public string choiceB; public int choiceBGoto; }
public DialogNode[] nodes; public TextMeshProUGUI speakerLabel; public TextMeshProUGUI textLabel; public TextMeshProUGUI choiceALabel; public TextMeshProUGUI choiceBLabel; public GameObject choicePanel; public GameObject continuePanel;
public float charsPerSecond = 40f;
[WasmSerialized] private int currentIndex; [WasmSerialized] private float revealStartTime; [WasmSerialized] private bool revealed;
void Start() { currentIndex = Mathf.Max(0, currentIndex); ShowNode(currentIndex); }
void Update() { if (revealed || nodes == null || nodes.Length == 0 || textLabel == null) return; string full = nodes[currentIndex].text ?? ""; float t = Time.time - revealStartTime; int chars = Mathf.Clamp(Mathf.FloorToInt(t * charsPerSecond), 0, full.Length); textLabel.text = full.Substring(0, chars); if (chars >= full.Length) EndReveal(); }
public void OnAdvanceClicked() { if (nodes == null || nodes.Length == 0) return; if (!revealed) { textLabel.text = nodes[currentIndex].text; EndReveal(); return; } int next = nodes[currentIndex].nextDefault; if (next < 0 || next >= nodes.Length) return; ShowNode(next); }
public void OnChoiceAClicked() { TakeChoice(nodes[currentIndex].choiceAGoto); } public void OnChoiceBClicked() { TakeChoice(nodes[currentIndex].choiceBGoto); }
private void TakeChoice(int goTo) { if (nodes == null || goTo < 0 || goTo >= nodes.Length) return; ShowNode(goTo); }
private void ShowNode(int idx) { currentIndex = idx; revealed = false; revealStartTime = Time.time;
DialogNode n = nodes[idx]; if (speakerLabel != null) speakerLabel.text = n.speaker ?? ""; if (textLabel != null) textLabel.text = "";
bool hasChoices = !string.IsNullOrEmpty(n.choiceA) || !string.IsNullOrEmpty(n.choiceB); if (choicePanel != null) choicePanel.SetActive(false); if (continuePanel != null) continuePanel.SetActive(!hasChoices);
if (choiceALabel != null) choiceALabel.text = n.choiceA ?? ""; if (choiceBLabel != null) choiceBLabel.text = n.choiceB ?? ""; }
private void EndReveal() { revealed = true; DialogNode n = nodes[currentIndex]; bool hasChoices = !string.IsNullOrEmpty(n.choiceA) || !string.IsNullOrEmpty(n.choiceB); if (choicePanel != null) choicePanel.SetActive(hasChoices); if (continuePanel != null) continuePanel.SetActive(!hasChoices); }}Related
Section titled “Related”- File Storage — for save progress extensions.
- Events — why Unity coroutines are discouraged here.