Choosing C# for Your Godot Project

Godot offers multiple options for your codebase. Here is why C# could offer your project the most benefits.

By: Spencer Chang, 02/08/2024

Thumbnail

Thumbnail taken from Godot's Blog Post

Introduction

Godot is a powerful open-source game engine that offers multiple choices of 'scripting' languages. Picking the right language for your codebase is an important choice for projects because it affects development speed, productivity, maintainability, and overall developer experience. While GDScript is an easy-to-learn language with minimal boilerplate, it does come with many downsides that can hinder long-term developement speed and increase the number of bugs in your code. Depending on the size of the project you are working on, you might find it better to use C# as your main scripting language instead.

In this article, I'll offer my opinion on why I prefer C# to GDScript to write clearer and maintainable code.

GDScript vs. C#

GDScript is the Godot Engine's built-in scripting language. It is dynamically-typed with optional static-typing, interpreted, and has a syntax similar to Python. It is currently favored by most Godot developers due to its simplicity, which in turn allows for speedy development for features without needing to deal with boilerplate code that other languages might offer. It is also integrated with the Godot engine, allowing for lots of syntactic sugars when interfacing with the engine.

gdscript
# $ -> A shorthand for 'get_node(Path)'
# item_id is statically-typed, but get_item(item_id) is dynamically-typed.
func get_item(item_id: String):
    return $inventory.get_item(item_id)

All these features make coding in GDScript feels extremely efficient for fast prototyping. However, one issue comes when developing large-scale projects (+10,000 lines).

C# (.NET) is Microsoft language that offers C-like syntax, a strongly-typed system, JIT-compiled code. It is offered as a choice to Godot developers who use the .NET Godot version. It has also been around since the year 2000 and has been actively maintained over the past decades.

Here's the equilvalent code to the one above.

c#
// everything is strongly typed
public Node GetItem(string itemId)
{
    return GetNode<Inventory>("Inventory").GetItem(itemId);
}

Downsides of Dynamically-Typing

Because GDScript is a dynamically-typed language, it allows developers to create type-unsafe code. Consider the following code:

gdscript
func add_one(number):
    return number + 1

This may seem like a fairly simple function. It's perfectly valid GDScript, so what could go wrong with it? In order to find out, we'll have to write some unit tests.

gdscript
assert(add_one(1) == 2)
assert(add_one(2) == 3)
assert(add_one(3) == 4)

Again, nothing seems wrong here right? We run these tests and we're able to pass each of them. However, remember that the parameter for add_one is not specified, therefore we can pass any value to the function, and Godot won't complain at first. Let's demonstrate this with this line.

gdscript
print(add_one("1"))

What should this code return? What type does it return? A string? A number? Unless you're well-versed in how Godot handles Type conversions, what this code does is completely unknown when you encounter this case. And if you guessed either a string or number, you would be mistaken. Because when running it, we actually get an error thrown. Invalid operands 'String' and 'int' in operator '+'.. Imagine you have a large project with hundreds of classes and functions. And some of them might rely on a function such as this. Unless you are thoroughly unit-testing your code, this is an extremely easy bug to miss, even in production.

What's the solution? To use static-typing.

gdscript
func add_one(number: int) -> int:
    return number + 1

Now, when we try to include this in our codebase:

gdscript
print(add_one("1"))

Godot displays Cannot pass a value of type "String" as "int". Crisis averted. Although Godot will still let you run this code, this is a much easier bug to catch and fix now.

So that's it? Just add some static-typing to this function and we're done? Right? Although static-typing does fix the majority of potentially type-unsafe bugs, it has its limitations. Consider this example:

gdscript
class Weapon:
    func swing(angle: float):
        # Some melee attacking logic

class Spell:
    func perform(position: Vector2):
        # Some spell performing logic

class Wizard:
    func attack_with(spell: Spell, position: Vector2) -> void:
        spell.Perform(position)

class Swordsman:
    func attack_with(weapon: Weapon, angle: float) -> void:
        weapon.Attack(angle)

Here we define 4 classes, Weapon, Spell, Wizard, and Swordsman. A Wizard will use a Spell when attacking. And a Swordsman will use a Weapon when attacking. And we add static-typing to our attack_with functions to ensure no type-unsafe operations happen.

However, what happens when we have a player that can be either a Wizard or Swordsman? Such as when they select their class? Consider this code:

gdscript
var player # player can be either Wizard or Swordsman
var equipped # equipped can be either a Weapon or a Spell

func attack() -> void:
    player.attack_with(equipped)

