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
MethodNamevariants.
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 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
+=on a custom[Signal], and every capturing lambda, has a matching-= -
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