Cyberpunk scene in SNES pixel art style with dialogue box and options

Introduction

Today we’re going to build a simple dialogue system in the Godot engine. This tutorial is broken up into several parts, each including completed source code on GitHub, plus a HTML build of the project at the end of each part that you can play right in your browser.

This is Part 1, which covers,

Here’s an example of where we’ll get to at the end of Part 1.

Screenshot of dialogue box showing character image and two dialogue options

Click here to play it in your browser

There is also a Part 2 and a Part 3.

Part 2 covers,

Part 3 covers,

To see where we’ll end up at the end of Part 3, click here

Part 4 is coming soon, and will cover how we can turn this into a reusable plugin for Godot.


The style I’m aiming for in this project is similar to retro nintendo RPGs. Think NES/SNES, or early Gameboy titles. Here’s a few references for the kind of thing we want to build.

Screenshot from Chrono Trigger showing character dialogue box

Screenshot from Chrono Trigger showing dialogue selection These screenshots from Chrono Trigger (1995) show how a window appears at the bottom of the screen to show dialogue, starting with the speaking character’s name in all caps. Simple choices are also shown with a selection indicator pointing to the chosen option.

Screenshot from Ducktales 2 (1993, NES) showing dialogue window with character portraits Ducktales 2 (1993, NES) has these character portraits beside the dialogue window to show who’s speaking.

Screenshot from Golden Sun (2001, GBA) showing dialogue options with character Golden Sun (2001, GBA) included a dialogue window and portrait window positioned dynamically on the screen, with simple yes/no options suitable for the smaller screen.

Screenshot from Donkey Kong Country 3 (1996, SNES) Donkey Kong Country 3 (1996, SNES). This game also used a dialogue window at the bottom of the screen, with selectable options built into the game scene itself.

Screenshot from Dune (1992, PC) showing dialogue options with character image Dune (1992, PC). A different layout and sizing suitable for a computer monitor, but still contains the main elements of a dialogue window, character portrait, and selectable dialogue options.

Screenshot from Banjo Kazooie on N64 Banjo Kazooie (1998, N64), a slightly later game for Nintendo 64, also includes a dialogue banner with a character portrait at the bottom of the screen, and also features text colouring and animation.

Project setup

Since I’m aiming for a SNES game style, we’ll be working with a screen resolution of 256x224. I’ve gone with a simple static layout that will occupy the bottom section of the screen, with a height of 64 pixels. I’m also going to include a character portrait element, which will go in the bottom left, in a 64x64 square.

I plan on using the dialogue section to also include dialogue options underneath.

Scene layout with pixel dimensions


I like designing things top-down, so before we get into scripting, let’s start by mocking up a UI.

