Procedurally Generated Poolrooms

Hello, I’ve recently been working on a game inspired by the poolrooms - it is very early in development right now. The game procedurally generates it’s levels, which I wanted to show off.

An example of 250 rooms:

Here is the generation process.

Heres some in game screenshots:



You can play a demo here - The Poolrooms - Roblox

Unaware how the game handles on tablet, phone and console

Planned features:

  • More verticality
  • Greater room variety - sometimes you get long corridors which feel boring to navigate
  • Entities (?)
  • Mapping feature

If anyone has any feedback or suggestions please let me know!

Thanks!

5 Likes

From what I can see, the game looks very promising, but most of the rooms at the moment are quite small. Maybe you could incorporate large open rooms into the design and randomly generate their floor plans in chunks.

5 Likes

Maybe make everything dark, changing the time to night, ambience etc and then fully rely on Lights. It would give the game a better ambience and wouldn’t make it insanely bright to look at.

3 Likes

Yeah I was struggling a lot with the lighting. Changed it up a bit, still a WIP but hows this?



Part of me feels it’s too dark now. Luckily the lights are just packages, so changing all of them is quite easy.

Thanks for the feedback!

1 Like

That’s a really interesting idea. Currently my map generation system wouldn’t really allow for that, but I was planning on overhauling it anyway.

I’ll definitely try prototyping it. Thanks for the feedback!

2 Likes

Try using beams with textures and use point lights.
Also, try changing the material to a more reflective material so the lights give a bit more feel to it.

1 Like

I love this system! I have a few questions though, Do you still work on this? are you selling this? I would love to utilize this generation in my own way!

Thanks for the kind words.

I stopped working on this a while ago, I may return to it in the future though.
I have no plans to sell it.

However, if you’d like, I could post some code snippets and just general thoughts about how the generation works. I’ll warn you though, the code was pretty bad and the generation is by no means perfect.

1 Like

I would love that. Do you have somewhere we can message each other on?

I was originally going to message you privately, but I decided to place this here in case anyone else was interested.

The generation can be broken down into 2 main steps

  • Repeatedly generate rooms until the desired room count is met
  • Once the number of rooms is met, seal off any empty door ways.

But I’m getting ahead of myself. The generation uses a set of pre made rooms, which are stored in server storage. In the image below, you can see all the room’s I’m currently using.

image

Each room can have it’s own shape, name, design, colour, lighting - however, to ensure they all connect together correctly, they must all share the same doorway.

Therefore, I made the following two pieces:
A “node” to connect two rooms together.
image
And the doorway itself.

Then, looking at each room, we can see these standardised doors and connection points.


Now for the scripting, I’ll do this in semi-pseudocode because the original is pretty messy.

image
The file structure looks something like this, the Util module is just to prevent the main script from becoming clogged with too many functions. In my version, both scripts were modules, and a different “main” script would call the generate function, but here I’ve opted to do it with a normal script instead - this thing is up to you really.

--=== MAIN SCRIPT INSIDE SERVERSCRIPTSERVICE ==---
local ServerStorage = game:GetService("ServerStorage")

local Util = require(script.Util) -- a module for storing functions about room generation

function generate_map(room_count)
	--// Delete the current map if there is one
	if workspace:FindFirstChild("Map") then
		workspace.Map:Destroy()
	end
	workspace.Terrain:Clear() -- this is to remove the water
	
	--// Create a new map
	local map_folder = Instance.new("Folder", workspace)
	map_folder.Name = "Map"
	
	-- Create the map data
	local current_map = { -- a table which stores all the data relevant to the map generation process
		rooms = {}, -- a list of all the currently spawned in rooms
		available_nodes = {}, -- a list of all the nodes which can be connected to
	}
	
	-- Create the starting room (safe room thingy)
	Util.CreateBase(current_map)
	
	-- Repeatedly generate rooms until the room count is met
	while #current_map.rooms ~= room_count do
		Util.AttemptRoomCreation(current_map)
	end
	
	-- Seal of all the still open doorways
	while #current_map.available_nodes > 0 do
		Util.SealNode(current_map, current_map.available_nodes[1])
	end
	
	print("GENERATION FINISHED")
	return current_map
end

So that main loop is pretty simple, the tricky part is the generate room method and the seal node method. Below is the util module with practically nothing in it.

-- SERVICES
local ServerStorage = game:GetService("ServerStorage")

-- Assets
local rooms_folder = ServerStorage.Assets.CompletedRooms
local cap = ServerStorage.Assets.Cap -- for sealing empty doors

-- MODULE
local util = {}
return util

To begin, I’ll add the create base function.


function util.CreateBase(current_map)
	local base = rooms_folder.Base:Clone() -- create a copy
	base.Parent = workspace.Map
	base:PivotTo(CFrame.new(Vector3.new(0,50,0))) -- this is just some set position for the main room to start in
	table.insert(current_map.rooms, base) -- now we add it to the table, so the map knows we've added a room
	
	-- CRUCIAL: We need to add all of this rooms available nodes to the available nodes table
