Capítulo 21C. PCG: generación procedural de contenido

CAP 21C · Bevy 0.18/0.19
"Si el jugador puede predecir tu dungeon, no es procedural. Es un laberinto con ruedas de entrenamiento."

— Anónimo, postmortem de un roguelike, 2019.

21C.1 Qué es PCG y por qué te importa

La generación procedural de contenido (PCG, por sus siglas en inglés) es el arte de hacer que tu juego cree su propio contenido: dungeons, mapas, nombres, items, quests, música. En un roguelike, cada run es diferente porque el mundo se genera en tiempo de carga. En un RPG, los nombres de los NPCs vienen de un sistema que combina sílabas. En un endless runner, los obstáculos se generan en bloques pre-armados.

Para un juego indie 2D con Bevy, PCG resuelve tres problemas reales:

  1. Rejugabilidad: si tu juego tiene 10 dungeons hechos a mano, el jugador se los aprende en 5 horas. Si tiene 10.000 dungeons procedurales, el jugador no se los aprende nunca.
  2. Tamaño del equipo: un dungeon de 50 salas hecho a mano son 50 archivos JSON. Un dungeon de 50 salas procedural son 100 líneas de código.
  3. Contenido dinámico: si tu juego se actualiza cada semana, generar 10 dungeons nuevos a mano es trabajo. Generarlos proceduralmente es 0.
Tres niveles de PCG en juegos 2D:
- Nivel 1: nombre de NPCs, items, pueblos (texto).
- Nivel 2: mapas, dungeons, niveles (geometría).
- Nivel 3: comportamiento, quests, música (reglas).
Este capítulo cubre los dos primeros. El tercero es un libro aparte.

21C.2 El PRNG seeded: la base de todo

Para que tu generación sea reproducible (misma seed → mismo dungeon), necesitás un PRNG (Pseudo-Random Number Generator) que se pueda sembrar. El crate bevy_rand (junto con su compañero bevy_prng, que aporta los tipos de algoritmo como ChaCha8Rng y WyRand) es la opción Bevy-nativa: expone PRNGs como recursos/componentes ECS con reflection y serialización gratis, y (clave para PCG) soporta seeding determinista y forking de RNGs desde una fuente global.

# cap-21C — sección 21C.2: Cargo.toml. Versión 0.15.0 para Bevy 0.19 (junio 2026).
[dependencies]
bevy = "0.19"
bevy_rand = { version = "0.15", features = ["wyrand", "chacha20"] }
bevy_prng = { version = "0.15", features = ["wyrand", "chacha20"] }
rand_core = "0.10"
//! cap-21C — sección 21C.2: PRNG seeded con bevy_rand 0.15.
use bevy::prelude::*;
use bevy_prng::WyRand;
use bevy_rand::prelude::{EntropyPlugin, GlobalEntropy};
use rand_core::RngCore;

#[derive(Resource)]
struct DungeonSeed(pub u64);

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        // Registramos el PRNG. `EntropyPlugin::::default()` crea
        // la entidad global marcada con `GlobalRng` y el recurso `GlobalEntropy<WyRand>`.
        .add_plugins(EntropyPlugin::<WyRand>::default())
        .insert_resource(DungeonSeed(0xDEAD_BEEF_CAFE_BABE))
        .add_systems(Startup, generate_dungeon)
        .run();
}

fn generate_dungeon(mut global_rng: GlobalEntropy<WyRand>) {
    // `GlobalEntropy<T>` es un SystemParam de solo escritura (mutable) que da
    // acceso al PRNG global. `next_u32()` viene del trait `rand_core::RngCore`.
    let r = global_rng.next_u32();
    // Para seeding determinista por dungeon, fork al PRNG global con una seed fija
    // y trabajá a partir de ese fork. (El forking preserva la cadena determinista.)
    // <!-- TODO: verificar — la firma exacta de `fork`/`fork_rng` cambia entre versiones;
    // consultá docs.rs/bevy_rand/0.15.0/bevy_rand/ para tu caso. -->
    println!("Dungeon empieza con u32={r}");
}

Sin seed fija, el dungeon es distinto cada run. Con seed fija, el jugador puede compartir su seed y otro jugador juega el mismo dungeon. Esa simple capacidad es oro para un roguelike. En bevy_rand la cadena determinista se preserva haciendo fork del PRNG global (en vez de leer thread_rng) y re-sembrando cada fork con la seed del nivel.

