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.

A conversation graph with conditionals.
A conversation graph with conditionals.

Here’s the conversation graph as Lua code.

LUA
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.

LUA
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.

LUA
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.

LUA
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.

LUA
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.

LUA
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.

Talking to the NPC as a noble.
Talking to the NPC as a noble.
Getting no information because you're a knight.
Getting no information because you’re a knight.

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.

Talking to the NPC as a peasant.
Talking to the NPC as a peasant.
Gathering some intel.
Gathering some intel.

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.

Gathering some intel.
Gathering some intel.

Let’s first add the message, as mFinishedText to the constructor of the DialogState.

LUA
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.

LUA
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.

LUA
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.

Exiting Correctly on an NPC node.
Exiting Correctly on an NPC node.

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.

Chaining NPC dialog nodes.
Chaining NPC dialog nodes.

Let’s define a new conversation graph in a new file Conversation_3.lua.

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.

LUA
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.

LUA
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.

The start of a monologue.
The start of a monologue.

But pressing Space does nothing! We need to update the input handling code to deal with chained dialog nodes.

LUA
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 end of a monologue.
The end of a monologue.

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.

Loops in conversation.
Loops in conversation.

This is a brand new conversation, therefore it needs a new file; Converation_4.lua.

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.

LUA
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.

The questions a detective might ask in an RPG.
The questions a detective might ask in an RPG.

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.

LUA
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).

LUA
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.

LUA
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.

LUA
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.

LUA
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.

LUA
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

LUA
1condition = NoFlag("clever"),

We can rewrite this so only a set list of functions may be used. Such as:

LUA
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.

LUA
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.

LUA
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.

LUA
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.

A conversation with no exit.
A conversation with no exit.

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.

LUA
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

  1. Add a once_per_conversation tag to a node. (Using local variables)
  2. Add a once_ever tag to a node. (Using persistent local variables)
  3. 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!