Converting my game to C#

Tiffany articles

I recently converted my game from a mix of GDScript and C++ into Godot C#.

View of the ocean with an island covered in trees visible on the horizon. The sun is currently setting. There is a tree on the right side of the screen
Screenshot for the article thumbnail

# Background

This is a hobby project that I’ve been working on since summer 2022. I use the latest stable version of Godot, which was 3.5 when I started and is now 4.5. I wrote the star rendering and faking relativity articles based on my work here.

Before converting, there were about 8000 lines of GDScript, and 8000 lines of GDExtension C++ code. GDScript and C++ worked together through Godot’s reflection system.

# Why not GDScript?

Performance was the main reason I couldn’t solely use GDScript. GDScript is simply too slow for computationally intensive things like terrain generation. I found that it was no less than 8x slower than C++, and up to 300x in certain pathological cases.

There are other problems too. GDScript is a dynamic language with little tooling. The only errors you get at compile time are when you open up the file and look at the problems window. You can’t see at a glance the errors across your entire project. The only way to know if you renamed all usages of a symbol is to run the project and see where it errors. I began to dread any kind of refactoring of the code, even simple renames, which made it hard to fix bugs or add new features.

That said, I think GDScript is still a good default scripting language for Godot. It’s probably sufficient for 99% of games. It just wasn’t enough for me.

# Why not C++?

Compile times were definitely a factor. The only supported build system being SCons didn’t help either, as it has a lot of overhead, making the build even slower. Slow compile times really hurt for things like visual tweaks.

The biggest issue was a bug in the Godot editor, though. It causes all GDScript files that reference C++ classes to fail to parse with a static method not found error every time the C++ code was recompiled.

Parse Error: Static method "some_cpp_method" not found in base "GDScriptNativeClass"

When GDScript files have a parse error, the editor treats them as if they had no exported properties. This means that saving any scene file will revert all script properties to default values. It wasn’t immediately obvious what happened until I ran the game and saw that everything was broken.

The only workaround was to restart the editor every time I recompiled the C++ code, which added a huge delay to the edit-compile-test cycle on top of the already annoying compile times.

A lot of error messages shaped like this: ERROR: res://some/script.gd:123 - Parse Error: Static function "some_func()" not found in base "GDScriptNativeClass"

# Why not Rust?

The main problem with Rust is that it’s not an officially supported language for Godot. The only way to use it is through third party bindings. I did try gdext-rs, but there were problems:

  • The build times were a constant issue, just like with C++. There is a lot of generated code in the bindings, which slows down the compile times significantly.
  • Hot reloading didn’t work at all, requiring editor restarts like with C++. I think this has been fixed since I last tried it.
  • Any time your rust code panics, it crashes the entire editor. For some reason the stack trace printing didn’t work either, requiring me to use a debugger to view the stack trace.
  • Rust’s memory model doesn’t match well to Godot’s memory semantics.

# The C# newcomer

For a long time I ignored C# because of the hefty runtime it required and the poor support it had. Also, a lot of the tooling was MS-centric (Visual Studio, etc.) and I run Linux as my primary OS.

Recently though, like in Godot 4.4 and 4.5, the C# support in Godot has become highly polished. There’s been an enormous amount of work into it, and it shows.

So I decided to give it a try. I made a test project, noodled around for a few hours, and liked it. I then started converting some code to C# in my main game project, knowing I could just revert the changes if it didn’t work out. It was going well, so I split off a before-csharp branch as a checkpoint and then started committing my work.

# The results

It took about a week. The project is currently sitting at around 25,000 lines of C#, but this is not 1:1 with the numbers above, as I added new features and new game systems during the conversion.

It takes about 1 second to compile the project.

I was able to massively simplify the code because I no longer needed everything to inherit from Node or Resource. I could use regular classes, I could use regular structs. I didn’t need to pack values into Vector4s or PackedInt32Arrays anymore.

I did refactors that I’d been dreading doing for over a year. I fixed bugs, I finished unimplemented functionality. I added new systems like a console window and command registry.

There is no C++ code left in the repository. It was all converted into C#, and performance is not any worse having done so.

C# makes it easy to avoid the cost of GC through usage of structs (which are value types) and arrays. I was following data oriented design practices in C++, and it was easy to continue doing so in C#. I started using a C# ECS library, fennecs.

There are only 2 .gd files left. I moved them into the assets folder as I don’t consider them part of the main codebase anymore.

  1. gui_theme.gd - Custom UI theme using ThemeGen.
  2. style_box_bevel.gd - Custom StyleBox subclass. Engine rejects StyleBox subclass written in C# for some reason, probably a bug.

The only downside I’ve run into has been the closed-source nature of C# IDE tooling. The official C# extension for VS Code is closed source, and only available via the official Microsoft build of VS Code. I’m willing to accept that for now though. Maybe someday there will be a good enough open source LSP I can use. (Or one already exists and I don’t know about it?)

The C# support in Godot adds about 50 megabytes to the size of the editor build, and seems to be about the same amount added to the size of exported projects. Also, C# can’t export to web currently, but neither can any project that uses the Forward+ or Mobile renderers, or uses compute shaders.

# Development setup

I’m using VS Code with the official MS C# extension and official godot-tools extension. I have a custom launch.json setup so I can press F5 to launch the game in the debugger.

I’m using CSharpier to auto-format all of the code. I changed it to use 4-wide tabs for indentation and 80 column width, left all other settings as default.

I have 190 unit tests across 14 test suites, using NUnit v4. I made a custom test runner that runs the test inside of Godot engine, to access Godot classes without crashing. NUnit’s DefaultTestAssemblyBuilder breaks if Assembly.Location is empty, so I wrote a custom ITestAssemblyBuilder implementation. The tests are run via a shell command, no editor plugin. Takes 2.5 seconds to compile and run all tests.

I tried to use the free trial of the Rider IDE, but that went pretty terribly. I couldn’t even get my code to compile until I clicked a checkbox that used MSBuild instead of the default toolchain. I never got debugging working in Rider either. The dotTrace profiler did work, but the results were unreliable due to it being a sampling profiler rather than an instrumentation based one. So I’m going to stick to VS Code, which works better and is free.

# Conclusion

So far, this has been going great! If I change my mind later down the road, I guess I’ll update this article.

If your project is large, very code-driven (e.g. because of procgen), or you care a lot about performance, then maybe give C# a try.