World and Terrain

How Underroot stores and generates the underground: the tile grid and its diff-based persistence, how a dig resolves, how a fresh column is generated from layer definitions, and how surface rocks and underground discovery pockets hang off that world.

Underground is positive Y; the ground surface is world Y 0. The tile grid uses GameConstants.TILE_SIZE (32 px) throughout.

WorldManager: the tile grid

scripts/world/WorldManager.gd is the autoload that owns the underground grid. There's no full 2D array. It keeps only the tiles that differ from what the generator would produce, plus a read-through cache of generated tiles:

var modified_tiles: Dictionary = {}   # Vector2i -> TerrainTile  (dug-out / damaged)
var _generated_cache: Dictionary = {} # Vector2i -> TerrainTile  (deterministic defaults)
var walls: Dictionary = {}            # Vector2i -> Dictionary (WallData shape)
var rooted_walls: Dictionary = {}     # Vector2i -> true
var gravestone: Dictionary = {}       # {"world_x", "contents", "name"} or empty

get_tile(position) resolves a tile in three steps: return the modified tile if one exists, else the cached generated tile, else generate one via LayerGenerator.generate_tile() and cache it. This keeps memory proportional to how much the player has changed, not to world size.

Reading and mutating tiles

Method Effect
get_tile(position) Resolves modified -> cache -> generate. Never returns null.
set_tile(position, tile) Marks the tile modified, stores it, emits EventBus.terrain_changed.
remove_tile(position) Empties the tile (dug out), stores it, updates connectivity, emits terrain_changed.
damage_tile(position, amount) Subtracts amount from hp; calls remove_tile when hp <= 0.
deepest_open_tile() Deepest dug-open tile — the "Dig Front" jump target. Returns (0,0) when nothing underground is open.

remove_tile() mutates the TerrainTile in place. Because that same object can live in _generated_cache, reset_world() clears the generated cache too — otherwise a dug-out tile would leak into the next fresh run.

World reset versus lineage

reset_world(new_seed) clears modified_tiles, _generated_cache, _connected, walls, rooted_walls, and gravestone, and assigns a new world_seed. A brand-new seed produces a different scatter of ore pockets and blend boundaries (layer order is fixed by depth and never reseeded). Lineage continuation keeps its world and never calls reset_world().

Surface connectivity

The player may only hand-dig tiles that connect back to the surface. WorldManager maintains a cached set of surface-reachable open tiles:

var _connected: Dictionary = {}   # Vector2i -> true

Digging can outrun the incremental mark: an excavator carves far-face-first, so its dug tiles can precede the moment the swath touches the connected set. remove_tile therefore re-floods whenever a freshly dug tile is newly connected. Hand-digging is immune because DigController gates each tile on is_surface_connected before starting.

Healing stranded pockets

collapse_stranded_pockets() fills in every empty underground tile that is sealed off from the surface, so the player never sees a gap that is visible but impossible to reach. It runs after rebuild_connected() on load and after collapse events.

plan_connectivity_safe_fill(count, depth_bias, exclude) chooses up to count empty tiles to refill (used by the Astrolabe restoration) while guaranteeing every remaining empty tile stays surface-connected. It grows a connected "keep open" region outward from the surface and fills only its complement, so no soft-lock can result. depth_bias > 0 shrinks deep caverns, < 0 shrinks shallow ones.

exclude (in practice MachineManager.occupied_tiles()) marks tiles that must never be filled. Keeping them out of the fill is not enough on its own: they were never entered into the keep-open region either, so the fill could pack every route to a surviving machine solid and leave it in a sealed bubble — its own tiles open, no way in. The count >= total branch did that to every machine by construction.

_spare_protected_access() closes it. For each protected tile that has lost its last open neighbour, it walks outward breadth-first through the planned fill and un-fills the shortest corridor back to open ground — or to daylight, which is the only thing left to reach when the restore is filling everything. Corridor tiles are added to the keep set so later protected tiles can land on them. Machines sitting in an open tunnel short-circuit before the search starts, so the usual cost is nothing; the worst case spends a handful of tiles out of the restore volume, once per ritual.

The Astrolabe re-checks the exclusion set at its write site as well. The planner's guarantee was advisory — nothing downstream enforced it — so "a ritual never fills a machine in" now holds whatever the planner decides. See The Astrolabe.

Walls, rooted walls, and the gravestone

Walls are stored as plain dictionaries in the walls map (see Dig, Build, and Gather Controllers for the shape and the placement flow). WorldManager exposes:

