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:

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