Cada vez que un jugador muere en un roguelike y empieza una nueva partida, visita una mazmorra diferente. Enemigos distintos, objetos distintos, disposición distinta. Esa variedad es el punto central, y todo viene de un único número.
Ese número es la seed. Utilizando un generador de números pseudoaleatorios y se obtendrá una secuencia infinita de valores "aleatorios" que, en realidad, son completamente deterministas. Misma seed, misma secuencia, siempre.
Para un jugador, eso es una curiosidad, algo que compartir en Discord con amigos para que puedan ver la misma partida. Para un desarrollador, es la diferencia entre un bug que puedes reproducir en cinco minutos y uno que solo vive en la memoria de un jugador que nunca podrá recrear las condiciones exactas.
Este artículo explora cómo funciona eso por dentro, por qué el determinismo es una característica y no una limitación, y cómo implementar un sistema de RNG con seed en Unity/C# que puedas usar en tus propios proyectos.
1. Lo aleatorio no es realmente aleatorio
Los ordenadores son máquinas deterministas. No pueden generar verdadera aleatoriedad. Al menos no desde software. Lo que normalmente se define como "aleatorio" en los juegos es pseudoaleatorio: una función matemática que produce una secuencia de números que parece aleatoria pero está completamente determinada por su valor inicial.
La familia de algoritmos más común para esto es el Generador Lineal Congruencial (LCG). La fórmula es casi vergonzosamente simple:
Introduce una seed, aplica la fórmula, obtienes un número. Aplica de nuevo, obtienes el siguiente. La secuencia se repite eventualmente (después de miles de millones de pasos con buenas constantes) pero dentro del rango de una sesión de juego, se comporta como si fuera genuinamente aleatoria.
El Random.value de Unity usa un algoritmo Xorshift, no LCG, pero la idea central es la misma: una función determinista inicializada con un valor de partida. El algoritmo importa poco para los juegos, lo que importa es la predictibilidad y la velocidad.
2. Qué hace realmente una seed
Una seed es simplemente el estado inicial del generador. Dos partidas con la misma seed consumirán la misma secuencia de números en el mismo orden, siempre que hagan las mismas llamadas en el mismo orden.
Por eso puedes compartir seeds en juegos como Spelunky, Dead Cells, The Binding of Isaac o Hades. La generación de niveles, la colocación de enemigos y los drops de objetos están todos impulsados por llamadas a RNG que ocurren en un orden fijo y predecible. La seed ancla toda la partida.
Una seed no describe un mundo. Describe una receta para construirlo.

La consecuencia práctica es que las seeds son ligeras. No es necesario serializar todo el estado de la mazmorra, solo guardar la seed y reproducir la generación. Así es como los juegos tienen miles de niveles únicos sin almacenar ninguno en disco. Es menos costoso guardar una cadena de caracteres que la información de cada objeto, enemigo o elemento del mundo.
3. El determinismo es una característica
Cuando los jugadores hablan de seeds, suelen referirse a "layouts interesantes para compartir". Pero para los desarrolladores, el determinismo resuelve un problema mucho más difícil: la reproducibilidad.
Un jugador reporta un crash que solo ocurre en una configuración específica de mazmorra. Con generación no determinista, ese bug podría ser imposible de reproducir. Con RNG con seed, pides la seed, reproduces la partida, y el bug aparece en tu pantalla en menos de un minuto.
Registra la seed al inicio de la sesión. Aunque nunca la expongas a los jugadores, tenerla en tus informes de crash te ahorrará horas de depuración. Una línea de log se paga sola la primera vez que necesitas reproducir un bug reportado.

