Rooms, portals and line of sight

A Hammerite level is carved, not built: solid rock everywhere, and air brushes cut the rooms out of it. The compiler works out from those brushes which rooms there are and where they open into each other, and a game can ask the same questions its AI needs - which room am I in, where are the doors out of it, can that guard see me. The manual explains the model as an author meets it.

All of it is in map space, the coordinates the brushes are authored in. Under a HammeriteMap3D that has been moved, convert with map_3d.to_local(world_point) first.

1. Which room

A HammeriteCell is one convex piece of carved air. A room is a group of them - a pillar splits a hall into four cells that are still one room - and cell.key() names the room, not the piece:

var cell: HammeriteCell = map.point_in_cell(map_3d.to_local(guard.global_position + Vector3.UP * 64.0))
if cell:
    var room: Vector2i = cell.key()

point_in_cell() is null in rock. Ask from the head or the chest rather than the feet: a point exactly on a room's floor is in no cell.

Cells are derived, and replaced every time the level is compiled. Keep the key, not the cell: the key is the same room after a recompile, and map.get_region_cells(room) finds its cells again.

What a room is: its properties are those of the air brush that carved it - map.get_cell_property(cell, &"some/property"). Whether it is indoors has two answers, deliberately different:

  • map.cell_reaches_sky(cell) - sky light reaches it, through a window if need be. The lighting question.
  • map.cell_is_open_to_sky(cell) - it has open sky over it. A room with a window onto a courtyard is not: its rain is at the window. The question for weather and acoustics.

2. What joins rooms

A HammeritePortal joins two rooms, in one of two ways:

  • an aperture - the rooms abut on a shared wall, and the portal is the hole in it: a doorway, a window. It has a polygon and a plane.
  • an overlap - two air brushes interpenetrate, so they are one continuous space with no wall between to hole. It has neither.

portal.is_aperture() tells them apart. The author's rule that follows: overlap to extend a space, abut to divide one.

for portal: HammeritePortal in map.get_portals_for_cell(room.x):
    var beyond: Vector2i = portal.other_region(room)
    if beyond == Vector2i.ZERO or not portal.is_aperture():
        continue
    _doorways.append({"to": beyond, "name": portal.get_portal_name(), "way": portal.normal_from(room)})

get_portals_for_cell() takes the air brush's id and answers for every room carved from that brush, so other_region() returning Vector2i.ZERO means the portal is not one of this room's.

A portal is replaced on every compile too. Its key() - the two air brushes and which of their openings it is - is what stays. An aperture can be named (set_portal_name(), or the Audio Portals tool); the name is stored on the wall faces it is cut from, so it survives every edit, and audio and navigation read the same one: "vault_door" is one door to every system. map.aperture_at(point) finds the opening a point stands in.

3. Line of sight

func can_see(map_3d: HammeriteMap3D, eye: Vector3, target: Vector3) -> bool:
    return map_3d.map.segment_in_air(map_3d.to_local(eye), map_3d.to_local(target))

segment_in_air() walks the line from room to room through the openings, and is false as soon as it would enter rock - or if either end is in rock.

Never use a raycast against the brushes for this. In a carved world it is wrong both ways: the air brushes are in the tree, so open space reads as blocked; and a block of rock is one authored brush with rooms cut out of it, so a line straight through the rock crosses none of its faces and reads as clear. bvh_raycast() is for picking, not seeing.

segment_in_air() ignores detail brushwork - a table, a crate stack - and entities, by design. A guard that should lose the player behind a crate asks Godot's physics as well:

var seen: bool = can_see(map_3d, eye, target) \
    and get_world_3d().direct_space_state.intersect_ray(
        PhysicsRayQueryParameters3D.create(eye, target, 1, [self])).is_empty()

With the native kernel built, segment_in_air() costs microseconds and may be asked from several threads at once once the world is compiled.

4. When the world changes

Compilation is lazy: an edit that changes the rooms emits map.worldrep_changed, and the next query compiles. Anything you hold that came from the rooms - a guard's patrol of room keys, a table of doorways - rebinds there, by key:

func _on_worldrep_changed() -> void:
    _patrol_cells = map.get_region_cells(_patrol_room)
    _doorways.clear()

A variant switch changes what exists without an edit, and says so on map.variant_changed. A new map arrives on the HammeriteMap3D's map_changed.

See also

Hammerite 1.0 · built from a6dd634, 2026-10-10