~3 min read

C# in Godot — practice and nuances

Godot .NET edition, differences from GDScript, migration, gotchas for developers coming from the Unity world.

GDScript is a great language, but if you come from Unity (or a serious .NET project), you’ll want to work in C#. Godot supports this via a separate build — the Godot .NET edition.

How the .NET edition differs

ParameterGodot StandardGodot .NET edition
Editor size~60 MB~120 MB
ScriptingGDScript, GDExtension+ C# (.NET 8)
Web export❌ (does not work as of May 2026)
C# AOT exportn/aNativeAOT (with caveats)
.csprojn/aStandard

Download the .NET variant from godotengine.org/download. The Steam version has a separate SKU “Godot Engine (.NET)”.

Web

Using Node.js + TypeScript in an Express project VS using Deno or Bun. The basic logic is the same, but the runtime and tooling differ.

Unity

Your first C# script

using Godot;

public partial class Enemy : Node3D
{
    [Export]
    public int MaxHp { get; set; } = 100;

    [Export(PropertyHint.Range, "1,10,0.1")]
    public float Speed { get; set; } = 3.0f;

    [Signal]
    public delegate void DiedEventHandler(Node killer);

    private int _hp;

    public override void _Ready() {
        _hp = MaxHp;
        GD.Print($"Enemy spawned with {MaxHp} HP");
    }

    public override void _PhysicsProcess(double delta) {
        // delta is of type double (not float like in Unity)
        Position += Vector3.Forward * Speed * (float)delta;
    }

    public void TakeDamage(int amount, Node attacker) {
        _hp -= amount;
        if (_hp <= 0) {
            EmitSignal(SignalName.Died, attacker);
            QueueFree();
        }
    }
}

Key differences from Unity:

  • partial — mandatory (the code generator adds code for signals).
  • [Export] instead of [SerializeField] — the attribute for Inspector visibility.
  • PropertyHint.Range instead of [Range(1, 10)] — a different syntax.
  • [Signal] public delegate — signals are declared as delegates with the EventHandler suffix.
  • _Ready, _PhysicsProcess (PascalCase) — not _ready, _physics_process.
  • (double)delta — you need to explicitly cast to float if you use it with Vector3.

Comparing syntax with Unity C#

UnityGodot C#
class : MonoBehaviourpartial class : Node (or Node2D/Node3D)
[SerializeField][Export]
void Update()public override void _Process(double delta)
void FixedUpdate()public override void _PhysicsProcess(double delta)
void Start()public override void _Ready()
transform.positionPosition (no transform)
Vector3.forwardVector3.Forward (PascalCase)
gameObject.SetActive(false)Visible = false (or ProcessMode = Disabled)
GetComponent<T>()GetNode<T>("Name")
Instantiate(prefab)prefab.Instantiate<T>()
Destroy(go)QueueFree()
Time.deltaTimethe delta parameter of the method
Time.time(float)Time.GetTicksMsec() / 1000.0f
Input.GetKey(...)Input.IsActionPressed("...")
Debug.Log(...)GD.Print(...)

Signals — two ways

Declaration:

[Signal]
public delegate void DamagedEventHandler(int amount);

[Signal]
public delegate void DiedEventHandler();

Emit:

EmitSignal(SignalName.Damaged, 10);
EmitSignal(SignalName.Died);

Subscription:

// Type-safe
enemy.Damaged += OnEnemyDamaged;

void OnEnemyDamaged(int amount) {
    GD.Print($"Enemy took {amount} damage");
}

SignalName is an auto-generated class with constants. The IDE provides autocomplete.

Resources and Exports

A C# Custom Resource is almost like a Unity ScriptableObject:

[GlobalClass]
public partial class WeaponData : Resource
{
    [Export] public string DisplayName { get; set; } = "Pistol";
    [Export] public int Damage { get; set; } = 10;
    [Export] public float FireRate { get; set; } = 0.5f;
    [Export] public PackedScene ProjectilePrefab { get; set; }
}

The [GlobalClass] attribute registers the class globally — it will appear in FileSystem → New Resource → WeaponData (like a scriptable object in Unity).

Usage:

public partial class Weapon : Node3D
{
    [Export] public WeaponData Data { get; set; }
    [Export] public Marker3D Muzzle { get; set; }

    private double _nextFireTime = 0;

    public void TryFire() {
        var now = Time.GetTicksMsec() / 1000.0;
        if (now < _nextFireTime) return;
        _nextFireTime = now + Data.FireRate;

        var bullet = Data.ProjectilePrefab.Instantiate<Node3D>();
        bullet.GlobalTransform = Muzzle.GlobalTransform;
        GetTree().CurrentScene.AddChild(bullet);
    }
}

Async / Await

The standard .NET async/await works:

public async Task LoadAndShow() {
    await ToSignal(GetTree().CreateTimer(1.0), Timer.SignalName.Timeout);
    GD.Print("1 second passed");

    await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
    GD.Print("Next frame");
}

ToSignal(node, "signal_name") is the key helper: it returns a Task that completes when the signal is received.

Limitations of C# in Godot

1. Web export doesn’t work

As of May 2026, this is the main limitation. C# does not export to HTML5. The team is working on a solution via .NET 10 and NativeAOT, but it’s not in production yet. If the target is the browser, GDScript remains.

2. Limited hot reload

GDScript recompiles on the fly; C# requires stopping Play Mode → rebuild → run again. This iterates slower than GDScript.

3. .NET 8 (or 9 depending on Godot)

Older C# features are available; the very latest ones — sometimes not. Check the target framework in .csproj.

4. PackedScene.Instantiate in C# is a bit more verbose

// GDScript: var enemy = enemy_scene.instantiate()
// C#:
Node enemy = enemyScene.Instantiate();
// Or with a generic:
var enemy3D = enemyScene.Instantiate<Node3D>();

5. NativeAOT export — experimental

NativeAOT provides fast startup and a smaller build size, but not all C# features work (reflection, runtime code generation). With Godot 4.6 — limited support; keep an eye on releases.

When to choose C# vs GDScript

C# is a good choice if:

  • You have a .NET team / .NET background.
  • You need existing .NET libraries (Newtonsoft.Json, AutoMapper, etc.).
  • It’s a large project where strong typing is important for maintenance.
  • A web target is not needed.

GDScript is better if:

  • It’s a small-to-medium project.
  • Web export is mandatory.
  • The team are gamedevs, not senior .NET developers.
  • Iteration speed (hot reload) is critical.
  • You rely on community plugins (most are GDScript).

Performance

C# in Godot is roughly 5–10× faster than untyped GDScript, 2–3× faster than typed GDScript. This is noticeable on CPU-bound workloads: procedural generation, AI with a large number of agents, complex math.

For gameplay logic (90% of game code) the difference isn’t critical — both languages handle it.

Mixing GDScript and C#

It’s possible. In a single project, different nodes can have different languages. Calling between them works through the shared ClassDB API.

// C# calls a GDScript method
var gdNode = GetNode("GDScriptNode");
gdNode.Call("some_method", arg1, arg2);
# GDScript calls a C# method
var cs_node = $CSharpNode
cs_node.Call("SomeMethod", arg1, arg2)

Not lightning-fast (reflection under the hood), but it works.