Carving out caverns

In this section of the tutorial we’ll create a more interesting place to kick kobolds, and put some in the game world to start.

Getting started on a map

Let’s create a new file in the root of the project called levelgen.lua. We’ll return a function from this module that takes a few parameters.

local PARTITIONS = 3

--- @param rng RNG
--- @param player Actor
--- @param width integer
--- @param height integer
return function(rng, player, width, height)
   local builder = prism.MapBuilder(prism.cells.Wall)

   -- world building code goes here!

   return builder
end

We give the level building function an RNG which will be exclusive to it, the player we want to place, and the width and height of the map we want generated. Inside we create a MapBuilder and return it. The constant PARTITIONS will define the grid size of the rooms.

Populating the void

The first step is filling the void with a width * height initialization of pits and walls. To decide where to put which we’ll use love.math.perlinNoise. We’ll offset the Perlin noise to a random point and set a Wall for values greater than 0.5.

Note

Perlin noise is deterministic and returns the same value for the same point, so offsetting where we start guarantees a random pattern.

 -- Fill the map with random noise of pits and walls.
local nox, noy = rng:random(1, 10000), rng:random(1, 10000)
for x = 1, width do
   for y = 1, height do
      local noise = love.math.perlinNoise(x / 5 + nox, y / 5 + noy)
      local cell = noise > 0.5 and prism.cells.Wall or prism.cells.Pit
      builder:set(x, y, cell())
   end
end

Making room

Next, we’ll generate rooms in a grid, where the width and height are determined by our PARTITIONS constant. First, create a table of Rectangles to hold our rooms.

-- Create rooms in each of the partitions.
--- @type table<number, Rectangle>
local rooms = {}

We’re going to omit one of the rooms to introduce some variance.

local missing = prism.Vector2(
   rng:random(0, PARTITIONS - 1),
   rng:random(0, PARTITIONS - 1)
)

Then we’re going to calculate the total width and height of our patitions.

local pw, ph = math.floor(width / PARTITIONS), math.floor(height / PARTITIONS)

After that let’s set some reasonable limits on the minimum and maximum room width and height.

local minrw, minrh = math.floor(pw / 3), math.floor(ph / 3)
local maxrw, maxrh = pw - 2, ph - 2 -- Subtract 2 to ensure there's a margin.

Next we loop through each of our partitions and build a room so long as it’s not the one we’re omitting. We create a Rectangle, hash its partition coordinates, and put it into our table of rooms. Finally we draw the room onto our map with MapBuilder:drawRectangle().

for px = 0, PARTITIONS - 1 do
   for py = 0, PARTITIONS - 1 do
      if not missing:equals(px, py) then
         local rw = rng:random(minrw, maxrw)
         local rh = rng:random(minrh, maxrh)
         local x = rng:random(px * pw + 1, (px + 1) * pw - rw - 1)
         local y = rng:random(py * ph + 1, (py + 1) * ph - rh - 1)

         local roomRect = prism.Rectangle(x, y, rw, rh)
         rooms[prism.Vector2._hash(px, py)] = roomRect

         builder:drawRectangle(x, y, x + rw, y + rh, prism.cells.Floor)
      end
   end
end

Carving hallways

Next we’ll define a local function to draw the classic Rogue style L shaped hallways between rooms. It accepts two Rectangles representing the rooms, and if both a and b exist we draw a hallway between them. We use the level generator’s RNG to determine if we should start vertically or horizontally for a little bit of spice.

-- Helper function to connect two points with an L-shaped hallway.
--- @param a Rectangle
--- @param b Rectangle
local function createLShapedHallway(a, b)
   if not a or not b then return end

   local ax, ay = a:center():floor():decompose()
   local bx, by = b:center():floor():decompose()
   -- Randomly choose one of two L-shaped tunnel patterns for variety.
   if rng:random() > 0.5 then
      builder:drawLine(ax, ay, bx, ay, prism.cells.Floor)
      builder:drawLine(bx, ay, bx, by, prism.cells.Floor)
   else
      builder:drawLine(ax, ay, ax, by, prism.cells.Floor)
      builder:drawLine(ax, by, bx, by, prism.cells.Floor)
   end
end

Now we’ll go through each room and try to connect it to the one to the right, and the one to the bottom. If either doesn’t exist the hallway helper won’t get past the guard and nothing will happen.

for hash, currentRoom in pairs(rooms) do
   local px, py = prism.Vector2._unhash(hash)

   createLShapedHallway(currentRoom, rooms[prism.Vector2._hash(px + 1, py)])
   createLShapedHallway(currentRoom, rooms[prism.Vector2._hash(px, py + 1)])
end

Spawning people

Now to place the player. We’ll select a random room and put the player on the center tile.

local startRoom
while not startRoom do
   local x, y = rng:random(0, PARTITIONS - 1), rng:random(0, PARTITIONS - 1)
   startRoom = rooms[prism.Vector2._hash(x, y)]
