From godot-prompter
Implements Godot 4.x signals in C# using [Signal] delegates, EmitSignal patterns, async awaiting, and event-driven architecture.
npx claudepluginhub jame581/godotprompter --plugin godot-prompterThis skill uses the workspace's default tool permissions.
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.
Provides C# conventions, GodotSharp API differences from GDScript, project setup with .csproj/NuGet, and interop for Godot 4.3+ development.
Provides C# patterns for Godot with clear interop boundaries, node ownership, and engine lifecycle awareness. Use when C# interop with scenes/resources grows or architecture needs review.
Provides Godot 4 GDScript patterns for architecture, signals, scenes, state machines, and optimization. Useful for building games, game systems, and best practices.
Share bugs, ideas, or general feedback.
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.
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.
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.
+= 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}";
}
}
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;
}
_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.
-= operator and _ExitTree() cleanupC# 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) { /* ... */ }
}
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;
}
}
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:
IsInstanceValid guards, but crashes in release builds.Rule: every += in _Ready() must have a matching -= in _ExitTree().
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).
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);
}
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}");
}
Task.WhenAnyusing 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;
}
CancellationTokenSourceusing 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());
}
}
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);
}
}
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] |
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)."));
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));
| 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() |
[Signal] delegate name ends with EventHandlerEmitSignal uses SignalName.XxxName constants (not raw strings)EmitSignal argument count and types match the delegate signature exactly+= connections are in _Ready() (not in constructors or field initializers)+= has a matching -= in _ExitTree()IsInstanceValid guards are in place where the signal source may be freed before the subscriberasync signal awaits are not blocking _Ready() directly; deferred if necessaryConnect("snake_case_name", Callable.From<T>(Method))RefCounted-derived wrapper classes used for complex signal payloads, not plain C# classes or structs