Creating a dialogue tree for your game

ui tutorials

10/1/2024

Mihail Todorov

Creating a dialogue tree is essential for immersive storytelling in games, allowing for dynamic conversations between players and NPCs. Coherent Gameface, with its powerful UI system, offers a robust way to build and display these dialogue systems. In this tutorial, we’ll walk through how to create a dialogue tree from scratch using Gameface, helping you bring more interactive experiences into your game.

Where to find the working example

You can find the whole project source in the ${Gameface package}/Samples/uiresources/UITutorials/DialogueTree folder.

Creating our dialogue tree

The first thing we need to do is create our dialogue tree. For this example we’ll be creating a dialogue tree that has 2 to 3 options per dialogue and will be at least 5 levels deep.

Once we have that we need to present it in a format that will benefit us the most. This is why for this particular example we’ll make it into a JavaScript object in the following format

1
{
2
text: "",
3
responses: [
4
{
5
text: "",
6
dialogueNumber: 0,
7
}
8
]
9
}

Where text is the guild master question, and responses are the possible choices that the player would have. In the responses dialogueNumber refers to the guild master question that will be shown when the response is chosen and it’s index in the array.

For this example we’ve added the dialogue tree as JavaScript array as we are mocking the interactions, but in a game it should come from the backend/game.

1
const dialogueTree = [
2
{
3
text: "Ah, welcome! I hear you've come seeking work. There's a mission available, but it's not for the faint of heart. What do you say? Interested?",
4
responses: [
5
{
6
text: "Yes, I'm ready for anything.",
7
dialogueNumber: 1,
8
},
9
{
10
text: "Tell me more before I decide.",
11
dialogueNumber: 2,
12
},
13
{
14
text: "I'm not sure. What's the reward?",
15
dialogueNumber: 3,
16
}
17
]
18
},
140 collapsed lines
19
{
20
text: "Good to hear! There's a band of marauders causing trouble near the northern pass. We need someone to deal with them. Do you accept?",
21
responses: [
22
{
23
text: "I'll take care of it.",
24
dialogueNumber: 4,
25
},
26
{
27
text: "Is there any backup for this mission?",
28
dialogueNumber: 5,
29
}
30
]
31
},
32
{
33
text: "The mission involves taking down a notorious bandit leader. They've terrorized nearby villages for weeks. You'll need to be clever as well as strong. What do you think?",
34
responses: [
35
{
36
text: "I'll do it. The villages need help.",
37
dialogueNumber: 6,
38
},
39
{
40
text: "This sounds dangerous. How many people are we talking about?",
41
dialogueNumber: 7,
42
}
43
]
44
},
45
{
46
text: "The reward? Well, aside from the gratitude of the villages, there's gold and a rare item—a relic with great power.",
47
responses: [
48
{
49
text: "I'm in. Tell me what needs to be done.",
50
dialogueNumber: 8,
51
},
52
{
53
text: "A relic? What kind of power are we talking about?",
54
dialogueNumber: 9,
55
}
56
]
57
},
58
{
59
text: "Excellent. You leave immediately. I'll mark the location on your map. Be careful out there.",
60
responses: [
61
{
62
text: "Understood. I'm heading out now.",
63
dialogueNumber: null,
64
},
65
{
66
text: "Any advice before I go?",
67
dialogueNumber: 10,
68
}
69
]
70
},
71
{
72
text: "We're sending only you. The Guild is stretched thin right now. Do you still want to proceed?",
73
responses: [
74
{
75
text: "Yes, I'll manage.",
76
dialogueNumber: null,
77
},
78
{
79
text: "No, I need support.",
80
dialogueNumber: null,
81
}
82
]
83
},
84
{
85
text: "Good. You'll be a hero to these people.",
86
responses: [
87
{
88
text: "I'll make sure of it.",
89
dialogueNumber: null,
90
}
91
]
92
},
93
{
94
text: "The leader has a handful of loyal fighters, but their numbers are small. A direct fight might not be wise.",
95
responses: [
96
{
97
text: "I'll take them by surprise, then.",
98
dialogueNumber: null,
99
},
100
{
101
text: "What if I negotiate with them?",
102
dialogueNumber: 11,
103
}
104
]
105
},
106
{
107
text: "Head to the northern pass and find the marauders. The relic is rumored to be in their possession.",
108
responses: [
109
{
110
text: "I'll find it and take them down.",
111
dialogueNumber: null,
112
},
113
{
114
text: "Is there a safe way to retrieve the relic without fighting?",
115
dialogueNumber: 12,
116
}
117
]
118
},
119
{
120
text: "It's an ancient charm said to grant enhanced strength in battle. Many would pay a high price for it.",
121
responses: [
122
{
123
text: "That's good enough for me. I'll take the mission.",
124
dialogueNumber: 8,
125
},
126
{
127
text: "That sounds too dangerous for me. I'll pass.",
128
dialogueNumber: null,
129
}
130
]
131
},
132
{
133
text: "Negotiating with bandits? You'd be risking everything. But if that's what you believe is right, I won't stop you.",
134
responses: [
135
{
136
text: "I'll find a way to end this peacefully.",
137
dialogueNumber: null,
138
},
139
{
140
text: "On second thought, I'll go with the ambush.",
141
dialogueNumber: null,
142
}
143
]
144
},
145
{
146
text: "Perhaps, if you're stealthy enough. But I doubt they'll let you walk out with it once they see you.",
147
responses: [
148
{
149
text: "I'll risk it. I'm good at staying unseen.",
150
dialogueNumber: null,
151
},
152
{
153
text: "I'll think of another approach.",
154
dialogueNumber: null,
155
}
156
]
157
}
158
];