El determinismo también permite tests con seeds fijas. En lugar de testear "¿tiene la mazmorra al menos una sala?" con una mazmorra aleatoria en cada ejecución, testeas con una seed fija y aseveras resultados exactos. Los tests se vuelven estables, reproducibles y significativos.
4. El problema del estado global
La clase Random de Unity es un singleton global. Llamando a Random.InitState(seed) al inicio, todos los sistemas que llaman a Random.value o Random.Range comparten la misma secuencia.
Esto crea un acoplamiento frágil entre sistemas sin relación. Si se añade un nuevo efecto de partículas que llama a Random.Range una vez, toda mazmorra generada a partir de ese punto será diferente, incluso con la misma seed. Toda la partida se rompe.
1// Frágil: cualquier nueva llamada a Random en cualquier lugar rompe el determinismo
2Random.InitState(1337);
3
4// Sistema A consume las llamadas 1, 2, 3
5int numSalas = Random.Range(5, 10);
6
7// Efecto visual nuevo añadido. Consume la llamada 4
8float offsetParticula = Random.Range(0f, 1f);
9
10// Sistema B ahora obtiene un valor diferente al esperado
11ItemType botin = (ItemType)Random.Range(0, poolObjetos.Length);La solución es aislar los streams de RNG. Cada sistema principal obtiene su propio generador, inicializado de forma independiente a partir de la seed raíz. Los cambios en el sistema de partículas no pueden afectar a la generación de mazmorras porque están consumiendo de pools diferentes.
5. Construyendo el sistema en Unity/C#
La abstracción central es una interfaz IRng. Cada sistema que necesite aleatoriedad recibe una dependencia a esta interfaz en lugar de llamar a Random directamente. Esto desacopla el código de gameplay del generador subyacente y hace el sistema testeable.
1public interface IRng
2{
3 int Next(int min, int max);
4 float NextFloat(float min = 0f, float max = 1f);
5 bool Chance(float probability);
6 T Pick<T>(IList<T> list);
7 void Shuffle<T>(IList<T> list);
8 T PickWeighted<T>(IList<T> items, IList<float> weights);
9}La implementación concreta envuelve System.Random de .NET, que es rápido, está bien testado, y no depende de Unity en absoluto. La clave es que se inicializa explícitamente, no desde el tiempo o el estado del sistema:
1public class SeededRng : IRng
2{
3 private readonly System.Random _random;
4
5 public SeededRng(int seed)
6 {
7 _random = new System.Random(seed);
8 }
9
10 public int Next(int min, int max) =>
11 _random.Next(min, max);
12
13 public float NextFloat(float min = 0f, float max = 1f) =>
14 min + (float)_random.NextDouble() * (max - min);
15
16 public bool Chance(float probability) =>
17 NextFloat() < probability;
18
19 public T Pick<T>(IList<T> list) =>
20 list[Next(0, list.Count)];
21
22 public void Shuffle<T>(IList<T> list)
23 {
24 for (int i = list.Count - 1; i > 0; i--)
25 {
26 int j = Next(0, i + 1);
27 (list[i], list[j]) = (list[j], list[i]);
28 }
29 }
30
31 public T PickWeighted<T>(IList<T> items, IList<float> weights)
32 {
33 float total = 0f;
34 foreach (var w in weights) total += w;
35 float roll = NextFloat(0f, total);
36 float cumulative = 0f;
37 for (int i = 0; i < items.Count; i++)
38 {
39 cumulative += weights[i];
40 if (roll < cumulative) return items[i];
41 }
42 return items[items.Count - 1];
43 }
44}6. Dividir la seed en streams
Una seed raíz impulsa todos los sistemas, pero cada sistema obtiene una seed derivada para que sus streams estén aislados. La forma más sencilla es hacer un hash de la seed raíz con una constante específica del dominio:
1public class RngFactory
2{
3 private readonly int _rootSeed;
4
5 public RngFactory(int rootSeed)
6 {
7 _rootSeed = rootSeed;
8 }
9
10 public IRng For(RngDomain domain)
11 {
12 // XOR con un primo específico del dominio para derivar una seed única
13 int derivedSeed = _rootSeed ^ (int)domain * 2654435761;
14 return new SeededRng(derivedSeed);
15 }
16}
17
18public enum RngDomain
19{
20 DungeonLayout = 1,
21 EnemySpawns = 2,
22 LootDrops = 3,
23 AmbientEffects = 4,
24 ShopInventory = 5,
25}Ahora cada sistema solicita su propia instancia de IRng a la factory. Añadir un nuevo sistema de partículas bajo AmbientEffects tiene impacto cero en el layout de mazmorras o en los drops de loot:
1public class DungeonGenerator
2{
3 private readonly IRng _rng;
4
5 public DungeonGenerator(RngFactory factory)
6 {
7 _rng = factory.For(RngDomain.DungeonLayout);
8 }
9
10 public Dungeon Generate(DungeonConfig config)
11 {
12 int numSalas = _rng.Next(config.minRooms, config.maxRooms);
13 // Cada llamada consume del stream DungeonLayout únicamente
14 // ...
15 }
16}Los streams aislados solo funcionan si cada sistema hace un número consistente de llamadas a RNG por operación. Si la generación de salas llama a _rng.Next() un número variable de veces según resultados anteriores, añadir un nuevo tipo de sala puede seguir desplazando la secuencia. Es indispensable diseñar los generadores para que el número de llamadas sea determinista dados los mismos inputs.
Con esta arquitectura, los streams de cada sistema evolucionan de forma completamente independiente:
- seed 12345, DungeonLayout → hash(12345, 1) → stream de layout.
- seed 12345, EnemySpawns → hash(12345, 2) → stream de spawns.
- seed 12345, LootDrops → hash(12345, 3) → stream de loot.
- seed 12345, AmbientEffects → hash(12345, 4) → stream de partículas.
Cada stream evoluciona de forma independiente. Un nuevo efecto de partículas que consume de AmbientEffects nunca toca el stream de loot, sin importar cuántas llamadas haga.
7. Selección aleatoria ponderada
El azar plano da a cada objeto del pool las mismas probabilidades. Eso funciona para tipos de sala, pero no para el loot, donde generalmente se busca que cada objeto tenga una probabilidad de generarse diferente (dependiendo de rarezas, por ejemplo). La selección ponderada es la herramienta utilizada por los roguelikes para codificar intención en probabilidad.
La idea: cada objeto lleva un peso. El generador lanza un número contra el peso total y devuelve el objeto cuyo rango acumulado contiene ese número. Una espada con peso 60 y una reliquia con peso 10 sobre un total de 100 da a la espada un 60% de probabilidad y a la reliquia un 10%.
1// Pool de objetos con pesos — los pesos son datos, no código
2var items = new List<TipoObjeto> { TipoObjeto.Espada, TipoObjeto.Escudo, TipoObjeto.Reliquia };
3var pesos = new List<float> { 60f, 30f, 10f };
4
5// _rng consume del stream LootDrops
6TipoObjeto recompensa = _rng.PickWeighted(items, pesos);
7
8// Espada: 60%, Escudo: 30%, Reliquia: 10%En la práctica, los pesos pertenecen a archivos de datos, no al código. Añaden un campo de peso a cada definición de objeto para que los diseñadores puedan ajustar tasas de drop en una hoja de cálculo sin tocar el código ni recompilar.
Para pools grandes (cientos de entradas), Alias Method puede ser una opción: setup O(n), extracción O(1). Para pools de loot típicos en roguelikes (10-100 objetos), el recorrido lineal de PickWeighted es suficientemente rápido y mucho más simple de depurar.
8. Caso práctico: Otter's Hell
Otter's Hell es un roguelite bullet hell desarrollado en Unity durante cuatro años, donde Sparkles, la última general del Imperio Nutria, navega planetas generados proceduralmente combatiendo contra los ejércitos Hámster. Cada partida se siente diferente, y esa variedad la impulsa completamente el RNG con seed dividido en tres streams independientes.
Generación procedural de niveles
Cada planeta se ensambla en tiempo de ejecución desde un pool de plantillas de salas. El generador de mazmorras usa un stream de layout para elegir los obstáculos, elementos del entorno, trampas, puntos de interés... El resultado es un mapa diferente en cada partida, pero que respeta restricciones estructurales.
Así mismo, el orden en el que se visitan los niveles se genera al inicio de la partida, seleccionando los layouts desde una pool de opciones diferentes.