Truco: derivá la seed del nivel de algo determinista (un hash del nombre del jugador, del día, etc.). El jugador siente que tiene control ("mi dungeon de hoy") pero vos no tenés que diseñarlo.

21C.3 Generación de nombres: la prueba de fuego del "para torpes"

Empezar con nombres es la forma más rápida de ver resultados PCG. La idea: combinar sílabas de listas pre-armadas.

//! cap-21C — sección 21C.3: generación de nombres.
// En bevy_rand 0.15, lo idiomático es usar `Entropy<T>` por entidad o
// `GlobalEntropy<T>` global. Aquí usamos `GlobalEntropy<WyRand>` por simplicidad.
use bevy_prng::WyRand;
use bevy_rand::prelude::GlobalEntropy;
use rand_core::RngCore;

const PREFIJOS: &[&str] = &[
    "Bal", "Tor", "Kra", "Mor", "Eld", "Fae", "Vex", "Zar", "Dre", "Syl",
];
const SUFIJOS: &[&str] = &[
    "dor", "wyn", "mir", "gorn", "iel", "ax", "or", "is", "en", "ius",
];

fn generate_name(mut rng: GlobalEntropy<WyRand>) -> String {
    // `next_u32` da un entero; módulo el largo para indexar la lista.
    let pre = PREFIJOS[(rng.next_u32() as usize) % PREFIJOS.len()];
    let suf = SUFIJOS[(rng.next_u32() as usize) % SUFIJOS.len()];
    format!("{}{}", pre, suf)
}

// 10 nombres: "Baldor", "Torwyn", "Kramir", "Morgorn", "Eldiel", ...
// <!-- TODO: verificar — para muestras más ricas (shuffle, gen_range, choice),
// habilitá el trait `rand::Rng` (crate `rand` 0.10) sobre el PRNG de bevy_rand.
// La firma exacta de `Entropy::fork` para obtener un sub-RNG mutable varía entre versiones. -->

Para nombres más creíbles, usá Markov chains sobre una lista de nombres reales (extraída de, digamos, tolkien.com). O usá un crate como names (que tiene listas de nombres por cultura).

21C.4 Dungeons con autómatas celulares

Para un dungeon 2D "estilo cave", el algoritmo clásico es un autómata celular:

Reglas:
- Empezá con un grid de 30x30, cada celda es "muros" o "suelo" con 45% de probabilidad.
- Iterá 5 veces:
  - Si una celda es "muros" y tiene 4+ vecinos "muros", sigue siendo "muros".
  - Si una celda es "muros" y tiene 3-4 vecinos "muros", se queda.
  - Si una celda es "suelo" y tiene 5+ vecinos "muros", se convierte en "muros".
- Al final, tenés cuevas orgánicas.
//! cap-21C — sección 21C.4: autómata celular para cuevas.
use bevy_prng::WyRand;
use bevy_rand::prelude::GlobalEntropy;
use rand_core::RngCore;

const WIDTH: usize = 60;
const HEIGHT: usize = 40;
const ITERATIONS: u32 = 5;

#[derive(Resource)]
struct Cave {
    pub tiles: Vec<bool>,  // true = muro, false = suelo.
}

impl Cave {
    // `rng` aquí es el PRNG crudo (trait `RngCore`). En bevy_rand 0.15 se obtiene
    // pasando el SystemParam `GlobalEntropy<T>` por referencia `&mut`.
    fn new(rng: &mut impl RngCore) -> Self {
        let mut tiles = vec![true; WIDTH * HEIGHT];
        for t in tiles.iter_mut() {
            // `next_u32()` en [0, u32::MAX]; lo normalizamos a [0,1) y comparamos con 0.45.
            let r = rng.next_u32() as f32 / u32::MAX as f32;
            *t = r < 0.45;  // 45% muros.
        }
        Self { tiles }
    }

    fn step(&mut self) {
        let prev = self.tiles.clone();
        for y in 0..HEIGHT {
            for x in 0..WIDTH {
                let idx = y * WIDTH + x;
                let wall_neighbors = count_wall_neighbors(&prev, x, y);
                let is_wall = prev[idx];
                self.tiles[idx] = match (is_wall, wall_neighbors) {
                    (true, n) if n >= 4 => true,   // sigue siendo muro.
                    (true, 3)            => true,   // estable.
                    (false, n) if n >= 5 => true,   // suelo se vuelve muro.
                    _ => false,
                };
            }
        }
    }
}

