agentleFS
jame581jame581/GodotPrompter/skills/csharp-signals/SKILL.md

csharp-signals

Skill870 starsChanged 21 days agoMITView on GitHub

Use when implementing signals in C# — [Signal] delegates, EmitSignal patterns, async signal awaiting, and event-driven architecture. Triggers on declaring, emitting, connecting, or awaiting a signal from a C# script, and on a signal crossing between C# and GDScript in either direction.

The file

We split this skill into the rules it gives an agent

3 rules. About 0 are common, 1 is distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Use EmitSignal(SignalName.SignalName, args...).

    line 51
  2. Distinctive:

    Use lambdas for one-off, short-lived responses.

    line 133
  3. Very specific:

    When the signal is declared in a GDScript node and the listener is C#, use node.Connect("signal_name", new Callable(this, …

    line 190

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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 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 Variant-compatible: numeric types, bool, string, enums, Godot structs (Vector2, Color, …), StringName, NodePath, GodotObject subclasses, Godot.Collections.Array/Dictionary (including the generic Array<T>/Dictionary<TKey, TValue>) and packed-array equivalents such as int[]. Other C# classes and structs fail the build with GD0202.


2. Emitting Signals

Use EmitSignal(SignalName.SignalName, args...). The SignalName nested class is auto-generated by the Godot source generators at build time — one cached StringName 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 does not check its arguments against the delegate. With the wrong argument count, += handlers on a custom [Signal] are skipped without an error, and Connect/GDScript receivers log Error calling from signal … Method expected N argument(s), but called with M. With a wrong type, C# handlers get the value converted or defaulted (0, null); a typed GDScript receiver logs Cannot convert argument N from X to Y and is not called.


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 MethodName variants.


8. Common Mistakes

MistakeSymptomFix
Forgetting EventHandler suffix on the delegateBuild fails with GD0201: the delegate name must end with 'EventHandler'Rename to XxxEventHandler
Wrong parameter types in EmitSignalC# handlers: no error, the value is converted or defaulted (0, null); typed GDScript receivers: Cannot convert argument N from X to Y, handler not calledMatch 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 a custom [Signal] or a capturing lambdaObjectDisposedException on the next emission after the receiver is freed; built-in signals with a method handler are cleaned up automaticallyAdd a matching -= in _ExitTree() (or Dispose()) for those two cases
Passing wrong argument count to EmitSignal+= handlers silently not called; Connected receivers log "Method expected N argument(s), but called with 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 handlerOn a custom [Signal] the handler fires twice per emission; on a built-in signal the second += logs "already connected"Check 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 += on a custom [Signal], and every capturing lambda, has a matching -=
  • 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

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.