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.
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"}| Event | Means | Do |
|---|---|---|
message_start | The reply is beginning | Show the dialogue box. |
content_block_delta | A piece of text | Append delta.text. This is the one that matters. |
message_delta | Carries stop_reason | Check it before treating the reply as complete. |
message_stop | Finished | Re-enable input. |
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.
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;
}
}\n\n and keeping the remainder is what makes this reliable.Parsing one event
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
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();
}.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.
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_deltais the only event you must handle.DownloadHandlerBufferwaits for everything; useDownloadHandlerScript.- 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.