Assets I’ll be using:

  1. This 24x24 sprite for the NinePatchRect (I quickly whipped this up using Piskel. <div class='pixel-image-wrapper'></div>

  2. zx_spectrum font https://www.dafont.com/zx-spectrum-7.font

  3. These 32x32 portrait images https://emily2.itch.io/pixel-portraits-32x32

  4. These great cyberpunk sprite sets https://ansimuz.itch.io/warped-city and https://ansimuz.itch.io/warped-city-2

  5. This 32x32 speech bubble sprite. Another asset I quickly draw in Piskel. <div class='pixel-image-wrapper'></div>

Having high quality assets available FOR FREE is incredibly helpful for writing tutorials like this one. If you download these assets, please remember to support artists in their tireless work and donate, if you can!


First, we have some settings to change.

Open your Project Settings (Project -> Project Settings)

Our scene is going to be made up of these nodes:

Let’s get started!

  1. Create a new scene. Choose ‘Other node’ and choose Control. Name it ‘DialogueUI’.

  2. Add a NinePatchRect and call it PortraitRect

  3. Expand its Rect properties to set its x,y position to 0, 160, and set its size to 64x64. This will be the area for our portrait.

  4. Select the ui_border.png image from the FileSystem and drag it into the Texture property of our PortraitRect node. Also ensure the import settings for the image have Filter unchecked so that we get crisp pixels.

  5. Set Patch Margin to 8 pixels for each side.

  6. Add another NinePatchRect and call it DialogueRect

  7. Set position to 64, 160. Set size to 192x64. Update the Patch Margin to 8 pixels each side.

  8. Set its texture to ui_border.png as well. Your viewport should be looking something like this: Viewport with NinePatchRect textures laid out

  9. Drag in a portrait image as a child of the PortraitRect. Name it ‘Portrait’ and position it at (32,32) to centre it. In my case, my portrait is only 32x32 pixels, so I’ve scaled it up 2x. I’ve also edited it to add transparency at the edges so it doesn’t overlap the border. Adding a portrait image

Creating the font resource:

  1. Create a new resource of type DynamicFont. Call it zx_spectrum.tres. Drag the imported zx_spectrum.ttf file into the Font attribute of your new resource. Set the size of the font to 7.Creating the font resource

Now, let’s start adding our text:

  1. Add a RichTextLabel as a child of the DialogueRect and position it at the top of the dialogue box. Call it ‘CharacterName’.

  2. To set up the font, expand the Theme Overrides tab. Drag our font into the Normal Font attribute section. Add in some text to help with placement.

  3. Add a second RichTextLabel below the first. Call this one Dialogue. Again, set the font, add some text, and position and scale it. I’ve kept the size as two lines only so that we can fit in two selection options below.

  4. Add two more RichTextLabels for Choice A and Choice B. I’ve also added two extra labels called SelectA and SelectB, which I plan on using as indicators for which option is selected.

At this point, it should look something like this.

Viewport layout with text labels

And your scene hierarchy should look something like this.

Scene hierarchy with portrait and text labels added

Defining dialogue via a script

Great! It’s starting to look like something now. Unfortunately, it doesn’t actually *do *anything yet. If we play the scene, it’ll just show us the static text we just defined.

So, what sorts of things do we want our dialogue box to be able to do?

  1. Follow a predefined text sequence, with player choices determining the path through the conversation.

  2. Show the name and portrait of the character currently speaking.

These two will be enough to get started. It won’t be very dynamic or flexible, but it’ll be enough to get a basic conversation happening.

Let’s get stuck into some scripting!

Add a script to our base Control node and call it DialogueUI.

Let’s start simple. Instead of having our text set in the inspector, let’s define it in our script and set it there instead. To do that, we’re going to need to define some string constants, and get some references to our text labels so that we can modify them.

extends Control

onready var name_node = get_node("DialogueRect/CharacterName")
onready var dialogue_node = get_node("DialogueRect/Dialogue")
onready var choice_a_node = get_node("DialogueRect/ChoiceA")
onready var choice_b_node = get_node("DialogueRect/ChoiceB")

func _ready():
  name_node.text = "ALEX"
  dialogue_node.text = "Hey there.\nDo you like apples?"
  choice_a_node.text = "Sure do!"
  choice_b_node.text = "No way, gross!"

All we’re doing here is grabbing our text labels and then setting their text properties when we start the scene. If you want to make sure it’s working, go ahead and change the text in the inspector so that we can see our script is actually setting the correct text.

Playing the scene gives us this:

Scene view with text displayed via script

That’s great, but it still doesn’t actually do anything. Let’s start with the most basic thing we can think of. How about, “When I press Enter, show some new dialogue”. Add this to your script:

func _process(delta):
  if Input.is_action_just_pressed("ui_accept"):
    dialogue_node.text = "This is what I'll say next."

Now, if you play the scene, the dialogue text will be replaced with this new text when you hit Enter.

Exciting stuff! But not very flexible. What we’d like instead is to be able to step through a whole list of dialogue pieces one bit at a time. To do that, we’ll need to starting tracking the current ‘state’ of our conversation.

For now, our ‘state’ will just be the index of where we’re up to in the conversation. Our dialogue will be defined in an array that we can step through using the Enter key.

Add this to the top of your script:

var dialogue = [
  "Hey there.\nDo you like apples?",
  "I like other fruits too.",
  "Bananas are my favourite!"
]

var current_index = 0

Then, change our _process function to look like this:

func _process(delta):
  if Input.is_action_just_pressed("ui_accept"):
    current_index += 1
    dialogue_node.text = dialogue[current_index]

We also need to modify our _ready function to set the dialogue initially.

func _ready():
  name_node.text = "ALEX"
  dialogue_node.text = dialogue[current_index]
  choice_a_node.text = "Sure do!"
  choice_b_node.text = "No way, gross!"

Now, when we run the scene, we’ll see that when we press the Enter key, we step through each piece of dialogue defined in our array!

There’s still some issues with our script. We’re overrunning the end of our array, so we’ll get an error once we get to the end of the conversation. Let’s fix our _process function so prevent that from happening.

func _process(delta):
  if (Input.is_action_just_pressed("ui_accept")
      and current_index < (dialogue.size() - 1)):
    current_index += 1
    dialogue_node.text = dialogue[current_index]

We will now only progress to the next part of the conversation if the next index won’t overrun the size of the array.

Introducing branching conversation options

Great! We can now create a conversation as a pre-defined sequence of dialogue sections. Unfortunately, it’s not so much a conversation as it as a monologue. Next, let’s look at how we can introduce some choices.

First, what does it look like to have choices in our conversation? How does that affect the flow of the dialogue, and how might we represent that in our script?

We currently have control in our script over which part of the conversation we show in the UI. It’s just that the only control we’ve given the player is to go one step forward. What if, instead of just stepping forward, we allow the choice the player makes to define the next index?

We’ll want to have different choices for each step of the conversation too. At the moment, each step in our conversation is just a string representing the dialogue. We need to make this more complex, so that we can include our choices in there too. For that, we’ll use a dictionary. Let’s modify the dialogue array to look like this:

var conversation = [
  {
    "dialogue": "Hey there.\nDo you like apples?"
  },
  {
    "dialogue": "I like other fruits too."
  },
  {
    "dialogue": "Bananas are my favourite!"
  }
  ]

We haven’t added any additional information for choices yet, we’ve just modified our data structure so that our dialogue now lives inside a “dialogue” key on a dictionary. We’ve also renamed our variable to conversation in preparation for this holding additional data for our conversation, other than just dialogue.

Let’s fix up our _ready and _process functions to use this new structure:

func _ready():
  name_node.text = "ALEX"
  dialogue_node.text = conversation[current_index]["dialogue"]
  choice_a_node.text = "Sure do!"
  choice_b_node.text = "No way, gross!"

func _process(delta):
  if (Input.is_action_just_pressed("ui_accept")
      and current_index < (conversation.size() - 1)):
    current_index += 1
    dialogue_node.text = conversation[current_index]["dialogue"]

Running the scene should still allow us to step through the conversation exactly as before.

Now, let’s modify our conversation array to include a choice:

var conversation = [
  {
    "dialogue": "Hey there.\nDo you like apples?",
    "choices": [
      {
        "dialogue": "Sure do!",
        "destination": 1
      },
      {
        "dialogue": "No way, gross!",
        "destination": 2
      }
      ]
  },
  {
    "dialogue": "You like apples? Me too!"
  },
  {
    "dialogue": "You don't?\nThat's a shame."
  },
  {
    "dialogue": "I like other fruits too."
  },
  {
    "dialogue": "Bananas are my favourite!"
  }
  ]

As you can see, our choices have their own piece of dialogue, plus something called destination. This is the index of the next piece of dialogue to follow this choice. Index 1 is the response for when the player says they like apples. Index 2 is for when the player says they don’t like apples.

Again, before we start writing code for a UI to manage our choice selection, let’s write the simplest code we can to test out our new dialogue paths.

func _process(delta):
  if current_index < (conversation.size() - 1):
    var previous_index = current_index
    
    if conversation[current_index].has("choices"):
      if Input.is_action_just_pressed("ui_up"):
        current_index = conversation[current_index]["choices"][0]["destination"]
        
      if Input.is_action_just_pressed("ui_down"):
        current_index = conversation[current_index]["choices"][1]["destination"]
      
    if Input.is_action_just_pressed("ui_accept"):
      current_index += 1
    
    if current_index != previous_index:
      dialogue_node.text = conversation[current_index]["dialogue"]

There’s a couple of things we’ve done here.

First, we’re using the Up and Down arrows (ui_up and ui_down) for our choice selection. We only check these when our current conversation step actually has choices. We’ll add the UI controls later.

Second, we’ve introduced a local variable, previous_index. This is how we’re going to detect changes in the current_index so that we know when we need to update the text label.

Now, if we run the scene, at the first step of the conversation we can press either the Up or Down arrow, and we’ll see the appropriate response. Afterwards, by pressing Enter, the conversation will continue sequentially.

There’s still one problem here we need to fix. If we take the “I like apples” path to index 1, we’ll end up moving forward through the “I don’t like apples” part too. We need a way to skip ahead from index 1 to index 3, where our two conversation branches come together again. Basically, our dialogue sections need destinations too.

First, add a destination to this piece of dialogue.

{
  "dialogue": "You like apples? Me too!",
  "destination": 3
},

Next, let’s handle that when calculating our update to current_index.

if Input.is_action_just_pressed("ui_accept"):
  current_index = conversation[current_index].get(
    "destination", current_index + 1
  )

This will check to see if our current conversation piece has a destination and use that to update current_index. If it doesn’t, then we just use current_index + 1 as the default.

And that’s it! Now, when you run the scene and press Up to take the “I like apples” path, you’ll skip over the negative response and continue the conversation afterwards.

Making defining dialogue choices easier

Before we continue on, there’s one more feature we can add that will make our lives a lot easier while editing conversations. At the moment, our destination values are all absolute indices within the array. This means that, if we wanted to insert an extra piece of dialogue at the beginning, we’d need to update all the destinations afterwards.

What would be a lot nicer is if we could label our conversation sections in some way, and then just say “go to part 2 of the conversation”.

Sounds much more convenient. Let’s do it!

Let’s update our conversation to look like this:

var conversation = [
  {
    "dialogue": "Hey there.\nDo you like apples?",
    "choices": [
      {
        "dialogue": "Sure do!",
        "destination": "apples_good"
      },
      {
        "dialogue": "No way, gross!",
        "destination": "apples_bad"
      }
      ]
  },
  {
    "label": "apples_good",
    "dialogue": "You like apples? Me too!",
    "destination": "part_2"
  },
  {
    "label": "apples_bad",
    "dialogue": "You don't?\nThat's a shame."
  },
  {
    "label": "part_2",
    "dialogue": "I like other fruits too."
  },
  {
    "dialogue": "Bananas are my favourite!"
  }
  ]

We’ve replaced our destination integers with some string values instead. We’ve also added some label keys with values to match the destinations we want to use.

Next, we’re going to define a helper function for us to find the index of a dictionary with a given label:

func get_index_of_label(label):
  for i in range(conversation.size()):
    if conversation[i].get("label") == label:
      return i
  
  assert(false, "Label %s does not exist in this conversation!" % label)

We’ve added an assert here for when we can’t find a part of the conversation with a label. If we hit this, it means we’ve make a typo or forgotten to define a particular label and we need to go back and fix it!

Finally, let’s modify our _process function to use labels to calculate updates to current_index.

func _process(delta):
  if current_index < (conversation.size() - 1):
    var previous_index = current_index
    var destination = null
    
    if conversation[current_index].has("choices"):
      if Input.is_action_just_pressed("ui_up"):
        destination = conversation[current_index]["choices"][0]["destination"]
        
      if Input.is_action_just_pressed("ui_down"):
        destination = conversation[current_index]["choices"][1]["destination"]
      
    if Input.is_action_just_pressed("ui_accept"):
      destination = conversation[current_index].get("destination", false)
    
    if destination != null:
      if destination:
        current_index = get_index_of_label(destination)
      else:
        current_index += 1
    
    if current_index != previous_index:
      dialogue_node.text = conversation[current_index]["dialogue"]

Instead of setting the current_index directly, we now set a destination variable instead, and then calculate the current_index afterwards. We’ll still fall back to incrementing by one if no label is defined. This saves us from needing to label every single conversation piece.

Notice that we default destination to null, and then set it to false if we haven’t got a label as a destination. We need to distinguish between these two states because null means we shouldn’t progress through the conversation at all, while false means we should step forward one. Using null and false to mean two separate things isn’t ideal, but it works well enough for now so we’ll come back and clean it up later.

That’s it! Everything should work exactly as it did before, only now we don’t need to use absolute indices as a reference for each part of the conversation!

Scripting the UI

Now that we can make choices, let’s hook up our UI so that we can select them from our dialogue box.

Let’s start by setting the text on our choice labels from our conversation data. First, let’s define a couple of new functions.

func get_current_choice(choice_index):
  var choices = conversation[current_index].get("choices", [])
  if choice_index < choices.size():
    return choices[choice_index]
  else:
    return {}
    
func update_text_labels():
  name_node.text = "ALEX"
  dialogue_node.text = conversation[current_index]["dialogue"]
  choice_a_node.text = get_current_choice(0).get("dialogue", "...")
  choice_b_node.text = get_current_choice(1).get("dialogue", "")

The get_current_choice function is a helper that let’s us safely fetch choices and return convenient defaults if none exist.

The update_text_labels wraps up the updates for all our RichTextLabels into one place. You can see here how we’re using the get_current_choice function here to set the text on our choice labels. When we haven’t defined any choices and our only option is to go forward, we default to using the string “…” for Choice A, and a blank string for Choice B.

We can now update our script to use update_text_labels wherever we need them to update.

Our _ready function,

func _ready():
  update_text_labels()

And, in _process,

if current_index != previous_index:
  update_text_labels()

Now when we run the scene, we should see the Choice A and Choice B labels updating as we move through the conversation!

Refining the UI controls

Great! Next thing we need to do is let the player actually select the choices available. At the moment, we’re using the Up and Down arrows to make the choice directly. Let’s change that so that Up and Down only change our selection, and the player needs to actually press Enter to make the choice. We’ll also hook up those Select A and Select B nodes so that we can show the player the selection.

Our node will now need to remember the current selection so that when we press Enter, it knows which path to take. So, to start off, let’s define a new state variable for our choice, and add code to change it when we press the Up and Down keys.

Add a current_choice variable under our existing current_index state variable.

var current_index = 0
var current_choice = 0

Change the code that runs when we push the Up and Down arrows, to change our current choice.

      if Input.is_action_just_pressed("ui_up"):
        current_choice -= 1
        
      if Input.is_action_just_pressed("ui_down"):
        current_choice += 1

Finally, when we press Enter, we need to go to the destination selected by our choice. There’s a little bit of complexity here now, because we don’t always have a choice, or even a destination. Let’s list the different scenarios we have:

  1. No choices or destination. Result: just go to next conversation piece (increment index)

  2. No choices, but destination defined. Result: find the next index based on the destination label.

  3. choices is defined, and current choice has destination. Result: find the next index based on the destination label of the current choice.

  4. choices is defined, but current choice has no destination. Result: just go to next conversation piece (increment index)

You’ll notice that scenario 4) isn’t something we’ve encountered yet, but it follows on from the other scenarios. As the dialogue designer, you could potentially have one choice that just continues on the main path, and another that takes you onto a side tangent and then loops back.

