Dialogue Engine
Welcome to the first devlog for Hollows Academy! In this devlog, I will talk about how I developed a custom-built dialogue engine designed specifically around JRPG-style interactions and branching narrative logic. This devlog dives into the architecture behind that system—how text rendering, player choice, event triggers, and quest integration all work together under the hood.
Overview
The dialogue in Hollows Academy is powered by a fully custom-built dialogue engine, developed from scratch to support a traditional JRPG-style dialogue system. The engine handles dialogue flow, branching logic, and gameplay integration, while the system presents conversations through a typewriter-style text effect with synchronized audio feedback. Together, they enable meaningful player choice during interactions, allowing dialogue decisions to trigger quests, alter progression, and unlock gameplay rewards. Without further ado, let's get right into this devlog!
Part 0 - Starting a Conversation
First off, we have to discuss how to start a conversation. We need to implement the following:
- Preventing interaction spam using an interaction lock
- Ensuring a conversation isn't already running
- Load the dialogue lines into an active conversation
- Resetting the conversation indices
We do this through a method, StartConversation, which takes the parametres an Entity object (npc) and a DialogueConversation object (conversation).
public void StartConversation(Entity npc, DialogueConversation conversation) {
if (interactionLocked) return;
if (isConversationActive) return;
if (conversation == null || conversation.Lines == null || conversation.Lines.Length == 0) return;
collidedBody = npc;
activeConversation = conversation.Lines;
conversationIndex = 0;
currentDialogue = "";
isConversationActive = true;
isTalking = true;
DrawDialogue();
}
So this method prevents interaction spam using the interactionLocked variable, checks to make sure a conversation isn't already running, thus preventing a duplicate or overwritten conversation, loads the lines of dialogue into the activeConversation variable, which keeps track of what conversation is currently being drawn to the screen and resets the conversation index, which keeps track of how progressed the active dialogue conversation is. Finally, it calls DrawDialogue();, which is where the actual rendering begins.
Part 1 - Drawing a Dialogue Line
The dialogue in Hollows Academy is stored in a resource file, containing
an array of lines of dialogue that can then be loaded and read by the code. The resource
can also allow for a speaker's node path to be assigned to it, which allows the system to determine
who the speaker of that set of dialogue should be. It also has a choices array for any dialogue
that has an option to select choices, such as a character being able to accept or deny a quest through
conversation.
public partial class DialogueLine: Resource {
[Export] public NodePath speaker;
[Export(PropertyHint.MultilineText)] public string text;
[Export] public DialogueChoice[] choices;
[Export] public bool endConversation = false;
public Entity GetSpeaker(Node sceneRoot) {
if (speaker == null || speaker.IsEmpty)
return null;
return sceneRoot.GetNodeOrNull < Entity> (speaker);
}
}
The DrawDialogue function prepares a single line of dialogue from the DialogueLine
resource file and loads it:
then, DrawDialogue triggers the speaker animations (if present), and shows
the dialogue UI:
DialogueLine line = activeConversation[conversationIndex];
currentDialogue = ApplyDialogueVars(line.text ?? "");
and finally, it splits this dialogue into "pages" (splitting it into lines
that fit neatly in the dialogue box).
dialogueBox.Visible = true;
ui.Visible = false;
and now, it displays the first "page" of dialogue.
wrappedLines = currentDialogue.Split('\n');
pageIndex = 0;
ShowDialoguePage();
Part 2 - Paging System
The next part of the dialogue system is splitting the dialogue into "pages", which are dialogue lines that fit and appear in a textbox on screen at a given time. This system uses the ShowDialoguePage method to pull the current line of dialogue, replace variables to get the correct reference Entity who has the lines of dialogue, trigger the animations for the speakers of the dialogue and show the dialogue UI where appropriate. We show the dialogue box and hide the main UI using a dialogueBox variable and a ui variable.
dialogueBox.Visible = true;
ui.Visible = false;
Then it splits the dialogue into pages:
wrappedLines = currentDialogue.Split('\n');
pageIndex = 0;
And then finally, the method is called to display the pages.
ShowDialoguePage();
Now the system works by calculating which lines should appear on the current page, building the visible text and resetting the typewriter effect that the dialogue engine has.
int start = pageIndex * maxLinesPerPage;
int end = Math.Min(start + maxLinesPerPage, wrappedLines.Length);
for (int i = start; i < end; i++)
pageText +=wrappedLines[i] + "\n" ;
dialogueText.VisibleCharacters = 0;
charIndex = 0;
The code works by determining the start of the page of dialogue by calculating the start of the page using a page index and a set max line per page (which in this case is 3), then wrapping the lines so that there is no scrolling or unwrapped lines and finally, resetting the typewriter effect by setting the visible characters and charIndex to 0.
Part 3 - Typewriter Effect
The dialogue engine contains a unique typewriter effect, where the text appears letter by letter with a "talking" sound effect. The text appears character by character by using a timer to correctly show the characters letter by letter. Each tick of the timer reveals one more character by incrementing the current visible characters and the character index(charIndex):
dialogueText.VisibleCharacters++;
charIndex++;
It then plays a sound for each character, creating a "talking" effect, using an audioplayer2D node:
audioPlayer.Stream = talkSound;
audioPlayer.Play();
And finally, it stops when the line of dialogue is finished drawing to the screen.
Part 4 - Advancing Dialogue
The dialogue advancement is handled by an AdvanceDialogue method. The AdvanceDialogue
method's logic is as follows:
- If text is still animating, instantly finish it:
- If the line ends the conversation:
- If choices exist:
if (!isDialogueFinishedDrawing)
if (currentLine.endConversation)
if (currentLine.choices != null)
Otherwise, the dialogue moves to the next page or moves to the next line of dialogue.
Part 5 - Choice System
The choice systems allows for some interesting dialogue interactions, such as triggering quests and eventually, obtaining
items, weapons or pary members. The choice system is handled by the ShowChoices method, which takes an array of dialogue choices
as its parametres. It then enables a selection mode by doing:
isChoosing = true;
It then dynamically creates Label nodes to show and add in the choice options and then adds the labels to a container:
Label label = new Label();
label.Text = choice.text;
choicesContainer.AddChild(label);
Now we have to add the ability to navigate and actually select a choice from the choice menu. We do this
using a HandleChoiceInput method, which will allow the choice to be hovered over when up or down is pressed,
and allow for a choice to be selected with the accept key (which in Hollows Academy's case, is Enter).
The choice's highlighting for choice selection is done using Modulation, which in this case, makes all highlighted
choices yellow and the other choices white if they're not the currently highlighted choice.
Next, we add in a method to actually handle selecting a choice. We do this by adding in a method,
SelectChoice, doing:
selectedChoice = (selectedChoice + 1) % activeChoices.Length;
label.Modulate = i == selectedChoice ? Colors.Yellow : Colors.White;
This allows the choice UI to hide after selecting a choice, while also applying quest logic if applicable.
It also allows jumping to another dialogue line or ending the conversation as applicable.
if (choice.acceptsQuest)
collidedBody.AcceptQuest();
Now we can add in the logic for ending a conversation. We can do this by adding in a method, ClearDialogue.
We need to add cleanup steps, which will hide the dialogue UI and reinstate the overworld's normal UI. We do this
by:
conversationIndex = choice.nextLineIndex;
We can also reset the state flags in this method as well:
dialogueBox.Visible = false;
ui.Visible = true;
Finallly, we emit a signal to let the game know when a conversation has ended and apply a short interaction lock
to prevent being able to spam the conversation clearing.
isTalking = false;
isConversationActive = false;
EmitSignal(SignalName.ConversationEnded);
interactionLocked = true;
Part 6 - Final Notes
This system is built around a clear loop:
- Start a Conversation
- Draw a Line of Dialogue
- Animate the Text with a Typewriter Effect
- Wait for Input
- Advance, Branch or End Dialogue
Each piece of the dialogue engine is modular, which makes it easy to expand to include features such as:
- Portrait Systems for the Speakers
- Voice Variations for Speakers
- More Complex Branching Dialogue