This code is now type-unsafe and has potential for game-breaking bugs that are not caught during compile-time. If equipped can be either a Weapon or Spell, then the developer must somehow guarantee that equipped won't be a Weapon if player is a Wizard. And vice-versa: The developer must also somehow guarantee that equipped won't be a Spell if player is a Swordsman. Because there is not type-guarantee in this case, the developer will have to be extra vigilant and create additional logic that will ensure these conditions are met, all without crashing.

So how does C# fare any better in this regard?

Utilizing a Strong-Typing System

C# is a strongly-typed language. Therefore, the compiler practically guarantees your code to be type-safe. Consider the first example shown above:

c#
public int AddOne(int number)
{
    return number + 1;
}

In C#, you must declare the return type for every function/method, effectively preventing any type ambiguity issues encountered in GDScript. Essentially, C#'s compiler enforces type-safety.

Now for the second problem: Because we're writing in C#, this forces us to adopt a different paradigm when thinking of how to solve this issue. If we have a player that can be different kinds of classes, how can we model that so our code is correct? The answer, interfaces.

First, we can declare an interface for weapon holders.

c#
public interface IWeaponHolder
{
    void Attack();
}

Now, when we define our Sword and our Spell classes and utilize composition for our weapon holders.

c#
public class Sword
{
    public void Attack(float angle)
    {
        // swing the sword at the angle
    }
}

public class Spell
{
    public void Perform(Vector2 position)
    {
        // perform the spell at that position
    }
}

And we can have a more concrete definition of our Wizard and Swordsman classes.

c#
// note these are partial classes because of how Godot's source-generator system works.
public partial class Wizard : IWeaponHolder, Node2D
{
    public Spell Equipped
    {
        get;
        set;
    }

    public void Attack()
    {
        Vector2 castPosition = GD.GetGlobalMousePosition();
        Equipped.Perform(castPosition);
    }
}

public partial class Swordsman : IWeaponHolder, Node2D
{
    public Sword Equipped
    {
        get;
        set;
    }

    public void Attack()
    {
        Vector2 castPosition = GD.GetGlobalMousePosition();
        Vector2 currentPosition = this.GlobalPosition;
        float swingAngle = currentPosition.AngleTo(castPosition);
        Equipped.Perform(swingAngle);
    }
}

Using C# interfaces, C#'s type-system guarantees that our code is type-safe and the correct behavior occurs regardless of if the player is a Wizard or Swordsman. Although a similar implementation (perhaps through class inheritance) may exist through GDScript, C#'s strongly-typed nature forces us developers to write safer and correct code.

Refactoring

One major downside to using GDScript as the main scripting language is the lack of automatic refactoring options. Refactoring is the process of changing existing code such that the functionality is the same, but making it both easier to read and easier to maintain. Some basic refactoring techniques are renaming variables or breaking functions down into smaller ones with better names.

In GDScript, if I want to change a function signature from add_one(int) to add(int, int), I will have to manually look for every single instance of this function being called in my codebase and rename it because GDScript currently doesn't have the ability to do automatic renaming. With static-typing, Godot will give in-editor errors when you change the function signature, however any dynamically-typed objects that call originally called this function will need to be hunted down and fixed. And if you have a large codebase and the function you change is heavily depended on, this can be a massive headache with a high chance of missing an instance and causing a bug during runtime.

However, C# has very solid LSP (Language Server Protocol) automatic refactorings. Most code editors that support a C# LSP (Visual Studios, VSCode, Neovim, etc.) have support for many refactorings. If I want to rename a function, C# LSP has a "Rename Function", that utilizes the language's type system to find all instances where the function is called and renames it. If we need to change a function signature, we can be sure that the compiler will catch any places in our code that used to call our original function and force us to update it. Although it may be frustrating to constantly make the compiler happy, this prevents a plethora of potential runtime bugs that could be ruin player experiences.

Asynchronous Programming

While GDScript makes asynchronous operations extremely simple through the await keyword, GDScript currently has many limitations on what kind of asynchronous operations are possible. The biggest limitation is when handling multiple async operations at a single time. For example, if I want to asynchronously download a list of images from a server, I would have to do this:

gdscript
var assets = []

func retrieve_asset_list(asset_list: Array) -> void:
    for asset_name in asset_list:
        assets.push_back(await retrieve_asset(asset_name))

func retrieve_asset(asset_name) -> Image:
    # Do some stuff that downloads from the server and returns an image

Because we're awaiting each call sequentially, we're first downloading an asset, waiting for it to finish downloading, and then moving on to download the next one only when it's finished. If downloading multiple files, the user will experience longer wait times for the operation to finish. If we want to download multiple assets at the same time, it's not possible since GDScript doesn't currently offer a way to await multiple async calls at a time (Hey, thats me!).

