Chapter 14 of 15 11 min advanced

Streaming NPC Dialogue

Words appearing as they are written

Verified against a working setup on 2026-08-10

A three-second wait followed by a paragraph feels broken. The same three seconds with words appearing as they arrive feels like the character is talking. Nothing got faster — but the perceived latency drops to almost nothing, and for dialogue that is the whole game.

What streaming actually sends

Add "stream": true to the request and the response becomes server-sent events: a long-lived connection delivering small text chunks as the model produces them.

The wire format, trimmed
text
event: message_start
data: {"type":"message_start","message":{"id":"msg_01...","model":"claude-haiku-4-5"}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Forge's"}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" been cold"}}

event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}

event: message_stop
data: {"type":"message_stop"}
EventMeansDo
message_startThe reply is beginningShow the dialogue box.
content_block_deltaA piece of textAppend delta.text. This is the one that matters.
message_deltaCarries stop_reasonCheck it before treating the reply as complete.
message_stopFinishedRe-enable input.
Chunks are not words or sentences — they are token fragments, and they split mid-word. Append them to a buffer and render the buffer; never treat one chunk as a unit of meaning.

Unity does not stream by default

DownloadHandlerBuffer waits for the whole response, which throws away the entire benefit. To read as data arrives, implement DownloadHandlerScript and handle ReceiveData.

SseDownloadHandler.cs
C#
using System;
using System.Text;
using UnityEngine.Networking;

public class SseDownloadHandler : DownloadHandlerScript
{
    private readonly StringBuilder pending = new StringBuilder();
    private readonly Action<string> onDelta;

    // The byte[] gives the handler a reusable buffer instead of
    // allocating a fresh array for every chunk that arrives.
    public SseDownloadHandler(Action<string> onDelta) : base(new byte[4096])
    {
        this.onDelta = onDelta;
    }

    protected override bool ReceiveData(byte[] data, int dataLength)
    {
        if (data == null || dataLength == 0) return false;

        pending.Append(Encoding.UTF8.GetString(data, 0, dataLength));

        // SSE separates events with a blank line. A chunk can split an
        // event in half, so only consume up to the last complete one.
        string buffer = pending.ToString();
        int cut = buffer.LastIndexOf("\n\n", StringComparison.Ordinal);
        if (cut < 0) return true;

        foreach (string line in buffer.Substring(0, cut).Split('\n'))
        {
            if (!line.StartsWith("data:")) continue;

            string payload = line.Substring(5).Trim();
            if (payload.Length == 0) continue;

            string text = ParseTextDelta(payload);
            if (!string.IsNullOrEmpty(text)) onDelta?.Invoke(text);
        }

        pending.Remove(0, cut + 2);
        return true;
    }
}
The partial-event problem is the bug everyone hits. TCP chunks have nothing to do with event boundaries, so a chunk regularly ends halfway through a JSON payload. Buffering to the last \n\n and keeping the remainder is what makes this reliable.

Parsing one event

C#
using Newtonsoft.Json.Linq;

private static string ParseTextDelta(string payload)
{
    // Ignore anything that is not a text delta - other event types
    // are structurally different and will not have these fields.
    JObject o = JObject.Parse(payload);
    if ((string)o["type"] != "content_block_delta") return null;

    JToken delta = o["delta"];
    if ((string)delta?["type"] != "text_delta") return null;

    return (string)delta["text"];
}

Driving it from a MonoBehaviour

StreamingDialogue.cs
C#
private readonly StringBuilder spoken = new StringBuilder();

public IEnumerator Speak(string playerLine)
{
    spoken.Clear();
    dialogueText.text = string.Empty;

    using UnityWebRequest request = new UnityWebRequest(endpoint, "POST");
    request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(BuildJson(playerLine)));
    request.downloadHandler = new SseDownloadHandler(OnDelta);
    request.SetRequestHeader("content-type", "application/json");

    yield return request.SendWebRequest();

    if (request.result != UnityWebRequest.Result.Success)
        dialogueText.text = FallbackLine();
}

// Called from ReceiveData, which runs on Unity's main thread -
// so touching UI here is safe.
private void OnDelta(string text)
{
    spoken.Append(text);
    dialogueText.text = spoken.ToString();
}
Assigning .text on every delta dirties the canvas each time. Put the dialogue box on its own canvas so a rebuild does not touch your whole HUD — the split described in Canvas & UI.

Making it feel like speech

Do not render deltas raw

Chunks arrive unevenly — several at once, then a pause. Rendering them directly looks stuttery. Push arriving text into a queue and drain it at a steady characters-per-second rate, and you get the classic typewriter effect for free while staying ahead of the model.

If the queue empties because the model is slower than your reveal rate, simply pause. That reads as the character thinking, which is exactly the impression you want.

Let the player skip

Any dialogue system needs a skip. On the first press, dump the whole buffer received so far; on the second, close the box. Players who have read the line should never wait for an animation.

Cancelling cleanly

If the player walks away mid-sentence, stop. Because this is a coroutine on the NPC's MonoBehaviour, disabling the object stops it automatically — the behaviour described in Coroutines & Async. Dispose the request so the connection closes rather than lingering.

C#
private Coroutine speaking;

public void StartSpeaking(string line)
{
    StopSpeaking();
    speaking = StartCoroutine(Speak(line));
}

public void StopSpeaking()
{
    if (speaking == null) return;
    StopCoroutine(speaking);
    speaking = null;
}

private void OnDisable() => StopSpeaking();

What to take away

  • Streaming changes perceived latency, not real latency — and perception is what ships.
  • content_block_delta is the only event you must handle.
  • DownloadHandlerBuffer waits for everything; use DownloadHandlerScript.
  • Buffer to the last blank line — chunks split events in half.
  • Reveal at a steady rate rather than rendering deltas raw.
  • Stop the coroutine and dispose the request when the player leaves.