Procedural Generation Techniques in Unity and Godot
Build three small procedural generation techniques, seeded randomness, constrained room placement, and noise-based variation, side by side in Unity and Godot.
Procedural generation sounds like research territory, but most of what a solo developer needs comes down to three ideas. You need randomness you can repeat, a way to place pieces so they follow rules, and a way to add variation that looks natural instead of chaotic.
This tutorial builds each idea in both Unity (C#) and Godot 4 (GDScript). The goal is a small dungeon of rooms on a grid, decorated with variation, that you can regenerate exactly from a single number.
Technique 1: Seeded randomness
A pseudo-random number generator does not produce true randomness. It produces a fixed sequence of numbers that looks random. The seed chooses where that sequence starts. Give the generator the same seed and it gives you the same sequence every time.
That property is what makes procedural levels practical. If a player reports a bug on level 48213, you can type in 48213 and see the same level. You can also offer a daily challenge where everyone gets the same map, or save a whole level as one integer.
The rule to remember: every random decision in your generator must come from the seeded generator, in the same order. If one part of the code quietly uses a different random source, or if the order of calls changes, the output changes.
Unity
Unity’s documentation explains that UnityEngine.Random is a static class with shared global state, and suggests System.Random instances when you need independent generators. For level generation, an instance you own is the safer choice, because other scripts calling Random cannot disturb your sequence.
using UnityEngine;
public class LevelGenerator : MonoBehaviour
{
public int seed = 12345;
System.Random rng;
void Start()
{
rng = new System.Random(seed);
Debug.Log(rng.Next(0, 10)); // same value every run
}
}
System.Random.Next(min, max) excludes the max value, so Next(0, 10) returns 0 through 9.
Godot
Godot provides a RandomNumberGenerator class. Its documentation says a given seed produces a reproducible sequence. Set the seed before you draw any numbers.
extends Node2D
@export var seed_value := 12345
var rng := RandomNumberGenerator.new()
func _ready():
rng.seed = seed_value
print(rng.randi_range(0, 9)) # same value every run
In Godot, randi_range includes both ends. That difference from C# is a common source of off-by-one bugs when you port logic between engines.
The Godot docs also note that similar seeds can produce similar streams. If seeds come from player input, such as a typed word, run them through hash() first.
Technique 2: Room placement with constraints
Now use the seeded generator to place rooms on a grid. The method here is a constrained random walk:
- Start with one room at grid position (0, 0).
- Pick a random existing room and a random direction (up, down, left, right).
- If that neighboring cell is empty, place a room there.
- Repeat until you reach the target number of rooms.
The constraints are what keep the result playable. Rooms never overlap, every room touches another room so the level stays connected, and the total is fixed. You can add more rules later, such as a maximum distance from the start or no more than three neighbors per room.
Unity
// Inside LevelGenerator. Add "using System.Collections.Generic;" at the top.
public GameObject roomPrefab;
public int roomCount = 12;
public float cellSize = 20f;
List<Vector2Int> rooms = new List<Vector2Int>();
HashSet<Vector2Int> taken = new HashSet<Vector2Int>();
void Generate()
{
Vector2Int[] dirs = { Vector2Int.up, Vector2Int.down,
Vector2Int.left, Vector2Int.right };
rooms.Add(Vector2Int.zero);
taken.Add(Vector2Int.zero);
int attempts = 0;
while (rooms.Count < roomCount && attempts < 1000)
{
attempts++;
Vector2Int origin = rooms[rng.Next(0, rooms.Count)];
Vector2Int next = origin + dirs[rng.Next(0, 4)];
if (taken.Contains(next)) continue;
rooms.Add(next);
taken.Add(next);
}
foreach (var cell in rooms)
{
Vector3 pos = new Vector3(cell.x * cellSize, 0, cell.y * cellSize);
Instantiate(roomPrefab, pos, Quaternion.identity, transform);
}
}
Godot
@export var room_scene: PackedScene
@export var room_count := 12
@export var cell_size := 320.0
var rooms: Array[Vector2i] = []
var taken := {}
func generate():
var dirs = [Vector2i.UP, Vector2i.DOWN, Vector2i.LEFT, Vector2i.RIGHT]
rooms.append(Vector2i.ZERO)
taken[Vector2i.ZERO] = true
var attempts := 0
while rooms.size() < room_count and attempts < 1000:
attempts += 1
var origin: Vector2i = rooms[rng.randi_range(0, rooms.size() - 1)]
var next: Vector2i = origin + dirs[rng.randi_range(0, 3)]
if taken.has(next):
continue
rooms.append(next)
taken[next] = true
for cell in rooms:
var room = room_scene.instantiate()
room.position = Vector2(cell) * cell_size
add_child(room)
Notice the attempts cap in both versions. A constraint loop should always have an escape. Without one, a strict rule set can leave the loop searching forever.
Picking special rooms
Once the layout exists, assign roles. A simple, reliable rule: the start is at (0, 0), and the exit is the room with the largest grid distance from it. Add the absolute x and y differences to get that distance. Because the layout comes from the seed, the exit lands in the same place every time.
Technique 3: Noise for natural variation
Pure random values look like static. If you pick a random floor tile for every cell, neighbors have nothing in common. Noise functions fix this. They return values that change smoothly across space, so nearby points get similar results. That gives you patches of grass, clusters of rocks, or gradual shifts in color.
Unity
Unity provides Mathf.PerlinNoise(x, y). It returns a value around 0 to 1, and the docs warn it can land slightly outside that range, so clamp it if you depend on the bounds. The same coordinates always return the same value. To tie it to your seed, offset the coordinates by a seeded amount.
float offsetX = (float)rng.NextDouble() * 1000f;
float offsetY = (float)rng.NextDouble() * 1000f;
float scale = 0.1f;
float Sample(int x, int y)
{
return Mathf.Clamp01(Mathf.PerlinNoise(offsetX + x * scale,
offsetY + y * scale));
}
Godot
Godot’s FastNoiseLite resource has its own seed and frequency properties, and get_noise_2d(x, y) samples it. Lower frequency gives smoother, larger features.
var noise := FastNoiseLite.new()
func setup_noise():
noise.seed = seed_value
noise.frequency = 0.05
func sample(x: int, y: int) -> float:
return noise.get_noise_2d(x, y)
Print a few samples to see the range you get, then remap it to whatever your logic needs.
Using the values
Turn the sample into a decision with thresholds. For example, inside each room, loop over a small grid of floor cells and:
- Below a low threshold, place a puddle or dark tile.
- Above a high threshold, place a rock or crate.
- Otherwise, leave plain floor.
Tune the scale or frequency until the clusters feel right. Too high and it looks like scattered noise again. Too low and a whole room becomes one material.
Make the seed visible
Put the seed somewhere you can see it. Show it on the pause screen, write it to the log on every generation, and add a debug field where you can type one in. When something breaks, the seed is your reproduction step.
Two habits keep seeds reliable:
- Generate in a fixed order. Layout first, then special rooms, then decoration. If you add a new random step, add it at the end so existing seeds still produce the same layouts.
- Keep randomness out of frame timing. Do not draw random numbers from per-frame update code during generation. Run the whole generator in one pass.
For deeper reading, go to the Random and Mathf pages in the Unity Scripting API and the RandomNumberGenerator and FastNoiseLite class pages in the Godot documentation. They cover extra options, such as noise types and fractal settings, that you can layer onto this foundation once the basics work.