Currently, when we press Enter, all we do is set the destination by fetching it from our current conversation piece:

destination = conversation[current_index].get("destination", false)

This covers scenario 1) and 2), but we still need to implement 3) and 4), where we have some choices defined. Add a conditional statement around our existing code so we can handling these scenarios.

  if conversation[current_index].has("choices"):
    var choice = conversation[current_index]["choices"][current_choice]
    destination = choice.get("destination", false)
  else:
    destination = conversation[current_index].get("destination", false)

This should be pretty straightforward. If the “choices” key exists in our dictionary, fetch the “destination” for the current choice and assign it to destination. Otherwise, just execute our existing code.

That should be enough now to test out our code. Try running the scene again, and pressing Down before pressing Enter to see how the conversation will follow the second path.

Cleaning up our _process function

Let’s make one more improvement before moving on. We can make our _process function a lot simpler by pulling the logic to calculate our next index out into a separate function.

func get_next_index():
  var destination = null
  if conversation[current_index].has("choices"):
    var choice = conversation[current_index]["choices"][current_choice]
    destination = choice.get("destination")
  else:
    destination = conversation[current_index].get("destination")
    
  if destination:
    return get_index_of_label(destination)
  else:
    return current_index + 1

Now, in _process, we only need to call this, and then check if the current_index is going to change.

    if Input.is_action_just_pressed("ui_accept"):
      current_index = get_next_index()
  
    if current_index != previous_index:
      update_text_labels()

