There's something deeply satisfying about a game that's different every time you play it. Roguelikes, survival games, and dungeon crawlers all lean on procedural generation to keep things fresh — and building your own isn't as intimidating as it might seem. The algorithms are well-understood, and once you've got the core pieces in place, you can generate infinite worlds with a few hundred lines of code.
TL;DR: This tutorial covers procedural dungeon generation using BSP trees, connecting rooms with A* pathfinding, populating dungeons with enemies and loot, day/night cycles with lighting transitions, time-based event systems, idle resource generation, saving procedural worlds, and input handling. Every script is free to download individually.
We'll build a procedural dungeon from scratch, then layer on living-world systems like day/night cycles and timed events. By the end, you'll have a world that generates itself, populates itself, and evolves over time. Let's start with the rooms.
Dungeon Generation with BSP Trees
Binary Space Partitioning is the workhorse algorithm for dungeon generation. The idea is simple: start with a big rectangle, split it in half (randomly choosing horizontal or vertical), then recursively split each half until you reach a minimum room size. Each leaf node becomes a room. Our Procedural Dungeon script uses this approach:
using UnityEngine;
using System.Collections.Generic;
public class BSPDungeon : MonoBehaviour
{
[SerializeField] private int dungeonWidth = 50;
[SerializeField] private int dungeonHeight = 50;
[SerializeField] private int minRoomSize = 6;
[SerializeField] private int maxDepth = 5;
[SerializeField] private GameObject floorTile;
[SerializeField] private GameObject wallTile;
private List<RectInt> rooms = new List<RectInt>();
public void Generate()
{
rooms.Clear();
RectInt root = new RectInt(0, 0, dungeonWidth, dungeonHeight);
Split(root, 0);
BuildTiles();
}
private void Split(RectInt area, int depth)
{
if (depth >= maxDepth || area.width < minRoomSize * 2 || area.height < minRoomSize * 2)
{
int roomW = Random.Range(minRoomSize, area.width - 1);
int roomH = Random.Range(minRoomSize, area.height - 1);
int roomX = area.x + Random.Range(1, area.width - roomW);
int roomY = area.y + Random.Range(1, area.height - roomH);
rooms.Add(new RectInt(roomX, roomY, roomW, roomH));
return;
}
bool splitHorizontal = Random.value > 0.5f;
if (area.width > area.height * 1.25f) splitHorizontal = false;
else if (area.height > area.width * 1.25f) splitHorizontal = true;
if (splitHorizontal)
{
int splitY = Random.Range(minRoomSize, area.height - minRoomSize);
Split(new RectInt(area.x, area.y, area.width, splitY), depth + 1);
Split(new RectInt(area.x, area.y + splitY, area.width, area.height - splitY), depth + 1);
}
else
{
int splitX = Random.Range(minRoomSize, area.width - minRoomSize);
Split(new RectInt(area.x, area.y, splitX, area.height), depth + 1);
Split(new RectInt(area.x + splitX, area.y, area.width - splitX, area.height), depth + 1);
}
}
private void BuildTiles()
{
bool[,] floorMap = new bool[dungeonWidth, dungeonHeight];
foreach (var room in rooms)
{
for (int x = room.x; x < room.x + room.width; x++)
for (int y = room.y; y < room.y + room.height; y++)
floorMap[x, y] = true;
}
for (int x = 0; x < dungeonWidth; x++)
for (int y = 0; y < dungeonHeight; y++)
{
Vector3 pos = new Vector3(x, 0, y);
if (floorMap[x, y])
Instantiate(floorTile, pos, Quaternion.identity, transform);
else
Instantiate(wallTile, pos, Quaternion.identity, transform);
}
}
public List<RectInt> GetRooms() => rooms;
}The preference for splitting the longer axis prevents rooms from getting too narrow and corridor-like. Tweaking minRoomSize and maxDepth dramatically changes the feel — smaller minimums with deeper splits produce labyrinthine dungeons, while larger minimums create open arenas connected by passages.
Connecting Rooms with A* Corridors
BSP gives you rooms, but they're isolated. You need corridors between them. The simplest approach is connecting each room to the next one in the list with an L-shaped corridor. For more organic-feeling layouts, you can use our A* Pathfinding script to carve paths that route around existing rooms instead of cutting through them:
using UnityEngine;
using System.Collections.Generic;
public class CorridorBuilder : MonoBehaviour
{
[SerializeField] private int corridorWidth = 2;
public bool[,] ConnectRooms(List<RectInt> rooms, bool[,] floorMap)
{
for (int i = 0; i < rooms.Count - 1; i++)
{
Vector2Int start = RoomCenter(rooms[i]);
Vector2Int end = RoomCenter(rooms[i + 1]);
CarveLCorridor(floorMap, start, end);
}
return floorMap;
}
private void CarveLCorridor(bool[,] map, Vector2Int from, Vector2Int to)
{
Vector2Int current = from;
int dirX = to.x > from.x ? 1 : -1;
while (current.x != to.x)
{
CarveAt(map, current);
current.x += dirX;
}
int dirY = to.y > from.y ? 1 : -1;
while (current.y != to.y)
{
CarveAt(map, current);
current.y += dirY;
}
CarveAt(map, current);
}
private void CarveAt(bool[,] map, Vector2Int center)
{
int half = corridorWidth / 2;
for (int x = -half; x <= half; x++)
for (int y = -half; y <= half; y++)
{
int px = center.x + x;
int py = center.y + y;
if (px >= 0 && px < map.GetLength(0) && py >= 0 && py < map.GetLength(1))
map[px, py] = true;
}
}
private Vector2Int RoomCenter(RectInt room)
=> new Vector2Int(room.x + room.width / 2, room.y + room.height / 2);
}L-shaped corridors are the standard for roguelikes — they're simple, predictable, and always connect. If you want more natural-looking caves, run a cellular automata pass over the floor map after corridor generation to smooth out hard edges.
Populating Dungeons with Enemies and Loot
An empty dungeon is just a maze. You need enemies and loot to make it a game. The trick is placing spawns intelligently — enemies should be in rooms, not corridors. Loot should be spread across the dungeon, not clumped in one corner. Use the Wave Spawner for each room's enemy population and Pickup Collectible for loot drops:
using UnityEngine;
using System.Collections.Generic;
public class DungeonPopulator : MonoBehaviour
{
[SerializeField] private GameObject[] enemyPrefabs;
[SerializeField] private GameObject[] lootPrefabs;
[SerializeField] private int enemiesPerRoom = 3;
[SerializeField] private float lootChance = 0.4f;
public void Populate(List<RectInt> rooms)
{
for (int i = 0; i < rooms.Count; i++)
{
RectInt room = rooms[i];
// Skip the first room (player spawn)
if (i == 0) continue;
int enemyCount = Random.Range(1, enemiesPerRoom + 1);
for (int e = 0; e < enemyCount; e++)
{
Vector3 pos = RandomPointInRoom(room);
int prefabIndex = Random.Range(0, enemyPrefabs.Length);
Instantiate(enemyPrefabs[prefabIndex], pos, Quaternion.identity);
}
if (Random.value < lootChance)
{
Vector3 lootPos = RandomPointInRoom(room);
int lootIndex = Random.Range(0, lootPrefabs.Length);
Instantiate(lootPrefabs[lootIndex], lootPos, Quaternion.identity);
}
}
}
private Vector3 RandomPointInRoom(RectInt room)
{
float x = Random.Range(room.x + 1, room.x + room.width - 1);
float z = Random.Range(room.y + 1, room.y + room.height - 1);
return new Vector3(x, 0, z);
}
}Skipping room 0 for enemies gives the player a safe spawn area — a pattern every roguelike follows. Scale enemy difficulty by room index: earlier rooms get weaker prefabs, later rooms get tougher ones. The last room can house a boss using the multi-phase pattern from our Wave Spawner.
Day/Night Cycle
A day/night cycle transforms a static world into a living one. Enemies get more aggressive at night, shops close, nocturnal creatures appear. Our Day/Night Cycle script handles the sun rotation and ambient lighting transitions:
using UnityEngine;
using System;
public class DayNightCycle : MonoBehaviour
{
[SerializeField] private Light sun;
[SerializeField] private float dayLengthMinutes = 10f;
[SerializeField] private Gradient ambientColorGradient;
[SerializeField] private AnimationCurve sunIntensityCurve;
[SerializeField] private Color nightFogColor = new Color(0.05f, 0.05f, 0.15f);
[SerializeField] private Color dayFogColor = new Color(0.7f, 0.8f, 0.9f);
public event Action<float> OnTimeChanged;
public float CurrentTime { get; private set; } = 0.25f;
public bool IsNight => CurrentTime < 0.2f || CurrentTime > 0.8f;
void Update()
{
float daySpeed = 1f / (dayLengthMinutes * 60f);
CurrentTime = (CurrentTime + daySpeed * Time.deltaTime) % 1f;
float sunAngle = CurrentTime * 360f - 90f;
sun.transform.rotation = Quaternion.Euler(sunAngle, 170f, 0f);
sun.intensity = sunIntensityCurve.Evaluate(CurrentTime);
RenderSettings.ambientLight = ambientColorGradient.Evaluate(CurrentTime);
RenderSettings.fogColor = Color.Lerp(nightFogColor, dayFogColor, sunIntensityCurve.Evaluate(CurrentTime));
OnTimeChanged?.Invoke(CurrentTime);
}
}Set up the ambientColorGradient in the Inspector with warm orange at 0.25 (sunrise), bright white at 0.5 (noon), orange again at 0.75 (sunset), and deep blue at 0/1 (midnight). The sunIntensityCurve should peak at 0.5 and drop to near-zero during night hours. These two curves give you complete control over the mood at every hour.
Time-Based Events
A day/night cycle on its own is just pretty lighting. What makes it gameplay-relevant is events tied to time. Our Timer Countdown script handles individual timers, but for world-scale events you want a scheduler that hooks into the day/night system:
using UnityEngine;
using UnityEngine.Events;
using System.Collections.Generic;
public class TimeEventScheduler : MonoBehaviour
{
[System.Serializable]
public class TimeEvent
{
public string eventName;
[Range(0f, 1f)] public float triggerTime;
[Range(0f, 0.02f)] public float tolerance = 0.005f;
public UnityEvent onTrigger;
[HideInInspector] public bool firedToday;
}
[SerializeField] private DayNightCycle dayCycle;
[SerializeField] private List<TimeEvent> events;
private float lastTime;
void Update()
{
float t = dayCycle.CurrentTime;
foreach (var evt in events)
{
if (!evt.firedToday && Mathf.Abs(t - evt.triggerTime) < evt.tolerance)
{
evt.onTrigger?.Invoke();
evt.firedToday = true;
}
}
if (t < lastTime)
{
foreach (var evt in events)
evt.firedToday = false;
}
lastTime = t;
}
}Wire up events in the Inspector: merchant arrives at 0.3 (morning), wolves spawn at 0.8 (dusk), torch lights activate at 0.75 (evening). This system makes your procedural world feel like it has a rhythm — NPCs have schedules, danger shifts with the clock, and the player learns to plan around cycles.
Idle Resource Generation
If your procedural game has any basebuilding or resource management, idle income is a natural fit. Resources accumulate while the player explores the dungeon or even while the game is closed. Our Idle Income Generator script handles this, and it pairs with the Idle Game System for a complete economy:
using UnityEngine;
using System;
public class IdleResourceGenerator : MonoBehaviour
{
[SerializeField] private string resourceName = "Gold";
[SerializeField] private float baseRate = 1f;
[SerializeField] private float rateMultiplier = 1f;
private double accumulated;
private DateTime lastCollectionTime;
private const string SaveKeyPrefix = "idle_";
void Start()
{
string savedTime = PlayerPrefs.GetString(SaveKeyPrefix + resourceName + "_time", "");
if (!string.IsNullOrEmpty(savedTime) && DateTime.TryParse(savedTime, out DateTime saved))
{
lastCollectionTime = saved;
TimeSpan elapsed = DateTime.Now - lastCollectionTime;
accumulated = elapsed.TotalSeconds * baseRate * rateMultiplier;
}
else
{
lastCollectionTime = DateTime.Now;
}
}
void Update()
{
accumulated += baseRate * rateMultiplier * Time.deltaTime;
}
public int Collect()
{
int amount = (int)accumulated;
accumulated -= amount;
lastCollectionTime = DateTime.Now;
Save();
return amount;
}
private void Save()
{
PlayerPrefs.SetString(SaveKeyPrefix + resourceName + "_time", DateTime.Now.ToString("o"));
PlayerPrefs.Save();
}
void OnApplicationPause(bool paused)
{
if (paused) Save();
}
}The key trick is saving the timestamp when the player leaves and calculating accumulated resources when they return. This means resources genuinely accumulate while offline — the player opens the game to a pile of gold they earned while sleeping. It's the hook that keeps players coming back in idle games.
Saving Procedural Worlds
Here's the catch with procedural generation: if the world is random every time, how do you save and reload it? The answer is seeds. Instead of saving every tile position, save the random seed used to generate the dungeon. When the player loads their save, re-run the generation with the same seed and you get the exact same layout. Our JSON Save Utility handles serialization, and the Save System manages the full pipeline:
using UnityEngine;
using System.IO;
[System.Serializable]
public class DungeonSaveData
{
public int seed;
public int playerRoomIndex;
public float playerX;
public float playerZ;
public int[] clearedRooms;
public string[] collectedLoot;
}
public class DungeonSaver : MonoBehaviour
{
[SerializeField] private BSPDungeon dungeonGenerator;
private int currentSeed;
private string SavePath => Path.Combine(Application.persistentDataPath, "dungeon_save.json");
public void GenerateNewDungeon()
{
currentSeed = Random.Range(0, int.MaxValue);
Random.InitState(currentSeed);
dungeonGenerator.Generate();
}
public void SaveDungeon(Vector3 playerPos, int[] clearedRooms, string[] collectedLoot)
{
var data = new DungeonSaveData
{
seed = currentSeed,
playerX = playerPos.x,
playerZ = playerPos.z,
clearedRooms = clearedRooms,
collectedLoot = collectedLoot
};
string json = JsonUtility.ToJson(data, true);
File.WriteAllText(SavePath, json);
}
public DungeonSaveData LoadDungeon()
{
if (!File.Exists(SavePath)) return null;
string json = File.ReadAllText(SavePath);
var data = JsonUtility.FromJson<DungeonSaveData>(json);
currentSeed = data.seed;
Random.InitState(currentSeed);
dungeonGenerator.Generate();
return data;
}
}The save data includes which rooms are cleared and which loot was picked up, so the player's progress is preserved without storing the entire tile map. This approach scales to massive dungeons — even a 500x500 tile dungeon compresses down to a few kilobytes of save data because you're only storing the seed plus a handful of state flags.
Input Handling for Procedural Games
Procedural games often need flexible input because the player might be navigating a dungeon, managing a base, or browsing an inventory — all in the same session. Our Input Manager script provides context-based input switching so the same keys do different things depending on the active mode:
using UnityEngine;
using System;
using System.Collections.Generic;
public class InputContext : MonoBehaviour
{
public static InputContext Instance { get; private set; }
public enum Context { Gameplay, UI, Dialogue }
public Context CurrentContext { get; private set; } = Context.Gameplay;
public event Action<Context> OnContextChanged;
private Stack<Context> contextStack = new Stack<Context>();
void Awake() => Instance = this;
public void Push(Context ctx)
{
contextStack.Push(CurrentContext);
CurrentContext = ctx;
OnContextChanged?.Invoke(CurrentContext);
}
public void Pop()
{
if (contextStack.Count > 0)
{
CurrentContext = contextStack.Pop();
OnContextChanged?.Invoke(CurrentContext);
}
}
public bool IsGameplay => CurrentContext == Context.Gameplay;
public bool IsUI => CurrentContext == Context.UI;
}When the player opens their inventory, push Context.UI. When they close it, pop back to Context.Gameplay. Movement scripts check InputContext.Instance.IsGameplay before processing input, so WASD doesn't move the character while they're in a menu. The stack structure means nested UI (inventory → item details → confirm dialog) works correctly — each pop returns to the right context.
What We Built
That's a full procedural world pipeline — from empty space to a populated, time-driven, saveable dungeon. Here's the recap:
- Procedural Dungeon — BSP-based room generation with configurable depth and sizing
- A* Pathfinding — Corridor connections and enemy navigation on grids
- Wave Spawner + Pickup Collectible — Room population with enemies and loot
- Day/Night Cycle + Timer Countdown — Living world with time-driven events
- Idle Income Generator — Offline resource accumulation
- JSON Save Utility — Seed-based save/load for procedural layouts
- Input Manager — Context-stacked input handling for multi-mode gameplay
For a pre-integrated economy layer, check out the Idle Game System bundle, and for robust save/load across all your game systems, the Save System kit has you covered. Now go build something nobody's ever played before — literally.