A Simple And Effective Runtime Debug Console For Unity

This article describes a simple and effective way to include a runtime debug console in your Unity project. An example project using the debug console can be downloaded here.

As your game project matures and becomes more complex, testing new features during runtime can become increasingly time-consuming, and may introduce changes that wasn’t meant to be included in the released version (note that we’re not talking about unit tests here, or any other tests that are easily automated, but tests that require you to play the game).

For example: let’s say that you want to test that a barrel explodes as it should when you shoot at it and that the explosion causes nearby enemies to take an appropriate amount of damage. So you start the game, move into the room where the barrel and the enemies are, but before you can shoot at it, the enemy has detected you and gunned you down. So now what do you do? Disable the AI weapons before you run the game? Lower the damage that their weapons do? Disable the code that gives your character damage? Whatever you decide, you need to make changes to the game that allows you to test the feature, and then revert those changes once you’re satisfied that the code works.

Another (and in our view, better) approach is to have a debug console that quickly allows you to modify variables in the game during runtime. In the example above, a console command that would make your character temporarily invulnerable would be an easy and welcome solution.

Our requirements for a debug console was that it should be fairly stand-alone, super simple to use and not require a lot of rewrite of existing game code to accommodate it. Our solution was to use the C# Reflection library.

For the purposes of the debug console, Reflection can be thought of as a way to inspect classes, methods and method parameters, and to call class methods dynamically. A class method is registered in the debug console (a one-line call) and when the user types a command in the console, it’s effectively the same as when calling a method from the code.

It should be noted that Reflection is apparently not very performant and should perhaps not be allowed anywhere near the main game loop.

Features of the debug console:

  • User friendly. Just hit F12 and enter the command.
  • Stand-alone. The only requirement is that each class needs to register the commands that should be available in the console.
  • Tab completion of console commands.
  • Stores the command history for faster reuse.
  • Displays all available commands in the command output upon user request.
  • Allows classes to output information in the console window.
  • Disabled for Release builds.

Limitations of the debug console:

  • Only bool, string, float and int types are implemented as parameters for now (because that’s all we’ve needed so far). If you require additional parameters, you need to implement them. Note: We’ve read about several ways to generalize the parameter parsing, but all of them seem to come with ifs and buts and requirements that violated our need for simplicity and ease of use. Your needs may be different, and if so the place to implement it is in DebugConsoleCommand.TryExecuteCommand(…).
  • Limited tab completion. The code picks the first matching console command it finds in the list and doesn’t check for sameness in substrings (e.g. “GiveDamageToPlayer”, “GiveDamageToAIAgent” should stop at GiveDamageTo and allow the user to proceed typing from there, but we leave that as an exercise for the reader :P)

The code is separated into three classes:

  • DebugConsoleManager – The only class that other classes need to know about. Handles user input and registration of console commands.
  • DebugConsoleCommand – Stores information about a class method and the required parameters. Tries to parse and execute debug console commands. Reports user errors in the console output window. Calls class methods with the given parameters.
  • DebugConsoleUIHandler – Input and output handler.

Once a class has obtained a reference to the DebugConsoleManager, only one call to the DebugConsoleManager is required for each command:

AddConsoleCommand(<method name>, this);

For example:

DebugConsoleManager debugConsoleManager = FindObjectOfType<DebugConsoleManager>(true);
debugConsoleManager.AddConsoleCommand("DropBall", this);
debugConsoleManager.AddConsoleCommand("AddImpulseToBall", this);
debugConsoleManager.AddConsoleCommand("ShowBallRigidBodyProperties", this);

The code, then:

DebugConsoleManager.cs.

using System.Collections.Generic;
using UnityEngine;


public class DebugConsoleManager : MonoBehaviour
{
    [Header("Debug Console Manager properties")]
    [Tooltip("The debug console UI game object")]
    public GameObject _debugConsoleUIObject;

    private DebugConsoleUIHandler _debugConsoleUIHandler;


    // a dictionary that maps a method string to a console command. We use reflection to find and call the method from this string.
    private Dictionary<string, DebugConsoleCommand> _consoleCommands = new Dictionary<string, DebugConsoleCommand>();


    private void Awake()
    {
        // find the UI handler for the debug console and set it up
        _debugConsoleUIHandler = _debugConsoleUIObject.GetComponent<DebugConsoleUIHandler>();
        _debugConsoleUIHandler.Init();
        _debugConsoleUIHandler._onSubmitCommand += ParseCommand;
        _debugConsoleUIObject.SetActive(false);
    }