Setting up our dialogue UI

Before we make the logic for the dialogues we first need to set up the visual part. That would include creating our HTML and styling using the CSS.

In our HTML we have two major containers - the .npc-dialogue-container and the .player-options-container which will be inside the npc-dialogue-container

5 collapsed lines
1
<html lang="en">
2
<head>
3
<link rel="stylesheet" href="./css/style.css" />
4
</head>
5
<body>
6
<div class="npc-dialogue-container">
7
<div class="npc-dialogue" cohinline>
8
<span class="guild-master">Guild Master:</span>
9
<span class="guild-master-text">
10
"Ah, welcome! I hear you've come seeking work. There's a
11
mission available, but it's not for the faint of heart. What
12
do you say? Interested?"</span
13
>
14
</div>
15
<div class="player-options-container">
16
<p class="player-option" id="0" tabindex="1">
17
Yes, I'm ready for anything.
18
</p>
19
<p class="player-option" id="1" tabindex="1">
20
Tell me more before I decide.
21
</p>
22
<p class="player-option" id="2" tabindex="1">
23
I'm not sure. What's the reward?
24
</p>
25
</div>
26
</div>
6 collapsed lines
27
<div class="help-text">Press Enter to select the dialogue option</div>
28
<script src="./js/cohtml.js"></script>
29
<script src="./js/dialogue.js"></script>
30
<script src="./js/index.js"></script>
31
</body>
32
</html>

Here you can see in this HTML snippet, that we are using the first question and responses from the dialogue tree array. The reason is that we want it to be availabe when you load the sample, but in a real use-case scenario you can add the data using JavaScript.

Something else that we need to note here is that we add to the player-options an id that will be the dialogueNumber from the responses.

Once that is done we’ll simply style or UI by adding the following style.css

