# 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.

```gdscript
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:

```gdscript
EventBus.show_toast.emit(text, color, duration)
EventBus.tile_dug.emit(position, material_id)
```

Connect in a consumer's `_ready()`:

```gdscript
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`, and `VillageBackdrop` cap their redraw rate rather than repainting on every notification.
- Coalesce: `WorldManager` emits `terrain_changed` per cell for incremental updates but emits `terrain_reset` once 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 representative `EventBus` connections in `_ready()`.
- `scenes/world/World.gd` — connects to world/Maw signals to drive camera follow and screen shake.

## Related

- [The Autoload Model](/docs/underroot/the-autoload-model)
- [GameManager and the Run Lifecycle](/docs/underroot/gamemanager-and-the-run-lifecycle)
- [Performance Mode](/docs/underroot/performance-mode)
- [HUD and Bars Reference](/docs/underroot/hud-and-bars-reference)
- [The Talismans HUD](/docs/underroot/the-talismans-hud)