---
name: csharp-signals
description: 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.

```csharp
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 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.

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

```csharp
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.

```csharp
// 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.

```csharp
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`.

```csharp
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

```csharp
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.

```csharp
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`

```csharp
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`

```csharp
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.

```csharp
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.

```csharp
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);
}
```

```csharp
// 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:**

| Need                                    | Use                    |
|-----------------------------------------|------------------------|
| GDScript nodes also subscribe           | Godot `[Signal]`       |
| Editor signal connections needed        | Godot `[Signal]`       |
| `ToSignal` / `await` in Godot style     | Godot `[Signal]`       |
| Pure-C# communication, max performance  | Static C# event        |
| Signal needs to appear in the debugger  | Godot `[Signal]`       |

### Generic signal helper

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

```csharp
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.

```csharp
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:

```csharp
// 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

| Mistake | Symptom | Fix |
|---|---|---|
| Forgetting `EventHandler` suffix on the delegate | Compiles fine; signal does not appear in editor; `EmitSignal` fails at runtime with "signal not found" | 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 in `_ExitTree()` | Memory leak; crash on next signal emission after node is freed | Add `_ExitTree()` override with matching `-=` for every `+=` |
| 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 `+=` 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
