Conversations as data, walked twice.
Abstract. Rod and Reef has 117 dialogue files: 80 base conversations and 37 layers that add the lines for side quests and the story. Besides talking, NPCs offer quests, check them, pay them out, open shops, play cutscenes and say different things depending on the player’s progress. This article describes how that is built. Each conversation is a flat Luau table of named nodes, which a two-pass deserializer turns into a directed graph. Layers are combined with a right-biased union of maps. The entry node isn’t fixed, because a resolver picks it from the player’s quests. The client walks the graph to show the conversation, with a typewriter effect that counts visible graphemes and syncs to the recorded voice when there is one. The server walks the same graph in parallel and only accepts moves and actions that the conversation actually put in front of the player, with the exact payload they were authored with.
1. The problem
An NPC in Rod and Reef can do a lot in one conversation. It can offer a quest and accept it if the player says yes, check whether it’s done and jump to one line or another depending on the result, pay out the reward, open the rod shop or the selling menu, play a story cutscene, or send the player off to pick a boat for a trip. And the same NPC says different things depending on where the player is: it greets them the first time, reminds them of the quest they’re halfway through, offers the next one, or tells them it has nothing left.
That leaves three requirements. Content has to be data, because there are hundreds of lines written by people who shouldn’t be touching code, and a good part of it is generated by tools from the design documents. The format has to support branches, loops and actions. And since some of those actions pay out coins and experience, the server can’t take the client’s word when it says “the player picked this”.
2. The format
A conversation is a Luau table where every key is the name of a node:
MarksColdCall_Greet = {
Text = "Word is a Winterspire angler landed something rare. ...",
Responses = {
{
Text = "Let's go see it.",
Actions = { { Type = "AcceptQuest", QuestId = "MarksColdCall" } },
NextNode = "MarksColdCall_Accepted",
},
{ Text = "Maybe later.", NextNode = "MarksColdCall_Declined" },
},
},
MarksColdCall_Accepted = {
Text = "Good. Pick a boat with room for two. A decent one.",
Actions = { { Type = "ChooseRideBoat", QuestId = "MarksColdCall" } },
Close = true,
},
A node has a text and at most one of three exits: Responses if the player chooses, NextNode if the conversation continues on its own, or Close if it ends. A node with none of the three also ends. Actions can sit on a node, where they run when the node is shown, or on a response, where they run once the player moves past it. Two reserved keys, FirstNode and Speaker, say where the conversation starts by default and who is talking.
Keeping the table flat, with references by name instead of a nested tree, is what lets a node be reached from several responses and lets the graph have cycles. The typical case is checking a quest that isn’t done yet, which comes back to the same node every time the player talks to the NPC. It also makes generated files read well in a diff. Getting Luau’s analyzer in --!strict mode to accept these tables took an explicit type annotation, RawDialogueTable, because Luau infers the element type of an array from its first entry, and a list of responses where only the first one has Actions fails on every other entry.
3. From text to graph
DialogueTree.FromTable turns the table into a directed graph . Vertices come in six types (Root, Prompt, Response, Branch, Condition and Action), though tables only produce the first three.
The conversion runs in two passes, like an assembler resolving forward jumps [1]. The first pass creates a Prompt vertex for every key in the table. The second wires them up: the root points at FirstNode, each response becomes a Response vertex named <key>_r<i> with priority , and edges are added from each node to its responses and from each response or linear node to its NextNode. The passes have to be separate because a NextNode can name a node that appears further down the table, and Luau doesn’t define table iteration order anyway, so there is no “further down” to rely on. Both passes together cost . A name that doesn’t exist produces a warning and the edge is skipped, without breaking the conversation.
Each vertex returns its outputs sorted by priority, and that decides the order of the response buttons. A Branch vertex is transparent: both client and server pass straight through it to its children.
There is a second deserializer, FromConfiguration, which builds the same graph from Studio Configuration instances, the format of a visual editor, with references through ObjectValue and lookup by name as a fallback. What matters is that both sources end up as the same graph, so nothing else in the system knows where a conversation came from.
4. Layers
An NPC’s base conversation is generated by a tool from the design documents, and side quests and the story add nodes on top of it. If those nodes were written into the generated file, regenerating it would erase them. So they live in separate folders (SideQuestDialogue and StoryQuestDialogue) and are combined at load time.
The combination is a union of finite maps where the right-hand side wins:
The operation is associative but not commutative, so the order is fixed: base, then side quests, then story, and finally the tutorial’s lockout nodes, which are added to every NPC:
The story goes after the side quests so it wins any name collision, though in practice there are none, since every layer prefixes its nodes with the quest name (MarksColdCall_Greet). The union works per node, not per field, so a layer replaces whole nodes. And it runs on a copy, because the table require returns is cached and shared by everyone who asks for it, and writing the tutorial nodes straight into it would leave them baked in for good.
5. Where the conversation starts
FirstNode is only the default. For NPCs that hand out quests, the entry node is picked by QuestlineResolver.ResolveStartNode from the player’s profile, in this order:
- If the player has an active quest from this NPC, the conversation opens on that quest’s check node. Story quests are checked before the rest.
- If the player is following a quest in the journal, this NPC offers it, and the player can already take it, it opens on that quest’s greeting.
- Otherwise, it opens on the greeting of the first available quest.
- If the NPC has nothing left to offer, it opens on
AllDone. - If the NPC has no quests at all,
FirstNodeis used.
Two cases are handled before that resolution. During the game’s intro, every NPC except the one the tutorial is pointing at answers with a short line that depends on the stage, and the NPC being pointed at is computed by the same function the client uses to decide which prompt to show, so the two can’t drift apart. The other case is story quests that are cutscenes. If the NPC’s current offer has a scene attached, walking up to them opens no dialogue at all: the quest is accepted, the scene plays, and the reward lands as the world comes back. The server returns before it ever tells the client to open a conversation, so the client never opens something it would immediately have to close.
6. The client
6.1 Walking the graph
The client walks the graph with a simple rule. Given the outputs of the current vertex, if any of them is a response it shows them as buttons; otherwise it shows the NPC’s first line by priority; and if it’s a branch, it passes through. A line without responses waits for the player to advance, and when they do, the client tells the server which line it’s leaving.
Actions have two moments. A node’s actions are sent when the node appears on screen, which is what a line like “thanks, here’s your pay” needs. A response’s actions are held and sent once the player advances past the NPC’s reply. That ordering matters: the tool that generates story dialogue inserts an intermediate node between accepting and completing a talk-only quest, because without it the completion arrived before the acceptance and the quest stayed active forever, unpaid.
A response that checks a quest is a special case. It has two possible outcomes (done or not done) and the client doesn’t know which is right, because the server decides that by checking the objectives. So the client doesn’t follow that response’s NextNode and waits for the server to tell it where to jump.
6.2 The typewriter
Text appears letter by letter by changing MaxVisibleGraphemes, which indexes rendered graphemes. With RichText enabled, the length of the string isn’t the number of graphemes: <font color="#fff">hi</font> is 28 characters and draws 2. The first version looped over the raw length, so on a colored line the text finished appearing and the loop kept running, and beeping, 26 more times. Now the count strips the tags, counts each entity (&) as one character and then counts graphemes with utf8.graphemes, which groups an emoji or a letter with a combining accent as one [2].
If the line has a recorded voice, looked up by convention at SoundService.Dialogues.VoiceLines.<NPC>.<Node>, the speed is set so the text finishes with the audio:
If there is no voice, each letter triggers a short sound with slightly random pitch and volume, with uniform in , where and are attributes of each NPC. That way every character has a recognizable voice without anything being recorded.
The typewriter runs on its own thread and writes to a label shared by every line of the conversation. If a line started while the previous one was still appearing, the two loops fought over the label: the old one indexed into text that was no longer its own, and on screen the new line appeared cut off halfway. Worse, when it finished, the old loop re-ran its own node’s outputs, fired that node’s actions again and showed its responses over the line that had replaced it. Now every new line increments a generation counter, every thread keeps the value it started with, and it stops as soon as they differ, without writing or completing anything.
7. The server walks the graph too
7.1 The hole
The first version of the server accepted any node and any action with any payload as long as some conversation was open. Talking to the nearest villager was enough to send AdvanceObjective and CompleteQuest for any active quest and collect the reward. It’s the usual problem of trusting the client [3], and the fix was to have the server walk the same graph as the client, in parallel.
7.2 Reachable nodes
For every open conversation, the server keeps a set of reachable nodes. It starts with the entry node and grows by the same rules the client uses to show things. Showing an NPC line adds it to together with all of its responses, because they appear on screen with it. A branch is passed through and its children are processed.
With that, client messages are checked like this:
- Advancing from a node is only valid if and it’s an NPC line. If it is, its outputs are added to .
- Picking a response is only valid if and it’s a response. If it is, its actions are armed and its outputs are added.
- A jump decided by the server, such as the result of checking a quest, goes through a
jumpTofunction that adds the target to before telling the client to jump. Without it, the server’s walk would reject every step after the jump.
7.3 Armed actions
Actions are controlled with counters. For each node and each action authored on it, counts how many times it may still run. Showing a node or picking a response adds one, capped at 2, and running the action takes one away. An action of type and payload arriving from the client is accepted only if
where builds the canonical payload of the action authored on the node. The cap exists because a legitimate conversation spends each arming almost immediately, and a loop (a failed check that comes back to the same node) can arm the same action more than once. With the cap, walking a loop many times doesn’t stockpile executions.
lives in a shared module, ActionPayload, rather than being copied into client and server, because the comparison is only worth anything if both sides build the same string. Checking a quest encodes as questId|successNode|failureNode. Advancing an objective encodes as questId|objectiveId, with the amount and the island optional at the end. Since the fields are positional, naming an island forces the amount to be written even when it’s 1, instead of inventing a value that means “no amount here”.
The result is an invariant: every action the server runs corresponds to an action authored on a node the player was actually shown, with the exact payload it was authored with, and at most as many times as it was armed. The client still chooses among the legitimate options, since picking a response is exactly its job, but it can no longer make options up.
7.4 Which NPC is it
A conversation only opens if the player is within studs of the NPC (the interaction range plus a lag allowance), and a player with no HumanoidRootPart can’t talk to anyone, since otherwise having no body would be enough to skip the distance check. There is a 1.5 s cooldown between conversations.
NPCs are identified by name, and names aren’t unique: the docks have four boat managers with the same name, one per island. Looking one up by name returned whichever the table iteration hit first, which was on another island, and the distance check failed silently. That was the “I press E on the harbour master and nothing happens” report. Now the client sends the model along with the name, and the server only uses it if it’s in its NPC registry, so it’s a hint that gets verified, not something that’s trusted.
8. Content
The base dialogue for NPCs with fishing quests comes from a Python tool that reads per-NPC transcriptions in JSON and generates, in one run, the quest definitions, the base dialogue and the questline for each of them. Story dialogue comes from another tool that distinguishes two node shapes: quests with real work in them (catch something, find something), which follow the offer, accept, check and complete flow, and quests that are the conversation itself, where accepting already satisfies the only objective. Writing new content almost always means adding data.
9. Limitations
Nodes store conditions (Conditions) that aren’t evaluated yet. The conditional routing that exists lives in the entry-node resolver and in server-decided jumps, not inside the graph, so a branch that depends on state halfway through a conversation still needs a server action.
Layers merge whole nodes, and a layer can’t change just the text of a base node. The pipe-separated payload format is ad hoc: an identifier containing a pipe would break it, and nothing prevents that other than naming conventions. And the cap of two executions per armed action is a hand-picked number, enough for the loops that exist today.
10. What I took away
Server-side validation didn’t have to be a separate system with its own rules. It was enough for the server to walk the same graph as the client, with the same code for building payloads, and everything else came from comparing the two. The other thing I keep is the layering: separating generated content from hand-written content and combining them at load time is what made it safe to regenerate content without fear of losing anything.
References
- A. V. Aho, M. S. Lam, R. Sethi and J. D. Ullman. Compilers: Principles, Techniques, and Tools, 2nd ed. Addison-Wesley, 2006.
- Unicode Consortium. “Unicode Standard Annex #29: Unicode Text Segmentation”.
- G. Gambetta. “Fast-Paced Multiplayer (Part I): Client-Server Game Architecture”. gabrielgambetta.com.
This text by Mastedore. All rights reserved.