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