Aparición de enemigos
La composición de enemigos en cada sala la gestiona un stream de spawns separado. Al entrar en una sala, el sistema extrae de su RNG para seleccionar tipos de enemigo, cantidad y posiciones iniciales, todo dentro de presupuestos definidos por diseño para cada nivel del planeta. Como este stream está aislado del stream de layout, ajustar las disposiciones de las salas nunca cambia accidentalmente qué enemigos aparecen en ellas.
Generación de recompensas
Completar una sala activa un tercer stream: recompensas. El sistema pondera los objetos disponibles contra el build actual del jugador, aplica una selección ponderada y presenta tres opciones. Kepler, el dueño de la tienda dentro de la partida, usa el mismo stream inicializado por piso. Así el inventario de la tienda se fija para ese piso una vez generado, pero varía entre partidas y seeds.
1// Selección de recompensas simplificada de Otter's Hell
2public Item[] GenerarRecompensasDeSala(int cantidad = 3)
3{
4 var pool = _registroObjetos.GetDesbloqueados()
5 .Select(obj => (obj, peso: CalcularPeso(obj, _buildJugador)))
6 .Where(e => e.peso > 0)
7 .ToList();
8
9 var elegidos = new List<Item>();
10 for (int i = 0; i < cantidad && pool.Count > 0; i++)
11 {
12 var items = pool.Select(e => e.obj).ToList();
13 var pesos = pool.Select(e => (float)e.peso).ToList();
14 var pick = _rng.PickWeighted(items, pesos);
15 elegidos.Add(pick);
16 pool.RemoveAll(e => e.obj == pick); // sin duplicados en una misma oferta
17 }
18 return elegidos.ToArray();
19}
20
21// El peso favorece objetos con sinergia con el build actual
22private float CalcularPeso(Item obj, PlayerBuild build)
23{
24 float pesoBase = obj.PesoDeDrop;
25 float bonusSin = build.ContarSinergiasCon(obj) * 15f;
26 return pesoBase + bonusSin;
27}Separar los streams de spawn, layout y recompensas permitió rebalancear las tasas de drop de objetos sin tocar una sola línea del código de generación de mazmorras, y viceversa. Esa independencia fue crítica durante las pruebas de balanceo del juego.
9. Generar y mostrar seeds
Para las seeds orientadas al jugador, los enteros en crudo son feos y difíciles de recordar. Un patrón común es generar una seed aleatoria y codificarla como una cadena alfanumérica corta, haciendo que sea más fácil de compartir en una captura de pantalla o mensaje de Discord.
1public class SeedManager
2{
3 public int CurrentSeed { get; private set; }
4
5 // Genera una nueva seed aleatoria a partir del tiempo del sistema
6 public void NewRun()
7 {
8 CurrentSeed = Environment.TickCount;
9 Debug.Log($"[RNG] Seed de la partida: {ToDisplayString()}");
10 }
11
12 // Reproduce una partida específica por seed
13 public void SetSeed(int seed)
14 {
15 CurrentSeed = seed;
16 Debug.Log($"[RNG] Reproduciendo seed: {ToDisplayString()}");
17 }
18
19 public RngFactory CreateFactory() =>
20 new RngFactory(CurrentSeed);
21
22 // Codifica como cadena base-36 de 7 chars (ej. "3K9FWQZ") para mostrar al jugador
23 public string ToDisplayString() =>
24 EncodeBase36((uint)CurrentSeed);
25
26 public void SetFromDisplayString(string s) =>
27 CurrentSeed = (int)DecodeBase36(s);
28
29 private static string EncodeBase36(uint value)
30 {
31 const string chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
32 var result = new char[7];
33 for (int i = 6; i >= 0; i--)
34 {
35 result[i] = chars[(int)(value % 36)];
36 value /= 36;
37 }
38 return new string(result);
39 }
40
41 private static uint DecodeBase36(string s)
42 {
43 const string chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
44 uint result = 0;
45 foreach (char c in s.ToUpperInvariant())
46 result = result * 36 + (uint)chars.IndexOf(c);
47 return result;
48 }
49}Registra siempre la seed antes que nada. Si el juego se cuelga, la seed está en el archivo de log. Si un jugador reporta una mazmorra extraña, puede compartir la cadena de display desde el menú de pausa. Construir esto desde el primer día ahorrará varios dolores de cabeza en el futuro, tanto para debuguear como de cara a los usuarios.
10. Guardar y restaurar el estado del RNG
Si el juego tiene guardado a mitad de partida (tanto en tiempo real o con un menú), será necesario preservar la posición exacta de cada stream junto al resto del estado del juego. Un jugador que guarda antes de una sala de jefe y recarga, espera obtener la misma mazmorra, los mismos enemigos y las mismas recompensas, no una versión recién barajada.
System.Random no expone su estado interno directamente. La solución más simple pasa por rastrear cuántas llamadas ha consumido cada stream y avanzar hasta esa posición al restaurar.
1public record RngState(int Seed, int CallCount);
2
3// Añadir seguimiento de estado a SeededRng:
4public class SeededRng : IRng
5{
6 private System.Random _random;
7 private readonly int _seed;
8 private int _callCount;
9
10 public SeededRng(int seed)
11 {
12 _seed = seed;
13 _random = new System.Random(seed);
14 }
15
16 public int Next(int min, int max)
17 {
18 _callCount++;
19 return _random.Next(min, max);
20 }
21
22 public float NextFloat(float min = 0f, float max = 1f)
23 {
24 _callCount++;
25 return min + (float)_random.NextDouble() * (max - min);
26 }
27
28 // Chance, Pick, Shuffle, PickWeighted sin cambios. Llaman a Next/NextFloat
29
30 public RngState CaptureState() => new(_seed, _callCount);
31
32 public void RestoreState(RngState state)
33 {
34 _random = new System.Random(state.Seed);
35 for (int i = 0; i < state.CallCount; i++)
36 _random.NextDouble(); // avanzar hasta la posición guardada
37 _callCount = state.CallCount;
38 }
39}El avance rápido es O(n) en número de llamadas. En la práctica, una partida completa genera miles de llamadas por stream, por lo que restaurar al cargar tarda milisegundos. Si los contadores crecen mucho, es posible cambiar a un generador con estado serializable: .NET 8 tiene Random.GetState()/SetState(), y mt19937 puede serializarse con sus operadores de stream.
11. Testear con seeds fijas
La mejor parte de esta arquitectura es que los tests se vuelven triviales de escribir y completamente estables. No hay inestabilidad: un test con una seed fija producirá el mismo resultado en CI, en cualquier máquina, para siempre.
1[Test]
2public void DungeonAlwaysHasAtLeastOneRoom()
3{
4 var factory = new RngFactory(seed: 42);
5 var generator = new DungeonGenerator(factory);
6 var config = DungeonConfig.Default;
7
8 var dungeon = generator.Generate(config);
9
10 Assert.IsTrue(dungeon.Rooms.Count >= 1);
11}
12
13[Test]
14public void SameSeedProducesSameDungeon()
15{
16 var config = DungeonConfig.Default;
17
18 var dungeonA = new DungeonGenerator(new RngFactory(99)).Generate(config);
19 var dungeonB = new DungeonGenerator(new RngFactory(99)).Generate(config);
20
21 Assert.AreEqual(dungeonA.Rooms.Count, dungeonB.Rooms.Count);
22 Assert.AreEqual(dungeonA.Layout, dungeonB.Layout);
23}
24
25[Test]
26public void DifferentSeedsProduceDifferentDungeons()
27{
28 var config = DungeonConfig.Default;
29
30 var dungeonA = new DungeonGenerator(new RngFactory(1)).Generate(config);
31 var dungeonB = new DungeonGenerator(new RngFactory(2)).Generate(config);
32
33 // No está garantizado, pero astronómicamente probable con buenas constantes
34 Assert.AreNotEqual(dungeonA.Layout, dungeonB.Layout);
35}Ejecutar estos tests no cuesta nada y garantiza el contrato fundamental: el determinismo. Si una refactorización rompe la reproducibilidad con seed, el test falla inmediatamente, y, lo más importante, antes de llegar a los jugadores.
Conclusiones
Las seeds y el RNG determinista no son una característica avanzada sino una decisión de diseño fundamental. Construirlos desde el principio cuesta casi nada. Añadirlos más tarde, cuando los sistemas ya dependen del estado global de random, es genuinamente doloroso.
- Los generadores pseudoaleatorios son deterministas: misma seed, misma secuencia.
- Se debe aíslar los streams de RNG por sistema para que cambios no relacionados no rompan la reproducibilidad.
- Registrar las seeds siempre es una buena práctica(aunque nunca se expongan a los jugadores).
- Una selección ponderada para loot y pools de enemigos permite mantener los pesos en datos, no en código.
- Rastrea rel conteo de llamadas para habilitar guardado/restauración a mitad de partida sin serializar los internos del generador.
- Usar seeds fijas en los tests para aserciones estables y significativas.
- Diseñar generadores con un número consistente de llamadas a RNG para mantener los streams predecibles.

Los roguelikes que hacen bien el seeding (Spelunky, Hades, Caves of Qud) se sienten justos aunque sean brutales. Parte de esa justicia viene de saber que el juego no hace trampas: la misma seed siempre construirá el mismo mundo, y ese mundo juega con reglas consistentes.
El determinismo no es lo opuesto a la aleatoriedad. Es lo que hace que la aleatoriedad sea de fiar.


