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.

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.

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.

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.

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.
- 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.
- You might wonder why I do this:

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
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!