Starbrew Station has 50 manager scripts that handle everything from currency transactions to achievement tracking to audio playback. None of them know the others exist. They communicate exclusively through a single autoload -- EventBus.gd -- and that constraint is the best architectural decision we have made on this project. Here is how we got there, what the system looks like today, and what we would change if we started over.
The Problem With Direct Access
Early in development, Starbrew looked like most Godot projects. Managers were autoloads. If the reward system needed to grant credits, it reached directly into the economy manager:
# The antipattern: direct coupling between autoloads
GameManager.credits += 100
GameManager.lifetime_credits += 100
AudioManager.play_sfx("coin")
AchievementManager.check_credit_milestone(GameManager.credits)
This works fine for a game jam. It does not work fine for a game with 50 interconnected systems. Every manager accumulated references to every other manager. Changing the signature of a single function could break a dozen callers scattered across the codebase. Testing was essentially impossible -- you could not instantiate RewardManager without dragging in the entire autoload tree.
The breaking point came when we needed to add a prestige system that touched economy, units, decks, research, and achievements simultaneously. We realized that if we kept going down this path, every new feature would require modifying every existing system. We needed to invert the dependency flow.
Our EventBus Pattern
The fix was a single autoload, EventBus.gd, that owns every cross-system signal in the game. Managers emit signals when something happens, and other managers connect to the signals they care about. No manager ever imports, references, or calls another manager directly.
# EventBus.gd -- the only autoload managers interact with
extends Node
# Economy signals
signal currency_add_requested(amount: float, show_popup: bool, source: String)
signal currency_removed(amount: float, source: String)
signal currency_balance_changed(new_balance: float)
# Unit signals
signal unit_purchased(unit_id: String, cost: float)
signal unit_upgrade_requested(unit_id: String, level: int)
signal unit_production_tick(unit_id: String, output: float)
# Deck signals
signal card_drawn(card_data: Dictionary)
signal card_played(card_id: String, target: String)
signal deck_shuffled(deck_id: String)
# Audio signals
signal sfx_requested(sfx_name: String)
signal music_transition_requested(track: String, fade_time: float)
# Research signals
signal research_started(research_id: String)
signal research_completed(research_id: String)
# Achievement signals
signal achievement_unlocked(achievement_id: String)
signal milestone_reached(milestone_type: String, value: float)
Today EventBus contains 66 signals organized across six domains: Economy, Units, Decks, Audio, Research, and Achievements. The file is around 200 lines of pure signal declarations -- no logic, no state, just contracts.
Here is how the reward flow looks under this pattern:
# RewardManager.gd -- emits a request, does not touch any other manager
func grant_reward(amount: float, source: String) -> void:
EventBus.currency_add_requested.emit(amount, true, source)
# EconomyManager.gd -- listens for the request, owns the mutation
func _ready() -> void:
EventBus.currency_add_requested.connect(_on_currency_add_requested)
func _on_currency_add_requested(amount: float, show_popup: bool, source: String) -> void:
_balance += amount
_lifetime_earned += amount
EventBus.currency_balance_changed.emit(_balance)
# AudioManager.gd -- reacts to balance changes, knows nothing about rewards
func _ready() -> void:
EventBus.currency_balance_changed.connect(_on_balance_changed)
func _on_balance_changed(_new_balance: float) -> void:
_play_sfx("coin_collect")
# AchievementManager.gd -- also reacts, also knows nothing about rewards
func _ready() -> void:
EventBus.currency_balance_changed.connect(_on_balance_changed)
func _on_balance_changed(new_balance: float) -> void:
_check_credit_milestones(new_balance)
The reward manager emits a single signal. Three other managers react independently. None of them import each other. If we remove AchievementManager tomorrow, zero lines of code change anywhere else.
Query Services for Read-Only Access
The EventBus handles mutations and events well, but sometimes a system just needs to read a value. Firing a signal to ask "what is the current balance?" and waiting for a response signal is awkward and error-prone. For these cases, we introduced Query Services: lightweight, read-only singletons that expose getters but never mutate state.
# EconomyQueryService.gd -- read-only access to economy state
extends Node
var _economy_manager: Node
func setup(economy_manager: Node) -> void:
_economy_manager = economy_manager
func get_balance() -> float:
return _economy_manager.get_balance()
func can_afford(cost: float) -> bool:
return _economy_manager.get_balance() >= cost
EconomyQueryService and UnitQueryService are the two we use most. They are injected by the composition root and provide a clean read path that does not violate our "no direct manager access" rule. The key distinction: Query Services never call mutating methods. If you need to change state, you emit a signal through EventBus.
Signal Organization
With 66 signals, organization matters. We group them by domain within EventBus using comment headers, and we follow a strict naming convention:
- Requests end with
_requested-- these ask a system to do something:currency_add_requested,unit_upgrade_requested,research_started - Notifications use past tense -- these announce that something happened:
currency_balance_changed,unit_purchased,achievement_unlocked - Queries are handled by Query Services, not signals
This naming convention makes the signal's purpose immediately clear. When you see _requested, you know exactly one manager should handle it. When you see past tense, you know it is a broadcast that any number of systems can react to.
Initialization Order
Decoupled managers still have initialization dependencies. The achievement manager needs the economy system to exist before it connects to balance-change signals. We solve this with ManagerInitializer.gd, a 5-phase startup sequence that Main.gd orchestrates:
# ManagerInitializer.gd -- 5-phase startup respecting dependency order
extends Node
enum Phase { CORE, ECONOMY, GAMEPLAY, PRESENTATION, FINALIZE }
func initialize_all(managers: Dictionary) -> void:
# Phase 1: Core services (config, save/load, event bus validation)
await _init_phase(Phase.CORE, managers)
# Phase 2: Economy layer (currency, pricing, cost scaling)
await _init_phase(Phase.ECONOMY, managers)
# Phase 3: Gameplay systems (units, decks, research, prestige)
await _init_phase(Phase.GAMEPLAY, managers)
# Phase 4: Presentation (audio, UI, particles, popups)
await _init_phase(Phase.PRESENTATION, managers)
# Phase 5: Finalization (achievements, analytics, save trigger)
await _init_phase(Phase.FINALIZE, managers)
func _init_phase(phase: Phase, managers: Dictionary) -> void:
for manager in managers[phase]:
manager.initialize()
await manager.ready_confirmed
Main.gd serves as the Composition Root -- the single source of truth for dependency wiring. It instantiates all 50 managers, assigns them to their initialization phases, injects Query Services, and calls ManagerInitializer. No manager decides its own startup order. No manager creates its own dependencies. Main.gd does all of that in one place, making the entire dependency graph visible in a single file.
# Main.gd -- Composition Root (simplified)
extends Node
@onready var economy_manager := $EconomyManager
@onready var unit_manager := $UnitManager
@onready var achievement_manager := $AchievementManager
func _ready() -> void:
# Wire query services
EconomyQueryService.setup(economy_manager)
UnitQueryService.setup(unit_manager)
# Define phase assignments
var managers := {
Phase.CORE: [config_manager, save_manager],
Phase.ECONOMY: [economy_manager, cost_manager],
Phase.GAMEPLAY: [unit_manager, deck_manager, research_manager],
Phase.PRESENTATION: [audio_manager, ui_manager, popup_manager],
Phase.FINALIZE: [achievement_manager, analytics_manager],
}
ManagerInitializer.initialize_all(managers)
Testing Signal Contracts
The biggest payoff of this architecture is testability. Because every manager communicates through signals, we can test each one in isolation by emitting signals and asserting on the results. We do not need to instantiate the full game tree.
Starbrew currently has 1,963 tests across 55 test files. The bulk of them are signal contract tests: emit a signal, verify the correct manager responds with the correct behavior, and confirm it emits the expected downstream signals.
# test_economy_manager.gd -- testing signal contracts in isolation
func test_currency_add_updates_balance() -> void:
var economy := EconomyManager.new()
economy.initialize()
var balance_changed_fired := false
var reported_balance := 0.0
EventBus.currency_balance_changed.connect(
func(new_balance: float) -> void:
balance_changed_fired = true
reported_balance = new_balance
)
# Emit the request signal -- no direct method call
EventBus.currency_add_requested.emit(100.0, true, "test_reward")
assert_true(balance_changed_fired, "balance_changed should fire")
assert_eq(reported_balance, 100.0, "balance should be 100")
This test does not import RewardManager, AudioManager, or any other system. It tests EconomyManager in complete isolation by exercising the signal contract it advertises. If someone changes the signal signature in EventBus, every test that uses that signal breaks immediately -- giving us fast, precise feedback about contract violations.
What We Would Do Differently
The EventBus pattern has been a clear win, but we have learned some lessons along the way that we would apply from day one on the next project:
Typed signal payloads from the start. Several of our early signals pass raw floats and strings. Signals like currency_add_requested(amount: float, show_popup: bool, source: String) work, but they get fragile as the parameter list grows. We have started wrapping payloads in Resource objects for newer signals, and we wish we had done that everywhere from the beginning. A CurrencyEvent resource with named fields is far easier to extend than a growing parameter list.
Signal documentation as code. With 66 signals, it is not always obvious which manager owns the response. We now maintain a comment block above each signal documenting the emitter and the expected responder, but we should have enforced that convention from signal number one.
Fewer phases, stricter contracts. Five initialization phases have been sufficient, but we have a few managers that arguably sit between phases. In hindsight, we would define phase membership as a property on each manager rather than a central dictionary, making it easier to validate at startup that every manager is assigned to exactly one phase.
More Query Services, sooner. We resisted adding Query Services early because they felt like they violated the "everything through signals" purity. That led to some ugly workarounds where managers cached values from signals just to answer synchronous questions. Query Services for read-only access are not a compromise -- they are the right tool for synchronous reads.
Was It Worth It?
Absolutely. The upfront cost of EventBus was maybe two weeks of refactoring early in development. In return, we got a codebase where we can add a new manager in under an hour, delete a system without breaking anything else, and run nearly two thousand tests without standing up the full scene tree. For a game like Starbrew Station -- where idle mechanics mean dozens of systems ticking simultaneously -- that decoupling is not a luxury. It is a survival strategy.
If you are building a Godot project with more than a handful of autoloads that reference each other, consider whether an EventBus might save you from the dependency tangle. The pattern is simple. The discipline is the hard part. But once every manager on your team knows the rule -- emit, never import -- the architecture enforces itself.