end

This is a good time to talk about how nodes are stored in a room model. I just have a folder called nodes which holds all the nodes in that room.
image

Next, I’ll make a generic function which will do the following:

  • given a room
  • get all of its available nodes
  • add to the available nodes list
-- note, this isn't using util.add_nodes()
function add_nodes(map, room)
	for _, node in pairs(room.Nodes:GetChildren()) do
		table.insert(map.available_nodes, node)
	end
end

-- now we can finish the create base method
function util.CreateBase(current_map)
	local base = rooms_folder.Base:Clone() -- create a copy
	base.Parent = workspace.Map
	base:PivotTo(CFrame.new(Vector3.new(0,50,0))) -- this is just some set position for the main room to start in
	table.insert(current_map.rooms, base) -- now we add it to the table, so the map knows we've added a room
	
	-- CRUCIAL: We need to add all of this rooms available nodes to the available nodes table
	add_nodes(current_map, base)
end

The next biggest thing to add is the attempt to create room function. This will require lots of other functions to work, but I’ll implement this function first.

function util.AttemptRoomCreation(current_map)
	-- Create new room
	local new_room = get_random_room():Clone() -- another function we need to write
	
	-- Pick a node within the room to be its root (this is the node which connects to the map as it is)
	local self_node = get_random_node_within_room(new_room)
	new_room.PrimaryPart = self_node -- this is to make the whole room model "anchor"/"pivot" around this specific node
	
	-- Pick an available node to join this node to
	local join_node = get_random_node_within_map(current_map)
	
	-- JOIN
	new_room:PivotTo(CFrame.lookAt(join_node.Position, join_node.Position - join_node.CFrame.lookVector))
	
	-- Now we need to ensure that by adding this room, we haven't made some weird collision
	local bounding_box = get_bounding_box(new_room)
	
	local params = OverlapParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.FilterDescendantsInstances = {new_room}
	
	local collided = workspace:GetPartBoundsInBox(bounding_box.frame, bounding_box.size, params)
	
	if #collided < 15 then
		-- This function handles adding the new room to the map table and stuff
		RoomSuccess(current_map, new_room, bounding_box, join_node, self_node)
		return "success"
	else
		-- This function handles removing the room if it collided weirdly
		RoomFail(current_map, new_room, bounding_box)
		return "fail"
	end
end

So hopefully the logic is easy enough to step through. I want to make two quick caveats though.

  1. To position the room models, I was using the methods “model:PivotTo()” - I don’t know if this is the optimal way of doing this, recently I’ve been using the model:SetPrimaryPartCFrame() method. Again, which you use (if any of these) is really up to you.
  2. You might wonder why I do this:
    image
    Here’s what I remember: when the two rooms were in position, their walls would slightly overlap, so even if the room generated fine, it was still colliding with some parts. Therefore, I randomly chose 15 to be the threshold. THIS IS A TERRIBLE SOLUTION - you’re “magic number threshold” will be different depending on how your rooms are made, and the threshold will be completely different per each wall in each room. BETTER SOLUTION: Add the room you’re joining to to the over lap params filter table, like so:

Okay, two final things.
First, here are the functions needed for the function we just wrote.

