EventBus and the Signal Convention
EventBus is the single autoload every system in Underroot uses to talk to every other. The convention: all cross-system events flow through it, nodes never wire directly to each other. Plus the performance gotcha that comes with high-frequency signals.
What EventBus is
EventBus (scripts/core/EventBus.gd) extends Node and is loaded early (6th in the autoload order, before the systems that emit on it). It declares nothing but signals: no state, no logic. Its _ready() is an empty pass. Every signal carries an @warning_ignore("unused_signal") annotation because signals are emitted and connected from other scripts, so the editor would otherwise flag them as unused in this file.
extends Node
@warning_ignore("unused_signal")
signal tile_dug(position: Vector2i, material_id: String)
@warning_ignore("unused_signal")
signal show_toast(text: String, color: Color, duration: float)
The convention: everything goes through EventBus
The rule is absolute: cross-system events go through EventBus, and scene nodes never connect signals directly to one another. A producer emits; any number of consumers connect. Neither side needs a reference to the other, which keeps the autoloads and scene nodes decoupled and lets systems be added or removed without rewiring.
Emit from anywhere:
EventBus.show_toast.emit(text, color, duration)
EventBus.tile_dug.emit(position, material_id)
Connect in a consumer's _ready():
EventBus.new_day.connect(_on_new_day)
EventBus.tile_dug.connect(_on_tile_dug)
EventBus.player_died.connect(_on_player_died)
GameManager._ready() is a representative hub of connections — it listens to maw_breached_base, new_day, tile_dug, wall_built, machine_placed, player_died, villager_died, discovery_found, and inventory_added to keep current_run statistics current.
Representative signals
The signals below are declared in EventBus.gd, grouped there by domain. A representative selection; read the source for the complete set.
| Signal | Payload | Domain |
|---|---|---|
inventory_changed |
(material_id: String, new_amount: int) |
Resources |
inventory_added |
(material_id: String, amount: int) |
Resources — fires only on a new gain, carries the delta |
tile_dug |
(position: Vector2i, material_id: String) |
World |
mined_yield |
(material_id: String, amount: int, world_pos: Vector2, is_special: bool) |
World — player-driven mining only |
terrain_changed |
(position: Vector2i) |
World — any single-tile mutation |
terrain_reset |
() |
World — bulk change (load, collapse) |
wall_built / wall_damaged / wall_destroyed |
wall position + material/hp | Walls |
maw_breach_imminent / maw_breached_base / maw_repelled |
— / — / (pushed_back: float) |
Maw |
maw_pressure_changed |
(threat_state: String) |
Maw escalation |
player_died |
(cause: String) |
Survival |
food_changed / water_changed |
(amount: float) |
Survival |
storage_changed |
(stored_food: float, stored_water: float) |
Storage |
well_changed / pond_changed |
(well_water: float) / (water: float) |
Storage |
machine_placed |
(machine_id: String, position: Vector2i) |
Machines |
discovery_found |
(discovery_id: String, tile_pos: Vector2i, resolved_reward: Dictionary) |
Discovery |
recipe_unlocked |
(recipe_id: String) |
Crafting |
new_day |
— | Time |
milestone_reached |
(days: int) |
Run |
population_changed |
(new_pop: int) |
Village |
storm_warning / storm_started / storm_ended |
— / — / (result: Dictionary) |
Weather |
perf_mode_changed |
(enabled: bool) |
Performance |
show_toast |
(text: String, color: Color, duration: float) |
UI |
hud_style_changed |
(style: String) |
UI — HUD skin flipped between "classic" and "talismans" (Settings > Legacy UI); HUD.gd rebuilds the threat panel and bottom chrome on it |
ledger_opened |
— | UI — the TAB ledger became visible (talisman skin); the tutorial's ledger beat listens |
skill_tree_requested |
— | UI — the ledger's SKILLS button; HUD opens SkillTreeOverlay |
lineage_requested |
— | UI — the ledger's LINEAGE button; HUD opens LineageOverlay ("The Line") |
black_hollow_chronicle_requested |
— | UI — the ledger's BLACK HOLLOW button; opens the Chronicle viewer |
player_teleport_requested |
(world_pos: Vector2) |
UI/World — click-to-travel from the rail, the ledger's material and machine rows, and The Line's graves |
open_journal_tab |
— | UI — request to open the journal (classic sidebar tab or talisman journal drawer) |
The three *_requested overlay signals share one shape on purpose: the ledger owns no overlay itself, it just asks, and HUD.gd holds the live member and opens it. That keeps the request working across a skin flip that freed and rebuilt the overlay.
Some signals are deliberately split so consumers can distinguish a genuine event from a load-time replay. For example recipe_unlocked fires on a true new unlock, while recipe_rows_rebuild replays already-unlocked recipes so UI built before a save finished loading can rebuild its rows — progression listeners (telemetry, nudges) must stay on recipe_unlocked. Similarly, inventory_added fires only on a new gain (loads restore resources directly without it), letting listeners credit "amount earned" without diffing balances.
Note there is no "run ended" signal: a new run always passes through GameManager.reset_for_new_run() followed by a full scene change, so per-run state in autoloads is reset by a direct reset() call from reset_for_new_run(), and scene nodes are simply rebuilt. Per-generation death (lineage) has player_died.
The high-frequency signal gotcha
Some signals fire every frame. storage_changed is the canonical example: it can be emitted on every tick as stored food and water drain. A consumer that does heavy work in the handler — especially a full UI rebuild — pays that cost every frame, and rebuilding a panel's controls inside such a handler can eat the very button clicks the player is trying to make, because the button node is destroyed and recreated mid-interaction.
Guidance for handlers on high-frequency signals:
- Update existing labels/values in place; do not tear down and rebuild the control tree.
- Throttle expensive redraws. Render nodes such as
TerrainDisplay,MachineDisplay, andVillageBackdropcap their redraw rate rather than repainting on every notification. - Coalesce:
WorldManageremitsterrain_changedper cell for incremental updates but emitsterrain_resetonce after a bulk change (load, tunnel collapse) so consumers rebuild their full state a single time instead of thousands of times.
Before doing real work in a signal handler, ask how often the signal fires. If it can fire every frame, keep the handler O(1) and never rebuild UI controls inside it.
Key files
scripts/core/EventBus.gd— every cross-system signal, grouped by domain.scripts/core/GameManager.gd— a large set of representativeEventBusconnections in_ready().scenes/world/World.gd— connects to world/Maw signals to drive camera follow and screen shake.