Implementing advanced features of an RPG dialog engine.

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.

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.

Talking to the NPC as a noble.

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.

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.

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.

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.

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.

The start of a monologue.

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

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.

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.

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

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.

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

  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!