Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use when implementing decoupled communication between nodes — global EventBus autoload with typed signals
.claude/skills/jame581-event-bus/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-19 | ✗→✓ | ▲ Improved | 158% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 55% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 88% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 121% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 59% | 0% |
A global signal hub that lets unrelated nodes communicate without holding references to each other. All examples target Godot 4.3+ with no deprecated APIs.
> Related skills: component-system for direct signal communication between components, csharp-signals for C#-specific signal patterns, dependency-injection for alternative decoupling approaches, ability-system for an EventBus usage example with ability events.
An EventBus is a singleton autoload that acts as a central registry for signals. Instead of nodes connecting directly to each other, every node connects to (or emits on) the shared EventBus. This removes the need for one node to hold a reference to another.
Without EventBus With EventBus
────────────── ──────────────────────────
NodeA ──signal──► NodeB NodeA ──emit──► EventBus ──signal──► NodeB
──signal──► NodeC
──signal──► NodeDFlow diagram
┌─────────┐ emit(player_died) ┌───────────┐ player_died ┌──────────┐
│ NodeA │ ────────────────────► │ EventBus │ ───────────────► │ NodeB │
│(Player) │ │(Autoload) │ │ (UI) │
└─────────┘ └───────────┘ ───────────────► └──────────┘
player_died ┌──────────┐
│ NodeC │
│(AudioMgr)│
└──────────┘NodeA emits the signal. NodeB and NodeC each connected to EventBus independently. Neither knows the other exists.
| Scenario | Recommended approach | |--------------------------------------------|-------------------------------| | Parent notifying its own child | Direct signal or method call | | Child notifying its parent | Direct signal (bubble up) | | Two nodes with the same parent | Direct signal via parent | | Completely unrelated nodes in the tree | Event bus | | UI reacting to gameplay state changes | Event bus | | Audio manager reacting to game events | Event bus | | Data manager / save system reacting | Event bus | | Tight, performance-sensitive inner loop | Direct method call |
Rule of thumb: if you would otherwise need get_node("../../SomeDistantNode") or a hard-coded NodePath, the event bus is a better fit.
Create res://autoloads/event_bus.gd (or EventBus.cs), then register it in Project → Project Settings → Autoload with the name EventBus.
autoloads/event_bus.gd)gdscriptextends Node ## Emitted when the player character has died. signal player_died ## Emitted whenever the score changes. signal score_changed(new_score: int) ## Emitted when a level finishes successfully. signal level_completed(level_id: int) ## Emitted when the player picks up a collectible. signal item_collected(item_name: String) ## Emitted when the player's health changes. signal health_changed(current: int, maximum: int)
Autoloads/EventBus.cs)csharpusing Godot; /// <summary> /// Global signal hub. Register as an autoload named "EventBus". /// </summary> public partial class EventBus : Node { /// <summary>Emitted when the player character has died.</summary> [Signal] public delegate void PlayerDiedEventHandler(); /// <summary>Emitted whenever the score changes.</summary> [Signal] public delegate void ScoreChangedEventHandler(int newScore); /// <summary>Emitted when a level finishes successfully.</summary> [Signal] public delegate void LevelCompletedEventHandler(int levelId); /// <summary>Emitted when the player picks up a collectible.</summary> [Signal] public delegate void ItemCollectedEventHandler(string itemName); /// <summary>Emitted when the player's health changes.</summary> [Signal] public delegate void HealthChangedEventHandler(int current, int maximum); }
Consumers connect in _ready(). In C#, always disconnect in _ExitTree() to avoid dangling delegates and memory leaks.
gdscriptextends CanvasLayer # GDScript connections are reference-counted and cleaned up automatically # when the node is freed, but explicit disconnection is still good practice # for long-lived nodes that reconnect frequently. func _ready() -> void: EventBus.player_died.connect(_on_player_died) EventBus.score_changed.connect(_on_score_changed) EventBus.health_changed.connect(_on_health_changed) func _exit_tree() -> void: EventBus.player_died.disconnect(_on_player_died) EventBus.score_changed.disconnect(_on_score_changed) EventBus.health_changed.disconnect(_on_health_changed) func _on_player_died() -> void: $DeathScreen.show() func _on_score_changed(new_score: int) -> void: $ScoreLabel.text = "Score: %d" % new_score func _on_health_changed(current: int, maximum: int) -> void: $HealthBar.value = float(current) / float(maximum) * 100.0
csharpusing Godot; public partial class HudLayer : CanvasLayer { private EventBus _eventBus; public override void _Ready() { _eventBus = GetNode<EventBus>("/root/EventBus"); // Connect using strongly-typed delegate handlers _eventBus.PlayerDied += OnPlayerDied; _eventBus.ScoreChanged += OnScoreChanged; _eventBus.HealthChanged += OnHealthChanged; } // IMPORTANT: Always disconnect in _ExitTree() in C#. // C# delegates are not automatically cleaned up when a node is freed. // Failing to disconnect causes the EventBus to hold a reference to the // freed node, leading to memory leaks and InvalidOperationExceptions. public override void _ExitTree() { _eventBus.PlayerDied -= OnPlayerDied; _eventBus.ScoreChanged -= OnScoreChanged; _eventBus.HealthChanged -= OnHealthChanged; } private void OnPlayerDied() { GetNode<Control>("DeathScreen").Show(); } private void OnScoreChanged(int newScore) { GetNode<Label>("ScoreLabel").Text = $"Score: {newScore}"; } private void OnHealthChanged(int current, int maximum) { GetNode<ProgressBar>("HealthBar").Value = (double)current / maximum * 100.0; } }
Producers call EventBus.<signal_name>.emit(...) (GDScript) or EmitSignal(SignalName.*) (C#). The producer does not know which nodes are listening.
gdscriptextends CharacterBody2D @export var max_health: int = 100 var current_health: int = max_health var score: int = 0 func take_damage(amount: int) -> void: current_health = clampi(current_health - amount, 0, max_health) EventBus.health_changed.emit(current_health, max_health) if current_health == 0: EventBus.player_died.emit() func add_score(points: int) -> void: score += points EventBus.score_changed.emit(score) func collect_item(item_name: String) -> void: EventBus.item_collected.emit(item_name) func complete_level(level_id: int) -> void: EventBus.level_completed.emit(level_id)
csharpusing Godot; public partial class Player : CharacterBody2D { [Export] public int MaxHealth { get; set; } = 100; private int _currentHealth; private int _score; private EventBus _eventBus; public override void _Ready() { _currentHealth = MaxHealth; _eventBus = GetNode<EventBus>("/root/EventBus"); } public void TakeDamage(int amount) { _currentHealth = Mathf.Clamp(_currentHealth - amount, 0, MaxHealth); _eventBus.EmitSignal(EventBus.SignalName.HealthChanged, _currentHealth, MaxHealth); if (_currentHealth == 0) _eventBus.EmitSignal(EventBus.SignalName.PlayerDied); } public void AddScore(int points) { _score += points; _eventBus.EmitSignal(EventBus.SignalName.ScoreChanged, _score); } public void CollectItem(string itemName) { _eventBus.EmitSignal(EventBus.SignalName.ItemCollected, itemName); } public void CompleteLevel(int levelId) { _eventBus.EmitSignal(EventBus.SignalName.LevelCompleted, levelId); } }
Type every signal parameter. An untyped bus degrades into "what shape is this payload?" archaeology at every call site, and typos in parameter counts only surface at runtime. For anything richer than two or three primitives, pass a small Resource or a class_name'd data object rather than growing the parameter list.
Typed signal declarations, payload-object patterns, and the C# [Signal] delegate equivalents: references/typed-signals.md
Four recurring failures: routing everything through the bus when a parent could just reach its own child (over-decoupling); handlers whose side effects emit further signals, so tracing one event means reading every handler; circular chains, where a listener re-emits the signal it just received and loops forever; and connecting without disconnecting in C#, which leaks the handler for the bus's lifetime.
Each anti-pattern with the failing code, why it hurts, and the fix, in GDScript and C#: references/anti-patterns.md
Use GUT to verify both producer-side emission (watch_signals(event_bus) then assert_signal_emitted_with_parameters(...)) and consumer-side reactions (emit on the bus, then assert on the consumer's state). Always test against the real autoload EventBus retrieved via get_tree().root.get_node("EventBus"), not a fresh instance.
See references/testing.md for full producer-side and consumer-side test files plus a GUT-helper reference table.
EventBus autoload is registered in Project → Project Settings → Autoloadsignal foo(bar: int)) — no untyped signals_ready() and disconnects in _exit_tree() (mandatory in C#)EventBus, not by calling consumer methods directlyResource subclass, not a raw Dictionary_ExitTree() before merging| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | pass→pass | 17,681 | 11,250 | -36% | 1 | 1 | 0% | 3,316 | 5,126 | +55% | 0 | 0 | — |
case-02 | pass→pass | 13,250 | 9,242 | -30% | 1 | 1 | 0% | 2,505 | 4,701 | +88% | 0 | 0 | — |
case-03 | pass→pass | 9,558 | 5,615 | -41% | 1 | 1 | 0% | 1,780 | 3,925 | +121% | 0 | 0 | — |
case-04 | pass→pass | 15,769 | 8,842 | -44% | 1 | 1 | 0% | 2,833 | 4,495 | +59% | 0 | 0 | — |
case-05 | pass→pass | 13,682 | 5,498 | -60% | 1 | 1 | 0% | 2,299 | 3,842 | +67% | 0 | 0 | — |
case-06 | pass→pass | 11,919 | 8,198 | -31% | 1 | 1 | 0% | 1,905 | 4,123 | +116% | 0 | 0 | — |
case-17 | pass→pass | 14,085 | 8,446 | -40% | 1 | 1 | 0% | 2,080 | 4,106 | +97% | 0 | 0 | — |
case-07 | pass→pass | 13,747 | 8,439 | -39% | 1 | 1 | 0% | 2,178 | 4,186 | +92% | 0 | 0 | — |
case-08 | pass→pass | 13,575 | 7,899 | -42% | 1 | 1 | 0% | 2,443 | 4,181 | +71% | 0 | 0 | — |
case-09 | pass→pass | 9,470 | 7,351 | -22% | 1 | 1 | 0% | 1,644 | 4,122 | +151% | 0 | 0 | — |
case-10 | pass→pass | 15,810 | 9,805 | -38% | 1 | 1 | 0% | 2,343 | 4,344 | +85% | 0 | 0 | — |
case-11 | pass→pass | 9,773 | 7,137 | -27% | 1 | 1 | 0% | 1,718 | 4,168 | +143% | 0 | 0 | — |
case-12 | pass→pass | 13,590 | 10,147 | -25% | 1 | 1 | 0% | 2,471 | 4,750 | +92% | 0 | 0 | — |
case-13 | pass→pass | 8,893 | 5,756 | -35% | 1 | 1 | 0% | 1,599 | 3,840 | +140% | 0 | 0 | — |
case-14 | pass→pass | 9,608 | 8,557 | -11% | 1 | 1 | 0% | 1,683 | 4,177 | +148% | 0 | 0 | — |
case-15 | pass→pass | 14,684 | 9,983 | -32% | 1 | 1 | 0% | 2,209 | 4,412 | +100% | 0 | 0 | — |
case-16 | pass→pass | 12,823 | 6,745 | -47% | 1 | 1 | 0% | 2,184 | 3,930 | +80% | 0 | 0 | — |
case-18 | pass→pass | 16,993 | 12,303 | -28% | 1 | 1 | 0% | 2,785 | 4,811 | +73% | 0 | 0 | — |
case-19 | fail→pass | 13,078 | 15,442 | +18% | 1 | 1 | 0% | 2,010 | 5,188 | +158% | 0 | 0 | — |
case-20 | pass→pass | 9,268 | 6,579 | -29% | 1 | 1 | 0% | 1,801 | 4,081 | +127% | 0 | 0 | — |
case-21 | pass→pass | 7,416 | 5,606 | -24% | 1 | 1 | 0% | 1,410 | 3,918 | +178% | 0 | 0 | — |
case-22 | pass→pass | 4,355 | 3,308 | -24% | 1 | 1 | 0% | 649 | 3,286 | +406% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +5 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.