Birdoggydog's Builds

Stacking dungeon floors and building lifts in Godot

Four phone-drawn maps imported as one dungeon, with heights worked out from the ceilings, lifts that run on game time, a facility key and a map level per floor.

A dungeon in Blobber can have several floors now. The importer takes the maps I draw on the phone designer, one per floor, and builds them as one dungeon stacked in the rock, with a lift between each floor and the next. A new game can start inside it, and the top lift comes out on open ground. The first one is the game’s starting dungeon, the Breakout Site, which has four floors and four lifts and starts 37.6 m down.

57 seconds in first person. A new game starts inside the Breakout Site on floor 1, then a walk to each floor’s lift and four rides up (6 m, 10 m, 6 m and 15.6 m) seen from inside the car. The minimap changes floor after the first ride, and the last ride ends on open ground.

It’s all gray boxes for now. There’s no lift model and no themed walls. It’s also only built in the older test world so far, and I’ll get to that at the end.

Why did I want lifts and a start inside?

This is the first milestone of a goal I set: a playable slice of the game that starts in the dungeon I designed on the phone and ends with getting out of it to the town. My description of that dungeon asked for several floors joined by lifts, not ramps or staircases, with the party climbing out of a vat on the bottom floor and the top floor leading out into a tower.

Before this, a dungeon was one floor and the only way in was a ramp cut from the surface. I had it built as a system for any dungeon with several floors. Nothing about the Breakout Site is in code.

Keeping every floor in one file

The dungeon is one JSON file. The file itself is floor 1, written exactly like a one-floor dungeon always was, and the other floors are a list inside it:

{
  "id": "breakout_site",
  "name": "Breakout Site",
  "placed": {"entrance_at": [10, 0, -330], "facing": "north"},
  "stack": "up",
  "site_key": {"id": "breakout_site_facility_key", "name": "Facility Key"},
  "sheet": { "...": "floor 1's map, 36 by 36" },
  "floors": [
    {"level": 2, "name": "Breakout Site 2: Pump Level",     "sheet": {"...": "24 by 24"}},
    {"level": 3, "name": "Breakout Site 3: Sealed Archive", "sheet": {"...": "24 by 24"}},
    {"level": 4, "name": "Breakout Site 4: Tower Gate",     "sheet": {"...": "24 by 24"}}
  ]
}

"stack": "up" means floor 1 is the deepest, the party starts on its entrance square, no ramp is dug, and the last floor’s lift comes out on the ground at entrance_at. "down" means floor 1 is at the foot of its ramp like before and the others are under it. A file with neither is planned and built exactly as it was. I went with one file and not one file per floor because a dungeon is built whole or not at all, and a one-floor dungeon’s file doesn’t change.

Each extra floor is planned as its own dungeon with the id <dungeon>_f<level>. So floor 1’s things keep the ids they always had (breakout_site_exit_35_33), and floor 2’s get the floor in the name (breakout_site_f2_exit_3_6). The ids come from the floor and the square, so they’re the same on every import. All the floors’ locks are in one lock group named after the dungeon.

How does the importer decide where each floor goes?

I drew each floor on its own grid, so the maps don’t line up. The importer finds each floor’s way on: its one lift thing, or the exit marked "lift": true, or the exit the map says leads the way the stack goes, or its only exit. My maps were drafted before the importer knew what a lift was, so they use an exit on one floor and an entrance on the next. The importer treats that exit as the lift and says so in its report. If two exits could be the lift and nothing says which, it refuses the dungeon and names both squares.

Then it slides the next floor in x and z until that floor’s entrance is directly over the lift square:

var slide := before.centre(Vector2i(on["x"], on["y"])) - storey.centre(storey.entrance)
storey.at = before.at + slide

Heights are worked out and never asked for. There are two constants: 2 m of rock between a ceiling and the floor over it, and 1.6 m of ground over the tallest ceiling. The smallest rise between floors is a normal 4 m ceiling plus the 2 m of rock, so 6 m. Then for every earlier floor, the planner finds the tallest ceiling under the new floor’s footprint (grown by two squares for its walls), and the new floor has to clear that by 2 m as well.

A fixed height per floor would have cut one floor into another here. Floor 1 has a room with a 14 m cavern ceiling, and floor 3 happens to lie across it. So the floors ended up 0, 6, 16 and 22 m over floor 1, and floor 3 is 10 m over floor 2 and not 6. Floor 4 is 15.6 m under the ground and floor 1 is 37.6 m under it. The whole dungeon covers 171 by 120 m.

Every floor is added as a child node of floor 1’s node and all four are built at once. Planning takes 53 ms and building takes 281 ms for 2,103 nodes. Frame time on every floor averaged 11.5 ms with a worst of 19 ms, which is about the same as the old starting room (11.5 and 21.5), so I didn’t bother building one floor at a time.

Building a lift that runs on game time