fn count_wall_neighbors(tiles: &[bool], x: usize, y: usize) -> u32 {
    let mut count = 0;
    for dy in -1i32..=1 {
        for dx in -1i32..=1 {
            if dx == 0 && dy == 0 { continue; }
            let nx = (x as i32 + dx).rem_euclid(WIDTH as i32) as usize;
            let ny = (y as i32 + dy).rem_euclid(HEIGHT as i32) as usize;
            if tiles[ny * WIDTH + nx] { count += 1; }
        }
    }
    count
}

fn generate_cave(mut commands: Commands, mut rng: GlobalEntropy<WyRand>) {
    // `GlobalEntropy<WyRand>` derefs a `&mut WyRand`, que implementa `RngCore`.
    let mut cave = Cave::new(&mut *rng);
    for _ in 0..ITERATIONS { cave.step(); }
    commands.insert_resource(cave);
    // Spawnear tiles en el mundo Bevy a partir de `cave.tiles`.
    // (cada true = muro, false = suelo).
}

Truco: para el caso 2D, el autómata celular da cuevas orgánicas. Para dungeons con salas rectangulares (más al estilo Zelda), usá BSP (Binary Space Partition) en su lugar.

21C.5 Dungeons con BSP: salas rectangulares

El algoritmo BSP (Binary Space Partition) divide recursivamente el espacio en dos, luego crea salas en cada hoja:

//! cap-21C — sección 21C.5: BSP para dungeons.
use bevy_rand::prelude::*;
use rand::Rng;

#[derive(Debug, Clone, Copy)]
struct Rect { x: i32, y: i32, w: i32, h: i32 }

#[derive(Debug)]
enum Node {
    Leaf(Rect),
    Split { left: Box<Node>, right: Box<Node> },
}

fn split_rect(r: Rect, rng: &mut GlobalRng) -> Node {
    // Si el rect es muy chico, devolver como hoja.
    if r.w < 10 || r.h < 10 {
        return Node::Leaf(r);
    }
    // Decidir split horizontal o vertical según la forma.
    let split_horizontal = if r.w > r.h { false } else { true };
    let cut = if split_horizontal {
        let min = 4;
        let max = r.h - 4;
        if max <= min { return Node::Leaf(r); }
        rng.gen_range(min..max)
    } else {
        let min = 4;
        let max = r.w - 4;
        if max <= min { return Node::Leaf(r); }
        rng.gen_range(min..max)
    };
    let (r1, r2) = if split_horizontal {
        (Rect { x: r.x, y: r.y, w: r.w, h: cut },
         Rect { x: r.x, y: r.y + cut, w: r.w, h: r.h - cut })
    } else {
        (Rect { x: r.x, y: r.y, w: cut, h: r.h },
         Rect { x: r.x + cut, y: r.y, w: r.w - cut, h: r.h })
    };
    Node::Split {
        left: Box::new(split_rect(r1, rng)),
        right: Box::new(split_rect(r2, rng)),
    }
}

fn collect_leaves(node: &Node, out: &mut Vec<Rect>) {
    match node {
        Node::Leaf(r) => out.push(*r),
        Node::Split { left, right } => {
            collect_leaves(left, out);
            collect_leaves(right, out);
        }
    }
}

El resultado: una jerarquía de salas. Para conectar las salas con pasillos, agarrá el árbol y agregá un pasillo en cada split (conectando el centro de la sala izquierda con el centro de la sala derecha). El resultado es un dungeon de salas rectangulares conectadas, que es la estética clásica de un roguelike.

21C.6 Wave Function Collapse: el "más allá"

WFC es un algoritmo más avanzado: a partir de un set de "tiles" con reglas de adyacencia, genera un mapa que satisface las reglas. Es lo que usa Bad North (estética voxel) y Townscaper (generación de ciudades). Para 2D, sirve para "generar una aldea coherente", "generar un archipiélago de islas con biomas", "generar un nivel de plataforma con bloques que encajan".

WFC es mucho más complejo que un autómata celular. Para tu primer juego, no lo necesitás. Si te interesa, mirá el crate wave_function_collapse (Rust puro) o la librería de Maxim Gumin (la referencia canónica).

