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 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.
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
IsInstanceValidguards, 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:
| 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.
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
| 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 withEventHandler -
EmitSignalusesSignalName.XxxNameconstants (not raw strings) -
EmitSignalargument 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() -
IsInstanceValidguards are in place where the signal source may be freed before the subscriber - Lambdas stored in fields if disconnection is needed later
-
asyncsignal 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