RPG Conversation: Never Winter Nights Implementation Part II
In the previous article we used the How to Make an RPG game code to build a simple conversation engine like Never Winter Nights. The conversation we implemented was quite basic, in this article we’ll extend the code to deal with more advanced features.
The previous conversation graph project will be used as a base. You can get the code here.
Code Layout
The definitions for conversation graphs can get pretty long. In this project I’ve create “code/conversation”. Each conversation graph I make is in it’s own file and lives in this folder.
Graph with Conditional Replies
Let’s implement the example conversation from initial the first Never Winter Nights article.
This conversation changes the available replies depending on the social class of the hero. This means we need to add a “socialClass” tag to the hero for the conversation class to query.
In a full system it’s preferable to have the conditional checks on a node be data-driven, strongly typed affairs but here in the name of convenience we use the functions directly.
The conversation graph looks a little like this.

Here’s the conversation graph as Lua code.
1-- The Conversation_2.lua file2conversation_2 =3{4 type = "root",5 id = "root"6 condition = nil,7 children =8 {9 {10 type = "dialog",11 id = "intro_knight",12 condition = IsPlayerKnight,13 text = "Anything I can help you with sire?",14 children =15 {16 {17 type = "response",18 text = "Any news?",19 children =20 {21 {22 type = "dialog",23 text = "No, sire.",24 exit = true -- ends conversation25 }26 }27 },28 {29 type = "response",30 text = "No, thank you.",31 exit = true, -- ends conversation32 }33 }34 },35 {36 type = "dialog",37 text = "Hello. You ok?",38 children =39 {40 {41 type = "response",42 text = "Any news?",43 children =44 {45 {46 type = "dialog",47 text = "Heard one of royals has been sneaking"48 .. " out at night.",49 children =50 {51 {52 type = "response",53 text = "Interesting. Thanks.",54 exit = true55 }56 }57 }58 }59 },60 {61 type = "response",62 text = "Fine thank, just a bit busy now.",63 exit = true64 }6566 }67 }68 }69}
This conversation definition goes into it’s own Conversation_2.lua file.
Fiddling with the Game Code
Let’s start by adding a social class tag to the hero’s actor definition.
1-- The PartyMemberDefs.lua file2gPartyMemberDefs =3{4 hero =5 {6 id = "hero",7 socialClass = "knight"
And we’ll start storing the def directly in the Actor class like so.
1function Actor:Create(def)23 local growth = ShallowClone(def.statGrowth or {})45 local this =6 {7 mDef = def, -- <- this is new
Ok, that’s enough to write an IsKnight function, which we’ll use as the conditional test in our conversation graph. We’ll write this function at the top of the Conversation_2.lua file.
1-- The Conversation_2.lua file23local function IsKnight()4 local hero = gWorld.mParty.mMembers["hero"]5 return hero.mDef.socialClass == "knight"6end78conversation_2 = -- code omitted
To get this conversation to run instead of conversation_1 we need to update the trigger in the map file.
1function CreateTownMap(state)23 local id = "town"4 local townState = state.maps[id]56 local OnTalk = function(map, trigger, entity, x, y, layer)78 local dialogDef =9 {10 speakerX = x,11 speakerY = y,12 graphDef = conversation_2 -- <- now '2'13 }
And, with that, we’re ready to improve the DialogState.
Adding Conditional Checks to the DialogState
Each node in the conversation graph may have an optional condition field. When we select a node we look at all the children and only consider those with a condition function that returns true.
The magic happens in the DialogState.FilterNode function. Here’s the implementation.
1function DialogState:FilterNodes(nodeList)23 local passingNodes = {}45 for k, v in ipairs(nodeList) do6 if (v.condition == nil or7 v.condition() == true) then8 table.insert(passingNodes, v)9 end10 end1112 return passingNodes13end
Running The Conditional Conversation
When we run the code the hero is correctly recognized as a knight and the NPC speaks with the appropriate deference.


It works pretty well but note that we can’t actually exit the conversation when we get the second reply. The DialogState doesn’t support exit tags on dialog nodes. We’ll add that functionality shortly.
If we jump back into the PartyMemberDefinition table and remove the social class field, the conversation changes.


That’s working well. We can now create conversations that change depending on the player’s circumstances.
Graph with NPC Exit Tags
The previous conversation has a node with no children where the NPC says “No, sire”. This node has the exit flag set to true. This means when the node runs, we should put a message in the reply panel to the effect of “Press Space to Exit”.
The part of the graph we’re dealing with is shown below.

Let’s first add the message, as mFinishedText to the constructor of the DialogState.
1function DialogState:Create(dialogDef)23 local this =4 {5 -- code omitted6 mNoReplyText = "Press Space to Continue",7 mFinishedText = "Press Space to Exit",89-- code omitted
Now let’s update the Render function to display it when appropriate.
1function DialogState:Render(renderer)23 -- code omitted45 if self.mAreThereReplies then6 -- code omitted7 else8 -- code omitted910 local message = self.mNoReplyText1112 if self.mCurrentNode.exit then13 message = self.mFinishedText14 end1516 renderer:DrawText2d(x, y, message, self.mTextColor)17 end18end
Finally let’s update the HandleInput function so we can gracefully exit the conversation.
1function DialogState:HandleInput()2 -- only accept input when no transition is running.3 if self.mMode ~= eMode.Running then4 return5 end67 local spacePressed = Keyboard.JustPressed(KEY_SPACE)89 if self.mCurrentNode.exit and spacePressed then10 self:InitExitTransition()11 self.mMode = eMode.Exiting12 return13 elseif self.mAreThereReplies then14 self.mReplyMenu:HandleInput()15 elseif spacePressed then16 -- Deal with chained nodes17 end18end
When space is pressed we check the exit flag for the current node and if it’s true we exit. The exit flag takes precedence over any replies (if they exist, which generally they shouldn’t!).
Now if we run the Conversation_2 project, as a knight, the conversation will end after asking the NPC for news.
Below you can see the exit node prompt working correctly.

Graph with Chained NPC Dialog
Sometimes an NPC has a lot to say. To support a longish monologue we can have the child of a dialog node be another dialog node, instead of a reply.
You can see this visualized below.

Let’s define a new conversation graph in a new file Conversation_3.lua.
12local text1 = "And he turns to me, and says \"How the devil did you "3 .. "get in here?\", and it was then I knew it was time to scarper."4local text2 = "So I pointed over his shoulder shouted \"SHE-BEAR\" and was"5 .. " out of the window and down the alleyway in a flea's second."6local text3 = "Course, I didn't leave empty handed, on no, but as to what "7 .. "I took; that's a secret."89conversation_3 =10{11 type = "root",12 id = "root",13 condition = nil,14 children =15 {16 {17 type = "dialog",18 text = text1,19 children =20 {21 {22 type = "dialog",23 text = text2,24 children =25 {26 {27 type = "dialog",28 text = text3,29 children =30 {31 {32 type = "dialog",33 text = "Anyway, must dash.",34 exit = true35 }36 }37 }38 }39 }40 }41 },42 }43}
To make the graph definition a little more readable I’ve predeclared the text at the top. You can see it’s just NPC dialog, after NPC dialog until it reaches the final exit node. This conversation file has been added to the project’s manifest and dependancies files which means we can update the trigger on the map to load it.
1-- In map_town.lua2 local OnTalk = function(map, trigger, entity, x, y, layer)34 local dialogDef =5 {6 speakerX = x,7 speakerY = y,8 graphDef = conversation_3 -- we've changed this from 2 to 39 }
If you go and speak to the major the conversation will be displayed but the reply panels doesn’t look right: it shows the NPC’s second dialog text. We need to adjust the code to make it work correctly. We’re going to add an extra function that filters out any nodes that aren’t a response when we call MoveToNode.
1-- In DialogState.lua2function DialogState:FilterForResponses(nodeList)34 local passingNodes = {}56 for k, v in ipairs(nodeList) do7 if v.type == "response" then8 table.insert(passingNodes, v)9 end10 end1112 return passingNodes13end1415function DialogState:MoveToNode(def)16 self.mCurrentNode = def1718 local responses = def.children or {}1920 -- Filter the responses21 responses = self:FilterForResponses(responses)22 responses = self:FilterNodes(responses)
When we create the list of replies we ignore any nodes that don’t have a type “response” and remove all nodes where the condition fails. If we run the game now we get something like below.

But pressing Space does nothing! We need to update the input handling code to deal with chained dialog nodes.
1function DialogState:MoveToChainedNode(node)23 if node.exit then4 self:InitExitTransition()5 self.mMode = eMode.Exiting6 return7 end8 -- Deal with chained nodes9 local nodes = node.children or {}10 -- Filter the nodes11 nodes = self:FilterNodes(nodes)12 local nodeToChangeTo = nil13 for k, v in ipairs(nodes) do14 if v.type == "dialog" then15 nodeToChangeTo = v16 break17 end18 end1920 if nodeToChangeTo ~= nil then21 self:MoveToNode(nodeToChangeTo)22 else23 print("This conversation graph is broken!")24 end2526end2728function DialogState:HandleInput()29 -- code omitted3031 if self.mCurrentNode.exit and spacePressed then32 -- code omitted33 elseif self.mAreThereReplies then34 -- code omitted35 elseif spacePressed then36 self:MoveToChainedNode(self.mCurrentNode)37 end38end
When there are no replies to choose from and the player presses Space, we call MoveToChainedNode. MoveToChainNode checks the exit flag for the current node and if it’s true, it sets off the exit transition. Otherwise it filters the child nodes and iterates through them, taking the first “dialog” node. It then moves to this node. There shouldn’t be a situation where it fails to find a valid node, so if we don’t find one, we print a, not very helpful, error message to the console.
Here’s the end of the monologue showing that it works nicely.

The conversation graph for chained dialogs gets pretty deep and unwieldy but in a real RPG there’d be tools to hide the complexity.
Graph with Loops
Loops in the conversation graph let the player revisit the same topics in the same conversation. You might be playing a detective role and ask a suspect “I have some questions…” then you have a choice to topics to inquire about. After the witness replies to each topic you return to the questions node. This kind of structure is visualised below.

This is a brand new conversation, therefore it needs a new file; Converation_4.lua.
1-- Conversation_4.lua2conversation_4 =3{4 type = "root",5 id = "root",6 condition = nil,7 children =8 {9 {10 type = "dialog",11 text = "Can I help you detective?",12 id = "help",13 children =14 {15 {16 type = "response",17 text = "I have some questions.",18 children =19 {20 {21 type = "dialog",22 text = "What do you need to know?",23 id = "questions",24 children =25 {26 {27 type = "response",28 text =29 "Where were you last night, around seven?",30 children =31 {32 {33 type = "dialog",34 text = "Here. "35 .. "You can ask my customers.",36 children =37 {38 {39 type = "response",40 text = "Maybe I will.",41 jump = "questions"42 }43 }44 }45 },46 },47 {48 type = "response",49 text = "You ever been to Salem Hotel?",50 children =51 {52 {53 type = "dialog",54 text = "Can't say that I have.",55 children =56 {57 {58 type = "response",59 text = "Ok then.",60 jump = "questions"61 }62 }63 }64 }65 },66 {67 type = "response",68 text = "Do you know a Dr. Suther?",69 children =70 {71 {72 type = "dialog",73 text =74 "Only that he's the local Doc.",75 children =76 {77 {78 type = "response",79 text =80 "I have other questions.",81 jump = "questions"82 }83 }84 }85 }86 },87 {88 type = "response",89 text = "That's all for now",90 jump = "help"91 }92 }93 }94 }95 },96 {97 type = "response",98 text = "Maybe later.",99 exit = true100 }101 }102 },103 }104}105
This conversation let’s you interrogate a suspect by asking multiple questions. The loop back helps the player ask one question after another.
Adding the Conversation Trigger to the Game
Let’s hook it up to the trigger so we can start adding the loop back code.
1-- In map_town.lua2 local OnTalk = function(map, trigger, entity, x, y, layer)34 local dialogDef =5 {6 speakerX = x,7 speakerY = y,8 graphDef = conversation_4 -- from 3 to 49 }
If we run the game now we’ll be able to travel a couple of nodes down in the graph. But if we hit a node with a jump field we’ll crash because it has no children.

We need to alter the code so the jump flag is used to jump the current node back to a previous one. Rather than do this on-the-fly while navigating the graph, we’ll do it as preprocess step. We’ll search out the jump fields and replace them with children tables that reference an existing node. This means we needn’t update our conversation running code and we’ll be able to catch errors up front.
1-- In DialogState.lua2function DialogState:Create(dialogDef)34 -- code omitted56 setmetatable(this, self)7 this:ProcessGraph(this.mGraph)8 return this9end
At the end of the dialog state, after the conversation graph has been assigned, we do a process step. This process step gets called when we start a conversation but if we were a little more clever it could be done once on, or before, game load (and therefore catch errors even earlier).
1function DialogState:ProcessGraph(root)23 if root.processed == true then4 return5 end6 self.mIdToNode = self:CreateIdToNodeTable(root, nodes)7 self:AddLoops(root)89 root.processed = true10end
Here we process the graph using two functions and mark the graph as processed so we don’t process it more than once!
Creating an Id to Node Table
First we iterate through the entire graph (which at this point in guaranteed to be a tree) and store all nodes with an id in a lookup table. We do this with a function called CreateIdToNodeTable.
1function DialogState:CreateIdToNodeTable(root, nodes)2 local t = nodes or {}34 if root ~= nil then56 if root.id then78 if nodes[root.id] then9 local warning = string.format(10 "Root id [%s] appears more than once!",11 root.id)12 print(warning)13 end1415 nodes[root.id] = root16 end1718 for k, v in ipairs(root.children or {}) do19 self:CreateIdToNodeTable(v, nodes)20 end2122 end2324 return t25end
If we run this function on our conversation it creates a table like the below.
1{2 ["root"] = { .. the root node ... },3 ["help"] = { .. the first node ... },4 ["questions"] = { .. the question node ... }5}
With this table of named nodes we can iterate through the graph again, remove the jumps and replace them with references. We’ll call this function AddLoops.
Adding Loops to the Conversation
Let’s implement the AddLoop function.
1-- In DialogState.lua2function DialogState:AddLoops(node)34 if not node then5 return6 end78 if node.jump then9 -- Store the id locally and remove it from the node10 local id = node.jump11 node.jump = nil1213 -- Get the node the id points to14 local jumpNode = self.mIdToNode[id]1516 -- Some error checking17 if not jumpNode then18 local error = string.format(19 "Error: Couldn't find node with id [%s]", id)20 print(error)21 return22 end2324 if node.children then25 local warning = string.format(26 "Warning jump to [%s] destroys children in node.", id)27 print(warning)28 end2930 -- Add loop31 node.children = { jumpNode }3233 return -- we can skip this node's children34 end3536 for k, v in ipairs(node.children or {}) do37 self:AddLoops(v)38 end3940end
Here we can see AddLoops find the nodes with a jump field. It replaces the node’s children with a one element table pointing to the jump node.
This code now works with conversation loops, you can see the results below.
Graph with Local Variables
The flow of a conversation can change depending on what you say. Insult someone and they’re far less likely to continue to be pleasant to you. Flatter someone and perhaps they’ll be more likely to accidentally let you in on a confidential piece of information. Or, more prosaically, we can trim off conversation branches that already been chosen.
As an example conversation we’re going to create two questions you can ask an NPC. Each question you ask is removed from the replies once asked. Once all questions are asked we’ll reveal a new reply that let’s you disengage from the conversation.
This is conversation graph example number five. The code is shown below.
1-- Conversation_5.lua2function HasFlag(id)3 return function(flags)45 if type(id) == "table" then6 for k, v in ipairs(id) do7 if flags[v] == false then8 return false9 end10 end11 return true12 else13 return flags[id] == true14 end15 end16end1718function NoFlag(id)19 return function(flags)20 return not flags[id] or flags[id] == false21 end22end2324function SetFlag(id)25 return function(flags)26 flags[id] = true27 end28end2930conversation_5 =31{32 type = "root",33 id = "root",34 condition = nil,35 variables =36 {37 -- Flags to track whats been asked during this conversation38 ["clever"] = false,39 ["black_hand"] = false,40 },41 children =42 {43 {44 type = "dialog",45 id = "questions",46 text = "Yes?",47 children =48 {49 {50 type = "response",51 text = "Do you know where the leader of the black hand is?",52 condition = NoFlag("black_hand"),53 on_choose = SetFlag("black_hand"),54 children =55 {56 {57 type = "dialog",58 text = "Never heard of them.",59 children =60 {61 {62 type = "response",63 text = "I have more questions.",64 jump = "questions"65 }66 }67 }68 }69 },70 {71 type = "response",72 text = "Is there are a cleverman in this town?",73 condition = NoFlag("clever"),74 on_choose = SetFlag("clever"),75 children =76 {77 {78 type = "dialog",79 text = "Yeh, end of main street. You'll find him "80 .. " in the Hanged Man's Tavern",81 children =82 {83 {84 type = "response",85 text = "Thanks.",86 jump = "questions"87 }88 }89 }90 }91 },92 {93 type = "response",94 text = "Thanks for the help. Bye",95 condition = HasFlag({"clever", "black_hand"}),96 exit = true97 }98 }99 }100 }101}
Couple of things to notice about this conversation. The first point to note is we’ve predefined some functions that we use in the graph. All these functions are used to create functions that query and manipulate the local variable table.
- HasFlag - Creates a query that check if a given flag exists in the local variable table and if it’s set to true. (Works on a table of flags as well as single flag)
- NoFlag - Creates a query to check if a single flag is not present (or is false) in the local variables table.
- SetFlag - Set a local variable to true. It creates the variable if it doesn’t exist.
Second thing to notice is we have a new function field; on_choose. This function executes when the player chooses a reply.
The root node now has a table called variables. I’ve filled this out in the example but for our conversation it’s not necessary. This where the variables for the conversation are stored while it’s executing.
Data Driven
The conversation condition and on_choose fields make our simple text files contain code. This means we’ve moved from a simple data representation to code! The condition field doesn’t take a simple type; it takes a function. As a rule of thumb; code in game assets is best avoided because code can do nearly anything.
If you constrain what a game asset can do you reduce the bug surface area.
It’s not hard to return to data driven conversations. Instead of
1condition = NoFlag("clever"),
We can rewrite this so only a set list of functions may be used. Such as:
1-- somewhere global-ish2conversation_whitelist =3{4 ["no_flag"] = NoFlag,5 ["has_flag"] = HasFlag,6 ["set_flag"] = SetFlag7}89-- a little later in the code10condition = { "no_flag", "clever" }
Then when parsing the graph we know the limited number of functions it can use and we can look them and patch them in.
Add Conversation Variables to the Code base
Let’s update the DialogState to handle this new type of variable-enabled conversation. As with the other examples we first update the map trigger.
1-- In map_town.lua2 local OnTalk = function(map, trigger, entity, x, y, layer)34 local dialogDef =5 {6 speakerX = x,7 speakerY = y,8 graphDef = conversation_59 }
Everytime the player restarts the conversation we reset the variables to the their original starting values. This means we need to store the starting values. We store the starting values in a table called variables_original. We’ll do this operation in the ProcessGraph function as shown below.
1function DialogState:ProcessGraph(root)23 -- Reset variables to original values45 root.variables = root.variables or {}67 if root.variables_original == nil then8 local clone = ShallowClone(root.variables)9 root.variables_original = clone10 else11 local clone = ShallowClone(root.variables_original)12 root.variables = clone13 end1415 if root.processed == true then16 -- code omitted
This says, if there’s not a back up of the variable table make one. If there is a backup then set the current variable table to those values.
Conversation variables now reset each time we enter the conversation. Next let’s update the condition tests so they’re passed the variables table.
1function DialogState:FilterNodes(nodeList)23 local passingNodes = {}4 local variables = self.mGraph.variables or {}56 for k, v in ipairs(nodeList) do7 if v.condition == nil or8 v.condition(variables) == true9 then10 table.insert(passingNodes, v)11 end12 end1314 return passingNodes15end
Here we get the variable table from the conversation and pass it through to all node condition functions. This makes our nodes appear correctly. If we run the conversation now, the exit option will not be present.

Implementing the OnChoose Event
When a player chooses a reply some code may be run. We need to check for the on_choose field and call it, if it exists.
1function DialogState:OnReplySelected(index, item)23 -- First deal with exit commands4 if item.exit then5 self:InitExitTransition()6 self.mMode = eMode.Exiting7 return8 end910 if item.on_choose then11 local variables = self.mGraph.variables or {}12 item.on_choose(variables)13 end
That’s it! Run the code and the variables will be used to drive the flow of the conversation. You can see it working correctly in the video below.
That’s as far as we’ll take this conversation system but even now it’s very powerful and usable.
Neat Things to do with Local Variables
- Add a
once_per_conversationtag to a node. (Using local variables) - Add a
once_evertag to a node. (Using persistent local variables) - Saving out conversation state and loading it in
Final Words
It’s worth considering the goals of the Western RPG creators when they made this type of system. What were their goals from a player interaction point of view and from a game development point of view. Do their goals align with yours?
This is a powerful conversation system. In a full game it would be best to break out the functions like ProcessGraph into a ConversationGraph class. It’d also be worth adding as much as error checking as possible! Writing a full game’s conversation by hand would get old very fast, therefore sometime spent on tooling would be well worth it!