end

local playerPos = startRoom:center():floor()
builder:addActor(player, playerPos.x, playerPos.y)

We’re getting close now, but we need some kobolds to kick. Let’s go through every room that’s not the starting room and spawn a kobold there.

for _, room in pairs(rooms) do
   if room ~= startRoom then
      local cx, cy = room:center():floor():decompose()

      builder:addActor(prism.actors.Kobold(), cx, cy)
   end
end

Sending it back

builder:addPadding(1, prism.cells.Wall)

return builder

Finally we’ll pad the entire map in some walls and return the finished MapBuilder.

Complete levelgen.lua
local PARTITIONS = 3

--- @param rng RNG
--- @param player Actor
--- @param width integer
--- @param height integer
return function(rng, player, width, height)
   local builder = prism.MapBuilder(prism.cells.Wall)

   -- Fill the map with random noise of pits and walls.
   local nox, noy = rng:random(1, 10000), rng:random(1, 10000)
   for x = 1, width do
      for y = 1, height do
         local noise = love.math.perlinNoise(x / 5 + nox, y / 5 + noy)
         local cell = noise > 0.5 and prism.cells.Wall or prism.cells.Pit
         builder:set(x, y, cell())
      end
   end

   -- Create rooms in each of the partitions.
   --- @type table<number, Rectangle>
   local rooms = {}

   local missing = prism.Vector2(rng:random(0, PARTITIONS - 1), rng:random(0, PARTITIONS - 1))
   local pw, ph = math.floor(width / PARTITIONS), math.floor(height / PARTITIONS)
   local minrw, minrh = math.floor(pw / 3), math.floor(ph / 3)
   local maxrw, maxrh = pw - 2, ph - 2 -- Subtract 2 to ensure there's a margin.
   for px = 0, PARTITIONS - 1 do
      for py = 0, PARTITIONS - 1 do
         if not missing:equals(px, py) then
            local rw, rh = rng:random(minrw, maxrw), rng:random(minrh, maxrh)
            local x = rng:random(px * pw + 1, (px + 1) * pw - rw - 1)
            local y = rng:random(py * ph + 1, (py + 1) * ph - rh - 1)

            local roomRect = prism.Rectangle(x, y, rw, rh)
            rooms[prism.Vector2._hash(px, py)] = roomRect

            builder:drawRectangle(x, y, x + rw, y + rh, prism.cells.Floor)
         end
      end
   end

   -- Helper function to connect two points with an L-shaped hallway.
   --- @param a Rectangle
   --- @param b Rectangle
   local function createLShapedHallway(a, b)
      if not a or not b then return end

      local ax, ay = a:center():floor():decompose()
      local bx, by = b:center():floor():decompose()
      -- Randomly choose one of two L-shaped tunnel patterns for variety.
      if rng:random() > 0.5 then
         builder:drawLine(ax, ay, bx, ay, prism.cells.Floor)
         builder:drawLine(bx, ay, bx, by, prism.cells.Floor)
      else
         builder:drawLine(ax, ay, ax, by, prism.cells.Floor)
         builder:drawLine(ax, by, bx, by, prism.cells.Floor)
      end
   end

   for hash, currentRoom in pairs(rooms) do
      local px, py = prism.Vector2._unhash(hash)

      createLShapedHallway(currentRoom, rooms[prism.Vector2._hash(px + 1, py)])
      createLShapedHallway(currentRoom, rooms[prism.Vector2._hash(px, py + 1)])
   end

   -- Choose the first room (top-left partition) to place the player.
   local startRoom
   while not startRoom do
      local x, y = rng:random(0, PARTITIONS - 1), rng:random(0, PARTITIONS - 1)
      startRoom = rooms[prism.Vector2._hash(x, y)]
   end

   local playerPos = startRoom:center():floor()
   builder:addActor(player, playerPos.x, playerPos.y)

   for _, room in pairs(rooms) do
      if room ~= startRoom then
         local cx, cy = room:center():floor():decompose()

         builder:addActor(prism.actors.Kobold(), cx, cy)
      end
   end

   builder:addPadding(1, prism.cells.Wall)

   return builder
end

Updating GameLevelState

Head back to gamestates/gamelevelstate.lua and add the following line to the top of the file.

local levelgen = require "levelgen"

Then we’re going to change its constructor. Head to GameLevelState:__new and let’s replace the map builder code there with this:

local seed = tostring(os.time())
local mapbuilder = levelgen(prism.RNG(seed), prism.actors.Player(), 60, 30)

Now run the game! You’ll be exploring a map reminiscent of Rogue but with a lot more pits to kick kobolds into.

Descending to the next part

We’ve developed a simple level generation algorithm using RNG and MapBuilder. In the next section of the tutorial we’ll add a set of stairs and let the player descend deeper into the dungeon!