Scenes, doors and maps
How a hand-built location scene is put together: the node tree, the global singletons, door portals, and the collision and camera setup that click-to-move depends on. Read it before adding a location to games/crpg-realm.
The scene tree
A .tscn file is a saved tree of nodes, and a script on a node adds behaviour. One scene is the current scene at a time (get_tree().current_scene). A location change replaces it with change_scene_to_file("res://scenes/X.tscn"). Each location, such as AncientCatacombs, is one scene in scenes/ with a matching script in scripts/.
This is scenes/AncientCatacombs.tscn, with the repeated siblings collapsed. It is flat: there is no container node for entities.
AncientCatacombs (Node2D, y_sort_enabled = true, script AncientCatacombs.gd)
CombatManager (Node, CombatManager.gd)
Ground (TextureRect 2560x1440, mouse_filter = 2)
CatacombBoundaries (StaticBody2D)
WestBorder, EastBorder, NorthBorder, SouthBorder, WallLeftVault, WallRightVault
ForestExit, KeepSecretPassage (DoorPortal.tscn instances)
GrandSarcophagus (Area2D, TreasureChest.gd)
GroundItem_CryptKey, GroundItem_CryptPotion (GroundItem.tscn)
PoisonDartTrap, GlyphOfWarding, SpikePitTrap (Trap.tscn)
HeroPlayer (HeroPlayer.tscn, owns the Camera2D)
SirJustinGhost (NPCCharacter.tscn)
SkeletonArcher, CryptGuardian (ShadowHound.tscn)
CanvasLayer
NoticeLabel, PartyHUD (QuestLabel, BtnOptions, BtnInventory, BtnPause)
ActionLog, InventoryWindow, SettingsModal, CharacterStatusWindow
FogOfWar (FogOfWar.tscn)
Some nodes you see in play are created in code, not saved in the scene. PartyHUD._setup_toolbars() adds PortraitToolbar under the CanvasLayer and ActionToolbar under PartyHUD, and adds ShopWindow and CharacterStatusWindow if they are missing. A location script adds a PartyCompanion in _ready() when the party has more than one member.
The root has y_sort_enabled = true, so children are drawn in order of their y position and a node lower on screen is in front. Each actor’s origin should be at its feet. Ground sits at (0, 0) and so draws first.
Why the scene must stay flat
Several systems find gameplay nodes with get_tree().current_scene.get_children(), which returns direct children only: GameState.get_scene_traps(), the battle.enemies list that GameControlServer builds for /api/v1/state, party lookup, fireball targeting and enemy pack alerts. Put a trap or an enemy under a grouping node and these systems silently stop seeing it.
Autoloads and what is not one
The ten autoloads are listed on the reference page. CombatManager is not one: it is a child node of each combat scene, reached as $CombatManager. Pathfinder is a RefCounted class with static functions, preloaded by HeroPlayer.gd.
What a location script does
AncientCatacombs.gd shows the pattern. In _ready() it clamps the camera with hero.set_camera_limits(0, 0, 2560, 1440), sets the quest text with hud.update_display(...), adds a PartyCompanion if the party has more than one member, connects signals from the objects in the scene (the sarcophagus’s chest_opened adds a garrison-key, each Trap’s trap_detected, trap_disarmed and trap_triggered show a notice), sets patrol waypoints on the enemies, and registers moving actors with FogOfWar.register_actor().
Children run _ready() before their parent. By the time the location’s _ready() runs, HeroPlayer._ready() has already moved the hero to GameState.spawn_position.
How the scenes connect
graph LR
CS["CharacterSelect"] -->|"embark (no cartridge selected)"| HS[Homestead]
HS -->|"FrontDoor script, needs footlocker looted"| VS[VillageSquare]
VS -->|"ForestGate"| WF[WhisperingForest]
VS -->|"GarrisonGate, locked: garrison-key"| GK[GarrisonKeep]
WF -->|"VillageGate"| VS
WF -->|"CryptEntrance"| AC[AncientCatacombs]
AC -->|"ForestExit"| WF
AC -->|"KeepSecretPassage, locked: garrison-key"| GK
GK -->|"Malakor defeated"| VIC[VictoryScreen]
VIC -->|"Replay"| CS
Details that the graph hides:
Homestead’sFrontDoorhas notarget_scene.Homestead.gdconnectsdoor_enteredtotry_exit_to_village(), which refuses to leave untilflags.footlocker_lootedis set.VillageSquare’sGarrisonGateis aDoorPortallocked withrequired_key = "garrison-key".VillageSquare.gdalso connects itsdoor_enteredtotry_enter_garrison().- The ten building doors in the village (
DoorInn,DoorBlacksmith, and so on) have adoor_idbut notarget_scene. Clicking one only emitsdoor_entered. TacticalBattle.tscnis not linked from any scene. Tests load it through HTTP actions such asstart_tactical_battle. Its defeat screen sends you toVillageSquare.
Doors
scenes/components/DoorPortal.tscn is an Area2D with scripts/DoorPortal.gd. It exports:
| Export | Meaning |
|---|---|
door_id, door_name | Id used by tests and the HTTP API; the label shown |
target_scene | res://scenes/X.tscn to load. Empty means “do not change scene”. |
target_spawn | Where the hero appears in the next scene. Vector2(0, 0) means “do not set one”. |
is_locked, required_key | A locked door opens when GameState.has_item(required_key) is true. The key is not consumed. |
opens_quest_stage | If above 0, GameState.advance_quest() runs when the key opens the door |
try_enter() runs when you left-click the door: it checks the lock, emits door_entered, stores target_spawn in GameState.spawn_position, and calls change_scene_to_file(target_scene). Walking over a door does nothing. In the next scene, HeroPlayer._ready() moves the hero to spawn_position and resets it to zero. With no spawn set, the hero starts where the HeroPlayer node sits in that .tscn.
To link two scenes, instance DoorPortal.tscn as a direct child of the root of each, and set target_scene and target_spawn on both. A location script can also react to door_entered itself, as Homestead does. Do not both set target_scene and change scene in a handler: VillageSquare’s ForestGate does that and the scene change runs twice.
opens_quest_stage is applied when the key opens the door, before door_entered is emitted.
Map plate, colliders and camera
A location map has four parts, all direct children of the y-sorted root:
- The plate: a
TextureRectnamedGroundat (0, 0), 2560x1440, withexpand_mode = 1andmouse_filter = 2. Buildings, trees and walls are painted into this one image. - Colliders:
StaticBody2Dnodes on collision layer 1. Map edges useCollisionShape2Drectangles. Buildings useCollisionPolygon2Doutlines traced over the painted footprint. - Actors and objects: hero, NPCs, enemies, doors, traps and items.
FogOfWar, whosemap_widthandmap_heightdefault to 2560 and 1440. Set them to your plate size.
Because buildings are painted into the plate, the collider decides where the building “is”. Trace only the base of the walls, not the roof, so the hero can walk “behind” the building. VillageSquare.tscn does this for each house:
[node name="HouseBlacksmith" type="StaticBody2D" parent="."]
collision_layer = 1
collision_mask = 1
[node name="CollisionPolygon2D" type="CollisionPolygon2D" parent="HouseBlacksmith"]
polygon = PackedVector2Array(580, 260, 1090, 200, 1110, 400, 960, 430, 950, 400, 740, 400, 730, 340, 580, 340)
The camera is the Camera2D in HeroPlayer.tscn. Each location script clamps it with hero.set_camera_limits(left, top, right, bottom), which also turns camera smoothing off. The viewport is 1920x1080, so a 2560x1440 plate scrolls. TacticalBattle.gd uses (0, 0, 1920, 1080).
Click-to-move
HeroPlayer.move_to_point() and queue_move_point() ask Pathfinder.get_nav_path() for a route. The pathfinder returns the target directly if a three-ray line check on layer 1 is clear. Otherwise it builds, and caches per scene, a 20 px AStarGrid2D of the layer-1 colliders, maps the start and target to the nearest free cells, and smooths the path. CRPG world systems has the details.
Each location script (Homestead, VillageSquare, WhisperingForest, AncientCatacombs, GarrisonKeep) also defines get_nav_points() -> Array[Vector2]. Today that is only a fallback. Pathfinder uses the points to build a sparse graph only when it cannot build the grid. A new scene does not need get_nav_points() if its colliders are on layer 1.
Only layer-1 bodies block routes. Characters are on layer 1 too, so an NPC standing in a corridor can force a detour. Area2D nodes (doors, traps, chests, items) never block.
Map a new location
- Export a 2560x1440 PNG to
assets/backgrounds/and set it asGround.texture. Keepmouse_filter = 2. - Keep the four border shapes. Add a
StaticBody2D(layer 1, mask 1) with aCollisionPolygon2Dfor each wall, pillar or building on the plate. - Use your plate size in
set_camera_limits()and onFogOfWar. - Run the scene and click on the far side of each obstacle. The cyan line from
PathVisualizershows the route. If the hero walks into a wall and stops, a collider is missing or off the layer.
To check collision in the tests, the feature normal/10_house_wall_collision_pathfinding.feature walks the hero around the village houses to a door and an item. Its item and door steps call pickup() and try_enter() after the walk even if the hero stopped short, so a pass does not prove the route. Watch the video or assert on the hero’s position in /api/v1/state.