1
@font-face {
2
font-family: "IM FELL Double Pica SC";
3
src: url(../assets/im-fell-double-pica.sc.ttf);
4
}
5
6
body {
7
margin: 0;
8
padding: 0;
9
font-family: "IM FELL Double Pica SC";
10
width: 100vw;
11
height: 100vh;
12
color: white;
13
font-size: 2.4vh;
14
background-image: url(../assets/guild-master.jpg);
15
background-repeat: no-repeat;
16
background-size: cover;
17
background-position: center;
86 collapsed lines
18
}
19
20
.npc-dialogue-container {
21
position: absolute;
22
bottom: 3%;
23
width: 100vw;
24
display: flex;
25
align-items: center;
26
justify-content: center;
27
}
28
29
.npc-dialogue {
30
width: 80%;
31
height: 13vh;
32
display: flex;
33
align-items: center;
34
background-color: rgba(0, 0, 0, 0.7);
35
padding: 0 3vh;
36
border-image-slice: 27;
37
border-image-width: 40px;
38
border-image-outset: 0;
39
border-image-repeat: stretch;
40
border-image-source: url(../assets/dialogue-border.png);
41
border-radius: 10px;
42
}
43
44
.guild-master {
45
width: 20%;
46
color: #eecd9c;
47
}
48
49
.guild-master-text {
50
width: 80%;
51
}
52
53
.player-options-container {
54
position: absolute;
55
bottom: 120%;
56
right: 7%;
57
}
58
59
.player-option {
60
border-image-slice: 15;
61
border-image-width: 40px;
62
border-image-outset: 0;
63
border-image-repeat: stretch;
64
border-image-source: url(../assets/dialogue-border.png);
65
padding: 2vh;
66
width: 40vh;
67
display: flex;
68
align-items: center;
69
justify-content: center;
70
background-image: linear-gradient(
71
to right,
72
transparent,
73
black 50%,
74
transparent
75
);
76
}
77
78
.player-option:focus {
79
background-image: linear-gradient(
80
to right,
81
transparent,
82
#ac864d 50%,
83
transparent
84
);
85
}
86
87
.player-option:hover {
88
background-image: linear-gradient(
89
to right,
90
transparent,
91
#ac864d 50%,
92
transparent
93
);
94
}
95
96
.help-text {
97
position: absolute;
98
top: 15%;
99
left: 50%;
100
transform: translate(-50%, -50%);
101
background-color: black;
102
padding: 1vh;
103
}

Selecting an option

Creating the typing effect

In many games where you have dialogues the text appears as if it was typed on screen. To mimic this we’ll create a function called writeText and add the following code

index.js
1
const guildMaster = document.querySelector(".guild-master-text");
2
let interval, skipText = false;
3
4
function writeText(dialogue) {
5
return new Promise((resolve) => {
6
let index = 0;
7
8
interval = setInterval(() => {
9
if (index === dialogue.text.length || skipText) {
10
skipText = false;
11
resolve(dialogue);
12
clearInterval(interval);
13
interval = null;
14
return;
15
}
16
17
guildMaster.textContent += dialogue.text[index];
18
index++;
19
}, 25);
20
});
21
}

Here we return a Promise so that when the text finishes typing out, we can show the options for a response. We then set an interval and on each iteration we add a letter to the guildMaster element text content. Once all of the letters are typed out we clear the interval and resolve the promise.

We’ll also use a flag skipText, which will stop the interval and resolve the promise. This will allow us later to skip the text typing out if we want.

Creating the response options

To create the response options we’ll first set a template

1
function createDialogueOptionTemplate(text, id) {
2
return `<p class="player-option" id="${id}" tabindex="1">
3
${text}
4
</p>`;
5
}

where we set the id and the text.

Then we create a function to add the options

1
const options = document.querySelector(".player-options-container");
2
3
function addOptions(dialogue) {
4
clearInterval(interval);
5
interval = null;
6
guildMaster.textContent = dialogue.text;
7
options.innerHTML = dialogue.responses.reduce(
8
(acc, response, index) => {
9
acc += createDialogueOptionTemplate(response.text, index);
10
return acc;
11
},
12
""
13
);
14
15
options.firstChild.focus(); //We focus on the first option so it's easier to interact later using the keyboard
16
}

And finally we set the guild master dialogue along with the options

1
function setDialogue(dialogue) {
2
guildMaster.textContent = ""; //Clear the previous text
3
options.textContent = ""; //Clear the options as well
4
writeText(dialogue.text).then(addOptions);
5
}

