In this post, we will explore how we can enhance our debugging process by implementing seeded random number generation. We will demonstrate how this approach can lead to reproducible bugs that are easy to track down and fix, ultimately saving us hours of frustration and reducing the number of unfixable issues in our project.
Why is randomness horrible for debugging?
Random number generation is a great way to create dynamic and engaging gameplay. Whether it’s enemy spawn patterns, loot drops, or AI behavior, randomness adds the unpredictability that makes games fun. Let’s look at a classic example. We have an enemy spawner that randomly selects enemy types and positions. When the game runs, different enemies spawn in different locations, creating varied gameplay experiences. This is exactly what we want and it works great for players. But let’s zoom out a little.
When we’re debugging our game, we encounter a critical bug where the game crashes whenever a specific combination of enemies spawns near the player. However, this bug only happens “sometimes”, maybe one in every twenty playthroughs. We spend hours restarting the game, hoping to reproduce the exact conditions that trigger the crash. Each time we hit play, Unity generates different random numbers, creating different enemy configurations, and the bug remains elusive.
If we simply rely on Unity’s standard random system, we will have a chaotic debugging experience that confuses and frustrates us as developers. Instead, we want to be able to reproduce the exact same sequence of random events that caused our bug. As it stands, this is nearly impossible to implement with standard randomness.
We could try to log every random number generated and then replay them, but this creates massive log files that are hard to follow. Alternatively, we could try to force specific scenarios manually, but determining the exact sequence that triggers the bug is a challenge. How can we know which specific random values caused the issue?
Imagine that your QA tester comes over and says they found a game-breaking bug, but they can’t tell you how to reproduce it because “it just happens sometimes.” They’ve seen it three times in fifty playthroughs, but each time the conditions were different. Now you must somehow find this needle in a haystack, and this is where you lean back in your chair and start wondering if there’s a better way.
I believe you are beginning to understand the issue. Although debugging individual systems is straightforward, you will encounter much more complex scenarios when multiple random systems interact. The problem is that we are attempting to debug unpredictable behavior, but our current implementation makes bugs non-reproducible and challenging to isolate and fix. To resolve this, we must find a way to control randomness during development while maintaining unpredictability in production, allowing us to systematically debug issues.
What is seeded random number generation?
Seeded random number generation is a way of controlling randomness by providing an initial value (called a seed) that determines the entire sequence of random numbers that follow. This allows for predictable randomness, the same seed will always produce the exact same sequence of “random” numbers, making bugs reproducible while maintaining the appearance of chaos for players.
If we take a look at what seeded randomness looks like:
// Using the same seed always produces the same sequence
Random.InitState(12345);
Debug.Log(Random.Range(0, 100)); // Will always output: 64
Debug.Log(Random.Range(0, 100)); // Will always output: 12
Debug.Log(Random.Range(0, 100)); // Will always output: 89
// Reset with the same seed - same sequence again
Random.InitState(12345);
Debug.Log(Random.Range(0, 100)); // 64 again
Debug.Log(Random.Range(0, 100)); // 12 again
Debug.Log(Random.Range(0, 100)); // 89 again
Why should we use seeded random instead of standard randomness?
Our problem is that we need control over our debugging environment while maintaining true randomness for players. Imagine that we’re creating a debugging system that has logic in it. In that way, we will be able to reproduce any bug scenario by simply using the same seed that caused the original issue.
Let’s take a look at some example code. In the class below we have an example of a random service that can switch between development (seeded) and production (truly random) modes:
public static class RandomService
{
private static System.Random _random;
private static int _currentSeed;
private static bool _useFixedSeed;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Initialize()
{
// Development builds use fixed seeds for debugging
// Production builds use true randomness
#if UNITY_EDITOR || DEVELOPMENT_BUILD
SetFixedSeed(GetDevelopmentSeed());
#else
SetRandomSeed();
#endif
}
private static int GetDevelopmentSeed()
{
// Load from PlayerPrefs to persist between sessions
return PlayerPrefs.GetInt("RandomService_DebugSeed", 12345);
}
public static void SetFixedSeed(int seed)
{
_currentSeed = seed;
_useFixedSeed = true;
_random = new System.Random(seed);
UnityEngine.Random.InitState(seed);
Debug.Log($"RandomService: Using fixed seed {seed}");
#if UNITY_EDITOR
PlayerPrefs.SetInt("RandomService_DebugSeed", seed);
#endif
}
public static void SetRandomSeed()
{
_currentSeed = (int)System.DateTime.Now.Ticks;
_useFixedSeed = false;
_random = new System.Random(_currentSeed);
UnityEngine.Random.InitState(_currentSeed);
Debug.Log($"RandomService: Using random seed {_currentSeed}");
}
public static int GetCurrentSeed() => _currentSeed;
public static bool IsUsingFixedSeed() => _useFixedSeed;
// Use these instead of Unity's Random class
public static int Range(int min, int max) => _random.Next(min, max);
public static float Range(float min, float max) =>
(float)(_random.NextDouble() * (max - min) + min);
public static float Value => (float)_random.NextDouble();
public static bool Bool => _random.Next(2) == 0;
public static T Choose(params T[] choices)
{
if (choices.Length == 0) return default(T);
return choices[Range(0, choices.Length)];
}
}
If we follow the logic this is what it does:
- We check if we’re in development or production mode during initialization
- In development, we use a fixed seed that persists between Unity sessions
- In production, we generate a truly random seed based on the current time
- We provide wrapper methods that use our controlled randomness instead of Unity’s
- We log which seed is being used so we can reproduce issues later
The code shows how we took control of the random number generation and encapsulated it in a service, making it easier to debug and maintain in the future. By using conditional compilation, we ensure that development builds are reproducible while production builds remain unpredictable.
How do we make this system even more powerful?
In our RandomService, we can implement multiple random streams to prevent different systems from interfering with each other. We can do this for several reasons. Firstly, to decouple random systems from each other, avoiding situations where adding a random call to one system changes the behavior of another. Secondly, streams enable us to debug specific systems in isolation without worrying about cross-contamination from other parts of the code.
public static class RandomService
{
// ... previous code ...
private static Dictionary _randomStreams = new();
public static System.Random GetStream(string streamName)
{
if (!_randomStreams.ContainsKey(streamName))
{
// Create deterministic seed based on main seed + stream name
int streamSeed = _currentSeed + streamName.GetHashCode();
_randomStreams[streamName] = new System.Random(streamSeed);
}
return _randomStreams[streamName];
}
public static int RangeFromStream(string streamName, int min, int max)
{
return GetStream(streamName).Next(min, max);
}
public static T ChooseFromStream(string streamName, params T[] choices)
{
if (choices.Length == 0) return default(T);
var stream = GetStream(streamName);
return choices[stream.Next(0, choices.Length)];
}
// Weighted selection for loot systems
public static T WeightedChoice(string streamName, T[] choices, float[] weights)
{
if (choices.Length != weights.Length || choices.Length == 0)
return default(T);
float totalWeight = 0f;
foreach (float weight in weights)
totalWeight += weight;
if (totalWeight <= 0f) return choices[0];
var stream = GetStream(streamName);
float randomValue = (float)(stream.NextDouble() * totalWeight);
float currentWeight = 0f;
for (int i = 0; i < choices.Length; i++)
{
currentWeight += weights[i];
if (randomValue <= currentWeight)
return choices[i];
}
return choices[choices.Length - 1];
}
}
How do we create debugging tools for this system?
Now that we have our seeded random system, we want to create editor tools that make it easy to test different scenarios and switch between seeds during development.
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
public class RandomSeedWindow : EditorWindow
{
private int _seedValue = 12345;
[MenuItem("Tools/Random Debug/Open Seed Window")]
public static void ShowWindow()
{
GetWindow("Random Debug");
}
private void OnGUI()
{
GUILayout.Label("Random Seed Debugger", EditorStyles.boldLabel);
GUILayout.Space(10);
if (Application.isPlaying)
{
EditorGUILayout.BeginVertical("box");
GUILayout.Label($"Current Seed: {RandomService.GetCurrentSeed()}");
GUILayout.Label($"Using Fixed Seed: {RandomService.IsUsingFixedSeed()}");
EditorGUILayout.EndVertical();
}
else
{
EditorGUILayout.HelpBox("Enter Play mode to see current state", MessageType.Info);
}
GUILayout.Space(10);
_seedValue = EditorGUILayout.IntField("New Seed:", _seedValue);
GUI.enabled = Application.isPlaying;
if (GUILayout.Button("Set Fixed Seed"))
{
RandomService.SetFixedSeed(_seedValue);
}
if (GUILayout.Button("Use Random Seed"))
{
RandomService.SetRandomSeed();
}
GUI.enabled = true;
}
#endif
Real-world example: Enemy spawner system
Let’s look at how this system transforms a typical enemy spawner from an unpredictable debugging nightmare into a controlled, reproducible system:
public class EnemySpawner : MonoBehaviour
{
[SerializeField] private GameObject[] enemyPrefabs;
[SerializeField] private Transform[] spawnPoints;
[SerializeField] private float[] enemyWeights = { 50f, 30f, 15f, 5f }; // Common to rare
private const string ENEMY_STREAM = "EnemySpawner";
public void SpawnWave()
{
// This will be identical every time with the same seed
int enemyCount = RandomService.RangeFromStream(ENEMY_STREAM, 3, 8);
Debug.Log($"Spawning {enemyCount} enemies with seed: {RandomService.GetCurrentSeed()}");
for (int i = 0; i < enemyCount; i++)
{
// Choose spawn point using our seeded system
Transform spawnPoint = RandomService.ChooseFromStream(ENEMY_STREAM, spawnPoints);
// Choose enemy type with proper weighting
GameObject enemyPrefab = RandomService.WeightedChoice(ENEMY_STREAM, enemyPrefabs, enemyWeights);
GameObject spawnedEnemy = Instantiate(enemyPrefab, spawnPoint.position, spawnPoint.rotation);
// This log sequence will be identical each run with the same seed
Debug.Log($"Enemy {i}: {enemyPrefab.name} at {spawnPoint.name}");
// Even random variations are deterministic
Vector3 randomOffset = new Vector3(
RandomService.RangeFromStream(ENEMY_STREAM, -1f, 1f),
0,
RandomService.RangeFromStream(ENEMY_STREAM, -1f, 1f)
);
spawnedEnemy.transform.position += randomOffset;
}
}
[ContextMenu("Test Current Seed")]
public void TestCurrentSeed()
{
if (!Application.isPlaying) return;
Debug.Log($"Testing spawn with seed: {RandomService.GetCurrentSeed()}");
SpawnWave();
}
}
The beauty of doing it this way is that any time we encounter a bug, we can simply note the seed that was being used and instantly reproduce the exact same scenario. The enemy that crashed the game? It will spawn in the exact same position, with the same type, every single time we use that seed.
How does this improve our debugging workflow?
A common mistake I have seen in many projects is the assumption that because something is hard to reproduce, it’s not worth fixing or is “just a rare edge case.” Unfortunately, these rare bugs often become critical issues in production when thousands of players encounter them.
Good thing we have our seeded random system that controls all the randomness in our game.
When we encounter a bug during development, we can note the seed and share it with our team. When QA finds an issue, they can report both the steps to reproduce AND the seed value. When players report bugs in production (if we’re logging seeds), we can instantly jump into the exact scenario they experienced.
But we can’t just leave seeded random on in production because players want true unpredictability. Instead, we need to be strategic about when we use seeds. For development and testing, we use fixed seeds. For production builds, we generate truly random seeds but log them so we can reproduce player-reported issues.
Here’s how we might handle different scenarios:
public static class RandomService
{
// ... previous code ...
public static void HandleProductionBug(int reportedSeed)
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
Debug.Log($"Reproducing production bug with seed: {reportedSeed}");
SetFixedSeed(reportedSeed);
#else
Debug.LogError("Cannot reproduce production bugs in production build");
#endif
}
public static void LogSeedForCrashReport()
{
// Include seed in crash logs for production debugging
Debug.Log($"Crash report seed: {_currentSeed}");
// Send to analytics/crash reporting service
}
}
When should we use seeded random and when should we use true random?
So, now that we understand the power of using seeded random, should we abandon true randomness altogether? The answer is no. In production builds where we want genuine unpredictability for players, true randomness is the appropriate option. However, during development, testing, and debugging phases where we need reproducible scenarios, seeded random is the better choice.
The key is to use conditional compilation to automatically handle this decision:
#if UNITY_EDITOR || DEVELOPMENT_BUILD
// Use seeded random for debugging
SetFixedSeed(12345);
#else
// Use true random for players
SetRandomSeed();
#endif
Common pitfalls and how to avoid them
There are several mistakes that can break your seeded random system:
Mixing random systems: Don’t use Unity’s Random.Range() alongside your RandomService, as this will break reproducibility.
Order dependencies: Be careful about conditional random calls that might or might not execute, as this changes the sequence.
// Bad - this breaks reproducibility
void Update()
{
if (someCondition)
int random1 = RandomService.Range(0, 100); // Sometimes called, sometimes not
int random2 = RandomService.Range(0, 100); // This changes based on the above
}
// Good - use separate streams
void Update()
{
int random1 = RandomService.RangeFromStream("SystemA", 0, 100);
int random2 = RandomService.RangeFromStream("SystemB", 0, 100); // Always consistent
}
Performance considerations: Don’t create new Random instances frequently, as this is expensive. Use the stream system instead.
Advanced applications
For more complex games, you might want to integrate seeded random with your save system:
[System.Serializable]
public class GameSaveData
{
public int randomSeed;
// ... other save data
public void SaveRandomState()
{
randomSeed = RandomService.GetCurrentSeed();
}
public void LoadRandomState()
{
RandomService.SetFixedSeed(randomSeed);
}
}
You might also want to create different stream categories for different types of systems:
public static class RandomStreams
{
public const string ENEMIES = "Enemies";
public const string LOOT = "Loot";
public const string WEATHER = "Weather";
public const string PROCEDURAL_GENERATION = "ProcGen";
public const string AI_BEHAVIOR = "AI";
}
TL;DR
Create a seeded random service that uses fixed seeds during development and true randomness in production. Use separate random streams for different systems to prevent cross-contamination. Build editor tools to easily test different seeds and reproduce bugs. Always log the seed being used so you can reproduce issues later. Replace Unity’s Random class with your service throughout your project. This will transform your debugging process from guesswork into systematic problem-solving.
That pretty much covers it. With seeded random number generation, you’ve gained control over one of the most chaotic aspects of game development, making debugging predictable and efficient while maintaining the unpredictability that makes games engaging for players.