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:

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:

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.gd also 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_redraw only calls queue_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