Roblox Procedural Room Generation: Building Backrooms-Style Levels That Replicate Cleanly

Have you ever watched two players in the same Backrooms-style Roblox server describe two different hallways? If you have prototyped procedural rooms, you know that generating a maze is the easy part, and that keeping every client, enemy, and late joiner in agreement about it is where these projects stall.
This guide walks through one implementation from start to finish: a room graph built from a server-only seed, a small tile set driven by door masks, and a spawning loop designed around StreamingEnabled. Keep in mind that the numbers below are starting points for a 24-stud grid, so profile them against your own art before you commit.
Keep the layout seed on the server and build a room graph as plain data. Then clone modular tiles near players, and let replication and streaming deliver the same structural geometry to every client.
Why Procedural Rooms Break Under Replication
Most failed generators share a root cause: layout, geometry, and presentation are written as one function that creates Parts while it rolls dice. As a result, nothing can be tested without a running server, and any change to a prop shifts the random sequence for every wall after it.
The symptoms tend to appear in a predictable order, particularly once more than one player is walking the level. Some of the failures you are likely to meet include:
- Layout Divergence. Each client runs the generator independently and one stray random call produces a different maze. Players then see doors that the other player cannot.
- Server Hitches. The server clones the entire level in one frame at round start. Heartbeat stalls, and every connected player feels it at the same moment.
- Partial Rooms. Streaming delivers a room one Part at a time, so a player briefly sees a floor with no walls. In a horror game, that gap also reveals what sits behind them.
- Solved Mazes. The world seed reaches the client, and an exploiter runs the same generator offline to find the exit in seconds.
All of these trace back to where the seed lives and who builds what. That is why the design below separates those decisions first and only then writes code.
Choosing Who Builds The Geometry
Before you write a generator, decide which machine owns each layer of the level. There are three workable arrangements, and the differences show up in collision, bandwidth, and exploit exposure.
| Approach | Server Collision | Replication Cost | Exploit Exposure |
|---|---|---|---|
| Client builds everything from a shared seed | None, so server enemies and hit checks have nothing to use | Lowest | Highest, because the full layout can be derived |
| Server builds everything | Full | Highest, since every prop replicates | Low |
| Server builds structure, clients add decor | Full | Moderate | Low |
We recommend the third option for almost every room-based game. Walls, floors, and doors are authoritative on the server, while stains, ceiling tile variation, and flickering fixtures are cosmetic and can be built locally without a single extra replicated instance.
Build walls, floors, and doors on the server so enemies and hit checks have real collision. Build cosmetic props on the client from a per-room seed, which cuts replicated instances without exposing the layout.
If replication itself is unfamiliar territory, our guide to how Roblox replication works covers what crosses the client-server boundary and when. The rest of this walkthrough assumes that foundation.
Step 1: Generate A Room Graph As Pure Data
The generator should touch no Instances at all. It takes a seed and a grid size, and it returns an array where each entry is a 4-bit door mask: north is 1, east is 2, south is 4, and west is 8.
Here is a randomized depth-first carve, which guarantees that every cell can be reached from every other cell. Note that the function owns its own Random object and walks arrays in a fixed order.
local DIRS = {
{ dx = 0, dz = -1, bit = 1, opposite = 4 }, -- north
{ dx = 1, dz = 0, bit = 2, opposite = 8 }, -- east
{ dx = 0, dz = 1, bit = 4, opposite = 1 }, -- south
{ dx = -1, dz = 0, bit = 8, opposite = 2 }, -- west
}
local function buildGraph(seed, size)
local rng = Random.new(seed)
local masks = table.create(size * size, 0)
local visited = table.create(size * size, false)
local stack = { 1 }
visited[1] = true
while #stack > 0 do
local cell = stack[#stack]
local x = (cell - 1) % size
local z = (cell - 1) // size
local options = {}
for _, dir in ipairs(DIRS) do
local nx, nz = x + dir.dx, z + dir.dz
if nx >= 0 and nx < size and nz >= 0 and nz < size then
local nextCell = nz * size + nx + 1
if not visited[nextCell] then
table.insert(options, { cell = nextCell, dir = dir })
end
end
end
if #options == 0 then
table.remove(stack)
else
local pick = options[rng:NextInteger(1, #options)]
masks[cell] = bit32.bor(masks[cell], pick.dir.bit)
masks[pick.cell] = bit32.bor(masks[pick.cell], pick.dir.opposite)
visited[pick.cell] = true
table.insert(stack, pick.cell)
end
end
return masks
end
A pure depth-first maze is a tree, which means long corridors and no way around a pursuer. To fix that, make a second pass over each cell's east and south neighbors and open any closed wall with a probability of about 10% to 15%, which adds the loops that make a liminal space feel disorienting.
Determinism rule: never iterate a dictionary with pairs during generation, because key order is not a contract you can rely on. Use arrays with ipairs, and give each stage its own Random so that a change in one stage cannot shift another.
Because the function is pure, you can run it across 1,000 seeds from the Studio command bar and assert connectivity, dead-end ratio, and door symmetry. What's more, a bug report only needs a logged seed to reproduce the exact level.
Treat the graph as an array of 4-bit door masks produced by a seeded Random object. With no Instances involved, one seed always yields one layout, and you can test thousands of seeds without running a server.
Step 2: Design A Modular Tile Set Around Door Masks
A 4-bit mask has 16 possible values, which sounds like 16 models to build. In practice, rotation reduces that to five archetypes, and those five cover 15 of the 16 masks (the sixteenth is a sealed cell that the carve never produces).
Each archetype maps onto a set of rotated masks as follows:
- Dead End. One door, covering four masks through rotation. These are natural spots for loot, notes, or an unpleasant surprise.
- Straight. Two opposite doors, covering two masks. This is the long hallway that defines the genre, so it deserves the most visual variants.
- Corner. Two adjacent doors, covering four masks. Corners break sight lines, which matters for both tension and streaming.
- T-Junction. Three doors, covering four masks. Junctions create the decisions that make players lose track of their route.
- Cross. Four doors and one mask. Use it for pillared open areas, since a true cross reads as a wide room rather than a hallway.
Next, build a lookup table once at startup rather than solving the fit for every room. Rotate each archetype's base mask with the function below and record the archetype and turn count for all of its results.
local function rotateMask(mask, turns)
for _ = 1, turns do
mask = bit32.band(bit32.lshift(mask, 1), 15) + bit32.rshift(mask, 3)
end
return mask
end
A few modeling rules will save you from visible seams later. Keep the following in mind when you build the tiles themselves:
- Pivot At Cell Center. Set each Model's pivot to the exact center of its floor. Rotation then works without any offset math.
- Own Half The Wall. Build walls inset from the cell boundary so that two neighbors never occupy the same plane. Shared planes cause z-fighting, which is very visible on flat yellow wallpaper.
- Center Every Door. Door openings should sit at the midpoint of each edge with a fixed width. Any tile can then meet any other tile.
- Keep Parts Cheap. Anchor everything, use Box collision fidelity on MeshParts, and turn off
CanTouchandCanQueryon trim. A template with 40 Parts is a sensible ceiling.
Finally, compute each room's position from its grid coordinates rather than chaining one room's attachment to the next. Multiplying from integers avoids accumulated floating-point drift, so a room at the far end of the map lines up as precisely as the first.
Five archetypes cover every door combination a connected maze produces: dead end, straight, corner, T-junction, and cross. Rotate each in 90-degree steps and look up the right tile by its 4-bit door mask.
Step 3: Instantiate Rooms Near Players
Having the whole graph in memory does not mean the whole level should exist as Instances. A 32 by 32 grid is 1,024 cells, and at 40 Parts per tile that is 40,960 replicated Parts before a single prop is placed.
A spawning window changes that math considerably. With a radius of four cells, each player needs at most 81 rooms, so four players who split up peak at 12,960 Parts, and a group that stays together costs far less.
local CELL = 24
local function spawnRoom(cell)
local fit = TILE_LOOKUP[masks[cell]]
local room = archetypes[fit.archetype]:Clone()
local x = (cell - 1) % size
local z = (cell - 1) // size
room:PivotTo(CFrame.new(x * CELL, 0, z * CELL)
* CFrame.Angles(0, math.rad(-90 * fit.turns), 0))
room:SetAttribute('DecorSeed', decorSeedFor(cell))
room:AddTag('GeneratedRoom')
room.Parent = workspace.Level
return room
end
Three settings make this loop behave well in production. Each one addresses a specific failure from earlier in this guide:
- Atomic Streaming. Set
ModelStreamingModeto Atomic on every tile template. The room then arrives and leaves as one unit, which eliminates half-built rooms. - A Frame Budget. Queue spawn requests and work through them on Heartbeat, stopping once
os.clock()shows about 4 milliseconds spent. The remainder waits for the next frame. - Hysteresis. Spawn at four cells but destroy at six. Otherwise, a player pacing across a boundary forces the same room to be cloned and destroyed repeatedly.
Moreover, your fog should end before your window does. With rooms spawning 96 studs out, a FogEnd near 80 studs or an equivalent Atmosphere density hides the edge along straight corridors and adds to the mood at the same time.
For teleports, such as a player falling through into a new sublevel, spawn the destination first and call Player:RequestStreamAroundAsync() before you move the character. Our StreamingEnabled guide covers radius tuning and integrity modes in more depth.
Clone only the rooms within about four cells of a player, cap the work at roughly 4 milliseconds per frame, and set tiles to Atomic streaming. Peak Part count then follows player spread rather than total map size.
Step 4: Let Clients Dress The Rooms
Structure now replicates on its own, so the remaining job is making each room look different without paying to replicate props. This is where the DecorSeed attribute comes in, since attributes travel with the room and therefore reach late joiners automatically.
That detail is why a RemoteEvent is the wrong tool here. A fired event reaches only the players connected at that moment, while an attribute on a streamed model is simply present when the model arrives.
On the client, listen with CollectionService tags rather than walking the Workspace. GetInstanceAddedSignal('GeneratedRoom') fires as rooms stream in, and GetInstanceRemovedSignal fires as they leave, which gives you a clean lifecycle for free.
Parent local props to a client-side folder outside the room, keyed by the room's identity, because anything you parent inside a streamed model disappears when it streams out. When the room returns, the same seed rebuilds the same stains in the same places.
Exposure rule: derive DecorSeed from a separate random salt rather than from the world seed, for example Random.new(decorSalt + cell):NextInteger(1, 2147483647). A decor seed that can be traced back to the layout seed hands an exploiter the map.
Presentation can also live on this layer. Fluorescent flicker, for instance, belongs on the client, and our guides to Roblox lighting and atmosphere and Roblox sound design cover how to keep that hum convincing without overloading lower-end devices.
Store a per-room decor seed as an attribute on the server's room model. Clients read it as the room streams in, so late joiners get identical props without a RemoteEvent and without learning the layout.
Placing Exits And Enemies With The Graph
The graph keeps paying off after generation, because game logic can query it directly. Accordingly, a breadth-first search from the spawn cell gives you a distance for every room in a single pass over the array.
Those distances handle most placement decisions. Here is how they typically fit together:
- Exit Placement. Put the exit in the farthest cell, or choose randomly among the farthest 5%. If the longest path falls below your target, reroll with a new seed, capped at a few attempts.
- Key Items. Restrict required items to dead ends at a minimum distance. Players then have to explore rather than sprint along the shortest route.
- Enemy Routing. Run the search over the graph to get a door sequence, then use
PathfindingServiceonly for the room in front of the enemy. - Spawn Fairness. Reject enemy spawn cells within a set graph distance of any player. Straight-line distance is misleading in a maze, since the room behind a wall may be 30 cells away by foot.
Note that an enemy can stand in a room no player is near, which means that room might not exist yet. For that reason, simulate distant enemies abstractly on the graph and only give them a physical body once they enter a spawned window.
If your level runs in a reserved server, write the seed to server-side storage keyed by game.PrivateServerId so that it survives a party's return trip. Our guides on Roblox server architecture and cross-server messaging explain how those pieces connect.
Troubleshooting Common Generation Failures
Even a clean architecture produces a few recognizable bugs during its first playtests. These are the ones worth checking first:
Why do players fall through the floor after a teleport?
The character arrived before the destination streamed in. Spawn the room, await RequestStreamAroundAsync(), then move the character, and consider a StreamingIntegrityMode that pauses players outside loaded areas.
Why does the same seed produce a different layout after an update?
Any change to the order of random calls changes the output, even when the code looks equivalent. If you persist seeds, store a generator version beside them and retain the old generator until those saves expire.
Why do client scripts error when they reach for a room?
Under streaming, the room may not exist on that client at that moment. Drive client logic from tag signals, and check children with FindFirstChild rather than assuming a path resolves.
When frame time is the complaint rather than correctness, measure before you optimize. The Roblox performance profiling guide shows how to use the MicroProfiler to separate spawn cost from rendering cost, which often point to very different fixes.
Build The Graph First
A procedural level that replicates cleanly comes down to a few firm boundaries: the seed stays on the server, the graph stays pure, structure replicates, and decor stays local. All of the above can be built and tested in stages, so you may want to start with gray boxes on a 10 by 10 grid before any art exists.
If you are newer to Luau and some of this felt like a leap, our Roblox scripting path lays out the fundamentals in order. Once two players can walk your gray-box maze and agree on every hallway, the wallpaper is the easy part.


