The Scene Tree
The runtime scene tree Underroot builds during gameplay: the World.tscn node tree, the overlay CanvasLayers Main.gd stacks on top of it, and the per-frame _process heartbeat in World.gd that drives the simulation.
Two roots: Main and World
Gameplay is a two-level structure. scenes/main/Main.tscn is a bare Node running scenes/main/Main.gd; it is the runtime root. In _ready(), Main instantiates World.tscn as a child and then layers a series of overlay CanvasLayers beside it:
func _load_world() -> void:
var world_scene := load("res://scenes/world/World.tscn") as PackedScene
add_child(world_scene.instantiate())
So the world (terrain, controllers, camera, the core HUD) lives under the World node, while transient full-screen surfaces (cutscenes, popups, the game-over screen) are siblings added by Main.
The World.tscn node tree
World.tscn is rooted at a Node2D running scenes/world/World.gd. Its children, in tree order:
World (Node2D) — World.gd
VillageBackdrop (Node2D, z=-50) — surface scenery
UndergroundBackground (Node2D, z=-1)
TerrainDisplay (Node2D) — viewport-culled tile drawing
GraveyardDisplay (Node2D) — deep-forest graveyard hill
ForestDisplay (Node2D) — trees / bushes / pond
RockDisplay (Node2D) — surface rocks
WallDisplay (Node2D) — placed walls
MawDisplay (Node2D, z=-1, pos (700,0)) — east Maw creature
MawDisplayFront (Node2D, z=7, front_layer)
MawDisplayWest (Node2D, z=-1, pos (-700,0), scale (-1,1), hidden) — Two Fronts west Maw (front_index=1)
MawDisplayWestFront (Node2D, z=7, front_layer)
PlayerMarker (Node2D) — animated player dot
DigController (Node2D) — left-click dig input
GatherController (Node2D) — surface harvest input
BuildController (Node2D) — right-click wall placement
MachineDisplay (Node2D) — draws placed machines
MachineController (Node2D) — machine placement/interaction
DiscoveryLayer (Node2D, z=1) — discovery pocket hints
ParticleLayer (Node2D, z=10) — dig/harvest particles
DynamiteController (Node2D, z=11) — dynamite / mining charges
Camera2D (pos (0,120), zoom (0.85,0.85))
HUD (CanvasLayer) — HUD.gd
BuildMenu (CanvasLayer) — BuildMenu.gd (hosts CraftMenu)
VillagerVisitPopup (CanvasLayer, layer=5)
TutorialOverlay (CanvasLayer, layer=10)
VillageBar (CanvasLayer)
A few structural details from World.tscn:
- The Maw is multi-front in the scene, not just the controller. There is a canonical east
MawDisplayat(700, 0)and a mirrored, initially hiddenMawDisplayWestat(-700, 0)withscale = (-1, 1)andfront_index = 1for the Two Fronts challenge. Each has afront_layerchild (z=7) that draws the creature's foreground over the terrain. - Z-ordering is explicit.
VillageBackdropsits far back atz=-50; particles (z=10) and dynamite (z=11) draw in front of the world; the Maw's front layers usez=7. - The camera starts at
Vector2(0, 120)with zoom0.85, matching the constants used across the coordinate system. - The HUD's children are skin-dependent.
HUD.gdspawns the active HUD skin's surfaces at runtime — classic threat panel + bottom bar, or the talisman instruments (medallion, crest, charm, rail, ledger, speech bubbles). The scene-declaredVillageBaris hidden and processing-disabled under the talisman skin, never freed. See The Talismans HUD.
World.gd also spawns a few helper nodes at runtime that are not in the .tscn: a particle burst pool, a PerfMonitor, a PerfSuggestionModal, and — on web builds only — a WebGraphicsNotice.
Main.tscn runtime overlays
Every overlay is a CanvasLayer (or a Node2D wrapped in a CanvasLayer when it needs to draw in screen space) that Main._ready() adds after loading the world. From scenes/main/Main.gd:
| Overlay | Script | Notes |
|---|---|---|
| Offline summary | scripts/ui/OfflineSummaryController.gd |
Catch-up report on resume |
| Game over screen | scripts/ui/GameOverScreen.gd |
Death / lineage screen |
| Screen flash | scenes/ui/ScreenFlash.gd |
Danger vignette pulses |
| Floating text pool | scenes/ui/FloatingTextPool.gd |
Pooled floating labels |
| Village building popup | scenes/ui/VillageBuildingPopup.gd |
|
| Healer intro popup | scenes/ui/HealerIntroPopup.gd |
|
| Storage warning popup | scenes/ui/StorageWarningPopup.gd |
|
| Weather display | scenes/ui/WeatherDisplay.gd |
Node2D in a CanvasLayer (layer 3) for screen-space rain |
| Storm cutscene | scenes/ui/StormCutscene.gd |
|
| Astrolabe cutscene | scenes/ui/AstrolabeCutscene.gd |
|
| Astrolabe unlock modal | scenes/ui/AstrolabeUnlockModal.gd |
|
| New materials modal | scenes/ui/NewMaterialsModal.gd |
|
| Surge music modal | scenes/ui/SurgeMusicModal.gd |
|
| Milestone cutscene | scenes/ui/MilestoneCutscene.gd |
|
| Challenge mastery cutscene | scenes/ui/ChallengeMasteryCutscene.gd |
|
| Challenge aid modal | scenes/ui/ChallengeAidModal.gd |
|
| Storm particle canvas | scenes/ui/StormParticleCanvas.gd |
Node2D in a CanvasLayer (layer 17) |
| Storm toast | scenes/ui/StormToast.gd |
Most are created by small _load_* helpers that new a CanvasLayer, set_script(...), and add_child(...). After wiring the overlays, _ready() runs _check_offline_return(), calls GameManager.show_breach_toasts(), and starts music unless a fresh run is about to hand control to Wren's tutorial intro.
The World.gd heartbeat
World.gd is the simulation's heartbeat: its _process(delta) advances the core managers every frame. The relevant part:
func _process(delta: float) -> void:
if not _world_initialized:
_world_initialized = true
# restore announced layers, seed current layer index from camera...
if not _intro_active:
SurvivalManager.process_survival(delta)
if not bool(GameManager.current_run.get("tutorial_clock_frozen", false)):
MawController.process_chew(delta)
VillageManager.process_village(delta)
MachineManager.process_machines(delta)
_pan_camera(delta)
_apply_camera_shake(delta)
_check_layer_transition()
_request_redraw(delta)
The four simulation ticks it drives each frame:
SurvivalManager.process_survival(delta)— food/water/fuel drain.MawController.process_chew(delta)— the Maw eating toward the base.VillageManager.process_village(delta)— population and daily village logic.MachineManager.process_machines(delta)— automated machine work.
Two clocks can be frozen. While Wren's blocking intro modal is up, _intro_active is true and both survival drain and the Maw are paused (the player can't act behind the modal). During the guided tutorial's Phase A, current_run.tutorial_clock_frozen keeps survival live (so the berry beat visibly moves the meter) but holds the Maw's grace clock until the pivot beat. VillageManager and MachineManager always tick, so a placed machine never stalls.
The rest of _process handles the camera: WASD/arrow panning and drag in _pan_camera / _unhandled_input, periodic rumble and shake in _apply_camera_shake (rumble only when a Maw front is genuinely close to its defense line or under 10 minutes to breach), layer-band announcements in _check_layer_transition, and dirty-flag redraw gating in _request_redraw (day/night tint changes and throttled pond shimmer).
World.gdalso owns the procedural_draw()for the underground layer bands, depth fog, surface strip, ambient tint, and pond. To avoid repainting static geometry every frame,_request_redrawonly callsqueue_redraw()when the day tint moves or the pond shimmer timer elapses (throttled to 30 fps).
How this differs from Main's autosave
The heartbeat lives in World.gd, but autosave does not — it is a separate _process timer in Main.gd that calls SaveManager.save_game() every 20 seconds (AUTO_SAVE_INTERVAL := 20.0). See Repository Layout and Boot Flow for that path.
Key files
scenes/world/World.tscn— the authored world node tree.scenes/world/World.gd— the world root; per-frame heartbeat, camera, and_draw().scenes/main/Main.tscn/scenes/main/Main.gd— runtime root; instantiates World and all overlays.