40. Dialogue Part 2
Since last lesson, talking to an NPC has been a single linear thread. Press the interact button and the conversation walks through one node after another, no branching, no consequences, no record of what happened. The dialogue state lives directly on the entity as a single dialogue_local_id string, and game_update mutates it inline.
That works for "press A to read the next line", but it doesn't work for anything resembling a quest. We need the player to be able to choose something, and for that choice to do something - start a quest, change what the NPC says next time, fire an event into the wider game.
In this lesson we'll:
- Add
next_startandeventsto theDialoguestruct so dialogue nodes can change the NPC's starting node and emit events - Introduce three new event types:
Dialogue_Initiate,Dialogue_Choice_Select, andQuest_Start - Move dialogue state from per-entity to the global
Game_State - Load quest definitions from
data/quests/*.json - Wire up the UI so each choice is its own focusable element
- Set up Marcus in the cellar to give a real quest with a real reward
By the end, you can walk up to Marcus, accept his rat-killing quest, and the game knows you've accepted it. Decline him and he'll say something different next time you talk to him. The systems are talking to each other through events instead of through inline mutation.
Dialogue Struct Changes
The old Dialogue struct had four fields: a local id, a speaker, a text id, and a list of choices. Add two more:
Dialogue :: struct {
local_id: string,
speaker: string,
text_id: string,
choices: []Dialogue_Choice,
// set the local start of dialogue for the entity
next_start: string,
events: []Event,
}
next_start overrides where this entity's dialogue starts on the next interaction. Once the player picks a choice that sets next_start = "job", Marcus will skip his initial greeting and jump straight to the job node every time the player talks to him from then on. This is the cheapest possible "remember the conversation" mechanism - no flags, no state machines, just a string per entity.
events is a list of events fired when the dialogue node is entered. This is the hook for quest starts, item rewards, scene changes, whatever. The dialogue system doesn't need to know what these events do - it just emits them and lets the relevant systems handle them.
The entity itself also needs renaming. dialogue_local_id (the current cursor) becomes dialogue_local_id_start (the starting cursor for this entity's next conversation):
Entity :: struct {
// ... existing fields ...
dialogue_local_id_start: string,
}
The distinction matters. The cursor during a conversation now lives on Game_State, not the entity. The entity only remembers where to begin next time.
Dialogue State on Game_State
The old code tracked dialogue with a single field:
in_dialogue_with_index: Maybe(int),
A Maybe(int) pointing into the scene's entity array. Replace it with a small cluster of fields that describe the active conversation:
dialogue_open: bool,
dialogue: Dialogue,
dialogue_entity_handle: Entity_Handle,
dialogue_entity_def_id: int,
dialogue_open is the gate that the rest of the game uses to know whether to render the dialogue UI and block interaction input. dialogue is the currently-displayed node - a value copy, not a pointer, because dialogue nodes are tiny and copying them avoids lifetime concerns. dialogue_entity_handle and dialogue_entity_def_id together let us find the entity (for writing dialogue_local_id_start back) and its definition (for looking up nodes by id).
Why a handle and a def id? The handle lives in scene memory and points to the runtime entity. The def id points to the static definition that holds the full dialogue array. Both are needed: one to mutate state, one to read content.
The Three New Event Types
Event_Type :: enum {
// ... existing ...
Dialogue_Choice_Select,
Dialogue_Initiate,
Quest_Start,
}
Two payloads, plus we lean on a plain string payload for Quest_Start:
Event_Payload_Dialogue_Choice_Select :: struct {
dialogue: Dialogue,
index: int,
}
Event_Payload_Dialogue_Initiate :: struct {
entity_handle: Entity_Handle,
entity_def_id: int,
}
Event_Payload :: union {
// ... existing ...
Event_Payload_Dialogue_Choice_Select,
Event_Payload_Dialogue_Initiate,
string,
}
Dialogue_Initiate fires when the player walks up to an NPC and presses interact. Dialogue_Choice_Select fires when the player picks a choice. Quest_Start fires when a dialogue node's events array contains it.
Adding string to the payload union is a deliberate shortcut. Quest starts only need to carry a quest name. Wrapping that in a one-field struct would be more correct but adds noise without adding clarity. If quest events grow more fields later, we'll promote it.
The Dialogue_Initiate Handler
The handler is where the entity's stored dialogue_local_id_start gets resolved into an actual dialogue node:
events_subscribe(
&gs.events,
.Dialogue_Initiate,
proc(event: Event) {
gs := cast(^Game_State)context.user_ptr
payload := event.payload.(Event_Payload_Dialogue_Initiate)
entity := entity_get(gs.scene, payload.entity_handle)
entity_def := entity_def_by_id(payload.entity_def_id)
gs.dialogue_entity_handle = payload.entity_handle
gs.dialogue_entity_def_id = payload.entity_def_id
gs.dialogue_open = true
if entity.dialogue_local_id_start == "" {
gs.dialogue = entity_def.dialogue[0]
} else {
index, node_found := dialogue_node_find_index(
entity_def.dialogue,
entity.dialogue_local_id_start,
)
if !node_found {
log.errorf(
"Could not find dialogue with local_id: '%s'",
entity.dialogue_local_id_start,
)
index = 0 // fallback to index 0
}
gs.dialogue = entity_def.dialogue[index]
}
},
)
The empty string case is the "first time talking to this NPC" path - start at node 0. Otherwise, look up the node by id. If the id doesn't exist in the dialogue array (data error), log and fall back to node 0 rather than crashing. This is the kind of defensive fallback that's cheap to write and saves you hours when a JSON typo would otherwise hard-crash a release build.
dialogue_node_find_index is a small helper added to entities.odin:
dialogue_node_find_index :: proc(
entity_dialogue: []Dialogue,
key: string,
) -> (
index: int,
ok: bool,
) {
for dialogue, i in entity_dialogue {
if dialogue.local_id == key {
return i, true
}
}
return -1, false
}
Linear scan is fine. Entities have a handful of dialogue nodes each, and this only runs on interaction, not per frame.
The Dialogue_Choice_Select Handler
This is where most of the new logic lives. When the player clicks a choice, four things can happen:
- The choice has a
next_start- update the entity's starting cursor - The dialogue terminates (no choices, or
nextis empty) - close the UI - The dialogue advances - look up the next node and set it as current
- The advanced-to node has events - emit them
events_subscribe(
&gs.events,
.Dialogue_Choice_Select,
proc(event: Event) {
gs := cast(^Game_State)context.user_ptr
payload := event.payload.(Event_Payload_Dialogue_Choice_Select)
entity := entity_get(gs.scene, gs.dialogue_entity_handle)
if payload.dialogue.next_start != "" {
entity.dialogue_local_id_start = payload.dialogue.next_start
}
// terminate
if len(payload.dialogue.choices) == 0 ||
payload.dialogue.choices[payload.index].next == "" {
gs.dialogue_open = false
gs.dialogue = {}
return
}
choice := payload.dialogue.choices[payload.index]
entity_def := entity_def_by_id(gs.dialogue_entity_def_id)
index, node_found := dialogue_node_find_index(entity_def.dialogue, choice.next)
if !node_found {
log.errorf(
"Could not find dialogue with local_id: '%s' from: %v",
choice.next,
entity_def.dialogue[payload.index].choices,
)
index = 0 // fallback to index 0
}
gs.dialogue = entity_def.dialogue[index]
for event in gs.dialogue.events {
emit(event)
}
},
)
The next_start write happens before the termination check on purpose. A dialogue node that closes the conversation can still update where the NPC will start next time. That's how Marcus's "Accept" choice both ends the current conversation and changes his greeting from then on.
The event emission loop at the bottom is the integration point with every other system. The dialogue system never imports the quest system, the inventory system, or anything else. It just walks the events array and fires. Quest starts, item grants, scene changes - they're all the same shape from here.
The Quest_Start Handler and the Quest System
The quest system is barely a system yet. Just an enum and a struct:
Quest_State :: enum {
Not_Started,
In_Progress,
Completed,
// for example:
Failed, // failed - can restart
Failed_Forever, // failed, can't restart
Invalidated, // something player did makes this quest invalid
}
Quest :: struct {
state: Quest_State,
}
The Failed, Failed_Forever, and Invalidated states are placeholders - they aren't reachable yet but they're sketched in to remind us what shape this needs to grow into. A quest that you've irreversibly locked yourself out of is meaningfully different from one you can retry, and that distinction wants to live in the type system, not in scattered booleans.
Quests are stored on Game_State as a map keyed by quest name:
Game_State :: struct {
// ... existing ...
quests: map[string]Quest,
}
The handler is six lines:
events_subscribe(&gs.events, .Quest_Start, proc(event: Event) {
gs := cast(^Game_State)context.user_ptr
payload := event.payload.(string)
if quest, ok := &gs.quests[payload]; ok {
quest.state = .In_Progress
log.infof("Quest started: '%s'", payload)
} else {
log.errorf("Tried to start quest that can't be found: '%s'", payload)
}
})
Note the & on the map lookup - we want a pointer to the value so the state mutation sticks. Without it, you'd be mutating a copy and the map would never change. This is the kind of thing the compiler can't catch and you only notice because the quest never actually starts.
Loading Quests From Disk
Quests live in data/quests/*.json. Each file is one quest. The directory load follows the same pattern as the entity and scene loaders:
{
dir, dir_err := os.open("data/quests")
if dir_err != nil {
log.errorf("%v", dir_err)
}
defer os.close(dir)
file_infos, read_dir_err := os.read_dir(dir, 0, context.temp_allocator)
if read_dir_err != nil {
log.errorf("%v", read_dir_err)
}
for file_info in file_infos {
data, read_err := os.read_entire_file(file_info.fullpath, context.temp_allocator)
if read_err != nil {
log.errorf("%s: %v", file_info.fullpath, read_err)
}
quest: Quest
json_err := json.unmarshal(data, &quest, .SJSON)
if json_err != nil {
log.errorf("Failed to parse JSON: %v", json_err)
}
name := file_info.name
name = strings.trim_suffix(name, ".json")
name = strings.clone(name)
gs.quests[name] = quest
}
}
The filename (minus .json) becomes the quest key. strings.clone is important here because file_info.name is backed by the temp allocator - without the clone, the key string would point at memory that gets reused on the next temp reset. Same trick we used for scene names.
The first quest is data/quests/cellar_rats.json:
display_name_id = "quests.name.cellar_rats"
rewards = [
{item = "potion", count = 3}
]
Neither display_name_id nor rewards actually exist on the Quest struct yet, so they're silently ignored on unmarshal. That's deliberate. The data format is being sketched out ahead of the systems that consume it, so quest content can be authored now and the runtime can catch up later. When rewards does get added to Quest, every existing quest file will pick up the field for free.
Quest text loads alongside the dialogue text, into the same gs.text map:
// Quest text
{
path := fmt.tprintf("data/text/%s/quests.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)", gs.language, 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)
}
}
One text map, multiple source files. Dialogue text and quest text share the lookup namespace because nothing in the rendering layer cares where a string came from - it just resolves an id.
The Interaction Path
The old game_update had a chunk of code that handled both "open a dialogue" and "advance through a dialogue" inline. It mutated entity.dialogue_local_id directly, walked the dialogue array, and managed in_dialogue_with_index itself. About 60 lines of branching logic.
With the event handlers doing the heavy lifting, the input path collapses to "find the closest NPC and emit Dialogue_Initiate":
if gs.input.selecting && !gs.dialogue_open {
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 {
entity := gs.scene.entities[closest_index]
emit(
Event {
type = .Dialogue_Initiate,
payload = Event_Payload_Dialogue_Initiate {
entity_def_id = entity.def_id,
entity_handle = entity.handle,
},
},
)
}
}
The !gs.dialogue_open guard is what prevents interact-spam from opening a second dialogue on top of the first. Choice advancement is now the UI's job, not the interact button's.
That's the architectural payoff. The interact key has one responsibility: start a conversation. Advancing through it is driven by the UI clicking choices, which emits choice events, which the handler processes. Each piece does one thing.
The Dialogue UI
The old UI rendered choices as a single row of yellow text with no interactivity - they were displayed but you couldn't click them. Advancement was driven by the interact button and always took choices[0].next. So "Accept" and "Decline" both looked clickable but the interact button just picked whichever was first.
The new UI makes each choice a focusable, hoverable, clickable element:
if gs.dialogue_open {
fui.col()
fui.col_box(&{flags = {.FIT_Y}}); {
fui.text(gs.text[gs.dialogue.speaker])
fui.text(gs.text[gs.dialogue.text_id])
fui.row_box(&{flags = {.FIT_X}, gap = 24}); {
choices := gs.dialogue.choices
if len(gs.dialogue.choices) == 0 {
choices = []Dialogue_Choice{{text_id = "done"}}
}
for choice, i in choices {
color := WHITE
s := fui.col_box(&{flags = {.FIT_X, .FOCUSABLE}}); {
// focus on first choice
if i == 0 && !fui.has_focus() do fui.set_focus(s.handle)
if s.is_hovered do color = YELLOW
fui.text(gs.text[choice.text_id], &{color = color})
if s.is_hovered && s.is_pressed {
emit(
Event {
type = .Dialogue_Choice_Select,
payload = Event_Payload_Dialogue_Choice_Select {
dialogue = gs.dialogue,
index = i,
},
debug_message = fmt.tprintf(
"%s: %s",
"Dialogue choice selected",
gs.text[choice.text_id],
),
debug_level = .Debug,
},
)
}
}; fui.pop()
}
}; fui.pop()
}; fui.pop()
}
A few things worth pulling out:
The empty-choices fallback synthesizes a single "done" choice. Some dialogue nodes are pure read-and-dismiss with no branching, and rather than special-casing them in the data, the UI fabricates a Done button. The author writes a node with no choices, the UI shows Done, the player clicks it, and the choice handler hits the termination path and closes the dialogue.
The i == 0 && !fui.has_focus() check auto-focuses the first choice when the dialogue opens. Without it, the dialogue opens with nothing selected and the player has to mouse-hover or tab to interact. With it, the first option is highlighted immediately and keyboard/gamepad players can confirm without touching the mouse.
has_focus is a new one-liner added to FUI:
has_focus :: proc() -> bool {
return g_state.focus != nil
}
The debug_message on the emitted event is a small luxury that pays off later. When dialogue is misbehaving, the event log shows you exactly which choice was clicked and what text it had. That's the difference between "dialogue is broken" and "the third choice on Marcus's job node is firing the wrong event".
Putting It Together: Marcus In The Cellar
To actually exercise all of this, Marcus gets a second entity definition (Marcus_Cellar) and a spawn in the flat scene:
spawners = [
{pos = [2, 0.5, 4], type = "Marcus_Town"}
{pos = [-2, 0.5, 4], type = "Marcus_Cellar"}
]
The cellar door now has an explicit position too, since it's an interactable entity rather than a scene marker:
doors = [
{pos = [-5, 0.5, -1.4], name = "CellarDoor", pair = "basement:Exit"}
]
The two test spawners - test and Test Enemy - get removed. They were placeholder scaffolding from earlier lessons, and now that there's real content to interact with, they're noise.
Marcus's dialogue gets a next_start on the relevant node so that once the player has reached the "ask about the job" branch, that becomes his starting point on the next conversation:
{
// ...existing choices...
next_start = "job"
}
And the text file gets two new lines for what Marcus says after the player has made a decision:
"dialogue.marcus.after_decline" = "Changed your mind?"
"dialogue.marcus.after_accept" = "How goes the rat hunt? Remember, I need 5 tails for a gold piece."
These hang off the job node depending on what the player did. The decline path leaves the door open. The accept path acknowledges what's going on without re-pitching the quest from scratch.
Summary
The dialogue system used to be a state machine living on the entity, driven by the interact button mutating a single string. It now sits on Game_State and is driven by two events: Dialogue_Initiate to open a conversation and Dialogue_Choice_Select to advance one.
Each dialogue node can carry events, which the choice handler emits when the node is entered. That's the seam quests slot into - a node fires Quest_Start, the quest handler flips the quest state to In_Progress, and nothing in the dialogue system needs to know that quests exist.
next_start gives entities a one-string memory of where to begin next time, which is enough to handle "you already accepted this" and "you already declined this" without a full dialogue flag system.
The UI now renders each choice as a focusable element with hover, click, and auto-focus. Empty-choice nodes synthesize a Done button so the data stays clean.
The next layer up - quest progression, objective tracking, reward granting - will hang off this same event-driven structure. The dialogue system is done. The quest system is sketched. The bridge between them is the events array on a dialogue node, which is exactly where it belongs.