Until they add the capabilities to do so, we're stuck with sequential asynchronous operations.

On the flip side, C# has a very solid implementation for asynchronous programming and thus allows for multiple asynchronous operations. Here is the same example but with a batched asynchronous call:

c#
public async Task RetrieveAssetList(string[] assetList)
{
    Task<Image>[] retrievalCalls = new Task<Image>[assetList.Length];
    for (int assetIndex = 0; assetIndex < assetList.Length; assetIndex++)
    {
        retrievalCalls[assetIndex] = RetrieveAsset(assetList[assetIndex]);
    }

    await Task.WhenAll(retrievalCalls); // waits for the entire batch to finish downloading
}

public async Task<Image> RetrieveAsset(string assetName)
{
    // retrieve asset from the server and return it
}

NuGet Packages

One of the strengths of using C# in Godot is the access to the extensive ecosystem of NuGet packages. NuGet, the package manager for .NET, provides a plethora of libraries and tools that can significantly enhance development efficiency and code quality. Among these tools are various linter packages, which help maintain code standards and detect potential errors early in the development process.

Example of Linter Packages:

  • SonarAnalyzer.CSharp: This package is a static code analyzer for C# that uses SonarQube to identify bugs and security vulnerabilities in the code. It helps enforce coding standards and improves code quality by providing detailed feedback on potential issues.
  • StyleCop.Analyzers: Focused on enforcing C# coding style and consistency, StyleCop Analyzers integrates with the Roslyn compiler to provide real-time feedback on code style violations directly in the development environment. This tool is invaluable for maintaining a consistent coding style across large projects and teams.

Using these linter packages, developers can automate the enforcement of coding standards, reduce the likelihood of bugs, and ensure that the codebase remains clean and maintainable. This is particularly beneficial in larger projects or teams where consistency and code quality are paramount. The ability to leverage such tools is a significant advantage of using C# for Godot projects, helping developers maintain high standards of code quality with minimal effort.

C# Disadvantages

While C# offers numerous advantages for Godot projects, developers should also be aware of its limitations and challenges:

Lack of Support for Web Exports

One of the most significant limitations of using C# in Godot is the current lack of support for exporting games to web platforms. This restricts the ability of developers to target the web as a platform, potentially limiting the audience reach of their projects.

Increased Project Complexity

C# projects can sometimes be more complex to set up and manage compared to GDScript-based projects. This complexity comes from the need to handle external dependencies, manage NuGet packages, and configure project files, which can be daunting for beginners or those not familiar with .NET ecosystems.

Steeper Learning Curve

For developers new to C# or object-oriented programming, there can be a steeper learning curve compared to GDScript. C#'s syntax and features, while powerful, are more complex and can take longer to master, potentially slowing down initial development progress.

Delayed Updates and Documentation

The documentation and community examples for C# in Godot are not as extensive as for GDScript. Additionally, updates and new features in Godot may be demonstrated and documented in GDScript first, leading to delays before equivalent C# information becomes available.

Conclusion

Choosing the right programming language for a Godot project is a critical decision that impacts not just the immediate development process but also the long-term viability and maintaianbility of the game. While GDScript offers simplicity and rapid prototyping capabilities, its dynamically-typed nature can introduce challenges as your project scales. We've seen how GDScript's flexibility, though beneficial in the early stages of development, can lead to type-safety issues and make large-scale refactoring a large headache.

On the other hand, C# emerges as a robust alternative, bringing its strongly-typed system to the forefront of game development in Godot. This ensures type safety, significantly reducing the risk of runtime erros related to type mismatches. Moreover, C#'s rich ecosystem and support for features like interfaces and async programming not only enhances code quality, but also opens up new possibilities for efficient game development. The ability to refactor code easily, handle complex async tasks efficiently, and utilization of community packages, showcases C#'s capability to manage the demands of large-scale game projects.

In conclusion, while GDScript is an excellent starting point for newcomers and smaller projects, C#'s structured approach to type safety, combined with its advanced programming features, makes it a compelling choice for developers looking to scale their Godot projects. By leveraging C#, teams can build more reliable, maintainable, and scalable games, ensuring a smoother development experience and a more robust final product. Ultimately, the choice between GDScript and C# should be informed by the project's specific needs, team expertise, and long-term goals, but for those looking towards large-scale development, C# offers a clear advantage in the Godot engine ecosystem.

Written by Spencer Chang, 02/08/2024