How I Made a Simple Dialogue System with Godot

Making a character say something is easy.  Making a character respond to a player’s choice isn’t much more difficult.  Making a reusable abstraction that does all of those things is a bit more involved, but easily achievable.  This article explains how I implemented a simple dialogue system for Space Thugs Infinity (play on itch.io).  By the way, if you are making a dialogue system you might also be interested in how to build a technology tree with Godot.

Space Thugs Infinity was inspired by the classic DOS game Tyrian and created with Godot and GDScript.  Tyrian is a vertical scrolling shooter with an upgrade system and storying telling elements.  The story telling aspect of Tyrian is easily overlooked because it mostly tucked away in the menu system that is only accessible between missions.  There is no form of dialogue system with the exception of a couple of non-interactive cut-scenes.  I wanted Space Thugs Infinity to have a more prominent story telling mechanic.

As a disclaimer, I do not consider myself a Godot, GDScript, or game development expert.  Space Thugs Infinity is the first game I have made with Godot, and the first game I have made that was worth sharing.  Additionally, this article includes code samples but it is not intended to be a tutorial.  As you read, know that there may be ways to improve upon my solution and that some steps for the implementation are omitted.

A dialogue system, or a dialogue tree, is a mechanic that allows the player to navigate their way through a conversation with a computer controlled character that responds to the player’s choices.  Tree implies that there are many branches off-shooting from one another and converging on a single trunk.  GDScript already has a perfect type to represent a data structure like this called a dict.

var dialogue = {
    "intro": {
        "dialogue":
            "Character dialogue",
        "affirmative": {
            "text": "Yes",
            "value": "example_effect"
        },
        "negative": {
            "text": "No",
            "value": ""
        }
    },
    "example_effect": {
        "func": "",
        "args": []
    }
} setget set_dialogue

