World Coordinate System and Camera
Underroot is a vertically deep 2D world where digging goes down into positive Y. The coordinate system, the tile size, the horizontal landmarks that structure the map, and the camera's default framing and clamping.
Y is down; the surface is world Y 0
The ground surface sits at world Y 0. Underground is positive Y — tiles go downward as the player digs, and depth increases with Y. There is no negative-Y underground; the sky and village scenery occupy the small negative-Y band above the surface.
GameManager._on_tile_dug() treats the dug tile's y directly as depth:
func _on_tile_dug(tile_pos: Vector2i, _mat: String) -> void:
current_run.blocks_mined = int(current_run.get("blocks_mined", 0)) + 1
var depth: int = tile_pos.y
if depth > int(current_run.get("depth_reached", 0)):
current_run.depth_reached = depth
TILE_SIZE and coordinate conversion
The canonical tile size is TILE_SIZE = 32 pixels, defined once in scripts/core/GameConstants.gd as GameConstants.TILE_SIZE. Controller and display scripts source it from there (const TILE_SIZE := GameConstants.TILE_SIZE) rather than hardcoding 32.
Tile coordinates are Vector2i; world (pixel) coordinates are Vector2. Conversion is a straight multiply by the tile size — a tile at (x, y) maps to world (x * 32, y * 32). World.gd performs this inline when following activity:
_camera_target = _clamped_target(Vector2(pos.x * 32.0, pos.y * 32.0))
WorldManager stores tiles keyed by Vector2i (modified_tiles, walls, rooted_walls, _connected) and works in tile space throughout; pixel conversion happens at the display and camera layers.
Horizontal landmarks
The map is organised left-to-right. The forest is on the left (negative X), the village and base sit near X 0, the east defense zone is to the right, and the Maw approaches from the far right. The horizontal constants live in scenes/world/World.gd, with the west-side lines and the graveyard in scripts/core/GameConstants.gd.
| Constant | Value (world X px) | Meaning | Source |
|---|---|---|---|
WORLD_LEFT |
-3200.0 |
Left world boundary | World.gd |
WORLD_RIGHT |
1000.0 |
Right world boundary | World.gd |
FOREST_RIGHT |
-250.0 |
Forest / base boundary | World.gd |
DEFENSE_LEFT |
250.0 |
Start of the east defense zone | World.gd |
MAW_FRONT_X |
700.0 |
East Maw's approach line | World.gd |
DEFENSE_RIGHT |
-1500.0 (= WEST_WALL_LINE_X) |
West defense-line marker (Two Fronts) | World.gd / GameConstants.gd |
WEST_WALL_LINE_X |
-1500.0 |
Inner edge of the west build zone | GameConstants.gd |
WEST_BUILD_OUTER_X |
-2195.0 |
Outer (forest-side) edge of the west build zone | GameConstants.gd |
GRAVEYARD_X |
-2050.0 |
Centre of the deep-forest graveyard hill | GameConstants.gd |
The architecture guide points to
WorldManager.gdforWORLD_LEFT/WORLD_RIGHT, but these constants (and the other X landmarks) actually live inscenes/world/World.gd.WorldManager.gdworks in tile space and holds no world-boundary constants.
The vertical extent is bounded by the camera limits, also in World.gd: CAM_Y_MIN = -345.0 (top edge, village sky) and CAM_Y_MAX = 10976.0 (bottom edge, the quartz floor). Depth layer bands are defined in the LAYERS array in World.gd, whose Y ranges match data/layers.json multiplied by 32.
The camera
The camera is a Camera2D child of World (scenes/world/World.tscn), driven by World.gd. Its scene-default transform is:
position = Vector2(0.0, 120.0)
zoom = Vector2(0.85, 0.85)
So the default position is (0, 120) at zoom 0.85. On a fresh start or a shallow save (depth_reached == 0), World._ready() pans the view fully up to CAM_Y_MIN so the village and sky fill the screen; a deep save keeps its loaded depth instead.
The camera follows activity and moves downward as the player digs. World.gd retargets _camera_target on tile_dug, wall_built, and machine_placed (with a guard so an autonomous machine dig does not steal the view from the player), then lerps toward the target each frame. Manual control is WASD/arrow panning and mouse-wheel/drag scrolling, all suppressed while a full-screen menu registers an input block via GameManager.is_world_input_blocked().
Every target passes through _clamped_target(), which keeps the view inside the world bounds accounting for the current zoom and viewport half-size:
func _clamped_target(pos: Vector2) -> Vector2:
var vp := get_viewport_rect().size * 0.5 / camera.zoom
return Vector2(
clampf(pos.x, WORLD_LEFT + vp.x, MAW_FRONT_X - vp.x),
clampf(pos.y, CAM_Y_MIN + vp.y, CAM_Y_MAX - vp.y)
)
Note that the X clamp runs to MAW_FRONT_X (700), not WORLD_RIGHT (1000) — the player cannot scroll the view past the east Maw's line.
Key files
scripts/core/GameConstants.gd—TILE_SIZE, west-zone lines, graveyard X.scenes/world/World.gd— world boundaries, zone landmarks, layer bands, camera follow/clamp/shake.scenes/world/World.tscn— theCamera2Dnode's defaultpositionandzoom.scripts/world/WorldManager.gd— tile-space grid keyed byVector2i.