All of the code to calculate the destination and the current_index can now be removed from _process.


Aside: Design notes

We could make this function simpler if we decided not to allow a destination at top level of our data structure, and instead just require that a single choice is defined. For example, this,

{
  "label": "apples_good",
  "dialogue": "You like apples? Me too!",
  "choices": [
    {
      "dialogue": "...",
      "destination": "part_2"
    }
  ]
},

instead of this,

{
  "label": "apples_good",
  "dialogue": "You like apples? Me too!",
  "destination": "part_2"
},

However, this is a lot of additional boilerplate code just to say what the next piece of dialogue is. If we were using this tool across a whole project, it’d get pretty annoying needing to write out a choice array with a single item every time. In this case, we have the opportunity to keep our data structure simple by handling the complexity in the internals of our class. We only have to do this once and then we benefit from then on, so it’s a good payoff!


There’s still a couple of pretty obvious issues that we should fix:

  1. There’s nothing to indicate to the player which option they’ve chosen.

  2. There’s nothing stopping the player from trying to choose options other than the ones defined and causing an error when trying to fetch an unknown index from the choices array. We need to bound our choices to just those defined in our conversation.

Let’s start with the first issue. You’ll remember that when we first set up the scene we included a couple of extra nodes (SelectA and SelectB) intended to be toggled to show the current selection. Well, it’s time to make our script aware of those nodes! Let’s add them at the top of our script, under the nodes we’ve already defined:

