Architecture
Understand the codebase in 10 minutes without opening the editor.
Big Picture
ASH & BRASS is a 2D top-down bullet-heaven action RPG built in Godot 4.
The architecture follows these principles:
- Data-driven: Enemies, weapons, waves, biomes are defined in
.tresresource files, not hardcoded. - Signal-based: Scripts communicate through an
Eventsautoload (pub/sub), not direct references. - Scene composition: Each "thing" (player, enemy, UI panel) is a self-contained scene that can be instantiated.
- Autoload managers: Global systems (save, audio, time) live as singletons.
Scene Tree
Runtime Hierarchy (main.tscn)
├── AudioController [autoload]
├── BeatClock [autoload]
├── ContentRegistry [autoload]
├── Events [autoload]
├── FortressController [autoload]
├── ModeController [autoload]
├── ModuleController [autoload]
├── SaveSystem [autoload]
├── TimeController [autoload]
├── WorldState [autoload]
│
└── Main (Node2D) ← current scene
├── CameraController
│ └── Camera2D
├── Player
├── Fortress
├── ProjectilePool
├── WaveDirector
├── BuildModeUI
├── SlowWalkerDirector
├── ScannerController
├── SceneManager
├── Scannable × N
├── ClubDoor
├── Enemies × N
├── Projectiles × N
├── Pickups × N
│
└── UI overlays (CanvasLayer)
├── HUD layer 10
├── GameOverOverlay layer 10
├── CriticalVignette layer 12
├── ScanRevealPanel layer 15
├── DialoguePanel layer 16
├── TradePanel layer 17
└── PauseMenu layer 20
Greybox Prototype (mayo_breach_greybox.tscn)
The Mayo Breach greybox is a self-contained vertical slice for testing combat mechanics. It does NOT use main.tscn.
├── TileMap
├── GreyboxPlayer [instance]
│ ├── Camera2D
│ ├── Weapon ← Cogblade Halo
│ └── RocketWeapon ← Brass Wasp
├── Spawner
├── ExtractionZone
├── CanvasModulate
├── WorldEnvironment
└── UI (CanvasLayer)
Note: There are two parallel player systems:player.tscn(the "real" game) andgreybox_player.tscn(the prototype). Eventually these will converge.
Autoloads
Autoloads are configured in Project Settings → Autoload. They exist before any scene loads and persist across scene changes.
| Name | Script | Responsibility |
|---|---|---|
| Events | scripts/autoload/events.gd | Signal bus — central pub/sub for decoupled communication |
| ContentRegistry | scripts/autoload/content_registry.gd | Data loader — scans res://data/ and user://mods/ by ID |
| FortressController | scripts/autoload/fortress_controller.gd | Fortress state: integrity, stop budget, scrap, stats |
| ModuleController | scripts/autoload/module_controller.gd | Module placement, build logic, grid snapping |
| ModeController | scripts/autoload/mode_controller.gd | Mode state machine: GROUND / COMMAND / DOCKED / CLUB |
| TimeController | scripts/autoload/time_controller.gd | Time scale management (slow motion, pause) |
| BeatClock | scripts/autoload/beat_clock.gd | Adaptive music timing. Emits heartbeat ticks |
| AudioController | scripts/autoload/audio_controller.gd | Audio playback abstraction |
| WorldState | scripts/autoload/world_state.gd | Global world state (loop index, flags) |
| SaveSystem | scripts/autoload/save_system.gd | Save/load persistence |
Golden Rule for Autoloads: Read freely. Write sparingly. Any script can readFortressController._scrap. But ONLYFortressControllershould mutate its own state. If you need to change scrap, call a public method or emit a signal.
Signal Flow
All gameplay events flow through the Events autoload. This is how scripts talk to each other without knowing each other exists.
Emitting an event
# ANYWHERE in the codebase:
Events.player_damaged.emit(10, "enemy.swarmer")
Listening to an event
func _ready() -> void:
Events.player_damaged.connect(_on_player_damaged)
func _on_player_damaged(amount: float, source_id: String) -> void:
print("Player took ", amount, " damage from ", source_id)
Why signals instead of direct references?
Bad (tight coupling):
var player = get_tree().get_first_node_in_group("player")
player.take_damage(10) # If player is refactored, this breaks
Good (loose coupling via Events):
Events.player_damaged.emit(10, "enemy.swarmer")
# Player listens and handles it. Enemy doesn't care HOW.
See the Signal Bus Reference for the complete list of signals.
Collision Layers
Configured in Project Settings → Layer Names → 2D Physics.
| Layer | Name | What Lives Here | Collides With |
|---|---|---|---|
| 1 | player_body | Player CharacterBody2D | 2 (enemies), 5 (fortress), 6 (pickups) |
| 2 | enemy | Enemy CharacterBody2Ds | 1 (player), 5 (fortress) |
| 3 | player_projectile | Player bullets/rockets | 2 (enemies), 5 (fortress) |
| 4 | enemy_projectile | Enemy bullets/bombs | 1 (player), 5 (fortress) |
| 5 | fortress | Fortress hull, modules | 1 (player), 2 (enemies), 3 (player proj), 4 (enemy proj) |
| 6 | pickup | Scrap, health orbs | 1 (player) |
Layer vs Mask
- Layer = "What I am" (other things detect me)
- Mask = "What I detect" (I collide with these)
Common mistakes
- Forgetting to set layers explicitly (relying on Godot defaults = layer 1 only)
- Setting mask to your OWN layer (a pickup masking layer 6 won't detect the player)
- Not updating masks when adding new entity types
UI Stack
UI uses CanvasLayer nodes with explicit layer values to control draw order:
| Layer Range | Purpose | Examples |
|---|---|---|
| 1–4 | Gameplay overlays | (reserved) |
| 5 | Mode indicator | ModeIndicator vignette |
| 6–9 | Alerts & warnings | (reserved) |
| 10 | HUD & gameplay UI | HUD, GameOverOverlay, TutorialOverlay |
| 12 | Critical effects | CriticalVignette (red flash) |
| 15 | Scan results | ScanRevealPanel |
| 16 | Dialogue | DialoguePanel |
| 17 | Trade | TradePanel |
| 20 | Menus | PauseMenu (must be on top of everything) |
Rule: Higher layer = drawn on top. PauseMenu at 20 means it covers ALL other UI.
Data-Driven Content
All game content is defined in data/ as .tres files with custom Resource scripts.
Content Folders
| Folder | Contains |
|---|---|
data/biomes/ | Zone definitions (hazards, tiles, boss pool) |
data/dialogue/ | NPC conversation trees |
data/enemies/ | Enemy stat blocks |
data/fortress/ | Fortress configurations |
data/lore/ | Scannable lore fragments |
data/modules/ | Buildable module definitions |
data/pickups/ | Scrap/health pickup data |
data/player/ | Player stat blocks |
data/projectiles/ | Projectile definitions |
data/scannables/ | Scan interactable definitions |
data/trades/ | NPC trade offers |
data/waves/ | Wave compositions |
data/weapons/ | Weapon definitions |
Where to Add New Things
New Enemy
- Add
EnemyDataresource todata/enemies/ - If greybox: extend
GreyboxEnemyBase(see Contributing Guide) - If main game: ensure
enemy.gdhandles the archetype - Add to a
WaveDataresource indata/waves/ - Add GUT test in
tests/unit/
New Level
- Create scene in
scenes/levels/ - Root should be
Node2D - Use
TileMapfor terrain - Instantiate player from
scenes/player/greybox_player.tscnorplayer.tscn - Add
Spawnernode withWaveDatareference - Add UI
CanvasLayerwith labels
New UI Panel
- Create scene in
scenes/ui/ - Root should be
CanvasLayer(for overlays) orControl(for widgets) - Pick appropriate layer number (see UI Stack above)
- Script goes in
scripts/ui/ - Connect to
Eventssignals; avoid direct scene references
New Autoload
Don't. We already have 10. If you think you need one, ask. Most global state can live in an existing autoload or be passed through signals.