Part 1/2 Recap

In Part 1, we set up our project and scripted a basic conversation with branching options.

In Part 2, we added character swapping, text formatting using BBCode, and handling variables within the conversation.


This part is going to focus on extending our script with a couple of extra enhancements: triggering arbitrary events during the conversation, and also handling longer sections of dialogue by scrolling the text.

Afterwards, we’re also going to look at how to integrate our conversation node into a larger game scene.

Triggering conversation events and scrolling text

There are two features we’re going to implement in this section:

  1. The ability to call arbitrary functions at any point during a conversation.
  2. Allow longer sections of text, and scroll the dialogue box when overflow occurs.

What I mean by the first point is something like this:

"dialogue": "Hi. Um...<pause 2.0> who are you?"

The section between the angle brackets <> is a function call. It won’t print out, but it will call a pause function that stops text printing for 2.0 seconds.

You could use the same concept to, for example, trigger an animation. Something like this:

"dialogue": "Let's go!<move player left 10.0>"

The second point, scrolling the dialogue, may sound trivial, but it’s deceptively complex. The RichTextLabel node doesn’t give us good information about whether or not line-wrapping is occurring, how many lines our text actually uses, whether overflow is occurring, etc.

To give a brief overview of a couple of useful functions the RichTextLabel node does give us:

  1. get_total_character_count() - Gives us the total number of characters in the text. Does not include BBCodes.
  2. scroll_to_line(int line) - Allows us to scroll the top of the window to match line. Wrapped text counts as one line.

In a nutshell, our strategy for correctly scrolling our text is going to be:

  1. Define a constant characters_per_line variable. For me, this is 21. This only works if you’re using a monospaced font (which we are). Otherwise, your characters per line will be variable and you won’t be able to use this method.
  2. Define a constant max_visible_lines variable. For me, this is two. If we go over this, it means we need to start scrolling.
  3. Detect where in the dialogue a word will take a line over characters_per_line and then insert a newline (\n) appropriately (ensuring scroll_to_line will work for us)
  4. Using the same principle as the inline function calls, insert a <scroll> after these newlines where vertical overflow will occur. Our scroll method will use scroll_to_line to increment the scroll position by one.

So, both of these are going to depend on us being able to call functions during a piece of dialogue. Let’s modify our first piece of dialogue in the conversation so that it uses both of these features.

"dialogue": "Hi, I'm ALEX. <pause 0.5>.<pause 0.5>.<pause 0.5>. [color=yellow]It's nice to meet you![/color] Uh...<pause 2.0> How should I refer to you?",

You can see that I’ve included several calls to a pause function which we’ll need to define. I’ve also included a bbcode tag to ensure the combination doesn’t interfere. I’ve also made the line longer so that it will need to wrap and scroll.

First, let’s figure out how we can interpret these inline function call tags within the <> brackets.

For the string parsing, we’re going to use a Regular Expression (GDScript class RegEx).

Define a new variable,

var inline_function_regex =

and in _ready,


Our regex string, <(?<function_call>.+?)>, uses a Named Capture Group to extract everything between <> brackets. You’ll see how we’ll use it in a moment.

Note: there’s no real reason why you can’t use something other than <> as an identifier - it’s just convenient because it doesn’t clash with the format brackets {} or the bbcode brackets [].

Tip: is a great tool for learning how a particular expression works. I’m by no means an expert at RegEx and I used this tool to come up with the expression used here.

The idea is that we’ll pre-process our string, save the function calls we need to make, remembering the index we need to call them at, and then remove them from the string.

To store the function calls, let’s define a variable.

var dialogue_calls = {}

Define a helper function which we’ll use for adding function calls to our dictionary in the required format.

func add_dialogue_call(index, dialogue_call):
  var dialogue_calls_at_index = dialogue_calls.get(index, [])
  dialogue_calls[index] = dialogue_calls_at_index

We store the functions by their index for easy lookup when we’re printing out the text. It’s possible to have multiple function calls at the same location, so each value is actually an array of function calls that we’ll need to iterate through.

We expect dialogue_call to be an array of strings containing each argument between the <> brackets (you’ll see why in a minute).

For example, if we had dialogue like this,

"Hi <pause 2.0><animate player talk>, <animate player wave>"

