Issues with procedural room generation

Hi there. So, here’s the situation: I’m trying to create a game featuring procedurally generated rooms, but with a slight twist—they operate in quadrants. Specifically, a “start” room generates a chain of rooms leading to a “finish” room, but in a branching pattern. Naturally, I’m using a 32x32 base grid, with a 512x512 grid surrounding the “start” (green) room. The generation is seed-based. I plan to tweak things later to get the exact result I want, but first, I need to fix the generation foundation. Basically, I want to generate three separate paths that eventually merge into a single path, while allowing for branches that can spawn dead-ends and loops. I wrote the scripts myself based on my knowledge of Studio. Also, the system is similar to the one used in the Roblox game Pressure during the “Operation Heartburn” event.

Here are the reference images and a video showing how the room generation works—both mine and theirs. (Note: I’m also having issues with rooms generating backwards and not aligning with the grid; I suspect I might need a grid underneath as well, though I recall seeing in some dev streams that they might not be doing it that way anymore. I also have some images showing the folder hierarchy.)





Just one more thing: the BoundingBoxes would act like a hitbox to prevent the rooms from overlapping. Also, from what I saw in their dev streams, there is a part called “BoundrySpawner” behind the “exit” part of the “finish” room—it seems to serve as a boundary or limiter, though I’m not sure exactly what for. Anyway, here is my code

local RoomGenerator = {}

local RNG = Random.new()
local Workspace = game:GetService("Workspace")

local PlacedRooms = {}
local GeneratedCount = 0
local FinishModel = nil

local FAIL_LIMIT = 10