function get_random_room()
	local rooms = rooms_folder:GetChildren()
	local random_room = rooms[math.random(1, #rooms)]
	if random_room.Name == "Base" then
		return get_random_room()
	else
		return random_room
	end
end
function get_random_node_within_room(room)
	local nodes = room.Nodes:GetChildren()
	return nodes[math.random(1, #nodes)]
end
function get_random_node_within_map(map)
	return map.available_nodes[math.random(1, #map.available_nodes)]
end
function get_bounding_box(room)
	local bb_frame, bb_size = room:GetBoundingBox()

	return {
		frame = bb_frame,
		size = bb_size
	}
end

And here are the success and fail functions.

function add_nodes(map, room)
	for _, node in pairs(room.Nodes:GetChildren()) do
		table.insert(map.available_nodes, node)
	end
end
function remove_node(map, node)
	table.remove(map.available_nodes, table.find(map.available_nodes, node))
	node:Destroy()
end

function FailRoom(map, room, bounding_box)
	room:Destroy() -- easy
end
function SuccessRoom(map, room, bounding_box, node1, node2)
	room.Parent = workspace.Map
	table.insert(map.rooms, room)

	add_nodes(map, room) -- this is one we wrote ages ago, it just adds all of this rooms nodes to the table

	convert_water_in_room(room) -- I wont go into this function, but it basically just creates some terrain water

      -- since we just connected these nodes, they're no longer "available", so we've got to remove them
	remove_node(map, node1); remove_node(map, node2) -- still need to write this one
end

FINAL THING! - this is going on forever.
Now, the script will generate a whole structure, but those “available nodes” will still be empty archways, so now we need to seal them.

function util.SealNode(current_map, node)
	local newCap = cap:Clone()
	newCap.CFrame = node.CFrame * CFrame.new(0,7,-1) * CFrame.Angles(0, math.rad(90), 0)
	newCap.Parent = workspace.Map
	remove_node(current_map, node)
end

Note that the “cap” asset is just a part which is sized to fit the archway we made at the start.

Okay, so I’ll post the utility module in full now.

-- UTILITY
-- SERVICES
local ServerStorage = game:GetService("ServerStorage")

-- Assets
local rooms_folder = ServerStorage.Assets.CompletedRooms
local cap = ServerStorage.Assets.Cap -- for sealing empty doors

function add_nodes(map, room)
	for _, node in pairs(room.Nodes:GetChildren()) do
		table.insert(map.available_nodes, node)
	end
end
function remove_node(map, node)
	table.remove(map.available_nodes, table.find(map.available_nodes, node))
	node:Destroy()
end

function get_random_room()
	local rooms = rooms_folder:GetChildren()
	local random_room = rooms[math.random(1, #rooms)]
	if random_room.Name == "Base" then
		return get_random_room()
	else
		return random_room
	end
end
function get_random_node_within_room(room)
	local nodes = room.Nodes:GetChildren()
	return nodes[math.random(1, #nodes)]
end
function get_random_node_within_map(map)
	return map.available_nodes[math.random(1, #map.available_nodes)]
end
function get_bounding_box(room)
	local bb_frame, bb_size = room:GetBoundingBox()

	return {
		frame = bb_frame,
		size = bb_size
	}
end

function FailRoom(map, room, bounding_box)
	room:Destroy() -- easy
end
function SuccessRoom(map, room, bounding_box, node1, node2)
	room.Parent = workspace.Map
	table.insert(map.rooms, room)

	add_nodes(map, room) -- this is one we wrote ages ago, it just adds all of this rooms nodes to the table

	convert_water_in_room(room) -- I wont go into this function, but it basically just creates some terrain water

	remove_node(map, node1); remove_node(map, node2) -- still need to write this one
end

-- MODULE
local util = {}

function util.CreateBase(current_map)
	local base = rooms_folder.Base:Clone() -- create a copy
	base.Parent = workspace.Map
	base:PivotTo(CFrame.new(Vector3.new(0,50,0))) -- this is just some set position for the main room to start in
	table.insert(current_map.rooms, base) -- now we add it to the table, so the map knows we've added a room
	
	-- CRUCIAL: We need to add all of this rooms available nodes to the available nodes table
	add_nodes(current_map, base)
end

function util.AttemptRoomCreation(current_map)
	-- Create new room
	local new_room = get_random_room():Clone() -- another function we need to write
	
	-- Pick a node within the room to be its root (this is the node which connects to the map as it is)
	local self_node = get_random_node_within_room(new_room)
	new_room.PrimaryPart = self_node -- this is to make the whole room model "anchor"/"pivot" around this specific node
	
	-- Pick an available node to join this node to
	local join_node = get_random_node_within_map(current_map)
	
	-- JOIN
	new_room:PivotTo(CFrame.lookAt(join_node.Position, join_node.Position - join_node.CFrame.lookVector))
	
	-- Now we need to ensure that by adding this room, we haven't made some weird collision
	local bounding_box = get_bounding_box(new_room)
	
	local params = OverlapParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.FilterDescendantsInstances = {new_room, joining_room}
	
	local collided = workspace:GetPartBoundsInBox(bounding_box.frame, bounding_box.size, params)
	
	if #collided < 15 then
		-- This function handles adding the new room to the map table and stuff
		SuccessRoom(current_map, new_room, bounding_box, join_node, self_node)
		return "success"
	else
		-- This function handles removing the room if it collided weirdly
		FailRoom(current_map, new_room, bounding_box)
		return "fail"
	end
end

function util.SealNode(current_map, node)
	local newCap = cap:Clone()
	newCap.CFrame = node.CFrame * CFrame.new(0,7,-1) * CFrame.Angles(0, math.rad(90), 0)
	newCap.Parent = workspace.Map
	remove_node(current_map, node)
end

return util

Final thoughts:

One potential drawback is the fact that all doors have to be the same size. We can get around this by making various types. For example, you could have small, medium and large. Then, large nodes must connect to large nodes, medium to medium, etc etc.

Another problem is the fact that with this system, loops will never form. You can think of this system as generating a graph, adding on nodes with one connection each time, like so:


So this is never possible
image

I never got round to fixing this though!

Okay done, sorry that was so long - I get a bit carried away. Hopefully it’s all understandable, do ask questions though!

2 Likes

Thank you for sharing this! I would like to message you somewhere privately so I can share some ideas and thoughts.

1 Like