SuperAPI AI Directory

csharp-signals

Coding & development

View repository

Updated 10-16 06:19

About this skill

Signals in C# (Godot 4.x)

This skill is C# only. For general C# conventions and project setup, see the csharp-godot skill. Godot signals in C# require a different mental model from GDScript: delegates declared with [Signal], strongly-typed +=/-= connections, and mandatory disconnection in _ExitTree(). All examples target Godot 4.x with no deprecated APIs.

Related skills: csharp-godot for C# conventions and project setup, event-bus for global signal hub architecture, component-system for signal-based component communication.


1. Signal Declaration

Signals are declared as public delegate void with the [Signal] attribute inside a partial class that extends a Godot type. The delegate name must end with EventHandler — Godot strips that suffix to produce the signal name exposed to the engine.

using Godot;

public partial class Player : CharacterBody2D
{
    // Signal name in engine: "HealthChanged"
    [Signal] public delegate void HealthChangedEventHandler(int current, int maximum);
// Signal name in engine: "Died"
[Signal] public delegate void DiedEventHandler();

// Signal name in engine: "ItemCollected"
[Signal] public delegate void ItemCollectedEventHandler(string itemName);

}


**Naming rules:**

| Delegate name                   | Engine signal name  |
|---------------------------------|---------------------|
| `HealthChangedEventHandler`     | `HealthChanged`     |
| `DiedEventHandler`              | `Died`              |
| `ItemCollectedEventHandler`     | `ItemCollected`     |
| `PlayerSpawnedEventHandler`     | `PlayerSpawned`     |

Omitting the `EventHandler` suffix is a compile error: the source generator reports GD0201, "The name of the delegate must end with 'EventHandler'".

**Parameter type constraints:** Signal parameters must be Godot-marshallable types: `int`, `float`, `bool`, `string`, `Vector2`, `Vector3`, `Color`, `GodotObject` subclasses, `GodotDictionary`, `GodotArray`. Plain C# classes, structs, and generics are not allowed as parameters.

---

## 2. Emitting Signals

Use `EmitSignal(SignalName.SignalName, args...)`. The `SignalName` nested class is auto-generated by the Godot source generators at build time — one static string constant per declared signal.

