Creating a dialogue tree for your game
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.
1const 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 a11 mission available, but it's not for the faint of heart. What12 do you say? Interested?"</span13 >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
6body {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 transparent75 );76}77
78.player-option:focus {79 background-image: linear-gradient(80 to right,81 transparent,82 #ac864d 50%,83 transparent84 );85}86
87.player-option:hover {88 background-image: linear-gradient(89 to right,90 transparent,91 #ac864d 50%,92 transparent93 );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
1const guildMaster = document.querySelector(".guild-master-text");2let interval, skipText = false;3
4function 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
1function 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
1const options = document.querySelector(".player-options-container");2
3function 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 keyboard16}
And finally we set the guild master dialogue along with the options
1function setDialogue(dialogue) {2 guildMaster.textContent = ""; //Clear the previous text3 options.textContent = ""; //Clear the options as well4 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
1document.addEventListener("click", () => {});2
3document.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.
1function 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:
1document.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
1document.addEventListener("keydown", ({ keyCode }) => {2 if (keyCode === 13) { // ENTER key3 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
1document.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 down15 const nextSibling = document.activeElement.previousElementSibling;16
17 if (nextSibling) nextSibling.focus();18 else options.firstChild.focus();19 }20
21 if (keyCode === 40) { //Arrow key up22 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:
1engine.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!