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.gd for WORLD_LEFT / WORLD_RIGHT, but these constants (and the other X landmarks) actually live in scenes/world/World.gd. WorldManager.gd works 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