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:

Scene Tree

Runtime Hierarchy (main.tscn)

root (Viewport)
├── 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.

MayoBreachGreybox (Node2D)
├── 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) and greybox_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.

NameScriptResponsibility
Eventsscripts/autoload/events.gdSignal bus — central pub/sub for decoupled communication
ContentRegistryscripts/autoload/content_registry.gdData loader — scans res://data/ and user://mods/ by ID
FortressControllerscripts/autoload/fortress_controller.gdFortress state: integrity, stop budget, scrap, stats
ModuleControllerscripts/autoload/module_controller.gdModule placement, build logic, grid snapping
ModeControllerscripts/autoload/mode_controller.gdMode state machine: GROUND / COMMAND / DOCKED / CLUB
TimeControllerscripts/autoload/time_controller.gdTime scale management (slow motion, pause)
BeatClockscripts/autoload/beat_clock.gdAdaptive music timing. Emits heartbeat ticks
AudioControllerscripts/autoload/audio_controller.gdAudio playback abstraction
WorldStatescripts/autoload/world_state.gdGlobal world state (loop index, flags)
SaveSystemscripts/autoload/save_system.gdSave/load persistence
Golden Rule for Autoloads: Read freely. Write sparingly. Any script can read FortressController._scrap. But ONLY FortressController should 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.

LayerNameWhat Lives HereCollides With
1player_bodyPlayer CharacterBody2D2 (enemies), 5 (fortress), 6 (pickups)
2enemyEnemy CharacterBody2Ds1 (player), 5 (fortress)
3player_projectilePlayer bullets/rockets2 (enemies), 5 (fortress)
4enemy_projectileEnemy bullets/bombs1 (player), 5 (fortress)
5fortressFortress hull, modules1 (player), 2 (enemies), 3 (player proj), 4 (enemy proj)
6pickupScrap, health orbs1 (player)

Layer vs Mask

Common mistakes

UI Stack

UI uses CanvasLayer nodes with explicit layer values to control draw order:

Layer RangePurposeExamples
1–4Gameplay overlays(reserved)
5Mode indicatorModeIndicator vignette
6–9Alerts & warnings(reserved)
10HUD & gameplay UIHUD, GameOverOverlay, TutorialOverlay
12Critical effectsCriticalVignette (red flash)
15Scan resultsScanRevealPanel
16DialogueDialoguePanel
17TradeTradePanel
20MenusPauseMenu (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

FolderContains
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

  1. Add EnemyData resource to data/enemies/
  2. If greybox: extend GreyboxEnemyBase (see Contributing Guide)
  3. If main game: ensure enemy.gd handles the archetype
  4. Add to a WaveData resource in data/waves/
  5. Add GUT test in tests/unit/

New Level

  1. Create scene in scenes/levels/
  2. Root should be Node2D
  3. Use TileMap for terrain
  4. Instantiate player from scenes/player/greybox_player.tscn or player.tscn
  5. Add Spawner node with WaveData reference
  6. Add UI CanvasLayer with labels

New UI Panel

  1. Create scene in scenes/ui/
  2. Root should be CanvasLayer (for overlays) or Control (for widgets)
  3. Pick appropriate layer number (see UI Stack above)
  4. Script goes in scripts/ui/
  5. Connect to Events signals; 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.