Skip to content

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.

  • A world Canvas with:
    • speakerLabel (TMP) — who’s talking.
    • textLabel (TMP) — dialog text (revealed over time).
    • continuePanel — a button hosting Advance (onClickOnAdvanceClicked).
    • choicePanel — two buttons with labels choiceALabel, choiceBLabel, wired to OnChoiceAClicked and OnChoiceBClicked.
  • A VisualNovel component on the world root. In its nodes[] array, lay out the story:
    • Each entry has speaker, text, and either nextDefault (for linear continuation) or choiceA / choiceAGoto / choiceB / choiceBGoto (for a branch).
[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
...
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.time deltas for the reveal animation — it’s cheap and the drift across a session doesn’t matter for text pacing.
  • Store currentIndex as [WasmSerialized] so inspector edits carry through and replaying from test mode resumes.
  • Validate nextDefault / choiceAGoto / choiceBGoto against nodes.Length before jumping. Invalid indexes silently do nothing.
  • Don’t parse dialog from FileStorage at runtime. Keep it in-inspector. You’d pay a per-node FileStorage.ReadFile and the sandbox’s string marshaling — wasteful for what’s essentially a static asset.
  • Don’t allocate new string(...) every frame. Substring allocates — that’s acceptable once per frame during a reveal. Don’t spam it beyond that.
  • Don’t use Unity’s IEnumerator coroutines for reveal timing. Use Update + a stored revealStartTime. 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, use Player.GetUserId() (+ AccessUserIdentity permission) and write to FileStorage.

Paste VisualNovel.cs into the validator. Expect:

  • err 0, warn 0 under World context.
  • info reporting Unity events detected: Start, Update.
  • No game events (dialog is fully local).
  • Audio per line — add an AudioClip field to DialogNode and play in ShowNode.
  • Portraits — a Sprite field + a dedicated UnityEngine.UI.Image swap.
  • Save progress — combine with the Idle save pattern to remember the last-seen node across sessions.
  • Flag-based branching — add a bool[] flags state and conditional goto logic in TakeChoice.
  • Rich text — since TMP supports inline tags, embed <color> / <b> in node text to style individual words.
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);
}
}
  • File Storage — for save progress extensions.
  • Events — why Unity coroutines are discouraged here.