Truco para empezar: un "WFC simplificado" es tomar un set de 4-5 tiles (pasto, agua, montaña, bosque) y colocarlos al azar con la restricción de que el "pasto" no puede estar adyacente a "montaña" más de dos veces seguidas. Es WFC del pobre y funciona para muchos juegos.

21C.7 Generación de items y stats

Para un RPG o roguelike, generar items con stats aleatorios es una de las PCG más comunes. La idea: un set de "bases" (espada, arco, capa) y un set de "modificadores" (de fuego, +10 daño, raro). Combinar.

//! cap-21C — sección 21C.7: items procedurales.
use bevy_rand::prelude::*;
use rand::seq::SliceRandom;

#[derive(Debug, Clone, Copy)]
enum ItemBase { Sword, Bow, Staff, Dagger, Shield }

#[derive(Debug, Clone, Copy)]
enum ItemMod { OfFire, OfIce, OfLightning, OfHealing, OfSpeed, OfVampire }

#[derive(Debug, Clone, Copy)]
enum Rarity { Common, Uncommon, Rare, Epic, Legendary }

#[derive(Debug)]
struct Item {
    name: String,
    base: ItemBase,
    mods: Vec<ItemMod>,
    rarity: Rarity,
}

fn generate_item(mut rng: ResMut<GlobalRng>, level: u32) -> Item {
    let bases = [ItemBase::Sword, ItemBase::Bow, ItemBase::Staff, ItemBase::Dagger, ItemBase::Shield];
    let mods = [ItemMod::OfFire, ItemMod::OfIce, ItemMod::OfLightning, ItemMod::OfHealing, ItemMod::OfSpeed, ItemMod::OfVampire];
    let rarities = [Rarity::Common; 50]
        .into_iter().chain([Rarity::Uncommon; 30])
        .chain([Rarity::Rare; 15])
        .chain([Rarity::Epic; 4])
        .chain([Rarity::Legendary; 1])
        .collect::<Vec<_>>();
    let base = *bases.choose(&mut rng.rng()).unwrap();
    let rarity = *rarities.choose(&mut rng.rng()).unwrap();
    let num_mods = match rarity {
        Rarity::Common => 0,
        Rarity::Uncommon => 1,
        Rarity::Rare => 2,
        Rarity::Epic => 3,
        Rarity::Legendary => 4,
    };
    let mut chosen_mods: Vec<ItemMod> = (0..num_mods)
        .map(|_| *mods.choose(&mut rng.rng()).unwrap())
        .collect();
    chosen_mods.dedup();
    let name = format!("{:?} {:?} of {:?}", base, rarity, chosen_mods.first().copied().unwrap_or(ItemMod::OfHealing));
    Item { name, base, mods: chosen_mods, rarity }
}

Tres trucos:

  1. Distribución de rareza no uniforme: el 50% son comunes, el 30% uncommon, etc. La mayoría de drops son aburridos; los legendarios son especiales.
  2. Items sin modificador son válidos: un "Sword of Nothing" sigue siendo un sword, solo que sin bono.
  3. El nivel del jugador afecta los stats: a mayor nivel, mayor probabilidad de items raros. Esto lo pasás como parámetro a generate_item.

21C.8 Glosario del capítulo

PCG: Procedural Content Generation. Generación de contenido por algoritmo, no a mano.
PRNG: Pseudo-Random Number Generator. Función que produce números "aleatorios" deterministas si tenés la seed.
Seed: valor inicial del PRNG. Misma seed → mismo output.
Reseed: cambiar la seed de un PRNG. Útil entre dungeons para que sean independientes.
Reproducible: propiedad de que el mismo input produce el mismo output. Imprescindible para PCG.
Markov chain: modelo probabilístico donde la probabilidad del siguiente estado depende solo del estado actual. Útil para nombres.
BSP (Binary Space Partition): algoritmo de dividir recursivamente un espacio en dos. Para dungeons.
Autómata celular: modelo donde cada celda cambia según sus vecinos. Para cuevas orgánicas.
Wave Function Collapse: algoritmo más complejo que genera mapas con reglas de adyacencia. Estado del arte en PCG.
Rarity (rareza): distribución no uniforme de items por nivel de poder. Común, Uncommon, Rare, Epic, Legendary.
Modificador: atributo extra que se aplica a un item base. "Espada de Fuego" = base Sword + mod OfFire.
Tabla de loot: lista de items posibles con sus probabilidades. Lo que se "drop" en cada evento.
Hashing determinista: función que produce el mismo output para el mismo input, sin randomness. Sirve para "nombre del jugador" → "seed del dungeon".
bevy_rand: crate de Bevy para PRNG integrado con el sistema de recursos.

