Skip to content

Skinflap/EventVault

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

EventVault — a decoupled event bus for Godot 4, done right

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.

What it does that a hand-rolled signal bus doesn't

  • Freed listeners are dropped, never called. Subscribe a node, let it free without unsubscribing, and the next emit simply skips it — no disconnect bookkeeping, no "Invalid call on freed instance" crash. The single most common Godot signal bug, gone. Pruning is by validity, so the boundary is exact: a queue_free()d node stays valid until Godot's deferred end-of-frame reap, so a same-frame emit after queue_free() still delivers to it once. Call unsubscribe() (or free()) for same-frame finality.
  • Typos and wrong payloads fail loud, not silent. declare(event, argc) registers a channel and its arg count. In strict mode, emitting an undeclared channel or the wrong number of args is a push_error and 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 priority order (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.

Install

  1. Copy addons/event_vault/ into your project.
  2. Project → Project Settings → Plugins → enable EventVault (or add event_vault.gd as an autoload named EventVault yourself).

Use

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.

Fire-once and deferred

EventVault.once("boss_intro_done", _start_fight)   # runs at most once
EventVault.emit_deferred_event("level_loaded", [level_id])   # fires on the idle frame

API

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.

Why a bus at all

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.

Test

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.

License

MIT — see LICENSE. Use it in anything, commercial or not.

About

No description, website, or topics provided.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors