Implementing an RPG conversation system.

In a previous article I looked at the Never Winter Nights conversation system and broke it down a little. In this article we'll implement a similar system using one of the How to Make an RPG examples projects as a base.

This a long and pretty technical article. Even a little dry in places but it tells you all you need to know to make a graph based conversation system.

Never Winter Nights structures it's conversation using a graph. The nodes represent NPC speech and PC replies. Some of the nodes have annotations that may:

  1. Determine if the node appears.
  2. Alter the game state.

For instance, imagine a game where you're talking to a shady politician and have some blackmail. Because you have the blackmail a special conversation option appears that makes him give you an item to keep you quiet. Once he gives you the item the game needs to record this has happened so he only gives it you once. We can design this interaction using node annotations and altering game state.

Setting Up the Project

The conversation structure is pretty simple. We could implement it in pure Lua but it's more fun to use real game code. I'll be using a modified example from the How to Make an RPG book. The example is from the last chapter of the book. It's been modified so the hero character is in a small room with the major.

Prerequisites

This example jumps into to a full codebase. If you're not an experienced programmer then it might a bit hard to follow fully.

The code uses:

  1. StateMachines (or actually a state stack)
  2. Tweening functions
  3. A small amount of vector math

The Starting Room

The starting point for the implementation looks like this screenshot:

The starting point for our implementation.

This starting area contains a single NPC. We'll add some NWN style dialog to this NPC.

When we talk to someone the conversation UI takes up the bottom half of the screen. We want the person we're speaking with to appear in the center of the top half of the screen. Therefore when entering a conversation we'll position the camera to center on the speaker as shown below.

Laying out the screen for our conversation.

The How to Make an RPG codebase uses a stack of states to control gameplay. Most gameplay occurs in the ExploreState which let's the player move a character around a map. We'll create a new state to handle conversations, that we'll call DialogState.

Let's create the DialogState class and push an instance of it on global state stack when the player interacts with the NPC. NPC interaction is done via a trigger object, defined and added in the map file.

First, we set the trigger, by editing the map file; as shown below.