onready var name_node = get_node("DialogueRect/CharacterName")
onready var dialogue_node = get_node("DialogueRect/Dialogue")
onready var choice_a_node = get_node("DialogueRect/ChoiceA")
onready var choice_b_node = get_node("DialogueRect/ChoiceB")
onready var select_a_node = get_node("DialogueRect/SelectA")
onready var select_b_node = get_node("DialogueRect/SelectB")

Now, when we have our first choice selected, we want to show SelectA and hide SelectB. When we have the second choice selected, vice versa we want SelectA hidden and SelectB shown.

Add this to our _process function, just under the part where we update our current_choice.

    if current_choice == 0:
      select_a_node.visible = true
      select_b_node.visible = false
    elif current_choice == 1:
      select_a_node.visible = false
      select_b_node.visible = true

This isn’t very generic (it only caters to having one or two options), but it’s enough for us to use for now.

If you run the scene, you’ll now see that pressing Down will appear to move the selection indicator down, and pressing Up will appear to move it back up again.

For this example, I’ve chosen to use two separate nodes to be shown/hidden to represent the two choices available, but your project might have different needs. You might want more than two choices, or you might want a completely different way to show the current selection. We can take a step in the right direction by pulling this logic out into a separate function which is solely responsible for displaying the current selection.

func update_select_indicators():
  var select_nodes = [
    select_a_node,
    select_b_node
  ]
  for node in select_nodes:
    node.visible = false
    
  select_nodes[current_choice].visible = true