    public void AddConsoleCommand(string methodName, object classObject)
    {
        if (_consoleCommands.ContainsKey(methodName))
            return; // already in the dictionary

        DebugConsoleCommand debugConsoleCommand = new DebugConsoleCommand(methodName, classObject).Init();
        if (debugConsoleCommand != null)
            _consoleCommands.Add(methodName, debugConsoleCommand);
    }


    private void ParseCommand(string command)
    {
        if (command == string.Empty)
            return;

        if (command == "?" || command.ToLower() == "help")
        {
            // show all commands
            ShowAllCommands();
            return;
        }

        string[] stringTokens = command.Split(new char[] { ' ' });
        string methodName = stringTokens[0];
        if (!_consoleCommands.ContainsKey(methodName))
        {
            _debugConsoleUIHandler.AddToOutput("Command '" + methodName + "' not found. Type '?' or 'help' to list all commands.");
            return;
        }

        DebugConsoleCommand debugConsoleCommand = _consoleCommands[methodName];
        string errorString = debugConsoleCommand.TryExecuteCommand(stringTokens);
        if (errorString != string.Empty)
            _debugConsoleUIHandler.AddToOutput(errorString); // show error from command execution failure
    }


    public void AddToOutput(string output)
    {
        _debugConsoleUIHandler.AddToOutput(output);
    }


    private void ShowAllCommands()
    {
        foreach(KeyValuePair<string, DebugConsoleCommand> keyValuePair in _consoleCommands)
        {
            DebugConsoleCommand command = keyValuePair.Value;
            _debugConsoleUIHandler.AddToOutput(command.GetUsageString());
        }
    }


    public void Update()
    {
        if (Debug.isDebugBuild)
        {
            if (Input.GetKeyDown(KeyCode.F12))
            {
                if (_debugConsoleUIObject.activeSelf)
                    _debugConsoleUIObject.SetActive(false);
                else
                {
                    _debugConsoleUIObject.SetActive(true);
                    _debugConsoleUIHandler.SetInputFieldFocus();
                }
            }
            else if (_debugConsoleUIObject.activeSelf)
            {
                if (Input.GetKeyDown(KeyCode.UpArrow))
                    _debugConsoleUIHandler.ScrollCommandHistory(true);
                else if (Input.GetKeyDown(KeyCode.DownArrow))
                    _debugConsoleUIHandler.ScrollCommandHistory(false);
                else if (Input.GetKeyDown(KeyCode.Tab))
                    _debugConsoleUIHandler.AutoCompleteText(_consoleCommands);
            }
        }
    }

}

DebugConsoleCommand.cs:

using System;
using System.Reflection;
using UnityEngine;


public class DebugConsoleCommand
{
    private string _methodName;             // the command name, i.e. the method name in some class
    private MethodInfo _methodInfo;         // the method info that correspond to the method name in the command
    private object _classObject;            // the instantiated object whose method will be called
    private ParameterInfo[] _parameters;    // the parameters that needs to be passed to the method
    private int _requiredParameterCount;


    public DebugConsoleCommand(string methodName, object classObject)
    {
        _methodName = methodName;
        _classObject = classObject;
        _requiredParameterCount = 0;
    }


    public DebugConsoleCommand Init()
    {
        // try to find the method in the instantiated object
        Type type = _classObject.GetType();
        MethodInfo[] methods = type.GetMethods();
        for(int i = 0, count = methods.Length; i < count; i++)
        {
            MethodInfo methodInfo = methods[i];
            if (_methodName == methodInfo.Name)
            {
                _methodInfo = methodInfo;
                break;
            }
        }

        if(_methodInfo == null )
        {
            Debug.LogWarning("DebugConsoleCommand.Init: Method: " + _methodName + " not found in " + _classObject.ToString() + ". Command will not be available.");
            return null;
        }

        // get the parameters for the method
        _parameters = _methodInfo.GetParameters();
        _requiredParameterCount = _parameters.Length;
        return this;
    }


    // tries to execute the given command with the supplied parameters. Returns empty string on success, or error string on fail.
    public string TryExecuteCommand(string[] givenParameters)
    {
        // go through each parameter (if any) and try to cast it to the required type. index 0 is the command token so we skip that.
        int givenParameterCount = givenParameters.Length - 1;
        if (givenParameterCount != _requiredParameterCount)
            return GetSyntaxErrorString(); // wrong parameter count for method

        object[] commandParameters = new object[_requiredParameterCount];

        for (int i = 0, count = _parameters.Length; i < count; i++)
        {
            ParameterInfo parameterInfo = _parameters[i];
            string givenParameter = givenParameters[i + 1];

            if (parameterInfo.ParameterType == typeof(string))
                // string
                commandParameters[i] = givenParameter;
            else if (parameterInfo.ParameterType == typeof(int))
            {
                // int
                int parameterAsInt = 0;
                if (!int.TryParse(givenParameter, out parameterAsInt))
                    return GetSyntaxErrorString();
                commandParameters[i] = parameterAsInt;
            }
            else if (parameterInfo.ParameterType == typeof(bool))
            {
                // bool
                bool parameterAsBool = true;
                if (!bool.TryParse(givenParameter, out parameterAsBool))
                    return GetSyntaxErrorString();
                commandParameters[i] = parameterAsBool;
            }
            else if (parameterInfo.ParameterType == typeof(float))
            {
                // float
                float parameterAsFloat = 0.0f;
                if (!float.TryParse(givenParameter, out parameterAsFloat))
                    return GetSyntaxErrorString();
                commandParameters[i] = parameterAsFloat;
            }
        }

        _methodInfo.Invoke(_classObject, commandParameters);
        return string.Empty;
    }


    public string GetSyntaxErrorString()
    {
        string errorString = _methodName + ": wrong syntax. Usage: " + GetUsageString();
        return errorString;
    }


    public string GetUsageString()
    {
        string usageString = _methodName  + " " + GetParametersAsString();
        return usageString;
    }


    public string GetParametersAsString()
    {
        string parametersString = string.Empty;
        for (int i = 0, count = _parameters.Length; i < count; i++)
        {
            ParameterInfo parameterInfo = _parameters[i];
            parametersString += "(" + parameterInfo.ParameterType.ToString() + ") ";
        }
        return parametersString;
    }

}

DebugConsoleUIHandler.cs:

using UnityEngine;
using TMPro;
using UnityEngine.Events;
using System.Collections.Generic;


class DebugConsoleUIHandler : MonoBehaviour
{

    [Header("Command Line UI Handler properties")]
    [Tooltip("The input field where the user enters commands")]
    public TMP_InputField _inputField;

    [Tooltip("The output line prefab")]
    public GameObject _outputLinePrefab;

    [Tooltip("The number of output lines")]
    public int _outputLinesCount = 38;

    [Tooltip("The output lines parent")]
    public Transform _outputLinesParent;

    [Tooltip("Show entered commands in output?")]
    public bool _showEnteredCommandsInOutput = false;

    public UnityAction<string> _onSubmitCommand;

    private List<TextMeshProUGUI> _outputLines = new List<TextMeshProUGUI>();
    private List<string> _commandHistory = new List<string>();
    private int _commandHistoryIndex;


    public void Init()
    {
        for (int i = 0; i < _outputLinesCount; i++)
        {
            GameObject outputLineObject = Instantiate(_outputLinePrefab);
            outputLineObject.transform.SetParent(_outputLinesParent, false);
            TextMeshProUGUI outputLine = outputLineObject.GetComponent<TextMeshProUGUI>();
            _outputLines.Add(outputLine);
        }

        _commandHistoryIndex = 0;
        for (int i = 0; i < _outputLinesCount; i++)
            _outputLines[i].text = string.Empty;
    }


    // called from input field (OnEndEdit)
    public void OnSubmitCommand()
    {
        if (Input.GetKeyDown(KeyCode.Escape) || Input.GetKeyDown(KeyCode.F12))
        {
            // OnEndEdit triggers on the escape key, and we don't want that
            SetInputFieldFocus();
            return;
        }
           
        string command = _inputField.text;
        _onSubmitCommand?.Invoke(command);
        _commandHistory.Insert(0, command);
        _commandHistoryIndex = 0;
        _inputField.text = string.Empty;

        if (_showEnteredCommandsInOutput)
            AddToOutput(command);

        SetInputFieldFocus();
    }


    public void ScrollCommandHistory(bool scrollUp)
    {
        int commandHistoryCount = _commandHistory.Count;
        if (commandHistoryCount == 0)
            return;

        if (scrollUp)
        {
            _inputField.text = _commandHistory[_commandHistoryIndex];
            _commandHistoryIndex++;
            if (_commandHistoryIndex >= commandHistoryCount)
                _commandHistoryIndex = 0;
        }
        else
        {
            _commandHistoryIndex--;
            if (_commandHistoryIndex < 0)
                _commandHistoryIndex = commandHistoryCount - 1;
            _inputField.text = _commandHistory[_commandHistoryIndex];
        }
        _inputField.caretPosition = _inputField.text.Length;
    }


