# 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:

```gdscript
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:

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

- `is_surface_connected(pos)` — true if `pos.y <= 0`, `pos` is in `_connected`, or any orthogonal neighbour is at surface or connected.
- `rebuild_connected()` — full BFS from the surface. Called after load and after an Astrolabe fill (it is connected to `EventBus.astrolabe_activated` in `_ready()`).
- `_flood_connected_from(start)` — incremental flood used by `remove_tile`, bounded by the size of the newly-connected component, so the common case (a neighbour already connected) is O(1).

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](/docs/underroot/the-astrolabe-and-harrow#survivors-are-neither-filled-in-nor-walled-off).

## Walls, rooted walls, and the gravestone

Walls are stored as plain dictionaries in the `walls` map (see [Dig, Build, and Gather Controllers](/docs/underroot/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:

- `is_exposed(position)` — true if any orthogonal neighbour is open air (`y < 0`) or an empty tile. Buried tiles cannot be dug.
- Treasure chests bypass tool restrictions entirely.
- `hands` may only dig the `surface`, `dirt`, and `clay` layers.
- Any other tool must list the tile's `material_id` in its `allowed_materials` (an empty list allows everything).

`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:

```gdscript
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`.

- **Ore inclusions** — `_material_from_layer` hashes the tile position with `WorldManager.world_seed` and walks the layer's `ore_inclusions`, each consuming a slice of probability by its `chance`. A miss falls through to `main_material`.
- **Blend zones** — near the bottom of a layer (`depth_end - blend_depth + 1` and below), a tile has a rising probability of instead generating from the next layer down, so boundaries interleave rather than being a hard line.
- **Seeding** — hashes mix in `WorldManager.world_seed`, so each fresh run scatters ore and blends differently. Seed 0 (every pre-seed save) reproduces the original layout exactly.

`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:

- **`scripts/world/RockManager.gd`** scatters a handful of surface boulders (6–9) between the forest and the base, giving the player enough stone to bootstrap a pickaxe before mining underground. Positions are randomised each run. See [Forest and Surface](/docs/underroot/forest-and-surface).
- **`scripts/world/DiscoveryManager.gd`** resolves underground discovery pockets (water, mushrooms, ore caches, hazards) per dug tile using a deterministic per-tile hash. It listens on `EventBus.tile_dug` and applies rewards immediately. `apply_dig` consults its `consumed_positions` to flag special digs. See [Forest and Surface](/docs/underroot/forest-and-surface) for the full discovery model.

## 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. |

## Related

- [Dig, Build, and Gather Controllers](/docs/underroot/dig-build-and-gather-controllers)
- [The Astrolabe](/docs/underroot/the-astrolabe-and-harrow)
- [Forest and Surface](/docs/underroot/forest-and-surface)
- [The Maw](/docs/underroot/the-maw)
- [World Coordinate System and Camera](/docs/underroot/world-coordinate-system-and-camera)
- [Save System and Migration](/docs/underroot/save-system-and-migration)