Adding interactions

For this example we’ll be using both the keyboard and the mouse to select a response. This is why we’ll add two event listeners - for click and for keydown

index.js
1
document.addEventListener("click", () => {});
2
3
document.addEventListener("keydown", () => {});

In our click event we need to check if the item that we are clicking is a response option and if the text is being typed out we want to skip it.

First we’ll create a function that handles the option selection.

1
function selectOption(element) {
2
const id = element.id;
3
engine.trigger("selectDialogue", id);
4
}

Where we get the id that we’ve set to correspond to the correct dialogue index and trigger an event in our game with it.

Now in the click event handler we do the following:

1
document.addEventListener("click", ({ target }) => {
2
if (target.closest(".player-options-container")) {
3
selectOption(target);
4
return;
5
}
6
7
if (interval) {
8
skipText = true;
9
return;
10
}
11
});

We check if the clicked element is an option and then run the function we’ve created. And we also check if the interval is set. If it is we change the skipText flag to true and it will resolve the promise in the writeText function.

Now we can do the same in the keydown handler

1
document.addEventListener("keydown", ({ keyCode }) => {
2
if (keyCode === 13) { // ENTER key
3
if (document.activeElement.closest(".player-options-container")) {
4
selectOption(document.activeElement);
5
return;
6
}
7
8
if (interval) {
9
skipText = true;
10
return;
11
}
12
}
13
});

We check if the user pressed enter, then we check if the current focused item is from the options and we select it.

Since we don’t have the ability to choose which option, we’ll also add the following logic to our keydown handler:

13 collapsed lines
1
document.addEventListener("keydown", ({ keyCode }) => {
2
if (keyCode === 13) {
3
if (document.activeElement.closest(".player-options-container")) {
4
selectOption(document.activeElement);
5
return;
6
}
7
8
if (interval) {
9
skipText = true;
10
return;
11
}
12
}
13
14
if (keyCode === 38) { //Arrow key down
15
const nextSibling = document.activeElement.previousElementSibling;
16
17
if (nextSibling) nextSibling.focus();
18
else options.firstChild.focus();
19
}
20
21
if (keyCode === 40) { //Arrow key up
22
const prevSibling = document.activeElement.nextElementSibling;
23
if (prevSibling) prevSibling.focus();
24
else options.lastChild.focus();
25
}
26
});

Here we check if the arrow up or down keys are pressed and if they are we focus on the next or previous sibling, depending on the key. If there is no sibling, meaning that the element is the first or last we just focus the last or first element so we can loop it around.

Tying everything together

We’ve added all of our logic, but even if we try to select an option nothing will happen as we haven’t “hooked it up to the game” yet. In this scenario we would listen for the "selectDialogue" event on our backend and then trigger an event from the game that will send us the correct text.

Since we don’t have a game we’ll mock that by listening for said event and getting the data from the dialogueTree array like so:

1
engine.whenReady.then(() => {
2
engine.on("selectDialogue", (id) => {
3
let index =
4
dialogueTree[currentDialogueIndex].responses[id].dialogueNumber;
5
if (!index) index = 0;
6
engine.trigger("changeDialogue", dialogueTree[index], index);
7
});
8
9
engine.on("changeDialogue", (dialogue, index) => {
10
currentDialogueIndex = index;
11
setDialogue(dialogue);
12
});
13
});

We wait for the engine object to be available, then we get the text from the dialogueTree when the event is triggered, here we need to check if the dialogueNumber is null and if it is we go back to the first text. And finally we trigger another event that will be listening on the frontend to change the text.

In conclusion

By following this guide, you’ve now learned how to create a functional dialogue tree using Coherent Gameface. With this system in place, your games can have branching conversations that enhance player immersion and choice. As you get more comfortable with Gameface, you can expand and customize your dialogue trees to meet the narrative needs of your game. Stay creative and keep exploring the possibilities Gameface brings to UI design!

On this page