In this example, the value of dialogue["intro"]["affirmative"]["value"] is "example_effect".  If you were to execute dialogue[dialogue["intro"]["affirmative"["value"]], you would get {"func: "", "args": []} as the result.  I chose this flat representation of a tree because it is easier to read and easier to interpret than a nested tree structure.

We need a second dict to tract the state of the dialogue.  If the dialogue tree is at dialogue["intro"], we should store the result in state.  Each time the dialogue progresses, the new state of the dialogue is stored in state.

var state = {} setget set_state

Both the dialogue and the state have simple setter methods.

func set_state(new_state):
    state = new_state
    pass

func set_dialogue(new_dialogue):
    dialogue = new_dialogue
    pass

I started with a base scene that has a Label node to display the text of the character that is speaking as well as two TextureButton nodes that have their own Label nodes for displaying the player’s responses called affirmative and negative.  I guess I could have called these yes and no but that felt too terse to me for no justifiable reason.

Scene screenshot

Example of dialogue scene with text label and buttons.

I connected signals for the affirmative and negative buttons to the dialogue node.  This allowed me to update the state based on whatever button was pressed as well as display the updated state afterwards.

func _on_affirmative_pressed():
    state = dialogue[state["affirmative"]["value"]]
    process_state(state)
    pass

func _on_negative_pressed():
    state = dialogue[state["negative"]["value"]]
    process_state(state)
    pass

The next step is to process the state.  In Space Thugs Infinity, two things can happen.  The state can move to a new dialogue scene, in which case the new dialogue should be displayed.  Or the state could move to an effect (a function).  In this case, the function in the effect should be called.  process_state handles both of these possibilities.

func process_state(state):
    if state.has("func"):
        return callv(state["func"], state["args"])
    if state.has("dialogue"):
        display_state(state)
    pass

If there is a "func" like in "example_effect", the function is called with its arguments.  It is important to note here that call is applied on this scene, so calling functions from other scenes won’t work.  This will become important later when I try to reuse this base dialogue scene.

If the state contains "dialogue", that dialogue needs to be displayed.  That’s where display_state comes in to play.  Display state updates the text displayed on the screen based on whatever the current state of the dialogue is.  This is as simple as updating the appropriate Label nodes.  In the snippet below, when display state is called with the desired state, it updates the character dialogue label as well as the potential affirmative/negative responses.

func display_state(state):
    $label.text = state["dialogue"]
    $affirmative/label.text = state["affirmative"]["text"]
    $negative/label.text = state["negative"]["text"]
    pass

The next step is to initialize the dialogue and process the first state when this scene is loaded.  This is done in the built in _ready function.

func _ready():
    set_state(dialogue["intro"])
    process_state(state)
    pass

Here is what this script looks like altogether.

extends Node

var dialogue = {
    "intro": {
        "dialogue":
            "Character dialogue",
        "affirmative": {
            "text": "Yes",
            "value": "example_effect"
        },
        "negative": {
            "text": "No",
            "value": ""
        }
    },
    "example_effect": {
        "func": "",
        "args": []
    }
} setget set_dialogue

var state = {} setget set_state

func _ready():
    set_state(dialogue["intro"])
    process_state(state)
    pass

func change_scene(scene):
    get_tree().change_scene(scene)
    pass

func set_state(new_state):
    state = new_state
    pass

func set_dialogue(new_dialogue):
    dialogue = new_dialogue
    pass

func display_state(state):
    $label.text = state["dialogue"]
    $affirmative/label.text = state["affirmative"]["text"]
    $negative/label.text = state["negative"]["text"]
    pass

func process_state(state):
    if state.has("func"):
        return callv(state["func"], state["args"])
    if state.has("dialogue"):
        display_state(state)
    pass

func _on_affirmative_pressed():
    state = dialogue[state["affirmative"]["value"]]
    process_state(state)
    pass

func _on_negative_pressed():
    state = dialogue[state["negative"]["value"]]
    process_state(state)
    pass

You might notice that there is also a function called change_scene.  This function is necessary because it is not possible to use call or callv to call get_tree().change_scene().  All functions called this way are methods of a specific object, so they will need to exist in your current scene or you will need to import them and call them by their object’s name (object_name.call(foo)).  I am including my own change_scene function in my base scene because my main use case is to use the dialogue to move the player to different scenes in the game.

I can run this base scene to see what it looks like, but it isn’t very interesting yet because there is no character avatar, and there is no dialogue.

Screen shot of base scene

To reuse this base scene, I created a new inherited scene from the base scene.  After adding a character avatar, I cleared the original script and added a new one that extends the script from the base scene.  I spent an inordinate amount of time trying to devise a super clever solution to initializing the scene using only the data for the dialogue.  I really wanted to be able to just pass in a dict of dialogue and call it good.  However, this came with a limitation that I could not live with.  Remember the load_scene function?  It was necessary for me write a wrapper so I could callv it from a string in the dialogue dict.  What if I need other functions?  I suppose I could have just kept adding every possible function to the base scene.  However, I felt that I might need to have functions that a specific to the inherited scene, especially if it needs to manipulate nodes unique to that scene based on the outcome of some dialogue like an extra button.

Here is a simplified example of how the inherited scene works.  In the example below, if the player clicks either button the state will be advanced to "load_shop".  This state calls the function change_scene which loads the shop scene.  The dialogue trees in Space Thugs Infinity are a little longer, but not by much.

extends "res://dialogue.gd"

const scene_dialogue = {
    "intro": {
        "dialogue":
            "Want to visit the shop?",
        "affirmative": {
            "text": "Thanks!",
            "value": "load_shop"
        },
        "negative": {
            "text": "Fine",
            "value": "load_shop"
        }
    },
    "load_shop": {
        "func": "change_scene",
        "args": ["res://shop.tscn"]
    }
}

func _ready():
    dialogue = scene_dialogue
    set_state(dialogue["intro"])
    process_state(state)
    pass

The downside of the inheritance route is that I have to call _ready() again and tell it to setup and process the scene.  Here is an example of what a scene from the game looks like using this dialogue system.

Sceenshot from game

So far, I am pretty happy with this dialogue system.  It isn’t too complicated and it allows me to add some interactive story telling elements to the game.  Loading a dialogue scene is as easy as calling get_tree().change_scene("res://my_dialogue_scene.tscn").  This happens in Space Thugs Infinity when a player’s ship enters the collision hitbox for a space station’s docking port.  If you are building a game with dialogue building elements, hopefully this article will give you some ideas about how you can accomplish this task in Godot with GDScript.  To see the gameplay discussed in this article, check out Space Thugs Infinity on itch.io.

Also, here are some other projects inspired by this post: