Running C# in an in-game Developer Console with Roslyn

Supercharge your in-game developer console with C# scripting! Using the Roslyn Scripting API, we'll turn a string of C# code into an invocable delegate in just one line. Then, we'll go further and compile our C# commands asynchronously, then invoke them on the update thread.
C# game-dev roslyn scripting

Example image

Introduction

When testing things during game development, it’s common to write temporary code that executes on a button press. (e.g., “When I press F1, call SpawnEnemy(Input.MousePosition);.”) This can be difficult to parameterize, however. If your SpawnEnemy method takes in more than just a location, such as an enum for your enemy type or an int for its level, you’ll need to either change the hard-coded values in the method call (hopefully with edit-and-continue) or pass in variables and create a UI to change them at runtime.

Fast-forward along in development and you may have dozens of these utility functions to manipulate your levels or game objects to test things out, and it can become increasingly inconvenient to maintain a way of calling them all at runtime from different key-presses.


Developer Console

You’ve probably seen plenty of developer consoles in games, usually designed to mimic standard command-line interfaces. You execute commands by writing the name of the command, followed by any parameters it may take, separated by spaces.

// Spawn a level 4 Zombie at (200, 200)
> SpawnEnemy Zombie 200 200 4

// or spawn a level 4 Zombie at the mouse position
> SpawnEnemyAtMouse Zombie 4

These are pretty simple to implement: split the input line along the ' ' character, and search a Dictionary<string, IConsoleCommand> using your first element in your array of splits. The rest of those elements are your command’s parameters. If your command has some way of defining what types it expects its parameters as, you can even parse the strings into those types before sending them to the command!

There’s a good implementation of this type of console here, but it seems to leave the parameter string parsing up to the individual commands.

It’s a simple implementation, but a robust one. It’s easy to add a command: you create a class that implements an IConsoleCommand interface that defines a Name, its parameters, and an Execute method, and all your operations and logic go inside of that method. Then you just add an instance to the dictionary.

But what if we could write operations and logic directly into the console itself?


Expressions

I was initially looking for a way to execute commands that could resolve expressions in their parameters. I wanted to be able to write SpawnEnemy Zombie (20 * 10) (20 * (5 + 5)) 4. I thought “Okay, maybe I can use Eto.Parse, write a simple grammar to give me a syntax tree that I can use to resolve the expression. But if I’m writing a grammar, I could change up the syntax. Maybe something like a method call…”

I slowly realized that my ideal format for writing commands in an in-game console was still C#. Luckily, this is possible with Roslyn. Better yet, it’s even more simple than the aforementioned console implementation.


Roslyn

Roslyn’s scripting APIs let us execute arbitrary C# code as a ‘script’. This means that, like typical scripting languages, we can execute statements without having to place them inside of a method. We can feed C# code from our console into the API and execute it relatively quickly.

To start off, install the scripting API assembly from NuGet:

Install-Package Microsoft.CodeAnalysis.CSharp.Scripting


After that, we can get started executing C# code from a string with just one statement:

CSharpScript.Create("System.Console.WriteLine(\"Hello!\");").CreateDelegate().Invoke();

That’s it! Now, let’s break down what’s happening:


The Create() call returns a Script<object> object. We’ll be using another overload of the Create() method later in order to pass some global variables and methods for our scripts to use. We then call the CreateDelegate() method on the Script<object>, which compiles the Script and returns a delegate that will run it. At this point, it’s functionally equivalent to having an Action delegate with System.Console.WriteLine("Hello!"); inside.


At this point, if you’ve tried the code yourself, you may have noticed that it’s pretty slow (around 1500 ms on my system). This is because .NET assemblies are lazily loaded, so we’re loading quite a few assemblies the first time we call this code. When placed at the start of a fresh console application, this code loads 28 assemblies! While subsequent calls are much faster (around 120 ms on my system), we still don’t want our game to lock up for 1500 ms the first time we send a command. Even if we compile the script asynchronously (which we should) so that it doesn’t block our game thread, we would still be waiting 1500 ms for our command to be invoked, so this is problematic for us.

Thankfully, there’s an easy fix. We can just load the assemblies on another thread while our game is still loading. If it takes 1500 ms to load the assemblies we need, and our game already takes 2000 ms to load, then it’s highly likely that they’ll be finished loading by the time our game finishes loading. We won’t need to invoke the script, just create it and grab its delegate on another thread. Here’s the code I use for this:

Task.Run(() => {
    CSharpScript.Create("System.Console.WriteLine();").CreateDelegate();
});


References and Globals

Now that we know how to turn a string into an executable C# delegate, we can look at one of the other overloads for CSharpScript.Create():

Create(string code, ScriptOptions options, Type globalsType);

We know what the code parameter is, so let’s look at the others.

Options

ScriptOptions is a class that (obviously) contains the options that the script will be run with. This is where we’ll specify which assemblies to reference and what namespaces to import. It has an elegant fluent API that we can use:

var scriptOptions = ScriptOptions.Default
    .WithReferences(typeof(RuntimeBinderException).Assembly)
    .WithImports("System");

Here we’re creating a ScriptOptions object that is a modified version of ScriptOptions.Default. We modify it to add a reference to the assembly that contains the RuntimeBinderException type (Microsoft.CSharp) so that we can use dynamic objects in our scripts (more on this later). We also import the “System” namespace so that we can simply write Console.WriteLine in our in-game console.

GlobalsType

The globalsType is a type that we’ll be creating. It’s members will be globals that we can access directly from our script. Let’s make a simple one for now.

public class ConsoleGlobals
{
    public int Foo = 100;
}

Let’s create a script that uses this type, passing in the ScriptOptions that we created earlier:

var script = CSharpScript.Create(
    code: "Console.WriteLine(Foo);",
    options: scriptOptions,
    globalsType: typeof(ConsoleGlobals))
        .CreateDelegate();

To use our globals, we’re going to need to pass in an object of that type to the Invoke() method on the delegate:

script.Invoke(new ConsoleGlobals()); // Output: 100

And it works! And since we’re passing in a reference to an object of our globalType, it can be modified by our script as well. If we change our code to Foo = 200;, we can see this:

var globals = new ConsoleGlobals();
Console.WriteLine(globals.Foo); // Output: 100
script.Invoke(globals);
Console.WriteLine(globals.Foo); // Output: 200

As you can see, this can be a pretty powerful tool for changing variables on-the-fly.


Hooking it up to an in-game Console

To hook this up to a developer console in-game, we need to parse the input string as a script whenever we submit it. For this I have a CommandProcessor class that looks something like this:

public class CommandProcessor
{
    public DebugConsole DebugConsole { get; }

    public CommandProcessor(DebugConsole debugConsole)
    {
        DebugConsole = debugConsole;
    }

    public void ParseInput(string input)
    {
        DebugConsole.Write($">{input}\n", textColor: Color.LightGreen, userInput: true);

        // Here is where we'll run our input as a script.
    }
}

The CommandProcessor has a reference to the DebugConsole that created it. DebugConsole is the class that my in-game developer console is implemented in. Whenever we get input to parse (DebugConsole calling our ParseInput method when we press ‘enter’), we’ll output it back to the console in a light green color before processing it. The ‘userInput’ bool is used to tell when a line written to the console was user input or not (so that the ‘up’ arrow can traverse previous lines of input.) How DebugConsole is implemented is unimportant; we’re just focusing on processing console commands given to us by it.

At this point, we could just put a call to CSharpScript.Create() with our input string as the code, whatever references and imports we want, and a globals type, but we’d like to create the script and delegate on a separate thread so that it doesn’t block our update thread. We’ll still be outputting our input string in light green immediately, which will help the console feel responsive despite some commands taking ~200 ms to compile and execute. We’ll create a ScriptCompilationResult class that our Task will return:

public class ScriptCompilationResult
{
    public bool Success { get; }
    public CompilationErrorException Exception { get; }
    public ScriptRunner<object> Script { get; }

    public ScriptCompilationResult(ScriptRunner<object> script)
    {
        Success = true;
        Exception = null;
        Script = script;
    }

    public ScriptCompilationResult(CompilationErrorException exception)
    {
        Success = false;
        Exception = exception;
        Script = null;
    }
}

Then, we’ll store a Task<ScriptCompilationResult> private field called _compilationResult in our CommandProcessor class, and use it in our ParseInput() method.

public void ParseInput(string input)
{
    DebugConsole.Write($">{input}\n", Color.LightGreen, userInput: true);

    if (_compilationResult == null)
    {
        // We'll make this method in a moment.
        _compilationResult = CompileScript(input);
    }
    else
    {
        DebugConsole.Write("Error: Compilation in progress.\n", Color.Red);
    }
}

If our result already exists (which can only mean that we tried to enter another command while one was still compiling), we’ll write an error to the console saying that the compilation is already in progress. A queue would be more robust, but I want to notice if any commands are so slow to compile that I was able to write a second one before the first one finished compiling. Now let’s look at the CompileScript() method:

(Note that ManaGame is not a typo, but the name of my engine’s Game subclass.)

private Task<ScriptCompilationResult> CompileScript(string code)
{
    return Task.Run(() =>
    {
        try
        {
            var script = CSharpScript.Create(
                code: code,
                options: ScriptOptions.Default
                    .WithReferences(
                        typeof(Game).Assembly,                    // MonoGame Assembly
                        typeof(ManaGame).Assembly,                // My Assembly
                        typeof(RuntimeBinderException).Assembly)  // Required for 'dynamic'
                    .WithImports(
                        "System",
                        "Microsoft.Xna.Framework"),
                globalsType: typeof(ManaGlobals))
                    .CreateDelegate();

            return new ScriptCompilationResult(script);
        }
        catch (CompilationErrorException e)
        {
            return new ScriptCompilationResult(e);
        }
    });
}

We create and start the task at the same time. Within it, we create the script and delegate in a try-catch block. If it succeeds, we return a new ScriptCompilationResult with the delegate passed to its constructor. If we got a CompilationErrorException, we pass the exception into the constructor instead.

Now, we’ll check to see if the task is done in an Update() method in our CommandProcessor class:

public void Update()
{
    if (_compilationResult == null) return;

    if (_compilationResult.IsCompleted)
    {
        var scriptResult = _compilationResult.Result;
        
        if (scriptResult.Success)
        {
            scriptResult.Script.Invoke(ManaGlobals.Instance);
        }
        else
        {
            DebugConsole.Write($"Error: {scriptResult.Exception.Message}\n", Color.Red);
        }

        _compilationResult = null;
    }
}

We exit early if the Task doesn’t exist. If it does, we check if it’s completed. If it is, we grab the result, and either invoke the script on our update thread or write the error to the DebugConsole, then set _compilationResult to null so that we can send more commands. I’m using a singleton for my globals object, so I pass ManaGlobals.Instance into the delegate invocation.

Here’s my globals class:

public class ManaGlobals
{
    public static ManaGlobals Instance => _instance ?? (_instance = new ManaGlobals());
    private static ManaGlobals _instance;

    public dynamic Variables { get; } = new ExpandoObject();
}

I use an ExpandoObject for a field called ‘Variables’. ExpandoObject is an object that can have members dynamically added at runtime. This means that we can write something like:

Variables.SomeText = "Hello, World!";

and then, on a subsequent call, use the variable:

Console.WriteLine(Variables.SomeText); // Output: Hello, World!

You can set initial values for members of this Variables object anywhere in your code. If you’re tweaking parameters for your player’s jump mechanics, you can initialize some values on Variables when your player is first created, and then reference them in your jump code. Then you’re free to change the values in your console and watch the changes get reflected instantly.


There’s one final thing you might want. If you want to be able to output the returned value of your script (if there is one) like a REPL, our Script.Invoke() call returns a Task<object>. We can just store that task’s result and write it to the console if it’s not null:

var returnedValue = scriptResult.Script.Invoke(ManaGlobals.Instance).Result;

if (returnedValue != null)
{
    Console.WriteLine(returnedValue.ToString());
}


Conclusion

Thanks for making it this far! This is my first post, so there’s a decent change that I’ve explained too much of what’s obvious and not enough of what isn’t. Feel free to comment down below or email me if you have any feedback.

Here’s that gif from the top again:

Example image

Final notes:

comments powered by Disqus