    public void AddToOutput(string output)
    {
        // scroll output lines
        for (int i = _outputLinesCount - 1; i > 0; i--)
            _outputLines[i].text = _outputLines[i - 1].text;

        TextMeshProUGUI currentOutputLine = _outputLines[0];
        currentOutputLine.text = output;
    }


    public void SetInputFieldFocus()
    {
        _inputField.ActivateInputField();
        _inputField.caretPosition = _inputField.text.Length;
    }


    public void AutoCompleteText(Dictionary<string, DebugConsoleCommand> consoleCommands)
    {
        string inputFieldText = _inputField.text;
        int inputFieldTextlength = inputFieldText.Length;
        if (inputFieldTextlength == 0)
            return; // early out

        foreach (KeyValuePair<string, DebugConsoleCommand> keyValuePair in consoleCommands)
        {
            string command = keyValuePair.Key;
            if (command.Length < inputFieldTextlength)
                continue;

            if (inputFieldText == command.Substring(0, inputFieldTextlength))
            {
                _inputField.text = command;
                _inputField.caretPosition = _inputField.text.Length;
                break;
            }
        }
    }
}

A note about DebugConsoleUIHandler.cs: It requires TextMesh Pro (a free Unity package that you can download with the package manager). It also needs several object references to be assigned in the editor:

  • _inputField. A reference to a TextMesh Pro input field.
  • _outputLinePrefab. A prefab that is nothing more than a TextMeshPro text component. It gets instantiated a number of times equal to _outputLinesCount. The output lines are where console output ends up.
  • _outputLinesParent. All instantiated output lines gets parented to this transform.

The OnSubmitCommand() method should be called on the OnEndEdit event. This is set in the InputField component in the editor.

To make it easier to understand how to use the debug console, we have created a simple Unity project where you can test it out, see how the pieces fit together and how the UI may be set up. It’s running on Editor version 2020.3.20f1 but you should be able to run it on any version later than that (no promises though!). Once you have opened the project in the editor, open the “DebugConsoleExample” scene. You can download the project from here.

The Example project has a class that uses the debug console, the BallHandler.cs (code below). If you look at the Start() method you see that it calls the DebugConsoleManager three times with AddConsoleCommand(…), once for each method that we want to be exposed in the debug console. The first parameter (a string) corresponds to a method name (DropBall(), AddImpulseToBall(float force), and ShowBallRigidBodyProperties()) and the second to the class itself. The method name is case sensitive and must match an existing method in the class in order for it to work.

using UnityEngine;


public class BallHandler : MonoBehaviour
{
    [Header("Ball Handler properties")]
    [Tooltip("The rigid body")]
    public Rigidbody _rigidBody;

    private DebugConsoleManager _debugConsoleManager;


    private void Start()
    {
        _debugConsoleManager = FindObjectOfType<DebugConsoleManager>(true);
        _debugConsoleManager.AddConsoleCommand("DropBall", this);
        _debugConsoleManager.AddConsoleCommand("AddImpulseToBall", this);
        _debugConsoleManager.AddConsoleCommand("ShowBallRigidBodyProperties", this);
    }


    public void DropBall()
    {
        _rigidBody.isKinematic = false;
    }


    public void AddImpulseToBall(float force)
    {
        Vector3 forceVector = new Vector3(0.0f, force, 0.0f);
        _rigidBody.AddForce(forceVector, ForceMode.Impulse);
    }


    public void ShowBallRigidBodyProperties()
    {
        string rigidBodyProperties = "Mass: " + _rigidBody.mass.ToString() + ". Drag: " + _rigidBody.drag.ToString() + ". Angular drag: " + _rigidBody.angularDrag.ToString();
        _debugConsoleManager.AddToOutput(rigidBodyProperties);
    }

}

That’s it. If you open the example project, open the “DebugConsoleExample” scene and run it, then hit F12 and type DropBall in the console, you should see the ball falling to the floor. To throw it upwards, try AddImpulseToBall 10.

Additional help:

  • Type “?” or “help” to see a list of all the commands you have registered.
  • Use the Up/Down arrow keys to go through your command history for the current session.
  • Use Tab to auto-complete a command.
  • Call the AddToOutput(string output) method in DebugConsoleManager to show output in the debug console.

If you have any questions or comments about the code, feel free to email us at info@syntheticforms.com.

/Roger

Disclaimer: Though we’ve tested the code and use it daily we cannot guarantee that it will work for you. Also, you use it at your own risk. We assume no responsibility for any problems you encounter.