Skip to content
Fondazione ITS INCOM

Examples · AR/VR & Game Developer

A dialogue tree for your NPC

A model writes the branching conversation of a blacksmith as JSON. A short Python checker walks the tree and finds the dead ends, the broken links and the endings nobody can reach, and the model fixes them. Then you play it, and read what no checker can see.

Intermediate · 45 min

The idea

A character the player can talk to, an NPC, needs a conversation with branches: every answer of the player leads somewhere else. Written by hand it is a lot of small pieces; written by a model it takes seconds, and it breaks in ways that are hard to see: a choice that leads to a node that does not exist, an ending that no path reaches, a node where the conversation stops with nothing to say how.

So:

  1. the model writes the tree as JSON, in a shape the schema forces;
  2. a checker of yours walks the tree and lists the problems;
  3. the problems go back to the model, which writes the tree again, up to three times;
  4. you play it in the terminal, and read it.

What you need

A key and the openai library, as in Your first API call in Python.

The model

Gemma 4 26B (gemma-4-26b). With qwen3.6-35b the trees had the same kinds of problems, and the checker caught them the same way. ministral-3-14b wrote lines and choices far too long, and once a tree of a single node.

The tree

A list of nodes. Each node has an id, the line the blacksmith says, the choices of the player, each with the next node it leads to, and an outcome: none while the conversation goes on, and at the end how it ended. For example:

{"id": "show_sword", "line": "Snapped clean through. Can you pay for the work?",
 "choices": [{"text": "I have 30 silver coins.", "next": "repair_agree"},
             {"text": "Mind your own business!", "next": "refused"}],
 "outcome": "none"}

The checker

What the schema cannot check, this file does. Save it as check_dialogue.py:

import json
import sys

OUTCOMES = ["sword_repaired", "quest_iron_ore", "refused", "left"]


def problems(nodes):
    found = []
    by_id = {}
    for n in nodes:
        if n["id"] in by_id:
            found.append(f"two nodes have the id {n['id']}")
        by_id[n["id"]] = n
    if "start" not in by_id:
        return found + ["no node has the id start"]

    for n in nodes:
        if len(n["line"]) > 140:
            found.append(f"{n['id']}: the line has {len(n['line'])} characters, more than 140")
        for c in n["choices"]:
            if c["next"] not in by_id:
                found.append(f"{n['id']}: the choice «{c['text']}» leads to {c['next']}, which does not exist")
            if len(c["text"]) > 40:
                found.append(f"{n['id']}: the choice «{c['text']}» has {len(c['text'])} characters, more than 40")
        if not n["choices"] and n["outcome"] == "none":
            found.append(f"{n['id']}: a dead end, with no choices and no outcome")
        if n["choices"] and n["outcome"] != "none":
            found.append(f"{n['id']}: it has the outcome {n['outcome']}, but the conversation goes on")

    def reachable(first):  # every node you can get to from first, first included
        seen, todo = set(), [first]
        while todo:
            node = by_id.get(todo.pop())
            if node and node["id"] not in seen:
                seen.add(node["id"])
                todo += [c["next"] for c in node["choices"]]
        return seen

    from_start = reachable("start")
    for n in nodes:
        if n["id"] not in from_start:
            found.append(f"{n['id']}: no path from start leads here")
    for outcome in OUTCOMES:
        if not any(by_id[i]["outcome"] == outcome for i in from_start):
            found.append(f"no path from start ends with {outcome}")
    for i in sorted(from_start):
        if not any(by_id[j]["outcome"] != "none" for j in reachable(i)):
            found.append(f"{i}: from here the conversation can never end")
    return found


if __name__ == "__main__":
    with open(sys.argv[1] if len(sys.argv) > 1 else "dialogue.json") as f:
        nodes = json.load(f)["nodes"]
    found = problems(nodes)
    for p in found:
        print("-", p)
    print(f"{len(nodes)} nodes, problems: {len(found)}")

It finds:

The writer

Save it as write_dialogue.py, in the same folder:

import json
import os

from openai import OpenAI

from check_dialogue import OUTCOMES, problems

client = OpenAI(
    base_url="https://api.ai.itsincom.org/v1",
    api_key=os.environ["API_KEY"],
)

schema = {
    "type": "object",
    "properties": {
        "nodes": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "id": {"type": "string"},
                    "line": {"type": "string"},
                    "choices": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {"text": {"type": "string"}, "next": {"type": "string"}},
                            "required": ["text", "next"],
                        },
                    },
                    "outcome": {"type": "string", "enum": ["none"] + OUTCOMES},
                },
                "required": ["id", "line", "choices", "outcome"],
            },
        },
    },
    "required": ["nodes"],
}

messages = [
    {"role": "system", "content": "You write branching dialogue for video games. A dialogue is a list of nodes. "
                                  "Each node has an id, the line the character says, and the choices of the "
                                  "player, each with the id of the node it leads to. A node without choices ends "
                                  "the conversation, and its outcome says how; every other node has the "
                                  "outcome none."},
    {"role": "user", "content": "Nessa, the blacksmith of a mountain village: gruff, but fair. The player brings "
                                "her a broken sword. The conversation can end in four ways: she repairs it for "
                                "30 silver coins (sword_repaired); she asks the player to bring iron ore from "
                                "the old mine first (quest_iron_ore); she refuses, because the player was rude "
                                "(refused); the player walks away (left). The first node has the id start. "
                                "8 to 12 nodes. Lines under 140 characters, choices under 40."},
]

