Making a dev console in Godot - A devlog


I’m working on a tactics game, and the core-est of core functionality and features are largely in the game. You can build units from different parts, enter battle, use unique abilities on those parts, win or lose, and repeat. The graphics, AI, sound, progression, and, you know, the entire rest of the actual game are still under way, but I’ve gotten to the point where just spinning up a quick debug build to test a feature out really isn’t cutting it anymore. So to help speed up the process, I’ve added a dev console to my game.

The basics

The console is an Autoloaded scene that sits on top of all other scenes and consists of just a LineEdit node for input and a RichTextLabel node for displaying results in an easy to format way, along with a few Control nodes for layout purposes. When running a debug build, this scene listens for the right key press and makes itself available to the user (me!).

The primary logic of the console is driven by Godot’s Expression class, which lets you parse and execute arbitrary code from a string. In this case, that’s the text I put into the LineEdit node. Starting out with just pulling the sample code straight from the docs is enough to get a basic parser that can handle built-in GDScript functionality, such as doing math or calling a global command like print.

# Source: https://docs.godotengine.org/en/stable/classes/class_expression.html
var expression = Expression.new()

func _ready():
	$LineEdit.text_submitted.connect(self._on_text_submitted)

func _on_text_submitted(command):
	var error = expression.parse(command)
	if error != OK:
		print(expression.get_error_text())
		return
	var result = expression.execute()
	if not expression.has_execute_failed():
		$LineEdit.text = str(result)

You make an instance of the Expression class, call parse and pass the string you want it to evaluate, and then check if there was any error parsing the code. If not, calling execute runs the expression you passed during parse and returns whatever that expression would return if called directly.

You can also define variables to use in your equations if you want by defining them during your parse action and then plugging in the values you want to use during the call the execute, though I haven’t yet needed to do this.

# Source: https://docs.godotengine.org/en/stable/tutorials/scripting/evaluating_expressions.html#passing-variables-to-an-expression

var expression = Expression.new()
# Define the variable names first in the second parameter of `parse()`.
# In this example, we use `x` for the variable name.
expression.parse("20 + 2 * x", ["x"])
# Then define the variable values in the first parameter of `execute()`.
# Here, `x` is assigned the integer value 5.
var result = expression.execute([5])
print(result)  # 30

This is all pretty neat on its own, but the default functionality is pretty limited on its own, and not too useful for a dev console. Things start to get interesting, though, when you set the base instance of the expression. By doing so, you can now make your Expression instance aware of the object passed to it, allowing you to call commands that exist in its script. In my dev console, I have a number of helpful functions defined, such as moving a unit, reloading a scene, and ending battles, which I make available to the dev console’s Expression instance by passing a reference into all calls to execute:

# Abbreviated for demonstration purposes

# The LineEdit node used for input
@onready var text_input = %text_input

func on_run_command(cmd: String) -> void:
	# Create an Expression instance
	var expression = Expression.new()
	var parse_error = expression.parse(cmd)
	if parse_error != OK:
		# Code here to log and format the error to the dev console
		return

	# First parameter is for variables, which I don't need, so I just pass an empty array as the first parameter
	# Second parameter is the object that can provide additional context to the command
	# In this case, I have "reload" defined in the same script as my Expression, so I just pass "self"
	var result = expression.execute([], self)
	if result != null:
		# Do stuff with the result

# Reload the current scene
func reload() -> void:
	get_tree().reload_current_scene()

With this setup, if I want to reload the current scene, I just type reload() into my console and the current scene reloads! My usage of the base_instance parameter is pretty simple, but you could easily use this to change the debug context throughout your game or hone in on specific scripts with just a few higher level calls to help you get the right reference to them.

Going further

The Expression class covers the basics needed for a dev console by handling parsing, error checking, and running code, which is great, but there’s more that I wanted to really make it useful to me, so here’s a look at some additional features I built into my console.

History

The very first feature I added was a simple history system so that I can easily call the same command, maybe with some minor changes, repeatedly without having to type everything out each time. To do this, I have an array that holds every command I pass to the console and an integer that tracks my current lookup index. Pressing up takes me to the previous command from where I’m at and pressing down takes me to the more recent command. This index resets when I enter a command, and viola.

var history: Array[String] = []
var history_index: int = -1