```csharp
using Godot;

public partial class Player : CharacterBody2D
{
    [Signal] public delegate void HealthChangedEventHandler(int current, int maximum);
    [Signal] public delegate void DiedEventHandler();
    [Signal] public delegate void ItemCollectedEventHandler(string itemName);

    [Export] public int MaxHealth { get; set; } = 100;
    private int _currentHealth;

    public override void _Ready()
    {
        _currentHealth = MaxHealth;
    }

    public void TakeDamage(int amount)
    {
        _currentHealth = Mathf.Clamp(_currentHealth - amount, 0, MaxHealth);

        // Type-safe emission — SignalName.HealthChanged is a generated constant.
        EmitSignal(SignalName.HealthChanged, _currentHealth, MaxHealth);

        if (_currentHealth == 0)
            EmitSignal(SignalName.Died);
    }

    public void CollectItem(string itemName)
    {
        EmitSignal(SignalName.ItemCollected, itemName);
    }
}

EmitSignal validates argument count and types at runtime in debug builds. Passing the wrong number of arguments raises an error immediately, making bugs easy to locate.


3. Connecting Signals

The += operator (preferred)

using Godot;

public partial class HudLayer : CanvasLayer
{
    private Player _player;

    public override void _Ready()
    {
        _player = GetNode<Player>("../Player");

        // Connect with += — mirrors C# event syntax.
        _player.HealthChanged += OnHealthChanged;
        _player.Died          += OnDied;
        _player.ItemCollected += OnItemCollected;
    }

    private void OnHealthChanged(int current, int maximum)
    {
        GetNode<ProgressBar>("HealthBar").Value = (double)current / maximum * 100.0;
        GetNode<Label>("HealthLabel").Text = $"{current} / {maximum}";
    }

    private void OnDied()
    {
        GetNode<Control>("DeathScreen").Show();
    }

    private void OnItemCollected(string itemName)
    {
        GetNode<Label>("PickupLabel").Text = $"Picked up: {itemName}";
    }
}

Lambda connections

Use lambdas for one-off, short-lived responses. Store the lambda in a field if you need to disconnect it later.

// Anonymous lambda — cannot be disconnected by reference later.
_player.Died += () => GetNode<AudioStreamPlayer>("DeathSound").Play();

// Stored lambda — can be disconnected.
private Action<string> _onItemCollected;

public override void _Ready()
{
    _onItemCollected = (itemName) =>
    {
        _collectCount++;
        UpdateCollectDisplay();
    };
    _player.ItemCollected += _onItemCollected;
}

public override void _ExitTree()
{
    _player.ItemCollected -= _onItemCollected;
}

Connecting in _Ready()

Connect inside _Ready() — or _EnterTree() for a node that is removed and re-added, since _Ready() runs only once. The node's references are resolved and the scene tree is available at that point. Connecting in the constructor or field initializers may fail because Godot node infrastructure is not yet initialised.


4. Disconnecting Signals

When a GodotObject is freed, Godot disconnects its connections, for emitters and receivers alike. Two cases are not cleaned up when the receiver is freed while the emitter lives on: a += on a custom [Signal], and a lambda that captures a variable. In both, the next emission reaches a disposed object and throws ObjectDisposedException, so disconnect with -= yourself in _ExitTree(). For a node that can leave and re-enter the tree, connect in _EnterTree() instead of _Ready(), which runs only once.

See references/disconnecting.md for the -= cleanup pattern, the two cases Godot does not clean up, and a SafeDisconnect helper.


5. Awaiting Signals

await ToSignal(node, SignalName.X) pauses until the signal fires. Returns Variant[] of the signal's args. Add a timeout with Task.WhenAny or a CancellationTokenSource to avoid permanent hangs.

See references/awaiting-signals.md for basic await, return-value extraction, and both timeout patterns.


6. Custom Signal Patterns

Three patterns: typed event args (wrap signal payload in a Resource subclass), static event bus (use C# static event as a pure-C# alternative when both peers are C#), generic signal helper (one helper per SignalName.X to reduce repetition).

See references/custom-signal-patterns.md for full code on each pattern.


7. Connecting GDScript Signals from C#

When the signal is declared in a GDScript node and the listener is C#, use node.Connect("signal_name", new Callable(this, MethodName.Handler)). The MethodName generated symbol still works for C# methods.

See references/connecting-gdscript-signals.md for the full pattern with both string-name and MethodName variants.


8. Common Mistakes

Mistake Symptom Fix
Forgetting EventHandler suffix on the delegate Build fails with GD0201: the delegate name must end with 'EventHandler' Rename to XxxEventHandler
Wrong parameter types or count in EmitSignal Runtime error in debug build: "Signal parameter mismatch" Match EmitSignal args exactly to the delegate signature
Connecting to a freed object ObjectDisposedException or silent crash on next signal emission Guard with IsInstanceValid before connecting; store reference safely
Not disconnecting a custom [Signal] or a capturing lambda ObjectDisposedException on the next emission after the receiver is freed; built-in signals with a method handler are cleaned up automatically Add a matching -= in _ExitTree() (or Dispose()) for those two cases
Passing wrong argument count to EmitSignal Debug-build error: "Expected N arguments, got M" Count signal delegate parameters and match exactly
Using static C# events for cross-language signals GDScript cannot observe the event; no error, just silent non-delivery Use Godot [Signal] for any signal GDScript must receive
Double-connecting the same handler Handler fires twice per emission; subtle logic bugs Check if already connected, or use ConnectFlags.OneShot; never call += twice for the same method
Awaiting a signal in _Ready() before the tree is ready NullReferenceException or signal never resolves because the source node is not yet in the tree Defer with await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame) first, or move the await to a method called after _Ready()

9. Checklist

  • Every [Signal] delegate name ends with EventHandler
  • EmitSignal uses SignalName.XxxName constants (not raw strings)
  • EmitSignal argument count and types match the delegate signature exactly
  • All += connections are in _Ready() (not in constructors or field initializers)
  • Every += on a custom [Signal], and every capturing lambda, has a matching -=
  • IsInstanceValid guards are in place where the signal source may be freed before the subscriber
  • Lambdas stored in fields if disconnection is needed later
  • async signal awaits are not blocking _Ready() directly; deferred if necessary
  • Cross-language signals (GDScript emitter, C# subscriber) use Connect("snake_case_name", Callable.From<T>(Method))
  • Static C# events used only for pure-C# communication paths
  • RefCounted-derived wrapper classes used for complex signal payloads, not plain C# classes or structs