Procedural narrative: historias emergentes con sistemas
Dwarf Fortress y RimWorld no escriben historias: las simulan. Storylets, traits, eventos y causalidad para que el jugador construya anécdotas sin que tú escribas guion.
Publicado: · Por Juanjo "Banyo" López
0. El problema: no se puede escribir todo
Un guion lineal envejece a la segunda partida. Y el branching exponencial — ese árbol de diálogo que se ramifica en cada elección — explota antes de llegar al acto dos. Si tu juego tiene 10 decisiones binarias, son 1024 finales. Si tiene 20, son más de un millón. Nadie escribe un millón de finales.
Dwarf Fortress, RimWorld, Crusader Kings, Sunless Sea, Wildermyth y compañía resuelven esto al revés: en lugar de escribir las historias, escriben las piezas y dejan que el sistema las combine. El jugador termina contando anécdotas que el equipo de diseño nunca imaginó — “mi enano se volvió alcohólico porque su gato murió por un duende, y luego juró venganza contra los duendes” — y eso es justo el punto.
Antes de tocar la demo, una definición que vas a ver repetida porque es la columna vertebral del tutorial.
¿Qué es procedural narrative?
Procedural narrative no genera texto: genera situaciones causalmente conectadas. El sistema simula personajes con traits, dispara eventos según condiciones, y deja que el jugador interprete la historia. Tú escribes piezas; el sistema las combina.
1. Demo
2. Concepto: cómo se simula una historia
Esta sección es el 80% del tutorial. El código de Unity de la sección 4 es una consecuencia trivial de lo que entiendas aquí.
2.1 Storylets vs narrativa lineal
Un storylet es una pieza pequeña y autocontenida de narrativa con tres partes:
- Preconditions: bajo qué condiciones del mundo se puede disparar.
- Content: qué texto, escena o efecto narrativo presenta al jugador (normalmente una plantilla con slots).
- Consequences: qué cambia en el mundo cuando se ejecuta.
Es la unidad mínima de la narrativa procedural. Composable, escribible por separado, testeable. El diseñador escribe 200 storylets; el sistema decide cuál mostrar cuándo.
Tres secciones: preconditions, contenido con slots, y efectos sobre el mundo.
Compara eso con un árbol de diálogo: el árbol exige saber, en tiempo de escritura, todas las rutas. El storylet solo exige saber bajo qué condiciones tiene sentido. El sistema se encarga del resto.
2.2 ¿Qué hace que una historia se sienta emergente?
La respuesta corta: causalidad rastreable. Cuando el jugador termina la partida, puede contar la secuencia: “X pasó por Y, lo que llevó a Z”. Si no puede, la historia se sintió aleatoria, no emergente.
La aleatoriedad pura no produce narrativa. Si tiras 1d20 cada minuto y muestras un evento del manual, eso es un generador de no-secuencias. La emergencia aparece cuando un evento cambia el estado de modo que abre o cierra otros eventos: el gato muere → el personaje entra en duelo → un storylet de “consuelo” se vuelve disparable → otro personaje gana un trait de “amigo cercano” → meses después ese vínculo determina una traición.
La causalidad no es un adorno: es lo único que distingue “historia” de “lista de cosas que pasaron”.
2.3 Character traits y memoria
Cada personaje tiene traits persistentes que sesgan qué storylets le aplican y cómo. Pueden ser estáticos al nacer (brave, greedy, claustrophobic) o adquiridos por eventos (duelo, hates(NPC42), veterano_de_guerra).
Urist McGato — los traits son handles para preconditions; las qualities son números modulables.
Los traits no son adornos visuales: son handles para las preconditions de los storylets. “Si el personaje es brave y greedy, puede dispararse el storylet desafia_al_dragon_por_oro”. Sin trait sistematizado, no hay forma sistemática de seleccionar storylets.
2.4 Quality-based narrative (Sunless Sea / Failbetter)
Failbetter Games (Fallen London, Sunless Sea) llevó esto a una forma muy elegante llamada quality-based narrative. En su modelo, todo es un número — una “quality”:
terror = 4ammo = 12reputacion_con_los_piratas = 7conocimiento_de_la_serpiente_norte = 2
Las preconditions son thresholds: “este storylet se desbloquea cuando terror >= 5”. Las consequences son aritmética: “este storylet sube terror en 2 y baja ammo en 1”. Eso es todo.
La ventaja es brutal: cualquier estado del mundo se modela como una quality, y los storylets son tablas casi puras. Es escribible por diseñadores sin código y se presta a herramientas de autoría visuales.
2.5 ¿Cómo decidir qué storylet disparar?
En cualquier tick puedes tener decenas de storylets cuyas preconditions se cumplen. Elegir uno al azar entre los elegibles funciona, pero produce historias planas. La solución estándar es utility scoring sobre los candidatos — exactamente la misma idea de Utility AI, aplicada a narrativa.
Cada storylet tiene un weight base, y opcionalmente un score(world) que produce un número en [0, 1] según qué tan “relevante” es ahora. Multiplicas, ordenas y eliges entre los top-N con aleatoriedad ponderada.
score_final = peso_base * score_contextual * (1 - penalizacion_recencia)
La penalización por recencia es clave: si el storylet ya se disparó hace 10 ticks, su score se reduce para evitar repetición. Sin eso, el mismo evento aparece tres veces seguidas y la ilusión de historia se rompe.
2.6 Causalidad y memoria de eventos
Aquí está la diferencia entre “evento aleatorio” y “narrativa”. Cada personaje mantiene un event log persistente:
Cada evento cambia el estado (trait o quality) que habilita el siguiente. Eso es causalidad rastreable, no aleatoriedad.
Los storylets futuros pueden leer ese log en sus preconditions: “este storylet de venganza solo aplica si el personaje tiene un hates(X) reciente”. Eso es lo que convierte la lista de eventos en una historia: el evento de hoy es legible por el storylet de mañana.
En la práctica, no guardas el log completo en memoria por tick — lo resumes en traits y qualities cuando es relevante. Pero conceptualmente, el storylet de venganza “se acuerda” del robo.
2.7 ¿Cuándo procedural narrative falla?
Falla en dos extremos opuestos, y los dos son tentaciones de diseñador novato.
Demasiado genérico: si tus storylets dicen “alguien muere” sin enganchar con traits ni log de eventos, todo se siente impersonal. Da igual qué personaje, qué momento, qué historia previa: lo mismo. El jugador percibe rápido que el sistema “no le habla a él”.
Demasiado específico: si cada storylet exige condiciones precisas — “personaje de 32 años, paladín, con espada bendita, en martes” — casi nunca se dispara y no se reusa. Escribes 200 piezas para que el jugador vea 12.
El equilibrio: plantillas con slot fillers. El storylet dice "{A} mata a {B} con {arma}". El sistema elige A, B y arma según qué personajes están vivos, qué relación tienen, qué armas hay en juego. Una pieza, cien manifestaciones distintas.
Los slots [A], [B], [arma] se resuelven en runtime con candidatos válidos según preconditions.
2.8 El director narrativo
RimWorld le llama “storyteller” (Randy, Cassandra, Phoebe). Es la capa superior que decide cuándo el sistema necesita intervención para mantener el ritmo.
Si el simulador deja solo a los storylets, dos cosas pasan: o el ritmo se aplana (todo es rutina, no hay drama) o explota (cinco crisis simultáneas, el jugador se ahoga). El director monitoriza:
- Tiempo desde el último evento dramático: si supera un umbral, fuerza un storylet de la categoría “amenaza”.
- Densidad de eventos: si en los últimos N ticks pasaron demasiadas cosas, baja la probabilidad de disparar nuevos.
- Curva de dificultad: el director sube la intensidad con el tiempo, como un DJ.
El director no escribe la historia, solo regula el grifo. Sigue siendo el sistema de storylets quien produce el contenido. Pero sin esa capa, la curva dramática es plana o caótica.
3. Pseudocódigo
struct Storylet
id: StoryletId
preconditions: List<Predicate> # sobre world/personajes
content: Template # texto con slots
effects: List<Effect> # cambios a aplicar
weight: Float # prioridad base
cooldownTicks: Int # tras dispararse, no se repite
function selectStorylet(world, candidates: List<Storylet>) -> Storylet?
eligible = []
for s in candidates:
if not all(p.check(world) for p in s.preconditions): continue
if world.tick - lastFired(s) < s.cooldownTicks: continue
eligible.append(s)
if eligible.empty: return null
scored = []
for s in eligible:
score = s.weight * scoreContextual(s, world)
score *= 1 - recencyPenalty(s, world)
scored.append((s, score))
return weightedRandom(scored)
function applyStorylet(s: Storylet, world)
slots = resolveSlots(s, world)
text = fillTemplate(s.content, slots)
world.log.append(text, slots, world.tick)
for effect in s.effects:
effect.apply(world, slots)
world.markFired(s.id, world.tick)
function directorTick(world)
timeSinceDrama = world.tick - world.lastDramaTick
if timeSinceDrama > DRAMA_PRESSURE:
s = selectStorylet(world, world.dramaPool)
if s: applyStorylet(s, world)
else:
s = selectStorylet(world, world.normalPool)
if s: applyStorylet(s, world)
Tres funciones, una idea. Todo lo demás es ergonomía de autoría: cómo escribir storylets sin querer reescribir el motor cada vez.
4. Implementación en Unity / C#
Los storylets viven mejor como ScriptableObject: los diseñadores los crean en el inspector sin tocar código, los versionas con git, y la dependencia entre storylets queda explícita.
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(menuName = "Narrative/Storylet")]
public class Storylet : ScriptableObject {
public string id;
[TextArea] public string contentTemplate;
public List<Precondition> preconditions;
public List<Effect> effects;
public float weight = 1f;
public int cooldownTicks = 50;
public bool IsEligible(World w) {
foreach (var p in preconditions)
if (!p.Check(w)) return false;
return w.Tick - w.LastFired(id) >= cooldownTicks;
}
public float Score(World w) {
float recency = Mathf.Clamp01((w.Tick - w.LastFired(id)) / 200f);
return weight * recency;
}
}
public class NarrativeDirector : MonoBehaviour {
public List<Storylet> storylets;
public World world;
public int dramaPressureTicks = 300;
void Update() {
if (Time.frameCount % 30 != 0) return; // tick cada 0.5s aprox
world.Tick++;
TryFireStorylet();
}
void TryFireStorylet() {
var eligible = new List<(Storylet s, float score)>();
foreach (var s in storylets) {
if (!s.IsEligible(world)) continue;
float score = s.Score(world);
if (world.Tick - world.LastDramaTick > dramaPressureTicks && s.IsDrama)
score *= 2f; // el director empuja drama si pasa mucho tiempo
eligible.Add((s, score));
}
if (eligible.Count == 0) return;
var chosen = WeightedPick(eligible);
Apply(chosen);
}
void Apply(Storylet s) {
var slots = SlotResolver.Resolve(s, world);
string text = TemplateFiller.Fill(s.contentTemplate, slots);
world.Log.Add(new Entry(text, world.Tick));
foreach (var e in s.effects) e.Apply(world, slots);
world.MarkFired(s.id, world.Tick);
}
}
Precondition y Effect son también ScriptableObject abstractos: HasTraitPrecondition, QualityThresholdPrecondition, ChangeQualityEffect, AddTraitEffect. Cada uno con su pequeña UI en el inspector. Así los diseñadores arman storylets arrastrando bloques.
5. En otros engines
- Godot: usa
Resourcecomo equivalente aScriptableObject. La claseStorylet extends Resourcecon@exportpara las preconditions y effects funciona igual. El director es unNodecon_physics_processo un timer. - Unreal:
UDataAssetcubre el rol deScriptableObjectcasi 1:1. Para autores no-programadores, expón los storylets como Data Tables si la estructura es uniforme, o comoUPrimaryDataAssetsi hay polimorfismo. - Web / JS / Twine / Ink: si tu proyecto es texto puro, Ink (de Inkle) ya implementa la mayoría de esto out-of-the-box: choices, knots, qualities, branching. No reinventes la rueda si tu juego es narrativo-primero.
6. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
Tu juego siempre genera las mismas 3 historias en orden distinto. ¿Qué le falta al selector de storylets?
Un storylet referencia a un personaje que ya murió hace 80 ticks. ¿Quién debería filtrar ese caso?
¿Por qué un sistema de storylets escala mejor que un árbol de diálogo gigante?
Quieres que tu historia emergente tenga ritmo (calma, tensión, clímax, respiro). ¿Cómo lo logras sin escribir el guion?
7. Siguientes pasos
La narrativa procedural es donde Utility AI deja de ser solo “decisión de NPCs” y se vuelve “decisión de qué historia contar”. Si quieres ir más profundo en la planificación de secuencias narrativas — no solo elegir el próximo evento sino encadenar acciones hacia un objetivo dramático — GOAP es el siguiente escalón natural.