21C.9 Trivia histórica

Rogue (1985) — el juego que inventó el "roguelike" y la PCG de dungeons. El desarrollador, Glenn Wichman, dice en entrevistas que el dungeon se generaba porque "no teníamos tiempo de hacer niveles a mano". Hoy, "Roguelike" es un género entero y "procedural" es un bullet point en Steam.
Spelunky (Derek Yu, 2008) — llevó la PCG al platformer 2D. Genera 16 "caves" que cambian cada día para todos los jugadores. Si tu seed es la misma, el mismo nivel. Inspiró a Hades, Dead Cells, Enter the Gungeon, Risk of Rain, ...
Minecraft (Markus Persson, 2011) — la PCG de mundos 3D que popularizó todo un género. Algoritmo basado en Perlin noise. "Infinito" porque el algoritmo se aplica en chunks.
No Man's Sky (Hello Games, 2016) — la PCG de universos enteros. 18 quintillones de planetas. Cada planeta generado por una seed.
Brogue (Brian Walker, 2010) — roguelike con dungeon generation "estética". Las salas y corredores se ven bien, no solo se generan. Es el ejemplo de "PCG con criterio".
Spore (Maxis, 2008) — PCG de criaturas. La criatura que ves es generada por un algoritmo que combina partes. Fue un fracaso comercial pero técnicamente impresionante.
Wave Function Collapse (Maxim Gumin, 2016) — el paper/lib que llevó la PCG a un nuevo nivel. Referencia canónica.
Bevy + bevy_rand (2023) — la integración oficial de PRNG con Bevy. Antes había que usar el crate `rand` a mano.
Procedural Content Generation Wiki (Mark R. Johnson, 2010) — la wiki académica de PCG. Tiene papers, algoritmos, y ejemplos para juegos 2D.

21C.10 Metedura de pata

❌ Usar `rand::thread_rng()` (PRNG no seeded) en tu PCG.
✅ `bevy_rand` con `reseed` antes de cada generación.
💡 Por qué: sin seed, no podés reproducir un bug. Sin seed, dos jugadores con el mismo nombre de save tendrían dungeons distintos. Sin seed, "mi seed" no es compartible.
❌ Generar el dungeon en el frame cero y no regenerarlo.
✅ Generar on-demand (al entrar al nivel) y guardar el resultado.
💡 Por qué: generar en el frame cero bloquea el inicio del juego. Generar on-demand aprovecha el tiempo de carga del nivel.
❌ Hardcodear los items buenos en la tabla de loot.
✅ Items buenos como resultado de la generación procedural; el jugador los descubre.
💡 Por qué: si hardcodeás, los jugadores los aprenden y la rejugabilidad muere. Si son procedurales, hay expectativa de "el próximo drop puede ser increíble".
❌ Hacer un dungeon de 500x500 tiles procedurales "para que sea grande".
✅ Dungeons de 60x40 tiles, y agregar "más pisos" en vez de más ancho.
💡 Por qué: a mayor mapa, más tiempo de generación, más memoria. 60x40 tiles = 2400 celdas = 5ms de generación. 500x500 = 250.000 celdas = 500ms. La diferencia se nota.

🎯 21C.11 Patrón del capítulo: "seed-driven design"

Problema: tu juego tiene niveles procedurales, pero los testers no pueden reproducir bugs. "Vi un enemigo imposible en un dungeon", ¿cómo lo reproduzco?

Solución: la seed es el bug report. Cuando un tester reporta "dungeon raro", le pedís la seed. Con esa seed, podés regenerar el dungeon exacto. La regla:

Cada nivel tiene un seed. Cada save tiene un seed. Cada bug report incluye el seed.

Cuándo sí:

Cuándo no:

Lo que vimos

En el siguiente

Capítulo 22: el cap 22 original cubrió Pathfinding A y navegación. Ahora vamos a entrar a un tema relacionado pero distinto: pathfinding con flow fields (más eficiente para "muchos enemigos persiguiendo al player") y cómo el pathfinding se integra con la IA (caps 21) y la física (cap 17). Spoiler: si tu juego tiene 500 enemigos en pantalla, A por enemigo se te cae; flow fields te salvan.