-- This is in map_town.lua. The map file for where our dialog takes place
function CreateTownMap(state)

    local id = "town"
    local townState = state.maps[id]

    local OnTalk = function(map, trigger, entity, x, y, layer)
        -- We haven't created this state yet but here's the code to push it
        -- on the stack

        local dialogState = DialogState:Create()
        gStack:Push(dialogState)
    end


    return
    {
        -- code omitted
        on_wake =
        {
            {
                id = "AddNPC",
                params = {{ def = "npc_major", id ="major", x = 5, y = 4 }}
            },
        },
        actions =
        {
            talk_action =
            {
                id = "RunScript",
                params = { OnTalk }
            },
        },
        trigger_types =
        {
            talk = { OnUse = "talk_action" },
        },
        triggers =
        {
            -- tile 5, 4 is where the NPC is standing
            { trigger = "talk", x = 5, y = 4 },
        },
    -- code omitted

This code creates a trigger that runs a function called OnTalk when the player interacts with the NPC. The OnTalk function creates a DialogState and pushes it in on the global stack. This DialogState is where we'll create the Never Winter Nights style conversation.

Starting Conversations and Focusing the Camera

The DialogState centers the camera on the speaker, let's the player navigate the conversation graph and then restores the camera when the state is exited. The conversation UI is made of two panels at the bottom of the screen. As the camera moves to the speaker the panels fade in. The transition takes 0.3 seconds so it feels nice and snappy.

The image below shows the panels correctly positioned.

Laying out the screen for our conversation.

Next let's write the camera movement code and the code to render the UI panels. I could do something fancy with nested states here but to make things simple the transitions are handled manually.

The code is quite lengthy so let's start with just the constructor.

DialogState = {}
DialogState.__index = DialogState

local eMode =
{
    Entering = "Entering",
    Running = "Running",
    Exiting = "Exiting"
}

function DialogState:Create(dialogDef)
    local this =
    {
        mMode = eMode.Entering,
        mDef = dialogDef,

        -- Code for the in/out transition
        mTransTween = nil,
        mTransDuration = 0.3, -- seconds
        mCamDiff = Vector.Create()
    }

    local layout = Layout:Create()
    layout:Contract('screen', 32, 20)
    layout:SplitHorz('screen', 'top', 'bottom', 0.5, 1)
    layout:SplitHorz('bottom', 'dialog', 'response', 0.4, 1)
    this.mLayout = layout

    self.mPanels =
    {
        layout:CreatePanel("dialog"),
        layout:CreatePanel("response")
    }

    setmetatable(this, self)
    return this
end

Mode Enum

Before defining the constructor we set up the equivalent of a Lua enum; eMode. There are three modes:

  • Entering - the enter transition.
  • Exiting - the exit transition.
  • Running - the state executing as normal.

In the Entering and Exiting modes all input is ignored. When the Running mode is entered the player can interact with the conversation.

DialogState Constructor

The constructor takes in a def parameter which will contain the conversation graph. We store the def in the this table and store the current mode, mMode as Entering.

We finish setting up the this table by adding the transition variables: a tween, a duration and a vector to track the camera movement.

To create the UI we use a special layout class. The layout class helps us quickly divide the screen into panels. After the layout operations we end up with two panels; "dialog" and "response". These are both positioned in the bottom half of the screen. The panels are stored in the this table as mPanels and the layout object is stored as mLayout. We'll use the layout object later to align text inside the panels.

Next let's implement the Enter and Exit functions. These set up the in and out transitions for the state.

function DialogState:Enter()
    self.mMode = eMode.Entering
    self:InitEnterTransition(self.mDef.speakerX,
                             self.mDef.speakerY)
end

function DialogState:InitEnterTransition(x, y)

    -- Find the follow state
    -- and tell the camera to stop following the player
    local explore = self:FindExploreState()
    self.mExploreState = explore
    explore:SetFollowCam(false)

    -- Store the original camera position for the player
    -- We'll restore this when we exit the dialog
    local pos = explore.mHero.mEntity.mSprite:GetPosition()
    self.mOriginalCamPos = Vector.Create()
    self.mOriginalCamPos:SetX(math.floor(pos:X()))
    self.mOriginalCamPos:SetY(math.floor(pos:Y()))


    -- Store the target position for the NPC camera alignment
    self.mNPCCamPos = Vector.Create()
    -- To center the NPC in the top half of the map we get it's pixel position
    -- Then add 0.25 of the height of the window
    local pX, pY = explore.mMap:GetTileFoot(x, y)
    self.mNPCCamPos:SetX(math.floor(pX))
    self.mNPCCamPos:SetY(math.floor(pY + System.ScreenHeight() * 0.25))

    -- Set the camera to start in the original camera position
    explore.mManualCamX = self.mOriginalCamPos:X()
    explore.mManualCamY = self.mOriginalCamPos:Y()

    -- Set up the transition tween and the different vector
    -- between where the camera is and where we want it to be
    self.mTransTween = Tween:Create(0, 1, self.mTransDuration,
                                    Tween.EaseOutQuad)
    self.mCamDiff = self.mOriginalCamPos - self.mNPCCamPos

    -- Set the UI alpha
    self:SetUIAlpha(0)
end

function DialogState:InitExitTransition()
   self.mTransTween = Tween:Create(1, 0, self.mTransDuration,
                                    Tween.EaseOutQuad)
end

function DialogState:FindExploreState()
    -- Don't have a great way to get access to this at the moment.
    local exploreState = nil
    for k, v in ipairs(gStack.mStates) do
        if v.__index == ExploreState then
            exploreState = v
            break
        end
    end
    return exploreState
end

function DialogState:Exit()
    self.mExploreState:SetFollowCam(true) -- restore the follow cam
end

OnEnter

OnEnter is called when the DialogState is pushed on the stack. This sets the mode to Entering and initializes the in-transition by calling InitEnterTransition with the NPC tile position.

InitEnterTransition

The InitEnterTransition moves the camera so the NPC is centered in the top half of the screen and fades in the UI.

Camera movement is normally controlled by the ExploreState. FindExploreState returns the ExploreState from the global stack. We tell it to stop updating the camera, as we want to control it during the conversation. We store the current position of the camera, so when the DialogState exits we can restore it's original position.

To a camera position that centers the top of the window on the NPC; we get NPC position in world space and add on half the window height.

With the two positions defined, we calculate the difference as a vector and store it in mCamDiff. Then we setup a tween to move the camera between the two positions.

At the end of the InitEnterTransition function we call self:SetUIAlpha(0) to make the panels invisible. As we write more of the code this SetUIAlpha function will update the alpha of all additional UI elements.

InitExitTransition

The next function defined is InitExitTransition. We call this function when we want to exit the state. It returns the camera to the original position and fades out the UI.

All the required camera positions have already been set by InitEnterTransition function. To set up the exit transition we just create a new tween that goes from 1 to 0.

FindExploreState

We've already made use of FindExploreState. It iterates through the global stack until it finds the ExploreState.

OnExit

The OnExit function tells the ExploreState to use the camera to resume following the hero character. OnExit is guaranteed to be called before the DialogState is removed from the stack. Updating the camera code here ensures we don't end up with a stuck camera that doesn't follow the hero!

Right, let's write everything else.

function DialogState:Update(dt)

    -- Update the camera position
    self.mExploreState:UpdateCamera(self.mExploreState.mMap)

    if self.mMode == eMode.Entering then
        self:UpdateEnter(dt)
    elseif self.mMode == eMode.Running then
        self:UpdateRun(dt)
    elseif self.mMode == eMode.Exiting then
        self:UpdateExit(dt)
    end
end

function DialogState:UpdateEnter(dt)

    if self.mTransTween:IsFinished() then
        self.mMode = eMode.Running
        return
    end

    self.mTransTween:Update(dt)
    local v01 = self.mTransTween:Value()

    -- Move the camera
    local camPos = self.mOriginalCamPos + (self.mCamDiff * v01)
    self.mExploreState.mManualCamX = camPos:X()
    self.mExploreState.mManualCamY = camPos:Y()

    -- Fade in the dialog box
    self:SetUIAlpha(v01)
end

function DialogState:SetUIAlpha(a01)
    local color = Vector.Create(1, 1, 1, a01)

    -- Set the panels alpha
    for _, v in pairs(self.mPanels) do
        v:SetColor(color)
    end
end

function DialogState:UpdateRun(dt)

end

function DialogState:UpdateExit(dt)

    if self.mTransTween:IsFinished() then
        gStack:Pop()
        return
    end

    self.mTransTween:Update(dt)
    local v10 = self.mTransTween:Value()

    local camPos = self.mOriginalCamPos + (self.mCamDiff * v10)
    self.mExploreState.mManualCamX = camPos:X()
    self.mExploreState.mManualCamY = camPos:Y()

    self:SetUIAlpha(v10)

end

function DialogState:HandleInput()
    -- only accept input when no transition is running.
    if self.mMode ~= eMode.Running then
        return
    end

    if Keyboard.JustPressed(KEY_SPACE) then
        self:InitExitTransition()
        self.mMode = eMode.Exiting
    end
end

function DialogState:Render(renderer)
    for _, v in pairs(self.mPanels) do
        v:Render(renderer)
    end
end

Update

The state has three modes. In the Update function we update the camera then call a different functions depending on the current mode.

UpdateEnter

UpdateEnter is the update function called when we're entering the state.

We first check if the transition tween is finished and if so, change the mode to Running and immediately return.

If the transition tween hasn't finished we update it and store it's value in v01. We then multiply the camera difference vector by v01 and add it to the original camera position. This moves the camera towards the NPC. We also set the alpha for the UI according to the v01 value; fading it in.

SetUIAlpha

The SetUIAlpha function takes in an alpha value in the range 0 to 1. 0 is transparent 1 is opaque. For now it sets the color of each panel to white with the given alpha value.

We'll be updating this function as we add more UI elements.

UpdateRun

The UpdateRun function is called when the dialog is running and the player can interact with the conversation graph. There's nothing we need to do here now.

UpdateExit

The UpdateExit function restores the camera and fades out the UI elements. If first checks if the transition tween is finished, if so it pops the dialog state off the stack, returning the game to the ExploreState.

If the transition tween isn't finished, it's updates it and uses it's value to move the camera and fade the UI.

HandleInput

The HandleInput function returns immediately unless the mode is set to Running.

It checks if the Space key has been pressed and, if so, starts the exit transition.

A DialogState with Transitions

That's the complete transition code for the DialogState. Check out how it looks below:

Now we've got the skeleton up and running, let's add a little meat.

The Dialog Interface

The interface seems simple. There are two panels; the dialog panel for the NPC and the responses panel for the PC.

We're going to assume the NPC dialog always fits in the top box. The bottom box contains a list of responses or a "Press Space to Continue" message in the case of a node with no responses.

Before processing the conversation graph let's write the code to display a node.

First we'll extend DialogState with a currentNode table. This table contains the dialog text and an optional list of responses.

The current node table looks something like this:

currentNode =
{
    text = "Hello, I'm an NPC. Doing some talking.",
    responses =
    {
        { text = "Hello." },
        { text = "Goodbye." }
    }
}

The dialog text is just a string but each reply is a table. The replies are tables because they may include some metadata. We assume the list of replies is filtered already (if there's a reply that requires you to have >15 charisma or something it's already been removed).

Displaying a Conversation Node

We'll use a Selection menu, from the How to Make an RPG book to manage and render the replies. The selection menu is a UI element that displays a list of options and lets the player scroll through and pick one.

In the DialogState we'll add a special MoveToNode function that sets the current node data and creates the selection menu for the replies.

Let's start by updating the constructor.

function DialogState:Create(dialogDef)
    local this =
    {
        -- code omitted

        -- Code for display
        mCurrentNode = {},
        mReplyMenu = nil,
        mAreThereReplies = false,
        mNoReplyText = "Press Space to Continue",
        mTextColor = Vector.Create(1,1,1,1),
    }

    -- code omitted

In the constructor we've added the following fields:

  • mCurrentNode - Stores the current node for the conversation graph.
  • mReplyMenu - A selection menu that let's the player browse and select a reply.
  • mAreThereReplies - sometimes there are no replies and NPC is just monologuing.
  • mNoReplyText - text to display in the reply box when there are no replies for the current node.
  • mTextColor - this color is used to fade the text out when the dialog is transitioning in and out.

With these new variables we can write the display code. But there's a problem: we need a node to display! For now we'll add a debug node in the Enter function as shown below.

function DialogState:Enter()

    -- Debug data to fill out the NPC
    self:MoveToNode
    {
        dialog = "Hello, I'm an NPC. Doing some talking.",
        children =
        {
            { text = "Hello." },
            { text = "Goodbye." }
        }
    }
-- code omitted

Let's write the MoveToNode function.

function DialogState:FilterNodes(nodeList)

    -- Filter code will go here!

    return nodeList
end


function DialogState:MoveToNode(def)
    self.mCurrentNode = def

    local responses = def.children or {}

    -- Filter the responses
    responses = self:FilterNodes(responses)

    if next(responses) == nil then
        self.mAreThereReplies = false
        return
    end

    self.mAreThereReplies = true

    -- This list may be filtered so give each entry
    -- an index now
    for k, v in ipairs(responses) do
        v.index = k
    end

    self.mReplyMenu = Selection:Create
    {
        data = responses,
        columns = 1,
        displayRows = 3,
        spacingY = 24,
        rows = #responses,
        RenderItem = function(self_, renderer, x, y, item)
            if item == nil then
                return -- draw a space for missing elements
            end
            renderer:AlignText("left", "center")
            local text = string.format("%d. %s", item.index, item.text)
            renderer:DrawText2d(x, y, text, self.mTextColor)
        end,
        OnSelection = function(...) self:OnReplySelected(...) end
    }
end

function DialogState:OnReplySelected(index, item)
    -- we'll fill this in later too
end

The MoveToNode code is suprisingly long but most of it is taken up by the menu creation code.

The first function we add is FilterNode. This is a skeleton function that returns whatever it's given. We'll add code here later but for now we're only interested in displaying a single node.

In the MoveNode function we start by assigning the mCurrentNode. This is where we get the NPC text and reply data to render in the UI.

We store the replies locally as responses and then filter them. In the future the filter code will remove any responses that can't be chosen. If there are no responses left after filtering, we set mAreThereReplies to false and leave the function.

If there are replies we need to setup the mReplyMenu. First we number the replies because I want to show the number next to each reply. Then we create the menu. The menu data source is the list of responses. The list of responses is a single column and we show a max of three replies on screen at once. There can be more replies but the player has to scroll down to see them.

Each row is 24 pixels tall. This is a fixed height, which means there's no support for multiline replies. The total number of rows is set to the length of the repsonse list.

Now we come to the two most interesting parts of the reply menu: RenderItem draws each reply using it's index that we set earlier in the function and it's text field. OnSelection is called when the player chooses a reply, it calls an empty function; OnReplySelected which we'll fill out later.

Before finishing up the Render function let's update SetUIAlpha so the text and menu fade in and out correctly.

function DialogState:SetUIAlpha(a01)
    -- code omitted

    -- Set the text color
    self.mTextColor:SetW(a01)
    -- Update the selection box cursor
    if self.mReplyMenu then
        self.mReplyMenu.mCursor:SetColor(color)
    end
end

Here we set the alpha for the text color and the color of the reply menu's cursor.

Next is the big fat Render function that positions and renders all the UI elements.

function DialogState:Render(renderer)
    renderer:ScaleText(0.75, 0.75)
    local leftPad = 8
    local topPad = 6
    local maxWidth = self.mLayout.mPanels["dialog"].width - (leftPad * 2)

    for _, v in pairs(self.mPanels) do
        v:Render(renderer)
    end

    local dX = self.mLayout:Left("dialog") + leftPad
    local dY = self.mLayout:Top("dialog") - topPad
    local color = self.mTextColor
    local text = self.mCurrentNode.text

    renderer:AlignText("left", "top")
    renderer:DrawText2d(dX, dY, text, self.mTextColor, maxWidth)

    if self.mAreThereReplies then
        local x = self.mLayout:Left("response") + leftPad
        local y = self.mLayout:Top("response") - topPad
        y = y - 12 -- replies are centered, so push down a little more
        self.mReplyMenu:SetPosition(x, y)
        renderer:ScaleText(0.75, 0.75)
        self.mReplyMenu:Render(gRenderer)
    else
        local x = self.mLayout:MidX("response")
        local y = self.mLayout:MidY("response")
        renderer:AlignText("center", "center")
        renderer:ScaleText(0.75, 0.75)
        renderer:DrawText2d(x, y, self.mNoReplyText, self.mTextColor)
    end
end

We use the mLayout object to help us get positions in the panels. After rendering the panels we draw the NPC's dialog in the top panel from the top left corner.

Then we draw the reply menu aligned to the top left corner of the bottom panel. If there are no replies then in the center of the bottom panel we draw the text "Press Space to Continue".

Run this code and you'll see something like the following image.

A first pass at displaying a conversation node.

This is looking pretty close to what we want, it just needs making functional.

As a first step on the path to fully functional demo let's allow the player to interact with the reply menu.

function DialogState:HandleInput()
    -- only accept input when no transition is running.
    if self.mMode ~= eMode.Running then
        return
    end

    if self.mAreThereReplies then
        self.mReplyMenu:HandleInput()
    elseif Keyboard.JustPressed(KEY_SPACE) then
        -- Move to the next node
    end

end

In HandleInput we return immediately unless we're in the Running state. If there are replies we tell the ReplyMenu to update itself. Otherwise we check if the player has pressed space and which moves the conversation on to the next node, once we write the code!

What the Display Code Doesn't Do

This code is good for a lot of games but there are some edge cases it doesn't cover.

  1. If NPC dialog gets too long, it just overflows the bottom of the panel. Ideally it should be paged.
  2. Replies can only be one line long. No line breaks.

The conversation interface limits reply size anyway. You can't have multiple paragraph replies in a list as it wouldn't work well visually.

Both these limitations can be addressed but it requires more code and I'm probably pushing it with this article as it is!

Here's my suggested interface for a more fully featured dialog UI.

Interface for a western style CRPG dialog in JRPG setting.

Running the Conversation Graph

We can now display a node! That's half the battle. The next task is to let the player move through the conversation graph. We'll start with a simple conversation, get that working, then add some conditions to the reply nodes!

Basic Two Question Conversation Graph

Here's an initial conversation graph definition.

local conversation_1 =
{
    type = "root",
    id = "root",
    children =
    {
        {
            type = "dialog",
            text = "Ah here you are. The young man looking for the lost city, if I'm not mistaken.",
            children =
            {
                {
                    type = "response",
                    text = "That's right. Have you got something for me?",
                    children =
                    {
                        {
                            type = "dialog",
                            text = "Well, I've been looking into it and the books say, it sank into the sea to the south. I guess it's gone!",
                            children =
                            {
                                {
                                    type = "response",
                                    text = "No, that's useful. Thanks!",
                                    exit = true
                                }
                            }
                        }

                    },
                },
                {
                    type = "response",
                    text = "Sorry, got to go.",
                    exit = true,
                }
            }

        }
    }
}

This is a very simple conversation definition. It doesn't have any conditions, it's not very deep and it doesn't have any loop-backs. Therefore it should be nice and simple to run.

If the conversation was more complicated then it'd clearer to have a special ConversationGraph class but, as it is, we'll just deal with the graph in the DialogState directly.

Let's begin by adding the conversation to the trigger on map, so it gets passed into the dialog state.

function CreateTownMap(state)

    -- code omitted

    local OnTalk = function(map, trigger, entity, x, y, layer)

        local conversation_1 = -- as above

        local dialogDef =
        {
            speakerX = x,
            speakerY = y,
            graphDef = conversation_1
        }

Next let's make update the DialogState so it stores the graph and choses the first viable root node.

function DialogState:Create(dialogDef)
    local this =
    {
        mMode = eMode.Entering,
        mDef = dialogDef,
        mGraph = dialogDef.graphDef,
-- code omitted

Here we choose the root node in the Enter function.

function DialogState:Enter()

    -- There may be multiple nodes, due to conditions
    -- Therefore get a filtered list and pick the first

    local nodes = self:FilterNodes(self.mGraph.children)
    local _, firstNode = next(nodes)
    self:MoveToNode(firstNode)
end

If you run the game now, it will correctly display the first node and the replies.

Displaying the first node in a conversation graph.

Now we need to handle replies and that is done in our OnReplySelected function.

function DialogState:OnReplySelected(index, item)

    -- First deal with exit commands
    if item.exit then
        self:InitExitTransition()
        self.mMode = eMode.Exiting
        return
    end

    -- Move to the child node
    local nodeList = self:FilterNodes(item.children)
    local _, node = next(nodeList)
    self:MoveToNode(node)

end

When a reply is selected we check for the "exit" flag, if it's there, then we fire off the exit transition and return.

If the exit flag isn't there, we filter the child nodes and move to the first valid one. That's all there is to it. If we run the code now we can navigate through the entire conversation!

Displaying the second node in a conversation graph.

Code

You can get the full code and project for this article from this link:

Project: Conversation Graph I

The project you want is called "nwn_dialog".

Take Aways

If you've followed all this; well done! Obviously conversation graphs are a pretty powerful structure and we've built quite a serviceable base but we've hardly scraped the surface of what a full engine would do.

In the next and final article we extend the code to handle graphs with conditionals, loopbacks, chained NPC nodes and variables.

To get the true power of the conversations you need really good tooling that allows text to be changed quickly, helps to check for mistakes and so on. To me the best tools are in-game, you should be able to open up a conversation graph, edit it and hot reload it without having to restart.

A good next step is to look at an RPG you like, that uses this type of conversation graph, and see how they structure their quests. Then try to transplant a quest from an existing RPG into your own engine.

Good luck and I wish you well on your conversation-craft.