In a previous article we 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:
- Determine if the node appears.
- 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:
- StateMachines (or actually a state stack)
- Tweening functions
- A small amount of vector math
The Starting Room
The starting point for the implementation looks like this screenshot:
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.
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.
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.
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.
- If NPC dialog gets too long, it just overflows the bottom of the panel. Ideally it should be paged.
- 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.
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.
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!
Code
You can get the full code and project for this article from this link:
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.
Enjoyed this article? Share or link to it, to support the site and help fellow developers.