This implementation works for my example, but you can change it to fit whatever your needs.

We also need to call the function when we update our current choice.

    if Input.is_action_just_pressed("ui_up"):
      current_choice -= 1
      update_select_indicators()
      
    if Input.is_action_just_pressed("ui_down"):
      current_choice += 1
      update_select_indicators()

We can now remove our if current_choice == 0:... code block.

Finally, now that we only call this update after an arrow key is pressed, we also need to call this at the start of the conversation. Otherwise, both select indicators will be showing initially. Update the _ready function so that we do an initial update of the select indicators.

func _ready():
  update_text_labels()
  update_select_indicators()

Try running the scene again to make sure everything is still working.


Ok, let’s fix our second issue now. At the moment, you might have noticed that if you press Up and Down multiple times, current_choice keeps incrementing or decrementing past the valid options of 0 or 1. Let’s define a few functions to help us keep our current_choice valid.

func get_current_choice_count():
  var choices = conversation[current_index].get("choices")
  if choices:
    return choices.size()
  else:
    return 1

func safe_select_previous_choice():
  current_choice = clamp(current_choice - 1, 0, get_current_choice_count() - 1)
  update_select_indicators()
  
func safe_select_next_choice():
  current_choice = clamp(current_choice + 1, 0, get_current_choice_count() - 1)
  update_select_indicators()

If we use the safe_ functions, there’s no way we can move past the first or last valid choice index. Notice in get_current_choice_count that we default to 1 if there are no choices defined. This is because we always have at least one valid choice (to move forward in the conversation).

We also call update_select_indicators() inside these functions. We want this to happen whenever current_choice gets updated, and this means we don’t need to remember to call it from within _process.

All that’s left is to use these in our _process function:

  if current_index < (conversation.size() - 1):
    var previous_index = current_index
    
    if Input.is_action_just_pressed("ui_up"):
      safe_select_previous_choice()
      
    if Input.is_action_just_pressed("ui_down"):
      safe_select_next_choice()
      
    if Input.is_action_just_pressed("ui_accept"):
      current_index = get_next_index()