for attempt in range(1, 4):
    answer = client.chat.completions.create(
        model="gemma-4-26b",
        temperature=0.7,
        messages=messages,
        response_format={"type": "json_schema", "json_schema": {"name": "dialogue", "schema": schema}},
    )
    text = answer.choices[0].message.content
    nodes = json.loads(text)["nodes"]
    found = problems(nodes)
    print(f"attempt {attempt}: {len(nodes)} nodes, problems: {len(found)}")
    for p in found:
        print("  -", p)
    if not found:
        with open("dialogue.json", "w") as f:
            json.dump({"nodes": nodes}, f, indent=2, ensure_ascii=False)
        print("Saved in dialogue.json")
        break
    # The model sees its own answer and the list of problems, and writes the whole dialogue again.
    messages += [
        {"role": "assistant", "content": text},
        {"role": "user", "content": "Your dialogue has these problems:\n" + "\n".join(found)
                                    + "\nWrite the whole dialogue again, with every problem fixed."},
    ]
else:
    print("Still wrong after three attempts: read the problems, and change the request.")

Run it with python3 write_dialogue.py. Each attempt takes a few seconds.

What you should see

attempt 1: 8 nodes, problems: 2
  - quest_iron_ore_final: no path from start leads here
  - no path from start ends with quest_iron_ore
attempt 2: 8 nodes, problems: 0
Saved in dialogue.json

The first tree had the ending with the ore quest, and no choice that led to it: in the game, that quest could never start. The second tree was right. Half of our twelve trees were right at the first attempt, and all twelve within three. Your tree will be different: temperature=0.7 gives a new conversation at every run.

Play it

Save it as play.py, and run it with python3 play.py:

import json

with open("dialogue.json") as f:
    nodes = {n["id"]: n for n in json.load(f)["nodes"]}

node = nodes["start"]
while True:
    print(f"\nNessa: {node['line']}")
    if not node["choices"]:
        print(f"[the end: {node['outcome']}]")
        break
    for number, choice in enumerate(node["choices"], 1):
        print(f"  {number}. {choice['text']}")
    pick = input("> ")
    while not (pick.isdigit() and 1 <= int(pick) <= len(node["choices"])):
        pick = input(f"A number from 1 to {len(node['choices'])}: ")
    node = nodes[node["choices"][int(pick) - 1]["next"]]
Nessa: Nessa looks up from the anvil, wiping soot from her brow. 'What is it? If it's just a dull blade, go to the whetstone.'
  1. Show her the broken sword.
  2. Apologize and leave.
> 1

Nessa: She inspects the jagged edge with a squint. 'Snapped clean through. Can you pay for the work?'
  1. I have 30 silver coins.
  2. I'm a bit short on coin.
  3. Mind your own business!
> 2

Nessa: She sighs, leaning against the anvil. 'No coin, no work. Unless... you bring me some iron ore from the old mine.'
  1. I'll get the ore for you.
  2. Never mind, then.
> 1

Nessa: She nods curtly. 'Bring the ore to this forge. Then we talk business.'
[the end: quest_iron_ore]

Read it: what the checker cannot see

The tree passed every check, and something is still wrong. Play the other paths:

Nessa: Nessa looks up from the anvil, wiping soot from her brow. 'What is it? If it's just a dull blade, go to the whetstone.'
  1. Show her the broken sword.
  2. Apologize and leave.
> 2

Nessa: You turn away from the heat of the forge and head back toward the village path.
[the end: left]

The lines mix what Nessa says with what she does, and the last one is not Nessa at all: it is a narrator. In a game, a subtitle with “Nessa: You turn away…” is a bug, and a voice actor, or the voices of the school, would read the stage directions aloud.

Add one sentence at the end of the system message: “A line is only what the character says aloud: no narration, no quotation marks.” In our four runs with it, every line was something Nessa says, the ending where the player leaves included: “Suit yourself. Come back when you're serious.”

The checker counts nodes and follows links. Whether Nessa is gruff but fair, whether the price is the 30 coins you asked for, whether rudeness is really what makes her refuse: that is for you to read, path by path.

Why four endings

While we were preparing this page, the request had three endings, without left. Twice, the model wrote a fourth one anyway, where the player walks away, and had no outcome to give it: three attempts, three times “a dead end, with no choices and no outcome”. Sending the problem back could not fix it, because the request had no word for what the model wanted to write. When the same problem comes back at every attempt, change the request, not the number of attempts.

In Unity

Put dialogue.json in Assets: Unity imports it as a TextAsset. JsonUtility reads it into three small classes with the same names as the keys. Save the script as NessaDialogue.cs, in Assets: Unity wants the file named after the class.

using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public class Choice { public string text; public string next; }

[System.Serializable]
public class DialogueNode { public string id; public string line; public Choice[] choices; public string outcome; }

[System.Serializable]
public class Dialogue { public DialogueNode[] nodes; }

public class NessaDialogue : MonoBehaviour
{
    public TextAsset dialogueFile;  // dialogue.json, dragged here in the Inspector
    readonly Dictionary<string, DialogueNode> nodes = new Dictionary<string, DialogueNode>();

    void Start()
    {
        foreach (var node in JsonUtility.FromJson<Dialogue>(dialogueFile.text).nodes)
            nodes[node.id] = node;
        Debug.Log(nodes["start"].line);
    }
}

Add the script to an object, drag dialogue.json into its Dialogue File field, and press Play: the Console shows the first line. JsonUtility cannot read a dictionary, nor a list at the top of the file: that is why the file is an object with a list of nodes, and the script builds the dictionary itself. We have not run it inside Unity: we compiled it with .NET, with .NET's JSON reader standing in for JsonUtility, and it read our dialogue.json.

Good to know

Exercise

All examples