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.
-- The Conversation_2.lua file
conversation_2 =
{
type = "root",
id = "root"
condition = nil,
children =
{
{
type = "dialog",
id = "intro_knight",
condition = IsPlayerKnight,
text = "Anything I can help you with sire?",
children =
{
{
type = "response",
text = "Any news?",
children =
{
{
type = "dialog",
text = "No, sire.",
exit = true -- ends conversation
}
}
},
{
type = "response",
text = "No, thank you.",
exit = true, -- ends conversation
}
}
},
{
type = "dialog",
text = "Hello. You ok?",
children =
{
{
type = "response",
text = "Any news?",
children =
{
{
type = "dialog",
text = "Heard one of royals has been sneaking"
.. " out at night.",
children =
{
{
type = "response",
text = "Interesting. Thanks.",
exit = true
}
}
}
}
},
{
type = "response",
text = "Fine thank, just a bit busy now.",
exit = true
}
}
}
}
}
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.
-- The PartyMemberDefs.lua file
gPartyMemberDefs =
{
hero =
{
id = "hero",
socialClass = "knight"
And we’ll start storing the def directly in the Actor
class like so.
function Actor:Create(def)
local growth = ShallowClone(def.statGrowth or {})
local this =
{
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.
-- The Conversation_2.lua file
local function IsKnight()
local hero = gWorld.mParty.mMembers["hero"]
return hero.mDef.socialClass == "knight"
end
conversation_2 = -- code omitted
To get this conversation to run instead of conversation_1 we need to update the trigger in the map file.
function CreateTownMap(state)
local id = "town"
local townState = state.maps[id]
local OnTalk = function(map, trigger, entity, x, y, layer)
local dialogDef =
{
speakerX = x,
speakerY = y,
graphDef = conversation_2 -- <- now '2'
}
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.
function DialogState:FilterNodes(nodeList)
local passingNodes = {}
for k, v in ipairs(nodeList) do
if (v.condition == nil or
v.condition() == true) then
table.insert(passingNodes, v)
end
end
return passingNodes
end
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
.
function DialogState:Create(dialogDef)
local this =
{
-- code omitted
mNoReplyText = "Press Space to Continue",
mFinishedText = "Press Space to Exit",
-- code omitted
Now let’s update the Render
function to display it when appropriate.
function DialogState:Render(renderer)
-- code omitted
if self.mAreThereReplies then
-- code omitted
else
-- code omitted
local message = self.mNoReplyText
if self.mCurrentNode.exit then
message = self.mFinishedText
end
renderer:DrawText2d(x, y, message, self.mTextColor)
end
end
Finally let’s update the HandleInput
function so we can gracefully exit the conversation.
function DialogState:HandleInput()
-- only accept input when no transition is running.
if self.mMode ~= eMode.Running then
return
end
local spacePressed = Keyboard.JustPressed(KEY_SPACE)
if self.mCurrentNode.exit and spacePressed then
self:InitExitTransition()
self.mMode = eMode.Exiting
return
elseif self.mAreThereReplies then
self.mReplyMenu:HandleInput()
elseif spacePressed then
-- Deal with chained nodes
end
end
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.
local text1 = "And he turns to me, and says \"How the devil did you "
.. "get in here?\", and it was then I knew it was time to scarper."
local text2 = "So I pointed over his shoulder shouted \"SHE-BEAR\" and was"
.. " out of the window and down the alleyway in a flea's second."
local text3 = "Course, I didn't leave empty handed, on no, but as to what "
.. "I took; that's a secret."
conversation_3 =
{
type = "root",
id = "root",
condition = nil,
children =
{
{
type = "dialog",
text = text1,
children =
{
{
type = "dialog",
text = text2,
children =
{
{
type = "dialog",
text = text3,
children =
{
{
type = "dialog",
text = "Anyway, must dash.",
exit = true
}
}
}
}
}
}
},
}
}
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.
-- In map_town.lua
local OnTalk = function(map, trigger, entity, x, y, layer)
local dialogDef =
{
speakerX = x,
speakerY = y,
graphDef = conversation_3 -- we've changed this from 2 to 3
}
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
.
-- In DialogState.lua
function DialogState:FilterForResponses(nodeList)
local passingNodes = {}
for k, v in ipairs(nodeList) do
if v.type == "response" then
table.insert(passingNodes, v)
end
end
return passingNodes
end
function DialogState:MoveToNode(def)
self.mCurrentNode = def
local responses = def.children or {}
-- Filter the responses
responses = self:FilterForResponses(responses)
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.
function DialogState:MoveToChainedNode(node)
if node.exit then
self:InitExitTransition()
self.mMode = eMode.Exiting
return
end
-- Deal with chained nodes
local nodes = node.children or {}
-- Filter the nodes
nodes = self:FilterNodes(nodes)
local nodeToChangeTo = nil
for k, v in ipairs(nodes) do
if v.type == "dialog" then
nodeToChangeTo = v
break
end
end
if nodeToChangeTo ~= nil then
self:MoveToNode(nodeToChangeTo)
else
print("This conversation graph is broken!")
end
end
function DialogState:HandleInput()
-- code omitted
if self.mCurrentNode.exit and spacePressed then
-- code omitted
elseif self.mAreThereReplies then
-- code omitted
elseif spacePressed then
self:MoveToChainedNode(self.mCurrentNode)
end
end
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.
-- Conversation_4.lua
conversation_4 =
{
type = "root",
id = "root",
condition = nil,
children =
{
{
type = "dialog",
text = "Can I help you detective?",
id = "help",
children =
{
{
type = "response",
text = "I have some questions.",
children =
{
{
type = "dialog",
text = "What do you need to know?",
id = "questions",
children =
{
{
type = "response",
text =
"Where were you last night, around seven?",
children =
{
{
type = "dialog",
text = "Here. "
.. "You can ask my customers.",
children =
{
{
type = "response",
text = "Maybe I will.",
jump = "questions"
}
}
}
},
},
{
type = "response",
text = "You ever been to Salem Hotel?",
children =
{
{
type = "dialog",
text = "Can't say that I have.",
children =
{
{
type = "response",
text = "Ok then.",
jump = "questions"
}
}
}
}
},
{
type = "response",
text = "Do you know a Dr. Suther?",
children =
{
{
type = "dialog",
text =
"Only that he's the local Doc.",
children =
{
{
type = "response",
text =
"I have other questions.",
jump = "questions"
}
}
}
}
},
{
type = "response",
text = "That's all for now",
jump = "help"
}
}
}
}
},
{
type = "response",
text = "Maybe later.",
exit = true
}
}
},
}
}
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.
-- In map_town.lua
local OnTalk = function(map, trigger, entity, x, y, layer)
local dialogDef =
{
speakerX = x,
speakerY = y,
graphDef = conversation_4 -- from 3 to 4
}
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.
-- In DialogState.lua
function DialogState:Create(dialogDef)
-- code omitted
setmetatable(this, self)
this:ProcessGraph(this.mGraph)
return this
end
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).
function DialogState:ProcessGraph(root)
if root.processed == true then
return
end
self.mIdToNode = self:CreateIdToNodeTable(root, nodes)
self:AddLoops(root)
root.processed = true
end
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
.
function DialogState:CreateIdToNodeTable(root, nodes)
local t = nodes or {}
if root ~= nil then
if root.id then
if nodes[root.id] then
local warning = string.format(
"Root id [%s] appears more than once!",
root.id)
print(warning)
end
nodes[root.id] = root
end
for k, v in ipairs(root.children or {}) do
self:CreateIdToNodeTable(v, nodes)
end
end
return t
end
If we run this function on our conversation it creates a table like the below.
{
["root"] = { .. the root node ... },
["help"] = { .. the first node ... },
["questions"] = { .. the question node ... }
}
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.
-- In DialogState.lua
function DialogState:AddLoops(node)
if not node then
return
end
if node.jump then
-- Store the id locally and remove it from the node
local id = node.jump
node.jump = nil
-- Get the node the id points to
local jumpNode = self.mIdToNode[id]
-- Some error checking
if not jumpNode then
local error = string.format(
"Error: Couldn't find node with id [%s]", id)
print(error)
return
end
if node.children then
local warning = string.format(
"Warning jump to [%s] destroys children in node.", id)
print(warning)
end
-- Add loop
node.children = { jumpNode }
return -- we can skip this node's children
end
for k, v in ipairs(node.children or {}) do
self:AddLoops(v)
end
end
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.
-- Conversation_5.lua
function HasFlag(id)
return function(flags)
if type(id) == "table" then
for k, v in ipairs(id) do
if flags[v] == false then
return false
end
end
return true
else
return flags[id] == true
end
end
end
function NoFlag(id)
return function(flags)
return not flags[id] or flags[id] == false
end
end
function SetFlag(id)
return function(flags)
flags[id] = true
end
end
conversation_5 =
{
type = "root",
id = "root",
condition = nil,
variables =
{
-- Flags to track whats been asked during this conversation
["clever"] = false,
["black_hand"] = false,
},
children =
{
{
type = "dialog",
id = "questions",
text = "Yes?",
children =
{
{
type = "response",
text = "Do you know where the leader of the black hand is?",
condition = NoFlag("black_hand"),
on_choose = SetFlag("black_hand"),
children =
{
{
type = "dialog",
text = "Never heard of them.",
children =
{
{
type = "response",
text = "I have more questions.",
jump = "questions"
}
}
}
}
},
{
type = "response",
text = "Is there are a cleverman in this town?",
condition = NoFlag("clever"),
on_choose = SetFlag("clever"),
children =
{
{
type = "dialog",
text = "Yeh, end of main street. You'll find him "
.. " in the Hanged Man's Tavern",
children =
{
{
type = "response",
text = "Thanks.",
jump = "questions"
}
}
}
}
},
{
type = "response",
text = "Thanks for the help. Bye",
condition = HasFlag({"clever", "black_hand"}),
exit = true
}
}
}
}
}
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
condition = NoFlag("clever"),
We can rewrite this so only a set list of functions may be used. Such as:
-- somewhere global-ish
conversation_whitelist =
{
["no_flag"] = NoFlag,
["has_flag"] = HasFlag,
["set_flag"] = SetFlag
}
-- a little later in the code
condition = { "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.
-- In map_town.lua
local OnTalk = function(map, trigger, entity, x, y, layer)
local dialogDef =
{
speakerX = x,
speakerY = y,
graphDef = conversation_5
}
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.
function DialogState:ProcessGraph(root)
-- Reset variables to original values
root.variables = root.variables or {}
if root.variables_original == nil then
local clone = ShallowClone(root.variables)
root.variables_original = clone
else
local clone = ShallowClone(root.variables_original)
root.variables = clone
end
if root.processed == true then
-- 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.
function DialogState:FilterNodes(nodeList)
local passingNodes = {}
local variables = self.mGraph.variables or {}
for k, v in ipairs(nodeList) do
if v.condition == nil or
v.condition(variables) == true
then
table.insert(passingNodes, v)
end
end
return passingNodes
end
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.
function DialogState:OnReplySelected(index, item)
-- First deal with exit commands
if item.exit then
self:InitExitTransition()
self.mMode = eMode.Exiting
return
end
if item.on_choose then
local variables = self.mGraph.variables or {}
item.on_choose(variables)
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_conversation
tag to a node. (Using local variables) - Add a
once_ever
tag 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!