wrote a quick in-game debug console for Godot 4, command registration is actually nice with Callables

489 views 4 replies

Started getting annoyed that every time I wanted to test something mid-session I had to stop, edit a script, recompile, run again. So I finally wrote a proper in-game debug console. Press `, type a command, get output. Nothing fancy, but it's already saved me a ton of context-switching.

The part I didn't expect: Godot 4's Callable type makes command registration genuinely clean. No reflection hacks, no string-based dispatch, no giant match statement somewhere. You just pass a callable and it works.

# DebugConsole.gd (autoload)
extends CanvasLayer

var _commands: Dictionary = {}

func register(name: String, callable: Callable, description: String = "") -> void:
    _commands[name] = { "callable": callable, "desc": description }

func execute(input: String) -> void:
    var parts = input.strip_edges().split(" ", false)
    if parts.is_empty():
        return
    var cmd = parts[0].to_lower()
    var args = parts.slice(1)
    if not _commands.has(cmd):
        _print_line("Unknown command: %s" % cmd)
        return
    _commands[cmd]["callable"].call(args)

Then from any system that wants to expose commands:

func _ready() -> void:
    DebugConsole.register("godmode", _cmd_godmode, "toggle invincibility")
    DebugConsole.register("tp", _cmd_teleport, "tp <x> <y>")
    DebugConsole.register("timescale", _cmd_timescale, "timescale <float>")

func _cmd_timescale(args: Array) -> void:
    if args.is_empty():
        DebugConsole.print_line("Current: %s" % Engine.time_scale)
        return
    Engine.time_scale = float(args[0])

Also added tab-completion over registered command names, which took maybe 20 minutes and is absolutely worth it. The help command just iterates _commands and prints descriptions.

Current command set: spawn, tp, timescale, godmode, give, and a reload_scene shortcut that has saved me probably a hundred alt-tabs already. Curious what commands other people find essential. What's the first thing you'd register?

if you haven't wired this up to print_rich yet it's worth doing — you can get color-coded output in the Godot console and pipe the same formatted string to your in-game console without maintaining two formatting paths. the BBCode tags strip out fine when you're rendering to a plain label if you just run them through a small regex on output.

ngl i built something similar but went with a CanvasLayer at layer 128 so it always renders on top without messing with any scene tree stuff. one thing i'd add to yours: a command history you can scroll with arrow keys. sounds minor but once you're actually using it in a real debugging session you'll want it badly. also consider binding toggle to a key that won't conflict with your game inputs — F12 or backtick tends to be safe.

Replying to CrystalWren: if you haven't wired this up to print_rich yet it's worth doing — you can get co...

the scrollback buffer approach you landed on is the same thing i ended up with. one thing worth adding: if you're piping OS.get_ticks_msec() timestamps into each entry you can use them to highlight entries that fired within the same frame, which makes spotting double-execution bugs much easier. color-coding by timestamp delta rather than severity level turned out to be more useful for me in practice.

Replying to CrystalWren: if you haven't wired this up to print_rich yet it's worth doing — you can get co...

the scrollback limit is something i wish i'd built in from the start rather than bolted on later. i went with a RichTextLabel with max_lines_visible set and a manual trim on the underlying BBCode string every N lines — not the cleanest solution but it keeps memory flat during long sessions. the print_rich tip upthread is good too, hadn't thought to unify the formatting path like that.