The lift is a Node3D in the middle of its square. The car is a StaticBody3D, 2.9 m square and 0.3 m thick, flush with the floor, and the squares it stops on have no floor slab at all because the car is the floor. The shaft is four walls 0.3 m thick from the lower stop’s ceiling to the upper stop’s floor, with a glowing amber band every 2 m so there’s something to watch go past. Each stop has a gate across every open side, and a gate is solid whenever the car isn’t at that stop.

You use it, you don’t walk onto it. A panel in the car sends it and a panel at each stop calls it. I threw out “walk on and it leaves” because the party would get carried off by stepping on the wrong square.

Its whole state is two numbers: travel (meters from the near stop) and going (1, -1 or 0). It only moves when the game clock does:

func _on_clock_advanced(game_seconds: float) -> void:
    if going == 0:
        return
    var carried := _party != null and aboard(_party.global_position)
    travel = clampf(travel + going * speed * game_seconds, 0.0, length())
    if travel <= 0.0 or travel >= length():
        going = 0
    _settle()          # puts the car at `travel`, shuts or opens the gates
    if carried:
        _carry()       # party.global_position.y = the car's top; velocity.y = 0

Because it’s connected to the clock’s signal, a stopped clock (turn-based mode, a menu) stops the lift, and a test can advance the clock and check that half the time is half the travel. The speed is 1.5 m per second of game time, so the four rides take 4, 6.7, 4 and 10.4 seconds. I watched the film and the lift looks like a lift at that speed. A save stores [travel, going] for each lift, and loading a save made mid-ride picks the ride back up.

Monsters don’t use the lift. If something alive is in the shaft, it refuses to move and says so: “LIFT: load refused. Something alive is in the shaft, and it is not on the list.”

One trap: the party’s check for what it’s looking at measures distance along the ground and ignores height, so a panel 10 m overhead counted as in reach. Each panel is now only usable while the party is within 2.2 m of its height.

Opening most doors with one facility key

The first plan was a temporary key so the test could get through locked doors. I liked the idea of a facility key, so it’s a real piece of content now. It’s the Chief Scientist’s key and it opens most doors, and a few doors should still need keys from bodies around the level.

So the dungeon file can have a site_key. It isn’t on any map, because somebody hands it over. When keys are read, its lock list is every key-locked door, on any floor, that no key drawn on that floor names. A door that names its own key still wants that key, and chests never count. In the Breakout Site the Facility Key opens 10 doors, 9 on floor 1 and 1 on floor 2. It doesn’t open floor 3’s lobby door, which wants the pass lying on that floor.

Giving each floor its own level on the map

The map had six levels, which are height bands of the outdoor world. Now the list can grow: the six bands come first, and then one level is added for each floor of every stacked dungeon. Nothing gets renumbered, so an older save still loads and just has nothing explored on the new levels.

A floor says the party is on it when the party is over one of its open squares and under that square’s ceiling. On a lift square the lower floor’s reach goes up the shaft to 1 m short of the next stop, so the map keeps showing the floor I left until I’ve nearly arrived.

Twelve frames from the film in two columns, each with a caption, from the start on floor 1 through four lift rides to open ground. The round minimap in each frame’s corner shows a different floor’s rooms.
Frames from the film in order. The minimap in the top right corner changes to the new floor's rooms after a ride.

The film only shows this on the minimap. The full-screen map isn’t in it.

What did walking it turn up?

A test walks the Breakout Site from the vat room to the open air: 188 squares and 4 rides, with 58 checks passing. A second test builds small two-floor dungeons, stacked up and stacked down, and passes 141 checks covering the rides, the refusals, the key, the map levels and saves on each floor and mid-ride. The three dungeons that already shipped produce import reports that are byte for byte what they were.

The import writes one report for the whole dungeon. It has a section called NOT UNDERSTOOD with 39 lines, and none of them refuses the dungeon. That’s where the things my maps ask for but the importer can’t build yet go, like a window between a room and a hall, a door broken part open, and a security console that unlocks doors from another room.

Floor 1 had two doors drawn side by side across one hall, and each got built the full width of the hall, one over the other, so both had to be opened. That was my mistake. I’d put two there by accident, so the map loses one.

One unrelated test started failing on one check out of 134, and the cause was in the test. The party’s attack hits whatever the crosshair is on, and the crosshair’s target is set in the party’s own physics step. The test asked for the first round of a turn-based fight before the party had looked. Monsters are rolled at a random size, and this run rolled one 1.554 m tall, under the party’s 1.6 m eye line. A level gaze passed over its head, so all four characters swung at nothing. The test now aims, runs two physics steps, checks the crosshair is on the monster, and then asks for the round.

The big thing that’s left is the real game. The tests, the film and a start script all run the older world that’s built in code as one flat field. The game proper runs a streamed world where a dungeon is placed by a marker, and that marker only knows how to place a dungeon by the top of its ramp. This one has no ramp. Also, a party that starts inside and gets wiped out is sent to the old starting room and not back to the dungeon’s start square.

Next

The story of the place (its people, notes and quests) is on a second branch that lands next. The three characters on floor 1 are stand-ins with no dialog until then.