39. Dialogue Part 1
NPCs have been silent statues. You can walk up to one, bump into it, and that's the end of the interaction. In an RPG, that's a problem - NPCs are where quests come from, where lore lives, and where the player gets pointed at the next thing to do.
In this lesson we'll:
- Add a data-driven dialogue format to entity definitions
- Load all UI text from translatable JSON files
- Validate dialogue references at load time so typos surface immediately
- Wire up an interaction button that finds the nearest NPC
- Walk the player through a converastion
Data-Driven Dialogue
We could hardcode dialogue trees in Odin. We won't. Dialogue is content, not logic - it changes constantly, it needs to be translated, and writers shouldn't have to recompile to fix a typo.
The format hangs off the entity definition. Each dialogue node has a local id (to reference it from other nodes), a speaker, a text id (to look up the actual string), and a list of choices. Choices point to the next node by local id.
In entities.odin:
Dialogue_Choice :: struct {
text_id: string,
next: string,
}
Dialogue :: struct {
local_id: string,
speaker: string,
text_id: string,
choices: []Dialogue_Choice,
}
Add the dialogue array to Entity_Definition:
Entity_Definition :: struct {
// ... existing fields ...
dialogue: []Dialogue,
}
And a single field on the runtime Entity to track which node the conversation is currently on:
Entity :: struct {
// ... existing fields ...
dialogue_local_id: string,
}
Note that the dialogue definition lives on the entity definition (shared, immutable data loaded from JSON), but the cursor lives on the entity instance. Two NPCs sharing a definition can be on different lines of conversation independently.
The Marcus Definitions
We'll need two versions of Marcus - one for the town and one for the cellar. Same character, different state. The town Marcus offers the job. The cellar Marcus explains the details and starts the quest.
Why two definitions instead of one with branching? Marcus is in two different scenes, and switching scenes destroys the entities. If we want Marcus to remember whether you accepted the quest, the state needs to live somewhere persistent (like the save file or a global flag) rather than on the entity. For now, the two-definition approach gets us moving without solving that bigger problem.
data/entities/marcus_town.json:
name = "Marcus_Town"
display_name = "entity.marcus.name"
collider_radius = 0.5
movement_speed = 2
_flags = ["Noclip"]
_layer = "NPC"
dialogue = [
{
local_id = "greeting"
speaker = "entity.marcus.name",
text_id = "dialogue.marcus.greeting_morning",
choices = [
{ text_id = "next", next = "job" }
],
},
{
local_id = "job",
speaker = "entity.marcus.name",
text_id = "dialogue.marcus.greeting_job",
choices = [
{ text_id = "done" },
],
},
]
Two nodes: greeting and job. The greeting has a single "next" choice that advances to the job pitch. The job node has a "done" choice with no next, which ends the conversation.
data/entities/marcus_cellar.json is the same structure, but with more nodes for the accept/decline branch. The full file is in the project but the interesting bit is the choice that fires an event:
{
local_id = "accepted",
speaker = "entity.marcus.name",
text_id = "dialogue.marcus.cellar_accepted"
next_start = "after_accept",
events = [
{type = "Quest_Start", payload = "cellar_rats"}
],
},
The
eventsandnext_startfields aren't wired up in the code yet - they'll come in the quest system lesson. We're laying the data shape now so we don't have to revisit these JSON files later.
Text Files
Speaker names and dialogue lines reference text ids, not the strings themselves. The strings live in a separate file keyed by language.
data/text/en/dialogue.json:
{
"dialogue.marcus.greeting_morning" = "Good morning, Ara!",
"dialogue.marcus.greeting_job" = "If you need some coin, I have a job for you. Meet me in the cellar.",
"dialogue.marcus.greeting_cellar" = "Ara! Great. I need you to clear out the rats from this cellar. They are getting into the grain.",
"dialogue.marcus.job_details" = "Bring me back 5 rat tails. I'll give you a gold piece in exchange. Deal?",
"dialogue.marcus.cellar_accepted" = "Perfect. I'll be outside.",
"dialogue.marcus.cellar_declined" = "Ah, that's too bad... Speak to me if you change your mind. I'd hate for everyone to starve to death.",
"next" = "Next",
"done" = "Done",
"accept" = "Accept",
"decline" = "Decline",
}
data/text/en/entities.json:
{
"entity.marcus.name" = "Marcus"
}
The naming convention (dialogue.marcus.*, entity.marcus.name) is just for human readability. The system treats them as opaque keys. Splitting into multiple files keeps things organised - all entity names in one file, all dialogue in another. Add quests.json, items.json, ui.json etc as needed.
Generic words like "next", "done", "accept", "decline" don't need a namespace prefix because they're reused everywhere.
Loading Text
Add a flat string map to Game_State alongside a language code:
Game_State :: struct {
// ... existing fields ...
text: map[string]string,
language: string,
in_dialogue_with_index: Maybe(int),
}
In game_post_window_create, set the language and load both text files:
gs.language = "en"
// Load text
{
// Dialogue strings
{
path := fmt.tprintf("data/text/%s/dialogue.json", gs.language)
data, read_err := os.read_entire_file(path, context.allocator)
if read_err != nil {
log.errorf("No dialogue found: create %s (error: %s)", path, read_err)
}
root, parse_err := json.parse(data, .SJSON)
if parse_err != nil {
log.panicf("Failed to parse JSON: %v", parse_err)
}
for k, v in root.(json.Object) {
gs.text[k] = v.(json.String)
}
}
// Entity strings
{
path := fmt.tprintf("data/text/%s/entities.json", gs.language)
data, read_err := os.read_entire_file(path, context.allocator)
if read_err != nil {
log.errorf("No entity text found: create %s (error: %s)", path, read_err)
}
root, parse_err := json.parse(data, .SJSON)
if parse_err != nil {
log.panicf("Failed to parse JSON: %v", parse_err)
}
for k, v in root.(json.Object) {
gs.text[k] = v.(json.String)
}
}
}
The data uses context.allocator (not temp_allocator) because the strings need to outlive this procedure - they're referenced for the lifetime of the program. Both files dump their key-value pairs into the same flat map. Namespacing in the keys prevents collisions.
A missing file is a log error, not a panic. A typo in the JSON itself is a panic - if we can't parse, we can't continue.
Validation at Load
This is the part that earns its keep. Dialogue trees are a graph of string references. Without validation, a typo in next: "jbo" produces a conversation that ends mysteriously when the player hits the choice, with no error. By the time you notice, you've probably broken three other things.
Validate everything at load and log loudly. Add this after the entity definitions and text are both loaded:
// Validate Dialogue
{
for entity_def in gs.entity_definitions {
local_ids := make(map[string]bool, context.temp_allocator)
for dialogue in entity_def.dialogue {
if _, exists := local_ids[dialogue.local_id]; exists {
log.errorf(
"Duplicate local dialogue id: '%s' on '%s'",
dialogue.local_id,
entity_def.name,
)
}
local_ids[dialogue.local_id] = true
if _, exists := gs.text[dialogue.text_id]; !exists {
log.errorf("Missing text id: '%s' on '%s'", dialogue.text_id, entity_def.name)
}
if len(dialogue.choices) == 0 {
log.errorf(
"Dialogue must always have a 'choice'. Use a choice with a single option for Next/OK/Done.",
)
}
}
for dialogue in entity_def.dialogue {
for choice in dialogue.choices {
if choice.next != "" {
if _, exists := local_ids[choice.next]; !exists {
log.errorf(
"Missing local dialogue id: '%s' on '%s'",
choice.next,
entity_def.name,
)
}
}
if _, exists := gs.text[choice.text_id]; !exists {
log.errorf(
"Missing text id: '%s' on '%s' in '%s'",
choice.text_id,
entity_def.name,
dialogue.local_id,
)
}
}
}
}
}
Four checks per entity definition:
- Duplicate local ids - two nodes can't share the same name within an entity, or
nextreferences become ambiguous - Missing text ids on nodes - the dialogue line itself must exist in the text map
- Empty choices arrays - every node must have at least one choice, even if it's just "Done" to close the conversation. Forcing this keeps the runtime simpler
- Missing local id references in choices - a
nextthat points nowhere is a dead end
Note we do two passes. The first pass collects all local ids into a set. The second pass validates that choices point to ids that exist. We need the full set before we can check references, otherwise forward references would all fail.
The local_ids map uses temp_allocator because it's only needed during this validation pass. It gets freed automatically.
A future improvement: also detect unreachable nodes. A node that no choice ever points to (and isn't the first node) is dead content. Worth flagging.
Spawning Marcus
In data/scenes/flat.json, add the town Marcus to the existing spawners:
spawners = [
{pos = [-4.0, 0.5, 4.0], type = "test"},
{pos = [-5.0, 0.5, 8.0], type = "Test Enemy"},
{pos = [2, 0.5, 4], type = "Marcus_Town"}
]
Create data/scenes/cellar.json for the cellar version:
spawners = [
{pos = [2, 0.5, 4], type = "Marcus_Cellar"},
]
In scene2.odin, when spawning an entity with dialogue, set the cursor to the first node:
entity_data.position = spawner.pos
entity_data.position.y += entity_data.collider_radius
if len(entity_def.dialogue) > 0 {
entity_data.dialogue_local_id = entity_def.dialogue[0].local_id
}
This means the first time you talk to any NPC, the conversation starts at whatever the writer listed first in the JSON. Convention: put greeting first.
The Interaction Loop
Now the runtime. Pressing the select/interact button does one of two things depending on state:
- Not in dialogue: find the nearest NPC within range, start a conversation
- In dialogue: advance to the next node based on the (currently first) choice
There are technically three states if you consider "closed dialogue" separately from "selecting a choice", but the implementation collapses cleanly into two branches.
In game_update, after the player update:
MIN_INTERACT_DIST :: 2
if gs.input.selecting {
if entity_index, ok := gs.in_dialogue_with_index.?; ok {
// Already in dialogue - advance it
entity := &gs.scene.entities[entity_index]
entity_def := entity_def_by_id(entity.def_id)
if len(entity_def.dialogue) > 0 {
dialogue_index := -1
for d, i in entity_def.dialogue {
if d.local_id == entity.dialogue_local_id {
dialogue_index = i
break
}
}
// NOTE: should not be possible to have incorrect
// index due to validation pass at dialogue load
entity.dialogue_local_id = entity_def.dialogue[dialogue_index].choices[0].next
if entity.dialogue_local_id == "" {
gs.in_dialogue_with_index = nil
}
}
} else {
// Not in dialogue - find someone to talk to
closest_index := -1
closest_dist: f32 = MIN_INTERACT_DIST
for index in gs.scene.active_entities {
if player == &gs.scene.entities[index] do continue
entity := gs.scene.entities[index]
dist := rl.Vector3Length(player.position - entity.position)
if dist < closest_dist {
closest_index = index
closest_dist = dist
}
}
if closest_index >= 0 {
gs.in_dialogue_with_index = closest_index
entity := &gs.scene.entities[closest_index]
entity_def := entity_def_by_id(entity.def_id)
if len(entity_def.dialogue) > 0 && entity.dialogue_local_id == "" {
entity.dialogue_local_id = entity_def.dialogue[0].local_id
}
}
}
}
A few things worth pointing out:
Advancement is hardcoded to choice 0. This lesson sets up the data and the loop, but we're not yet rendering selectable choices the player can pick between. Every press just takes the first option. When we add a cursor and let the player choose, we replace choices[0] with choices[selected_index].
Closest NPC, not first NPC. MIN_INTERACT_DIST starts at 2 and shrinks as we find closer entities. Effectively we sort by distance and pick the nearest one within range. If you're standing between two NPCs, the one you're closer to wins, which matches player expectation.
Conversation ends when next is empty. A choice with no next field deserialises to an empty string. Empty string means end the conversation, which clears in_dialogue_with_index. Next press starts a new conversation with whoever's closest.
The validation pass is doing real work here. The comment should not be possible to have incorrect index due to validation pass is load-bearing. If validation didn't catch missing local ids, dialogue_index could stay -1 and we'd index into entity_def.dialogue[-1] and crash. Validation transforms a runtime crash into a startup log error.
Rendering the Dialogue Box
The UI side is straightforward. In ui_update, after the existing battle UI checks:
if index, ok := gs.in_dialogue_with_index.?; ok {
entity := gs.scene.entities[index]
entity_def := entity_def_by_id(entity.def_id)
dialogue_index := -1
for d, i in entity_def.dialogue {
if d.local_id == entity.dialogue_local_id {
dialogue_index = i
break
}
}
dialogue := entity_def.dialogue[dialogue_index]
fui.col()
fui.col_box(&{flags = {.FIT_Y}}); {
fui.text(gs.text[dialogue.speaker])
fui.text(gs.text[dialogue.text_id])
fui.row_box(); {
for choice in dialogue.choices {
fui.text(gs.text[choice.text_id], &{color = YELLOW})
}
}; fui.pop()
}; fui.pop()
fui.col()
}
Three text elements: speaker name, dialogue line, and a horizontal row of choice labels. Each text lookup goes through gs.text[...] to resolve the id. If you swap the language code, every string in the UI flips with no other changes.
The choices render in yellow but aren't selectable yet - they're just visual indicators of what's coming. The full choice system (cursor, navigation, picking a specific option) is the next lesson.
What Got Wired Up
Walk up to Marcus in the town. Press interact. He greets you, press again and he offers the job. Press again to close. Walk to the cellar, find the other Marcus, and he'll walk you through the quest offer.
That's a complete dialogue interaction - data-driven, translatable, validated at load, and rendered through the existing UI system. The pieces in place from earlier lessons (entity definitions from JSON, the FUI system, scene-based entity spawning) all just snap together.
Try It Yourself
- Add a second language. Copy
data/text/en/todata/text/fr/, translate a few strings, and changegs.language = "fr"to see it switch - Add a new NPC with their own dialogue tree
- What happens if you make a typo in a
nextreference? Check the log on startup
The Big Idea
Dialogue is content, not code. The moment you can write a conversation by editing JSON and adding strings to a text file - without recompiling - is the moment writers (or future-you) can iterate on the actual game.
The whole system is small. A struct, two files, a validation pass, an interaction loop, a UI block. That's it. The structure stays simple because the data carries the complexity.
And the validation pass earns its weight in gold. Every typo becomes a startup log line instead of a silent failure during play. Worth it.