Was hitting a wall with GDScript signals on a mid-size project: once you wire more than a handful of systems through an event bus, you lose all type information on the subscriber side. The handler just receives a pile of Variant arguments. Autocomplete goes dead, refactors get dangerous, and adding a new field to a signal means hunting down every connection manually hoping you got them all.
The pattern I landed on: define events as typed RefCounted classes and route them through a simple dispatcher keyed by class name, not a string you can typo.
# EventBus.gd (autoload)
extends Node
var _listeners: Dictionary = {}
func subscribe(event_type: GDScript, callback: Callable) -> void:
var key = event_type.resource_path
if not _listeners.has(key):
_listeners[key] = []
_listeners[key].append(callback)
func unsubscribe(event_type: GDScript, callback: Callable) -> void:
var key = event_type.resource_path
if _listeners.has(key):
_listeners[key].erase(callback)
func dispatch(event: GameEvent) -> void:
var key = event.get_script().resource_path
if not _listeners.has(key):
return
for cb: Callable in _listeners[key]:
cb.call(event)
# GameEvent.gd
class_name GameEvent
extends RefCounted
# EnemyDiedEvent.gd
class_name EnemyDiedEvent
extends GameEvent
var enemy_id: int
var position: Vector3
var killer_id: int
func _init(eid: int, pos: Vector3, kid: int) -> void:
enemy_id = eid
position = pos
killer_id = kid
Subscribing and dispatching in practice:
EventBus.subscribe(EnemyDiedEvent, _on_enemy_died)
EventBus.dispatch(EnemyDiedEvent.new(enemy.id, enemy.global_position, player.id))
func _on_enemy_died(event: EnemyDiedEvent) -> void:
ScoreSystem.add_kill(event.killer_id)
LootSystem.roll_drops(event.enemy_id, event.position)
Main tradeoffs vs plain signals: there's a small allocation per dispatch, and scripts need to be saved files rather than inline classes for the resource_path keying to work reliably. That's been fine for my project structure but it's worth knowing going in.
The real payoff is refactoring. Add or rename a field on an event class and the type checker finds every handler that needs updating. Way better than grepping for signal connection strings and hoping you caught them all.
Anyone else gone this route? Curious if there's a cleaner way to key the listener dictionary, since using resource_path feels slightly fragile if you ever reorganize your file structure mid-project.