csharp-signals

Use when implementing signals in C# — [Signal] delegates, EmitSignal patterns, async signal awaiting, and event-driven architecture

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 nameEngine signal name
HealthChangedEventHandlerHealthChanged
DiedEventHandlerDied
ItemCollectedEventHandlerItemCollected
PlayerSpawnedEventHandlerPlayerSpawned

Omitting the EventHandler suffix compiles without error but registers no Godot signal — the signal will not appear in the editor and EmitSignal will throw at runtime.

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.

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()

Always connect inside _Ready(). 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

The -= operator and _ExitTree() cleanup

C# delegates are not garbage-collected automatically when a node is freed. If you do not disconnect, the signal source holds a delegate reference to the freed node, causing memory leaks and ObjectDisposedException or InvalidOperationException on the next emission.

using Godot;

public partial class HudLayer : CanvasLayer
{
    private Player _player;

    public override void _Ready()
    {
        _player = GetNode<Player>("../Player");
        _player.HealthChanged += OnHealthChanged;
        _player.Died          += OnDied;
        _player.ItemCollected += OnItemCollected;
    }

    // _ExitTree is called before the node is removed from the tree.
    // This is the correct place to disconnect — _player is still valid here.
    public override void _ExitTree()
    {
        // Mirror every += from _Ready() with a -=.
        _player.HealthChanged -= OnHealthChanged;
        _player.Died          -= OnDied;
        _player.ItemCollected -= OnItemCollected;
    }

    private void OnHealthChanged(int current, int maximum) { /* ... */ }
    private void OnDied() { /* ... */ }
    private void OnItemCollected(string itemName) { /* ... */ }
}

SafeDisconnect pattern

When the signal source may have already been freed (e.g., it lives in a different scene), guard the disconnection with IsInstanceValid.

public override void _ExitTree()
{
    if (IsInstanceValid(_player))
    {
        _player.HealthChanged -= OnHealthChanged;
        _player.Died          -= OnDied;
    }
}

Why C# MUST disconnect (unlike GDScript)

In GDScript, signals use Godot's internal reference system, which automatically invalidates connections when a node is freed. In C#, the += operator creates a standard .NET multicast delegate, held by the emitting object. Godot's object lifetime system does not reach into .NET delegate lists. The result:

  • The emitter keeps the subscriber alive longer than expected (memory leak).
  • When the emitter fires the signal, the delegate invocation reaches a freed Godot node and throws.
  • The bug is often silent in debug builds with IsInstanceValid guards, but crashes in release builds.

Rule: every += in _Ready() must have a matching -= in _ExitTree().


5. Awaiting Signals

ToSignal(source, SignalName.SignalName) returns a SignalAwaiter that integrates with C#'s async/await. The method must be async and return Task (or void for fire-and-forget).

Basic await

public async void StartCutscene()
{
    // Pause normal gameplay.
    GetTree().Paused = true;

    // Wait until the animation finishes.
    await ToSignal(GetNode<AnimationPlayer>("AnimationPlayer"), AnimationPlayer.SignalName.AnimationFinished);

    GetTree().Paused = false;
    EmitSignal(SignalName.CutsceneCompleted);
}

Await with returned values

SignalAwaiter resolves to a Godot.Collections.Array containing the signal's parameter values.

public async void WaitForPlayerInput()
{
    var result = await ToSignal(this, SignalName.ItemCollected);
    string collectedName = result[0].AsString();
    GD.Print($"Player collected: {collectedName}");
}

Timeout pattern with Task.WhenAny

using System.Threading.Tasks;
using Godot;

public async Task<bool> WaitForSignalWithTimeout(float timeoutSeconds)
{
    // Build a timeout task using Godot's SceneTreeTimer.
    var timer = GetTree().CreateTimer(timeoutSeconds);
    var timeoutTask = ToSignal(timer, SceneTreeTimer.SignalName.Timeout).AsTask();
    var signalTask  = ToSignal(this, SignalName.Died).AsTask();

    var completed = await Task.WhenAny(signalTask, timeoutTask);

    if (completed == signalTask)
    {
        GD.Print("Signal fired before timeout.");
        return true;
    }

    GD.Print("Timed out waiting for signal.");
    return false;
}

Timeout pattern with CancellationTokenSource

using System.Threading;
using System.Threading.Tasks;
using Godot;

public async Task ListenUntilCancelled(CancellationToken token)
{
    while (!token.IsCancellationRequested)
    {
        // ToSignal does not accept a CancellationToken directly;
        // wrap it in a task-race pattern.
        var signalTask  = ToSignal(this, SignalName.ItemCollected).AsTask();
        var cancelTask  = Task.Delay(Timeout.Infinite, token);

        var completed = await Task.WhenAny(signalTask, cancelTask);
        if (completed == cancelTask) break;

        var result = await signalTask;
        HandleItemCollected(result[0].AsString());
    }
}

6. Custom Signal Patterns

Typed event args via wrapper class

Godot signal parameters must be marshallable types. Wrap a group of related values in a GodotObject-derived class so the whole payload is passed as a single parameter.

using Godot;

// Payload class — must extend GodotObject (or a subclass) to be signal-safe.
public partial class CombatEventData : RefCounted
{
    public int AttackerId  { get; set; }
    public int TargetId    { get; set; }
    public int Damage      { get; set; }
    public bool IsCritical { get; set; }
    public string DamageType { get; set; } = "physical";
}

public partial class CombatSystem : Node
{
    [Signal] public delegate void CombatHitEventHandler(CombatEventData data);

    public void ResolveAttack(int attackerId, int targetId, int damage, bool critical)
    {
        var data = new CombatEventData
        {
            AttackerId = attackerId,
            TargetId   = targetId,
            Damage     = damage,
            IsCritical = critical,
        };
        EmitSignal(SignalName.CombatHit, data);
    }
}

// Consumer
public partial class DamageNumberSpawner : Node
{
    private CombatSystem _combatSystem;

    public override void _Ready()
    {
        _combatSystem = GetNode<CombatSystem>("/root/CombatSystem");
        _combatSystem.CombatHit += OnCombatHit;
    }

    public override void _ExitTree()
    {
        _combatSystem.CombatHit -= OnCombatHit;
    }

    private void OnCombatHit(CombatEventData data)
    {
        SpawnNumber(data.TargetId, data.Damage, data.IsCritical);
    }
}

C# signal bus (static events as alternative to Godot signals for pure-C# communication)

When all subscribers are C# classes that never cross the GDScript boundary, a static event bus is simpler and faster than Godot signals. No EmitSignal, no marshalling overhead, no SignalName constants needed.

using System;

/// <summary>
/// Pure C# event bus. Use only when all producers and consumers are C# classes.
/// Do NOT use if GDScript needs to observe these events.
/// </summary>
public static class GameEvents
{
    public static event Action<int, int> HealthChanged;
    public static event Action           PlayerDied;
    public static event Action<string>   ItemCollected;

    // Helpers so callers don't need null checks.
    public static void RaiseHealthChanged(int current, int max) =>
        HealthChanged?.Invoke(current, max);

    public static void RaisePlayerDied() =>
        PlayerDied?.Invoke();

    public static void RaiseItemCollected(string name) =>
        ItemCollected?.Invoke(name);
}
// Producer
public partial class Player : CharacterBody2D
{
    public void TakeDamage(int amount)
    {
        _currentHealth = Mathf.Clamp(_currentHealth - amount, 0, MaxHealth);
        GameEvents.RaiseHealthChanged(_currentHealth, MaxHealth);

        if (_currentHealth == 0)
            GameEvents.RaisePlayerDied();
    }
}

// Consumer — must unsubscribe or leak memory.
public partial class HudLayer : CanvasLayer
{
    public override void _Ready()
    {
        GameEvents.HealthChanged += OnHealthChanged;
        GameEvents.PlayerDied    += OnPlayerDied;
    }

