During my time making (and not finishing) games in Godot, I've built a few generic systems and components that I like to reuse between my projects. One such system is a command console plugin.
It's really useful to have some variation of a dev cheat console if you'll be working on a game longer than the length of a game jam. It makes testing things alot easier and way less annoying. It may sound like alot of work to implement a system like this, but the time it saves adds up in the long term.
When making this system I had two main requirements:
When making a system like this, the most straight forward approach it is to hard code all the possible commands
in a long elif chain or a switch statement.
While this works, it's not very easy to add new commands and the script will get long fast. Instead, I opted for a data-based approach.
So, I made a Command class, this is the class that all new commands will inherit from.
It provides a helper function in the form of _parse_arg() which converts a string
from the player input into a usable variable. It also does some basic validity checks so each command doesn't have to implement it seperately.
@abstract
class_name Command
extends RefCounted
## Base class for debug commands.
## The minimum amount of arguments this command takes
var minimum_args: int
## The name of the command, used to call it in the console.
var command_name: String
## A description of the command, shown in the help menu.
var description: String
## The arguments for the command, shown in the help menu.
## This should be a string describing the expected arguments, e.g. "[arg1] ".
var arguments: String
# This should be overridden by each command to set the command properties.
## Sets up the properties for the command.
@abstract func _init() -> void
# The actual command logic should be implemented in this method by each command.
## Called when the command is executed.[br]
## Runs the logic for the command, taking in arguments through [param args].
@abstract func _run(args: Array[String]) -> void
## Attempts to run the command.
## Checks if the command can be run and then calls _run()
func attempt_run_command(args: Array[String]) -> void:
if args.size() < minimum_args:
CommandConsole.print_error_to_output("Command '%s' needs arguments %s" % [command_name, arguments])
return
_run(args)
## A helper function that attempts to parse a string into a usable variable type
func _parse_arg(arg: String) -> Variant:
if arg.is_valid_int():
return arg.to_int()
if arg.is_valid_float():
return arg.to_float()
var arg_lower = arg.to_lower()
if arg_lower == "true":
return true
if arg_lower == "false":
return false
return arg
A simple command, like a command to set the time scale of the game, could be implemented with just two functions.
We simply create a new script and override the _init() function to setup the data
about the command like its name, description, and arguments.
extends Command
func _init() -> void:
command_name = "set_time_scale"
description = "Sets the game speed"
arguments = ""
minimum_args = 1
...
Then, we implement the actual functionality of the script by overriding the _run() function.
...
func _run(args: Array[String]) -> void:
var time_scale = _parse_arg(args[0])
if time_scale is not float and time_scale is not int:
print("time_scale must be an float or int")
return
Engine.time_scale = time_scale
print("Set time_scale to %.2f" % time_scale)
To handle all of our seperate command scripts, we need some way to load and execute them.
This is where the CommandConsole autoload comes in.
For loading the commands, we can simply make a little function that goes through the directory where our
command scripts are stored and load them one by one when the game starts. While we could manually add each script to an
exported array, having it search the file system lets you just create the script and not have to worry about anything else.
Just make sure that you use ResourceLoader over DirAccess, as DirAccess
can't navigate res:// in exported builds.
class_name CommandConsole
...
func _load_commands() -> void:
for file_name in ResourceLoader.list_directory(COMMAND_DIR):
var command_script = ResourceLoader.load(COMMAND_DIR + file_name)
# Add command to _loaded_commands
var new_command: Command = command_script.new()
_loaded_commands.set(new_command.command_name, new_command)
Now we need a way to actually be able to run the commands.
This step might seem daunting at first glance, but it's easy with tokenization, which is basically just
a fancy way of saying breaking text into smaller pieces called tokens.
First, we take text input from the user, I use a LineEdit node for this,
and split it along it's whitespace.
# E.g. "set_time_scale 0.2" => ["set_time_scale", "0.2"]"
var args := new_text.split(" ")
...
From there, its super easy to take the first token as the command name and every token after that as an array of arguments to pass to our command.
...
# Take first token as command_name and rest as args
var command := args[0]
args.remove_at(0)
# Run the desired command, if it exists
if _loaded_commands.has(command_name):
var cmd: Command = _loaded_commands.get(command_name)
cmd.attempt_run_command(args)
And that's it! Now we can execute our commands in game.
Finally, now that we're done handling the commands we can move on to capturing the engine logs. Once again, this is one of
those things that sounds really difficult, but Godot makes it easy.
The OS class has a function, add_logger(), that allows us to easily intercept the internal message stream.
So all we need to do is create a custom logger to route what it recieves into our command console.
To create our custom logger we will extend the built-in Logger class and override
it's two functions, one for errors, the other for regular print messages.
class_name GameLogger
extends Logger
func _log_error(
function: String,
file: String,
line: int,
code: String,
rationale: String,
_editor_notify: bool,
error_type: int,
_script_backtraces: Array[ScriptBacktrace]) -> void:
var log_message = "[%s] %s:%d - %s%s" % [
function,
file,
line,
code if code else rationale,
"\n" + rationale if (code and not rationale.is_empty()) else "",
]
CommandConsole.print_error_to_output(log_message, error_type)
func _log_message(message: String, error: bool) -> void:
if error:
CommandConsole.print_error_to_output(message)
else:
CommandConsole.print_to_output(message)
Then all that's left to do is to register our custom logger with the engine when the game starts.
class_name CommandConsole
...
func _init() -> void:
OS.add_logger(GameLogger.new())
That's all there is to it! Admittedly, I've skipped over alot of details about exact implementations as well as all of the QOL stuff but I didn't want this post to be too long. Regardless, a download link for the plugin will be below if you're interested in that, but beware it's kinda messy in some parts. You can also find it on my Github. It also includes some instructions on how to set it up in your Godot project.
If this blogpost was interesting or helpful in any way let me know because I had fun writing it. Also, if you have any questions feel free to message me on Bluesky or Newgrounds or email me at doogler@doogler.net.