we’d expect this in our dialogue_calls dictionary,

  3: [
    ["pause", "2.0"],
    ["animate", "player", "talk"]
  5: [
    ["animate", "player", "wave"]

Next, define a new function, process_dialogue_inline_functions,

func process_dialogue_inline_functions(dialogue):
  # Clear our existing function call list
  dialogue_calls = {}
  var visible_character_count = 0
  var in_bbcode = false

  var i = -1
  while i + 1 < dialogue.length():
    i += 1
    var character = dialogue[i]
    # Ignore bbcode tag sections
    if character == '[':
      in_bbcode = true
    elif character == ']':
      in_bbcode = false
    elif in_bbcode:
    # If this is the start of an inline function call, process it and strip it from the dialogue
    if character == '<':
      var result =, i)
      if result:
        add_dialogue_call(visible_character_count, result.get_string("function_call").split(" "))
        dialogue.erase(result.get_start(), result.get_end() - result.get_start())
        i -= 1
      visible_character_count += 1

  return dialogue

Now, this function might look a little complex, but there’s really only a couple of things it’s doing.

It iterates through our dialogue string, character by character, ignoring any bbcode tags, and checks for our special character '<'.

When it finds a '<' it uses the regex to search from the current position, extracts the function_call capture group, splits it into an array, and then passes it to add_dialogue_call. Having saved that function, it then erases it from the dialogue string.

While iterating through the dialogue, we keep track of the number of visible characters since that’s what we iterate through when printing. It gives us the correct index at which to perform the function.

Finally, we return the modified dialogue with the function calls removed.

Also note that we use a while loop instead of a for loop. This is because the length of the dialogue can change during the loop (from the call to erase) and also we need to modify i when erasing a function call. It’s therefore simpler to use a while loop and manage the iterator variable i ourselves.

Finally, we need to call this function at the start of print_dialogue. We want to do it after we’ve formatted the dialogue, but before we assign it to bbcode_text.

In print_dialogue, replace the line where we assign bbcode_text with this:

var formatted_dialogue = dialogue.format({ "title": title })
dialogue_node.bbcode_text = process_dialogue_inline_functions(formatted_dialogue)

Our inline functions won’t be getting called yet, but you can still test this out from here.

If you run the scene and start the conversation, you should see that the function calls have been removed from the dialogue.

Next, let’s look at how we can actually call these functions while the dialogue is printing.

Let’s first define our pause function.

func pause(delay_string):
  var delay = float(delay_string)
  yield(get_tree().create_timer(delay), "timeout")

Pretty straightforward. All our arguments for these functions are going to be passed in as strings, so they’ll need to be cast first. So, we cast the string to a float, then we create a one-shot timer and yield.

Now let’s call our functions from print_dialogue.

Add this function to help us do that,

func call_dialogue_functions(index):
  var dialogue_calls_for_index = dialogue_calls.get(index)
  var results = []
  if dialogue_calls_for_index:
    for dialogue_call in dialogue_calls_for_index:
      var call_method = dialogue_call[0]

      results.push_back(callv(call_method, dialogue_call))
  return results

For the given index, get the array of dialogue calls to make, then iterate through, returning the results of each as an array. The first element of the array is the method name, and the remainder are the arguments.

Call the function from print_dialogue, before we start the timer or increment visible_characters,

for i in dialogue_node.get_total_character_count():
  # Process any function calls first, before showing the next character.
  var results = call_dialogue_functions(i)
  if results:
    for result in results:
      # This is what the function will return if it yields.
      # If it does yield, we also need to yield here to wait until that coroutine completes.
      if result is GDScriptFunctionState:
          yield(result, "completed")

In addition to calling call_dialogue_functions we also process the results here. In particular, we need to handle the case of when these functions yield. If they do, then we need to yield here too or else the pause will have no effect - it will run in a separate coroutine and not block our printing function.

That should be it! Try it out. When you run the scene, you should see that the dialogue pauses printing for the specified duration at the locations where we inserted those <pause> commands.

Ok, let’s talk about scrolling next. There’s a couple of things we need to do here. First, detect when we need to scroll. Second, perform the scroll at the correct time.

As mentioned earlier, the RichTextLabel node isn’t good at telling us when text overflows horizontally and wraps around. Therefore, we’re going to implement our own text wrapping. Given we’re using a font where characters have a fixed width, this is relatively simple. We can define a constant number of characters per line, and then insert newlines anywhere a word would take a line over that number. We’ll want to be a little strategic about where we place those newlines as well, so that we don’t break any words in half.

To do this, I’m going to expand our call to .format and define a new function, format_dialogue, that will handle both the .format and insertion of any required newlines.

First, there’s a couple of variables we’ll need:

export var characters_per_line = 21
export var max_lines_visible = 2

This is based on my own layout - yours might be different. Basically, this defines the size of the area we have that can display dialogue. Mine is 21 characters wide and 2 lines high.

Note, you might need to increase the width of your dialogue box for this section. We want to actively avoiding lines wrapping by hitting the side of the dialogue box, and instead use our own newlines.

Next, define our new function,

func format_dialogue(dialogue):
  # Replace any variables in {} brackets with their values
  var formatted_dialogue = dialogue.format(dialogue_variables())
  var characters_in_line_count = 0
  var line_count = 1
  var last_space_index = 0
  var ignore_stack = []
  # Everything between these brackets should be ignored.
  # It's formatted in a dictionary so we can easily fetch the corresponding close bracket for an open bracket.
  var ignore_bracket_pairs = { 
    "[": "]", 
    "<": ">" 

  for i in formatted_dialogue.length():
    var character = formatted_dialogue[i]
    # Ignore everything between [] or <> brackets.
    # By using a stack, we can more easily support nested brackets, like [<>] or <[]>
    if character in ignore_bracket_pairs.keys():
    elif character == ignore_bracket_pairs.get(ignore_stack.back()):
    elif not ignore_stack.empty():
    # Keep track of the last space we encounter. 
    # This will be where we want to insert a newline if the line overflows.
    if character == " ":
      last_space_index = i
    # If we've encountered a newline that's been manually inserted into the string, reset our counter
    if character == "\n":
      characters_in_line_count = 0
    elif characters_in_line_count > characters_per_line:
      # This character has caused on overflow and we need to insert a newline.
      # Insert it at the position of the last space so that we don't break up the work.
      formatted_dialogue[last_space_index] = "\n"
      # Since this word will now wrap, we'll be starting part way through the next line.
      # Our new character count is going to be the amount of characters between the current position
      # and the last space (where we inserted the newline)
      characters_in_line_count = i - last_space_index
    characters_in_line_count += 1
  return formatted_dialogue

func dialogue_variables():
  return {
    "title": title

I’ve included a few comments in this function to give more specific explanations for some of the lines. The overview is, we’re iterating through the dialogue string and counting the characters, ignoring bbcode tags [] and function call brackets <>. If the count goes over the maximum, we insert a newline at the start of the current word. If we encounter an existing newline (for example, if we’ve manually included one in our dialogue string), we reset our counter and start the new line.

I’ve also pulled dialogue_variables() out into a separate function so that it isn’t buried inside format_dialogue.

Now all that’s left to do is call our new function from print_dialogue. Replace the call to .format with this line,

var formatted_dialogue = format_dialogue(dialogue)

That’s really all we need to do. Our dialogue should now have newlines inserted at appropriate places so we don’t need to rely on the RichTextLabel to wrap our text any more.

The final thing we need to handle is scrolling. At the moment, if you play the scene, the dialogue will continue past two lines but the label won’t scroll to keep up and the dialogue won’t be visible.

As mentioned earlier, we’re going to utilise our new inline function call feature to enable this by inserting a "scroll" function call at the same locations as our newlines.

First, we need to ensure that the scrollbar is disabled on our Dialogue node.

Screenshot of Dialogue node settings with Scroll Active disabled

Now, let’s define our scroll function. We also need a variable to keep track of our current scroll position (since RichTextLabel doesn’t give it to us).

var current_scroll_position = 0

func scroll():
  current_scroll_position += 1

Next, we need to insert some calls to scroll into our dialogue_calls dictionary at appropriate positions. Modify process_dialogue_inline_functions so that it looks like this,

func process_dialogue_inline_functions(dialogue):
  # Clear our existing function call list
  dialogue_calls = {}
  var visible_character_count = 0
  var line_count = 1
  var in_bbcode = false

  var i = -1
  while i + 1 < dialogue.length():
    i += 1
    var character = dialogue[i]
    # Ignore bbcode tag sections
    if character == '[':
      in_bbcode = true
    elif character == ']':
      in_bbcode = false
    elif in_bbcode:
    # If this is the start of an inline function call, process it and strip it from the dialogue
    if character == '<':
      var result =, i)
      if result:
        add_dialogue_call(visible_character_count, result.get_string("function_call").split(" "))
        dialogue.erase(result.get_start(), result.get_end() - result.get_start())
        i -= 1
    # Perform manual scrolling.
    # If this is a newline and we're above the maximum number of visible lines, insert a 'scroll' function
    elif character == "\n":
      line_count += 1
      if line_count > max_lines_visible:
        add_dialogue_call(visible_character_count, ["scroll"])
      visible_character_count += 1

  return dialogue

You can see that we’ve added a line_count variable and included an extra elif clause for detecting newlines. The logic is fairly simple. If we detect a newline and the current line count is greater than the maximum number of lines we can show, insert a call to scroll at that location.

Ok, we’re ready to try it out! If you run the scene and start the conversation, you should now see that the dialogue scrolls down to the next line when it reaches the end of the last visible line.

Screenshot of scene with dialogue scrolled to the end

You might notice one issue though, if you have muliple pieces of dialogue in a row that require scrolling, the scroll position won’t reset.

It’s an easy fix - all we need to do is reset the scroll position when we start printing some new dialogue. Near the start of print_dialogue, where we set visible_characters to 0, edit your code to look like this,

  dialogue_node.visible_characters = 0
  current_scroll_position = 0

And that should be all we need to fix the issue.

Fixing the text skipping feature

Ok, you may have noticed one more, much more serious issue. Hitting Enter to skip to the end of the text now has a couple of problems.

  1. We skip right to the very last line, so some text won’t be shown at all. This most likely isn’t desirable for a player.
  2. Skipping to the end will also skip any inline function calls that we include. This might be ok for things like pauses, but it could cause problems for things like animations. Characters and objects could end up in the complete wrong spot!

While it would be possible to skip over a chunk of text and play out all the functions we need to immediately, it would be very complicated, and not something I really feel like doing given what would be a fairly small pay-off.

So, instead of fixing our now-broken feature, I’m going to solve the problem in a different way.

The problem statement is: players who have read the text before (or who are just fast readers) want to move through the dialogue as quickly as possible.

Notice I haven’t defined the problem as ‘skip the dialogue completely’. This would be more like skipping an entire cutscene. It might be something your game needs, but it’s not what I was intending for this feature.

So, let’s look at the problem statement. In particular, where I said “as quickly as possible”. Well, we have a limitation with our current implementation that we really only want to go one character at a time to ensure we don’t miss any function calls. So, let’s say “as quickly as possible” means “one character per frame” for our purposes. This means that, when the player presses and holds Enter to skip, we just want to show one character per frame, rather than waiting for the text timer.

The first thing I’m going to do is rename the skip_text_printing variable to fast_text_printing, so that we declare the variable like this.

var fast_text_printing = false

At this point, you should also rename all the other references to this variable in the file.

Next, I’m going to replace the skip_test_printing() function with these two functions,

func start_fast_text_printing():
  fast_text_printing = true
func stop_fast_text_printing():
  fast_text_printing = false

Instead of this being a single event that occurs when a button is pressed, it will be something that continues while the button is held down. That means we need both a ‘start’ and a ‘stop’ event.

Inside our _process function, change the code block that previously started with if skip_text_printing: to this,

if text_in_progress:
  if Input.is_action_just_pressed("ui_accept"):
  elif Input.is_action_just_released("ui_accept"):


Now these functions will get called on the press and release of the Enter key.

Finally, we need to change the logic in our print_dialogue function. At the end of the loop where we start the timer and show the next character, replace the existing code with this,

if fast_text_printing:
  dialogue_node.visible_characters += 1
  dialogue_node.visible_characters += 1
  yield(text_timer_node, "timeout")

What we’re doing here is, if we’re doing the fast text printing, instead of waiting for the timer we just wait for the next frame. This is pretty much as fast as we can go if we’re only going one character at a time.

That’s it! We’re ready to try it out now. If you run the scene and start the conversation, you should be able to speed up the text by holding down the Enter key!

You’ll notice that the pauses still take effect, which would be kind of annoying as a player if your intention was to skip forward as quickly as possible. Let’s look next at how we can selectively skip over some of our inline functions.

Within the loop inside print_dialogue, we’re going to expand our inline function-handling code a bit.

var results = call_dialogue_functions(i)
if results:
  for result in results:
    # This is what the function will return if it yields.
    if result is GDScriptFunctionState:
      # If we're trying to skip quickly through the text, complete this function immediately
      if fast_text_printing:
        while result is GDScriptFunctionState and result.is_valid():
          result = result.resume()
        # Otherwise, if the function has yielded, we need to yield here also to wait until that coroutine completes.
        yield(result, "completed")

We now have a conditional statement here checking our fast_text_printing variable. If the value is true, instead of yielding we call resume() on it. We do this multiple times in case the function yields multiple times (shown in an example in the GDScript docs

This should work with what we’ve currently got. Try running the conversation. You should be able to skip quickly through the pauses just like with the other text.

The only thing we want to be careful of is if we have function calls that we shouldn’t skip. For example, an animation that you want to complete before continuing.

If we want to do this selectively, we need the results returned from call_dialogue_functions to have a little more information. Let’s modify that function so that it also returns the name of the function the result is for.

In call_dialogue_functions, where we’re pushing the result onto our results array, change the line to this,

results.push_back([call_method, callv(call_method, dialogue_call)])

See how we’re now including the name of the method being called.

Let’s also define a list of functions that are safe for us to skip.

var skippable_functions = ["pause"]

Now, let’s modify our code in print_dialogue one more time to handle this new data structure.

var results = call_dialogue_functions(i)
if results:
  for result in results:
    if not result:
    var dialogue_function = result[0]
    var result_value = result[1]
    # This is what the function will return if it yields.
    if result_value is GDScriptFunctionState:
      # If we're trying to skip quickly through the text, complete this function immediately if we're allowed to do so.
      if fast_text_printing and dialogue_function in skippable_functions:
        while result_value is GDScriptFunctionState and result_value.is_valid():
          result_value = result_value.resume()
        # Otherwise, if the function has yielded, we need to yield here also to wait until that coroutine completes.
        yield(result_value, "completed")

That should now put the final touches on that feature. We can now move quickly through the text and still prevent certain important functions from being skipped over.

Integrating conversations into your game

Up until now we’ve been solely focussed on our dialogue scene. We’re going to take a bit of a detour here and start thinking about how we can fit this into a larger project.

Let’s imagine that this conversation is part of a cutscene in a 2D platformer. I’m going to create a new scene and, using some of the asset packs I’ve downloaded, I’m going to build a little scene with a couple of characters.

Here are the asset packs I’ve used:

I’m not going to give you my exact scene layout, so just have fun with this and build something cool! All you’ll need is a node to represent your player, and another to represent an NPC (who we’ll be having the conversation with).

Keep in mind how the scene is going to look with our dialogue UI at the bottom. Ensure the scene will still look ok with the bottom 64 pixels covered.

Here’s what I’ve come up with.

Screenshot of a scene for a platformer-style game made with free asset packs

What you’ll want to do now is drag in our DialogueUI scene. Position it at (0,0) so that it lines up with the bottom of the screen.

Screenshot of demo scene with UI node added

And you can see the basic structure of my scene here.

Screenshot of editor showing node hierarchy in scene

Basically, I’ve got a background and foreground layer, which contain all the static sprites for the environment. Then, I’ve got two AnimatedSprites to represent my player and my NPC. Finally, our DialogueUI node (which I’ve just name UI) sits at the bottom.

Now, what I want to do is to be able to walk my character up to the NPC and, when I get close enough, trigger the conversation from a button press.

This tutorial isn’t about 2D character control so I’m just going to write a really basic script.

Add a new script to your Player node and add this code.

extends AnimatedSprite

export var walk_speed = 40

var in_conversation = false

func _process(delta):
  if in_conversation:

  if Input.is_action_pressed("ui_right"):
    translate(Vector2(walk_speed * delta,0))
    flip_h = false
  elif Input.is_action_pressed("ui_left"):
    translate(Vector2(-walk_speed * delta,0))
    flip_h = true

I defined an "idle" and a "walk" animation on my sprite, so I’m playing the "walk" animation when my character is moving, and "idle" otherwise. Feel free to remove these lines if you haven’t bothered to animate your character.

I’ve also anticipated that we’ll want to prevent player movement while a conversation is active, so I’ve set up an in_conversation variable in preparation for that.

Now, we can move our character to the left and right, but what we want is for our dialogue UI to be hidden initially, and then appear and start the conversation when we get close to the NPC. There’s some tweaks we’ll need to make to our DialogueUI script so that we can start conversations from a script.

Change the _ready function to look like this,

func _ready():
  visible = false

and add a new function, start_conversation(),

func start_conversation():
  current_index = 0
  current_choice = 0
  visible = true

This means that our conversation won’t start automatically when we run our scene. Instead, it will wait for start_conversation() to get called. When start_conversation() is called, we set up the variables for our conversation, start the first piece of dialogue, and make our UI visible.

We need to add one more thing at the start of _process.

func _process(delta):
  if not visible:

This prevents button presses from advancing the conversation until we’ve actually started it.

You won’t see much yet if you run the scene. The dialogue UI will be hidden, but there’s nothing to trigger the start of the conversation.

Let’s add some code to our player script, so that it can trigger the conversation.

export var ui_path: NodePath
onready var ui = get_node(ui_path)

func _process(delta):
  if not in_conversation:
    in_conversation = true

Now, assign the ui_path variable to the UI node in the scene.

If you run the scene now, you should again see that the conversation starts immediately.

Now that we know our start_conversation function works you can remove the code we just added to the top of _process. We’re not going to need it.

Let’s add a script to our NPC. The NPC is going to let the player know when it’s within range, so the player knows when it can start a conversation.

Here’s my NPC script,

extends AnimatedSprite

export var player_path: NodePath
onready var player = get_node(player_path)

export var conversation_distance = 30

func _ready():
  $SpeechBubble.visible = false

func _process(delta):
  if abs(position.x - player.position.x) < conversation_distance:
    $SpeechBubble.visible = true
    $SpeechBubble.visible = false

Notice my NPC includes a node called SpeechBubble. I’ve added this as a visual indicator for when the player is able to talk to the NPC. Feel free to add this in as well if you like.

Screenshot of characters in scene with speech bubble appearing above head of NPC

This script also has a reference to the player (don’t forget to assign it in the scene view). If the distance between the NPC and the player is less than conversation_distance, the speech bubble indicator will show, and the NPC calls a function on the player, npc_in_range, to let it know that they can start a conversation.

Let’s define npc_in_range on our player now.

func npc_in_range():
  if not in_conversation and Input.is_action_just_pressed("ui_accept"):
    in_conversation = true

We’ll use the Enter key ("ui_accept") to trigger our conversation. A conversation can only be triggered if we aren’t already in a conversation.

So, essentially this function is triggered when our NPC is within range of the player, and if they are, we’ll start a conversation when Enter is pressed, as long as a conversation isn’t already in progress.

Run the scene to test out this code. You should be able to walk up to the NPC and press Enter to start the conversation.

Screenshot of scene showing UI visible after conversation is triggered

The next thing we need to consider is how to end the conversation. At the moment, we just get to the final piece of dialogue and end up stuck there. Let’s fix that.

To allow the player to get notified at the end of the conversation, we’re going to use a Custom signal.

Add this to your Dialogue UI script:

signal conversation_finished  

Next, define a finish_conversation function.

func finish_conversation():
  visible = false

Finally, add an else clause at the end of the _process function to call it at the end of the conversation.

    if Input.is_action_just_pressed("ui_accept"):

That else block should match up with if current_index < (conversation.size() - 1). If we’re inside that else block, it means we’re at the end of the conversation.

If the player presses Enter when at the end of the conversation, we’re going to call our new function finish_conversation, which hides the UI and emits our new signal.

Next, with the UI selected in the scene view, select to Signals tab and connect finish_conversation to the Player node.

Screenshot of editor window to connect signal to method on Player

All we need to do in our Player script is to set in_conversation to false when this signal is emitted.

func _on_UI_conversation_finished():
  in_conversation = false

That’s it! Test out your scene again. You should be able to run through the whole conversation with the NPC and then continue playing. The UI should hide once you’re done, and then you should be able to trigger the conversation again and repeat it.


In this section we looked at how to trigger events during a conversation, and how to incorporate our script into a larger scene.