Method Role
get_wall / place_wall / remove_wall Column access. remove_wall also drops any rooted binding.
bind_wall / unbind_wall / is_wall_bound Strange-root binding, tracked in rooted_walls; emits wall_bound / wall_unbound.
damage_wall(position, amount) Subtracts HP, emits wall_damaged, then wall_destroyed and remove_wall at hp <= 0.

The gravestone is a single record dropped when a digger falls. place_gravestone(world_x, contents, fallen_name) stores it and emits gravestone_placed; collect_gravestone() returns and clears the loot and emits gravestone_collected.

Tunnel collapse

collapse_tunnel(rng_seed) reverts dug tiles to natural terrain with a density gradient — tiles nearest x=0 have roughly a 90% fill chance dropping to ~15% at the far range (collapse_fill_near / collapse_fill_far / collapse_max_range from balance.json -> world). It skips machine footprint tiles, then calls rebuild_connected() and collapse_stranded_pockets() and emits terrain_reset.

TerrainDigging: resolving a dig

scripts/world/TerrainDigging.gd is a stateless RefCounted helper (class_name TerrainDigging). All dig math lives here so WorldManager stays a plain store.

Can this tile be dug

can_dig(position, tool_id) returns false for empty tiles, unexposed tiles, and materials the tool cannot break:

required_tool_for(material_id) finds the lowest-tier pickaxe whose allowed_materials include the material — used to tell the player which tool a too-hard tile needs.

Timing and durability

get_dig_time(position, tool_id) divides the material's dig_time by the tool's dig_speed_multiplier.

get_dig_cost(material_id, tool_id) returns the material's durability_cost, except for overreach: when the material's material_tier is exactly one above the tool's max_material_tier, the cost is multiplied by 4.0. Tier-0 materials and hands always pay the base cost.

Variable yield

roll_yield(material_id) returns how many units a mined tile grants. Materials with a yield_weights array roll a weighted amount (index i is the weight of yielding i + 1 units); everything else yields exactly 1.

Applying the dig

apply_dig(position) is the single mutation entry point:

WorldManager.remove_tile(position)
var amount := roll_yield(material_id)
Inventory.add(material_id, amount)
EventBus.tile_dug.emit(position, material_id)
var cost := get_dig_cost(material_id, ToolState.active_dig_tool)
ToolState.use_dig_tool(cost)

It emits tile_dug (which lets DiscoveryManager resolve any pocket on the tile), then reads DiscoveryManager.consumed_positions to decide whether the dig was "special", and emits mined_yield with the world-space position for floating-text feedback.

LayerGenerator: generating a column

scripts/world/LayerGenerator.gd (class_name LayerGenerator) produces the default tile for any (x, y) that has never been modified. It reads layer definitions from data/layers.json via DataRegistry.

Each layer defines a depth_start / depth_end band, a main_material, an optional blend_depth, and optional ore_inclusions. _get_layer_index_at_depth(y) finds the band containing depth y.

generate_tile(x, y) builds the TerrainTile, applies the material, then applies post-Astrolabe exotic enrichment via AstrolabeManager.roll_exotic_for_tile(x, y) (a no-op until the first ritual), and finally reads terrain_hp from the resolved material to set hp and max_hp.

TerrainTile: the tile record

scripts/world/TileData.gd defines class_name TerrainTile (extends RefCounted):

Field Meaning
position Vector2i grid coordinate.
material_id Material key, or "" when empty.
hp / max_hp Current and full terrain HP.
is_empty Dug out.
is_modified Differs from the generated default.

For persistence, to_compact() / from_compact() implement the save-version-2 compact form: a dug-out tile serializes to just [x, y], while a damaged-but-solid tile carries [x, y, material_id, hp, max_hp]. Since dug tiles dominate the modified set, this cuts autosave size and serialize time roughly fivefold. Legacy dictionary forms are still read by from_dict().

RockManager and DiscoveryManager on the world

Two systems attach content to the same world:

Key files

File Role
scripts/world/WorldManager.gd Tile grid store, surface connectivity, walls, rooted walls, gravestone, collapse, plan_connectivity_safe_fill / _spare_protected_access.
scripts/world/TerrainDigging.gd Stateless dig resolution: can_dig, timing, durability/overreach, roll_yield, apply_dig.
scripts/world/LayerGenerator.gd Generates default tiles from data/layers.json (inclusions, blend zones, seed).
scripts/world/TileData.gd TerrainTile record and compact save form.
scripts/world/RockManager.gd Surface boulder scatter and stone harvest.
scripts/world/DiscoveryManager.gd Underground discovery pockets.
data/layers.json Layer bands, main materials, blend depths, ore inclusions.