local function GetBoundingBoxes(model)
	local boxes = {}

	for _, v in ipairs(model:GetDescendants()) do
		if v:IsA("BasePart") and v.Name == "BoundingBox" then
			boxes[#boxes + 1] = v
		end
	end

	return boxes
end

local function GetExits(model)
	local exits = {}

	for _, v in ipairs(model:GetDescendants()) do
		if v:IsA("BasePart") and v.Name == "Exit" then
			exits[#exits + 1] = v
		end
	end

	local more = model:FindFirstChild("MoreExits", true)
	if more then
		for _, v in ipairs(more:GetDescendants()) do
			if v:IsA("BasePart") then
				exits[#exits + 1] = v
			end
		end
	end

	return exits
end

local function ResetExits(model)
	for _, exit in ipairs(GetExits(model)) do
		exit:SetAttribute("Used", false)
		exit:SetAttribute("Closed", false)
		exit.Transparency = 0
		exit.CanCollide = true
	end
end

local function HideExitVisual(exit)
	exit.Transparency = 1
	exit.CanCollide = false

	for _, obj in ipairs(exit:GetDescendants()) do
		if obj:IsA("Decal") or obj:IsA("Texture") then
			obj.Transparency = 1
		elseif obj:IsA("SurfaceGui") then
			obj.Enabled = false
		elseif obj:IsA("BasePart") then
			obj.Transparency = 1
			obj.CanCollide = false
		end
	end
end

local function UpdateExitVisual(exit)
	if exit:GetAttribute("Used") or exit:GetAttribute("Closed") then
		HideExitVisual(exit)
	end
end

local function PickRoom(folder, onlyFirst)
	local candidates = {}
	local total = 0

	for _, room in ipairs(folder:GetChildren()) do
		if onlyFirst and room:GetAttribute("FirstRoom") ~= true then
			continue
		end

		local w = math.clamp(room:GetAttribute("SpawnChance") or 100, 0, 100)
		if w <= 0 then
			continue
		end

		total += w
		candidates[#candidates + 1] = { room = room, weight = w }
	end

	if #candidates == 0 then
		return nil
	end

	local roll = RNG:NextNumber(0, total)
	local acc = 0

	for _, c in ipairs(candidates) do
		acc += c.weight

		if roll <= acc then
			return c.room
		end
	end

	return candidates[#candidates].room
end

local function GetRoomTemplates(folder)
	local templates = {}

	for _, room in ipairs(folder:GetChildren()) do
		if room:IsA("Model") then
			local weight = math.clamp(room:GetAttribute("SpawnChance") or 100, 0, 100)

			if weight > 0 then
				templates[#templates + 1] = room
			end
		end
	end

	for i = #templates, 2, -1 do
		local j = RNG:NextInteger(1, i)
		templates[i], templates[j] = templates[j], templates[i]
	end

	return templates
end

local function IsColliding(boxes, room)
	local overlap = OverlapParams.new()
	overlap.FilterType = Enum.RaycastFilterType.Exclude
	overlap.FilterDescendantsInstances = { room }

	for _, b in ipairs(boxes) do
		local touching = Workspace:GetPartsInPart(b, overlap)

		for _, p in ipairs(touching) do
			if p.Name == "BoundingBox" then
				return true
			end
		end
	end

	return false
end

local function Align(room, targetExit)
	local exits = GetExits(room)

	if #exits == 0 then
		return false
	end

	local chosen = exits[RNG:NextInteger(1, #exits)]
	local modelCF = room:GetPivot()
	local offset = modelCF:ToObjectSpace(chosen.CFrame)

	local targetCF = targetExit.CFrame * CFrame.Angles(0, math.rad(180), 0)

	room:PivotTo(targetCF * offset:Inverse())

	return true
end

local function TryGenerate(roomTemplate, targetExit, roomFolder)
	local clone = roomTemplate:Clone()

	local boxes = GetBoundingBoxes(clone)

	if #boxes == 0 and not clone.PrimaryPart then
		clone:Destroy()
		return nil
	end

	if #boxes == 0 then
		clone:Destroy()
		return nil
	end

	if not Align(clone, targetExit) then
		clone:Destroy()
		return nil
	end

	if IsColliding(boxes, clone) then
		clone:Destroy()
		return nil
	end

	clone.Parent = roomFolder

	PlacedRooms[#PlacedRooms + 1] = { Model = clone }
	GeneratedCount += 1

	return clone
end

local function CloseExit(exit)
	exit.Transparency = 1
	exit.CanCollide = false
	exit:SetAttribute("Closed", true)
	HideExitVisual(exit)
end

local function SpawnDeadEnd(exit, roomFolder, deadFolder)
	if exit:GetAttribute("Used") or exit:GetAttribute("Closed") then
		return
	end

	local template = PickRoom(deadFolder, false)

	if not template then
		CloseExit(exit)
		return
	end

	local result = TryGenerate(template, exit, roomFolder)

	if result then
		exit:SetAttribute("Used", true)
		UpdateExitVisual(exit)
	else
		CloseExit(exit)
	end
end

local function CloseAllUnusedExits(roomFolder, deadFolder, startModel)
	for _, data in ipairs(PlacedRooms) do
		local model = data.Model

		if model ~= startModel and model ~= FinishModel then
			for _, exit in ipairs(GetExits(model)) do
				if not exit:GetAttribute("Used") then
					SpawnDeadEnd(exit, roomFolder, deadFolder)
				end

				UpdateExitVisual(exit)
			end
		end
	end
end

local function TryForceGenerateFinish(roomTemplate, targetExit, roomFolder)
	local clone = roomTemplate:Clone()

	local boxes = GetBoundingBoxes(clone)

	if #boxes == 0 then
		clone:Destroy()
		return nil
	end

	if not Align(clone, targetExit) then
		clone:PivotTo(targetExit.CFrame * CFrame.Angles(0, math.rad(180), 0))
	end

	clone.Parent = roomFolder

	PlacedRooms[#PlacedRooms + 1] = { Model = clone }
	GeneratedCount += 1

	return clone
end

local function GetRoomsByDistanceFromStart(start)
	local startPos = start:GetPivot().Position
	local rooms = {}

	for _, data in ipairs(PlacedRooms) do
		local model = data.Model

		if model ~= FinishModel then
			local dist = (model:GetPivot().Position - startPos).Magnitude
			rooms[#rooms + 1] = { Model = model, Distance = dist }
		end
	end

	table.sort(rooms, function(a, b)
		return a.Distance > b.Distance
	end)

	return rooms
end

local function TryFinishOnExit(exit, roomFolder, finishFolder, force)
	local templates = GetRoomTemplates(finishFolder)

	for _, finishTemplate in ipairs(templates) do
		local result

		if force then
			result = TryForceGenerateFinish(finishTemplate, exit, roomFolder)
		else
			result = TryGenerate(finishTemplate, exit, roomFolder)
		end

		if result then
			exit:SetAttribute("Used", true)
			exit:SetAttribute("Closed", false)
			UpdateExitVisual(exit)

			FinishModel = result
			return true
		end
	end

	return false
end

local function SpawnFinish(start, roomFolder, finishFolder)
	local roomsByDistance = GetRoomsByDistanceFromStart(start)

	for _, roomData in ipairs(roomsByDistance) do
		for _, exit in ipairs(GetExits(roomData.Model)) do
			if not exit:GetAttribute("Used") and not exit:GetAttribute("Closed") then
				if TryFinishOnExit(exit, roomFolder, finishFolder, false) then
					return true
				end
			end
		end
	end

	for _, roomData in ipairs(roomsByDistance) do
		for _, exit in ipairs(GetExits(roomData.Model)) do
			if not exit:GetAttribute("Used") and not exit:GetAttribute("Closed") then
				warn("[RoomGenerator] Normal Finish failed. Forcing Finish spawn.")

				if TryFinishOnExit(exit, roomFolder, finishFolder, true) then
					return true
				end
			end
		end
	end

	for _, roomData in ipairs(roomsByDistance) do
		for _, exit in ipairs(GetExits(roomData.Model)) do
			warn("[RoomGenerator] No free exit for Finish. Forcing on any available exit.")

			if TryFinishOnExit(exit, roomFolder, finishFolder, true) then
				return true
			end
		end
	end

	return false
end

local function GenerateOnce(seed, maxRooms)
	local roomsFolder = Workspace:WaitForChild("Rooms")
	local roomFolder = Workspace:WaitForChild("RoomFolder")

	local zone = roomsFolder:WaitForChild("Zone")
	local start = zone:WaitForChild("Start")

	local main = roomsFolder:WaitForChild("MainRooms")
	local finish = roomsFolder:WaitForChild("Finish")
	local dead = roomsFolder:FindFirstChild("DeadEnds")

	RNG = Random.new(seed)

	for _, v in ipairs(roomFolder:GetChildren()) do
		v:Destroy()
	end

	PlacedRooms = {}
	GeneratedCount = 0
	FinishModel = nil

	ResetExits(start)

	PlacedRooms[#PlacedRooms + 1] = { Model = start }

	local queue = GetExits(start)
	local fails = 0

	while GeneratedCount < maxRooms and fails < FAIL_LIMIT do
		if #queue == 0 then
			break
		end

		local exit = table.remove(queue, 1)

		if not exit or exit:GetAttribute("Used") or exit:GetAttribute("Closed") then
			if exit then
				UpdateExitVisual(exit)
			end

			continue
		end

		local template = PickRoom(main, GeneratedCount == 0)

		if not template then
			break
		end

		local newRoom = TryGenerate(template, exit, roomFolder)

		if newRoom then
			fails = 0
			exit:SetAttribute("Used", true)
			UpdateExitVisual(exit)

			for _, ex in ipairs(GetExits(newRoom)) do
				queue[#queue + 1] = ex
			end
		else
			fails += 1
		end
	end

	local okFinish = SpawnFinish(start, roomFolder, finish)

	if okFinish then
		if dead then
			CloseAllUnusedExits(roomFolder, dead, start)
		end
	else
		warn("[RoomGenerator] Finish could not spawn. DeadEnds were not generated.")
	end

	return okFinish and GeneratedCount >= math.floor(maxRooms * 0.6)
end

function RoomGenerator.Generate(seed, maxRooms)
	local MAX_ATTEMPTS = 20
	local attempt = 0

	while attempt < MAX_ATTEMPTS do
		local currentSeed = seed or (math.floor(os.clock() * 100000) + attempt * 9999)

		local success = GenerateOnce(currentSeed, maxRooms)

		if success then
			print("[RoomGenerator] OK | Seed:", currentSeed, "Rooms:", GeneratedCount)
			return
		end

		attempt += 1
	end

	warn("[RoomGenerator] Failed after multiple seed attempts")
end

return RoomGenerator

If anyone can help, I’d be very grateful; I’ve been working on this for over two months and don’t know what to do :))

2 Likes

The backwards rooms issue is almost always a rotation or offset problem in your CFrame math. When you’re placing rooms procedurally, even a single flipped axis or inverted angle will cascade through the entire chain. Check how you’re calculating the exit CFrame of one room and using it as the entry point for the next—if you’re not accounting for the room’s local orientation correctly, you’ll get them backwards or rotated.

For the three-path merging system, the tricky part isn’t the generation itself but tracking which paths are “active” and when to force them to converge. You’ll need to tag each room with a path ID and a merge point (like “all three paths must reach room X by generation step 12”). Without that constraint, your algorithm will just keep branching. Make sure your seed controls not just room selection but also when paths are allowed to merge—otherwise you’re relying on randomness to do structural work.

Worth checking: are you storing the exit CFrame of each room before placing the next one, or calculating it fresh each time? The former is safer and easier to debug.

1 Like

Hi there thanks for the reply; it gave me some new ideas. I’m going to look into giving each path its own ID and storing the ID for each exit, since my script is currently recalculating and trying to search again. I was wondering about making generation easier: could I use a 512x512 grid (based on 32x32 cells) so the rooms know exactly where to spawn? Regarding my earlier comment about rooms generating “backwards” sorry, that was just a slip of the tongue. The issue is that the generator sometimes tries to build behind the starting room; even though the exits have the correct rotations and the room calculations are otherwise accurate, the generator might spawn a curved room that points backward and then continues generating in that direction. I suspect they use a “fake” grid or something similar, though I haven’t noticed it in the livestreams. I’ll dig a bit deeper into this, as room generation is pretty complex, but thanks again for the answer! :))

(I’m having to rely on the livestream, and along with their old generation which was only straight, there’s also something that I believe each exit has for a specific type of room, but I don’t know how it works, which is a folder called DirectionBlackList. It has 4 int values: Right, Left, Forward, Up, and Down. Whenever I saw these int values, in rooms, for example, curving to the right, the value of the Right Int Value was 3, but in rooms, for example, in a cross shape, the int value of the right exit was 1. I believe this serves to spawn more straight rooms than rooms that curve or something like that. I think this would help, wouldn’t it? In terms of generation, so as not to spam too many rooms that curve)