Try out the scene again. You’ll notice now that, no matter how many times you press Up or Down, it only requires a single press in the opposite direction to change your selection.

You might now have noticed there’s one final thing to fix. If we choose the second option at the first stage, we’ll end up with a blank second option selected at the next one. We need to reset our choice whenever we progress so we don’t end up with an invalid selection.

Let’s define a function for that as well, again also calling update_select_indicators since current_choice has changed.

func reset_selection():
  current_choice = 0
  update_select_indicators()

And at the end of _process,

    if current_index != previous_index:
      update_text_labels()
      reset_selection()

And that’s it! Try out the scene a few times, taking different paths. You should be able to make choices and follow each branch of the conversation through to the end.

Conclusion

Here’s the final script for reference:

extends Control

onready var name_node = get_node("DialogueRect/CharacterName")
onready var dialogue_node = get_node("DialogueRect/Dialogue")
onready var choice_a_node = get_node("DialogueRect/ChoiceA")
onready var choice_b_node = get_node("DialogueRect/ChoiceB")
onready var select_a_node = get_node("DialogueRect/SelectA")
onready var select_b_node = get_node("DialogueRect/SelectB")

var conversation = [
  {
    "dialogue": "Hey there.\nDo you like apples?",
    "choices": [
      {
        "dialogue": "Sure do!",
        "destination": "apples_good"
      },
      {
        "dialogue": "No way, gross!",
        "destination": "apples_bad"
      }
      ]
  },
  {
    "label": "apples_good",
    "dialogue": "You like apples? Me too!",
    "destination": "part_2"
  },
  {
    "label": "apples_bad",
    "dialogue": "You don't?\nThat's a shame."
  },
  {
    "label": "part_2",
    "dialogue": "I like other fruits too."
  },
  {
    "dialogue": "Bananas are my favourite!"
  }
  ]

var current_index = 0
var current_choice = 0

func _ready():
  update_text_labels()
  update_select_indicators()

func _process(delta):
  if current_index < (conversation.size() - 1):
    var previous_index = current_index
    
    if Input.is_action_just_pressed("ui_up"):
      safe_select_previous_choice()
      
    if Input.is_action_just_pressed("ui_down"):
      safe_select_next_choice()
      
    if Input.is_action_just_pressed("ui_accept"):
      current_index = get_next_index()
  
    if current_index != previous_index:
      update_text_labels()
      reset_selection()
  
func get_index_of_label(label):
  for i in range(conversation.size()):
    if conversation[i].get("label") == label:
      return i
  
  assert(false, "Label %s does not exist in this conversation!" % label)
  
func get_next_index():
  var destination = null
  if conversation[current_index].has("choices"):
    var choice = conversation[current_index]["choices"][current_choice]
    destination = choice.get("destination")
  else:
    destination = conversation[current_index].get("destination")
    
  if destination:
    return get_index_of_label(destination)
  else:
    return current_index + 1
  
func get_current_choice(choice_index):
  var choices = conversation[current_index].get("choices", [])
  if choice_index < choices.size():
    return choices[choice_index]
  else:
    return {}
    
func update_text_labels():
  name_node.text = "ALEX"
  dialogue_node.text = conversation[current_index]["dialogue"]
  choice_a_node.text = get_current_choice(0).get("dialogue", "...")
  choice_b_node.text = get_current_choice(1).get("dialogue", "")
  
func update_select_indicators():
  var select_nodes = [
    select_a_node,
    select_b_node
  ]
  for node in select_nodes:
    node.visible = false
    
  select_nodes[current_choice].visible = true

func get_current_choice_count():
  var choices = conversation[current_index].get("choices")
  if choices:
    return choices.size()
  else:
    return 1

func safe_select_previous_choice():
  current_choice = clamp(current_choice - 1, 0, get_current_choice_count() - 1)
  update_select_indicators()
  
func safe_select_next_choice():
  current_choice = clamp(current_choice + 1, 0, get_current_choice_count() - 1)
  update_select_indicators()
  
func reset_selection():
  current_choice = 0
  update_select_indicators()

And that’s the end of Part 1!


Continue to Part 2, where we look at adding multiple characters, text animation, and how to insert variables.