Every non-trivial game grows a global "event bus" — an autoload everything emits at and listens to, so systems don't have to hold references to each other. And almost every hand-rolled one ships the same three bugs: a listener leaks because its node freed without disconnecting (so the next emit crashes on a freed instance), a mistyped event name fails silently three systems away, and an accidental double-subscribe fires the handler twice. EventVault is the drop-in bus that closes all three by construction.
Godot 4.x · GDScript · no dependencies · ~200 lines you can read in one sitting.
- Freed listeners are dropped, never called. Subscribe a node, let it free without
unsubscribing, and the next
emitsimply skips it — nodisconnectbookkeeping, no "Invalid call on freed instance" crash. The single most common Godot signal bug, gone. Pruning is by validity, so the boundary is exact: aqueue_free()d node stays valid until Godot's deferred end-of-frame reap, so a same-frameemitafterqueue_free()still delivers to it once. Callunsubscribe()(orfree()) for same-frame finality. - Typos and wrong payloads fail loud, not silent.
declare(event, argc)registers a channel and its arg count. Instrictmode, emitting an undeclared channel or the wrong number of args is apush_errorand a no-op return of-1— caught at the call site instead of as a handler that mysteriously never ran. - Subscribe is idempotent. Re-subscribing the same handler updates its priority but never adds a second entry, so a handler can't accidentally fire twice.
once()subscriptions auto-retire. Fire-once listeners remove themselves after the first delivery; re-emitting never re-fires them.- Deterministic delivery order. Handlers run in
priorityorder (high first), so an audio cue can reliably land before the UI that depends on it. - Re-entrancy safe. A handler may subscribe or unsubscribe mid-emit; the in-flight delivery runs on a snapshot, so nothing is double-fired or skipped, and a newly-added handler waits for the next emit.
- Copy
addons/event_vault/into your project. - Project → Project Settings → Plugins → enable EventVault
(or add
event_vault.gdas an autoload namedEventVaultyourself).
emit(event, [a, b]) delivers to each handler as handler(a, b) — the array is spread into
positional arguments, exactly like a Godot signal carrying parameters.
func _ready() -> void:
# Optional: declare your channels up front. With strict on, a typo or a
# wrong-shaped payload becomes a loud error instead of a silent miss.
EventVault.strict = true
EventVault.declare("player_died", 2) # carries (peer_id, cause)
EventVault.declare("score_changed", 1)
EventVault.subscribe("player_died", _on_player_died)
EventVault.subscribe("score_changed", _update_hud, 10) # higher priority = earlier
func _on_enemy_kill() -> void:
EventVault.emit("score_changed", [_score])
func _on_fatal_hit(cause: String) -> void:
EventVault.emit("player_died", [multiplayer.get_unique_id(), cause])
func _on_player_died(peer_id: int, cause: String) -> void:
print("peer %d died: %s" % [peer_id, cause])
func _update_hud(score: int) -> void:
%ScoreLabel.text = str(score)No disconnect in _exit_tree, no null-checks before emit — if a subscriber's node is gone,
EventVault drops it the next time the event fires.
EventVault.once("boss_intro_done", _start_fight) # runs at most once
EventVault.emit_deferred_event("level_loaded", [level_id]) # fires on the idle frame| Call | Does |
|---|---|
subscribe(event, callable, priority=0) |
Listen for event. Idempotent; returns the callable for later unsubscribe. |
once(event, callable, priority=0) |
Listen for the next delivery only, then auto-retire. |
unsubscribe(event, callable) |
Stop listening. Returns true if a subscription was removed. |
emit(event, args=[]) |
Fire synchronously, high-priority first. Returns handlers notified, or -1 if rejected by strict mode. |
emit_deferred_event(event, args=[]) |
Fire on the next idle frame (safe from physics / _ready chains). |
declare(event, argc=-1) |
Register a typed channel; argc is the expected arg count (-1 = any). |
is_declared(event) |
Whether a channel was declared. |
listener_count(event) |
Live subscriber count (prunes freed ones first). |
prune() |
Eagerly sweep every channel for freed subscribers. Returns how many were dropped. |
clear(event) / clear_all() |
Forget subscribers on one event / all events. |
strict (property) |
When true, undeclared channels and wrong arg counts are rejected with push_error. Default false. |
event_fired(event, args) (signal) |
A firehose of every delivered event — for logging/debugging, not game logic. |
Direct signals are great between two nodes that know each other. A bus is for the many-to-many case — achievements, audio, analytics, UI, and quest logic all reacting to "enemy died" without any of them holding a reference to the combat system or to each other. The cost of that decoupling is usually the three bugs above; EventVault is what makes the decoupling free.
Pairs cleanly with the rest of the suite — SaveVault, SettingsVault, AudioVault, SceneVault, StatVault — same no-dependency, read-it-in-one-sitting rail.
godot --headless --path . -s tests/test_event_vault.gd
24 checks — payload delivery, freed-listener pruning, once, priority order, re-entrancy,
strict validation — exit 0 on all-pass.
MIT — see LICENSE. Use it in anything, commercial or not.