func on_input() -> void:
	if Input.is_action_just_pressed('_dev_console_enter'):
		history.push_front(text_input.text)
		_run_command(text_input.text)
		history_index = -1
		text_input.text = ''
	elif Input.is_action_just_released('_dev_console_prev'):
		if history.size() == 0:
			return
		history_index = clamp(history_index + 1, 0, history.size() - 1)
		text_input.text = history[history_index]
		# Hack to make the caret go to the end of the line
		# If I ever have a line of code over 100k characters, please send help
		text_input.caret_column = 100000
	elif Input.is_action_just_released('_dev_console_next'):
		if history.size() == 0:
			return
		history_index = clamp(history_index - 1, 0, history.size() - 1)
		text_input.text = history[history_index]
		text_input.caret_column = 100000

Autocomplete

Another feature I wanted was autocomplete. Unlike history, this one was a little trickier to figure out, but I think I’ve got a method that works well enough. As a start, on load, I populate the list of possible methods that can be called by making a call to get_script().get_script_method_list(), which returns a list of all methods defined in the script of the current object (ie the dev console) while also excluding any methods from the inherited class, which helps to keep the autocomplete list shorter and more relevant. This does include things that shouldn’t be manually called, like _ready, but that would be more of a concern if I wasn’t the sole user of the console.

It’s also worth noting that get_script_method_list() actually returns an array of dictionaries containing additional information about each function beyond just its name, such as the arguments it takes and any associated metadata, so I run a map command on the output to extract only the method names.

var autocomplete_methods: Array = []

# Get the list of methods defined in the current script without including any inherited functions
func _ready() -> void:
	autocomplete_methods = get_script().get_script_method_list().map(func (x): return x.name)

With the list populated, I can grab the current text in the console when Tab is pressed, call begins_with on each possible method to see if any of them start with whatever is in the console, and return the first match.

To make things a little more useful, I also track user inputs so that hitting Tab repeatedly steps through the list of all possible matches, and made it so that hitting Tab when the text input is empty lets me step through all methods in the list. I’m not certain if there’s a “better” way to do it (there’s certainly room for performance tweaks if that was needed), but it works for my needs and was fast to implement so I’ll probably stick with this implementation.

# Abbreviated to the relevant bits

# Where in the list of possible matches are we
var autocomplete_index: int = 0
# All methods that are viable for autocomplete
var autocomplete_methods: Array = []
# Track if that last input was related to autocomplete
var last_input_was_autocomplete: bool = false
# Store matches of the last autocomplete so that the search doesn't have to be repeated
# when Tab is pressed multiple times
var prev_autocomplete_matches: Array = []

# Get the list of methods defined in the current script without including any inherited functions
func _ready() -> void:
	autocomplete_methods = get_script().get_script_method_list().map(func (x): return x.name)

func autocomplete() -> void:
    var matches = []
    var match_string = text_input.text
    
    # Run through matches for the last string if the user is stepping through autocomplete options
    if last_input_was_autocomplete:
        matches = prev_autocomplete_matches
    # Step through all possible matches if no input string
    elif match_string.length() == 0:
        matches = autocomplete_methods
    # Otherwise check if each possible method begins with the user string
    else:
        for method in autocomplete_methods:
            if method.begins_with(match_string):
                matches.append(method)
    
    # Store matches string for later
    prev_autocomplete_matches = matches

    # Nothing to return if no matches
    if matches.size() == 0:
        return
    
    # Go to the next possible autocomplete option if the user is Tabbing through options
    if last_input_was_autocomplete:
        autocomplete_index = wrapi(
            autocomplete_index + 1,
            0,
            matches.size()
        )
    else:
        autocomplete_index = 0
    
    # Populate console input with match
    text_input.text = matches[autocomplete_index]
    # Make sure the caret goes to the end of the line
    text_input.caret_column = 100000

# Track if the user has hit any inputs that should reset the autocomplete index
if event is InputEventKey:
    last_input_was_autocomplete = Input.is_action_just_pressed('dev_console_autocomplete') \
        or Input.is_action_just_released('dev_console_autocomplete')

Closeout

And that’s a quick look at my dev console. It’s pretty simple, but has already made a big difference in how quickly I can test and debug my game. If this were something intended for end users, I’d probably add in some additional safety features to prevent cheating, some help or other documentation, and maybe even a custom parser so that you’re not having to write valid GDScript to call a function, but for a quick and easy way to speed up my development, this works well enough.