    public override void _ExitTree()
    {
        GameEvents.HealthChanged -= OnHealthChanged;
        GameEvents.PlayerDied    -= OnPlayerDied;
    }

    private void OnHealthChanged(int current, int max) { /* ... */ }
    private void OnPlayerDied() { /* ... */ }
}

When to choose static events vs Godot signals:

NeedUse
GDScript nodes also subscribeGodot [Signal]
Editor signal connections neededGodot [Signal]
ToSignal / await in Godot styleGodot [Signal]
Pure-C# communication, max performanceStatic C# event
Signal needs to appear in the debuggerGodot [Signal]

Generic signal helper

When you repeat the same connect-and-disconnect boilerplate, a helper extension reduces noise.

using System;
using Godot;

public static class SignalExtensions
{
    /// <summary>
    /// Connects a signal that automatically disconnects after firing once.
    /// </summary>
    public static void ConnectOnce(this GodotObject source, StringName signal, Action handler)
    {
        Action wrapper = null;
        wrapper = () =>
        {
            handler();
            source.Disconnect(signal, Callable.From(wrapper));
        };
        source.Connect(signal, Callable.From(wrapper));
    }
}

// Usage
_player.ConnectOnce(Player.SignalName.Died, () => GD.Print("Player died (once)."));

7. Connecting GDScript Signals from C#

When a GDScript node emits a signal and a C# node needs to subscribe, use the string-based Connect() API with Callable.From() to wrap the C# method.

using Godot;

public partial class ScoreDisplay : Label
{
    // Assume ScoreManager is a GDScript autoload with signal "score_changed(new_score: int)".
    private Node _scoreManager;

    public override void _Ready()
    {
        _scoreManager = GetNode("/root/ScoreManager");

        // Connect using the GDScript signal name (snake_case, as declared in GDScript).
        _scoreManager.Connect("score_changed", Callable.From<int>(OnScoreChanged));
    }

    public override void _ExitTree()
    {
        if (IsInstanceValid(_scoreManager))
            _scoreManager.Disconnect("score_changed", Callable.From<int>(OnScoreChanged));
    }

    private void OnScoreChanged(int newScore)
    {
        Text = $"Score: {newScore}";
    }
}

Important: Callable.From<T>() must match the signal parameter signature exactly. Use the GDScript snake_case signal name as a plain string — there is no SignalName constant for GDScript-declared signals on the C# side.

For signals with multiple parameters, use the overloads:

// GDScript signal: signal health_changed(current: int, maximum: int)
_player.Connect("health_changed", Callable.From<int, int>(OnHealthChanged));

// GDScript signal: signal item_collected(item_name: String)
_player.Connect("item_collected", Callable.From<string>(OnItemCollected));

8. Common Mistakes

MistakeSymptomFix
Forgetting EventHandler suffix on the delegateCompiles fine; signal does not appear in editor; EmitSignal fails at runtime with "signal not found"Rename to XxxEventHandler
Wrong parameter types or count in EmitSignalRuntime error in debug build: "Signal parameter mismatch"Match EmitSignal args exactly to the delegate signature
Connecting to a freed objectObjectDisposedException or silent crash on next signal emissionGuard with IsInstanceValid before connecting; store reference safely
Not disconnecting in _ExitTree()Memory leak; crash on next signal emission after node is freedAdd _ExitTree() override with matching -= for every +=
Passing wrong argument count to EmitSignalDebug-build error: "Expected N arguments, got M"Count signal delegate parameters and match exactly
Using static C# events for cross-language signalsGDScript cannot observe the event; no error, just silent non-deliveryUse Godot [Signal] for any signal GDScript must receive
Double-connecting the same handlerHandler fires twice per emission; subtle logic bugsCheck if already connected, or use ConnectFlags.OneShot; never call += twice for the same method
Awaiting a signal in _Ready() before the tree is readyNullReferenceException or signal never resolves because the source node is not yet in the treeDefer 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 += has a matching -= in _ExitTree()
  • 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