Pipeline / IA generativa Intermedio 19 min de lectura

Quests dinámicas con LLM: templates + variabilidad

Templates de quest con slots (give-item, kill-target, escort). El LLM rellena con narrativa contextual. Validación mecánica para que la quest sea siempre solvable.

Publicado: · Por Juanjo "Banyo" López

0. Introducción

El sistema de Radiant Quests de Skyrim era impresionante para 2011: el juego te generaba misiones secundarias sin fin, con NPCs reales, ubicaciones reales, recompensas escaladas. Y a las veinte quests el jugador notaba el patrón. Mismo wording, misma estructura, mismo “trae cinco {item} a {npc}. La maquinaria asomaba.

El cuello no era la mecánica — la mecánica funcionaba perfecto. El cuello era la envoltura narrativa: 30 plantillas de texto rotando hasta el infinito. Los LLMs resuelven justo eso, y solo eso. La mecánica de la quest sale de templates editados por diseñadores; el LLM solo escribe por qué la mecánica importa en esta partida concreta.

Este tutorial es sobre cómo separar las dos capas y por qué la separación no es opcional. Si dejas que el LLM diseñe la mecánica, te va a pedir “trae cincuenta dragones” en un juego sin dragones. Si dejas que el diseñador escriba toda la narrativa, vuelves a Skyrim 2011.

En este tutorial vas a ver:

  • Por qué los templates definen mecánica y el LLM solo escribe flavor.
  • El pipeline completo: snapshot del mundo → template → slots → LLM → validación → quest objeto.
  • Cómo validar que la quest sea siempre solvable antes de mostrársela al jugador.

1. Demo

Quest dinámica: template + LLM + validación Snapshot del mundo entra al pipeline, sale una quest mecánicamente válida con narrativa única. Mismo template, dos quest giver, dos historias.

2. Concepto y arquitectura

Quests dinámicas con LLM funcionan bajo el principio de separación: la mecánica de la quest (qué hace el jugador) sale de templates editados por diseñadores. El LLM solo escribe el texto narrativo (qué cuenta el NPC, por qué). Así la quest siempre funciona; solo el flavor varía.

2.1 ¿Por qué NO dejar que el LLM diseñe la mecánica?

Porque el LLM no tiene acceso a tu game state. Le pides una quest y te puede pedir “mata al dragón Aerion en las Montañas del Norte” en un juego que no tiene dragones, ni montañas, ni un NPC llamado Aerion. El modelo está entrenado en miles de juegos de fantasía: por defecto inventa contenido coherente con el género, no con tu mundo.

Esto se conoce como alucinación, y en un sistema de quests es letal: una quest que pide algo imposible no es un bug visual, es una quest no completable. El jugador la acepta, vagabundea buscando dragones, y se va del juego.

La regla dura: la mecánica sale de estructuras conocidas por el engine (templates con slots que apuntan a entidades reales). El LLM solo describe lo que esas estructuras ya garantizan que existe.

2.2 Templates de quest: la unidad mínima

Un template define una mecánica abstracta y los slots que necesita rellenar. Cuatro templates cubren el 90% de las quests secundarias que ha tenido un MMO en su historia:

TemplateMecánicaSlot narrativo
FetchTrae N items de tipo T a NPC X”Por qué X quiere esos items”
KillMata N enemigos de tipo T”Qué hicieron, por qué los quieren muertos”
EscortEscolta a NPC X de A a B”Quién es X, por qué viaja, qué teme”
DeliverLleva item I de A a B”Qué es I, por qué importa la entrega”
Template FETCH: mecánica vs narrativa
Template: FETCHMECÁNICA (engine)quest_giver : NpcIditem_type : ItemIdquantity : Intreward : RewardNARRATIVA (LLM)intro_dialogdescriptioncompletion¿por qué, quién, lore?quest final

Mismo template, dos columnas: la izquierda (engine) define slots que apuntan a entidades reales; la derecha (LLM) rellena el texto narrativo. Las dos se combinan en la quest final.

El diseñador escribe los cuatro templates una vez. El sistema genera cientos de quests combinando slots con NPCs y items reales del juego. El LLM le pone alma a cada una sin tocar la mecánica.

2.3 Slot fillers desde game state

Esta es la parte que evita las alucinaciones. Cuando el sistema decide instanciar un template Fetch, no le pregunta al LLM por el NPC ni por los items. Los elige él de listas que sabe que existen:

  • quest_giver = uno de los NPCs vivos cercanos al jugador con flag canGiveQuests.
  • item_type = uno de los items que aparecen en loot tables del bioma actual.
  • quantity = un número en un rango definido por el template (3–10 para fetch).
  • reward = generado por la economía del juego según el effort estimado.

Los slots quedan resueltos con entidades que existen y están disponibles. Solo después el sistema invita al LLM al pipeline, y le pasa los slots ya rellenos como contexto fijo.

2.4 Pipeline de generación: paso a paso

Pipeline de generación de quest
game state snapshotNPCs, items, lore, playerpick templatetemplate seleccionadoFetch, Kill, Escort, Deliverfill slotsslots resueltosnpc=Maerwyn, item=hierbabuild promptprompt LLMslots + lore + tonecall LLMnarrativa generadaintro, description, endvalidadorsolvable? path? reward?ok: persistquest activafail: discardretry (max N)passfail

Game state → template → slots mecánicos → prompt LLM → narrativa → validador. El validador bifurca: pasa y persiste, falla y reintenta con otro template.

Cada paso es independiente y testeable. Si el LLM falla (timeout, contenido extraño), descartas la narrativa y reintentas con la misma mecánica. Si la validación falla, descartas la quest entera y eliges otro template.

2.5 Validación mecánica obligatoria

Antes de mostrar la quest al jugador, el sistema verifica que es realmente completable. No es paranoia: es la diferencia entre un sistema productivo y un generador de quests rotas.

Checklist mínima:

  • El target existe y está accesible. El NPC sigue vivo, no está en una zona bloqueada por progresión, no acaba de irse del mapa.
  • Los items existen o son spawnables. Si la quest pide hierba_de_luna, el sistema verifica que aparece en al menos una loot table accesible al jugador en su nivel actual.
  • El path desde start a goal es navegable. Un BFS o A* rápido sobre el grafo de zonas confirma que el jugador puede físicamente llegar al objetivo.
  • La recompensa es razonable para el effort. Una fetch de tres hierbas no paga 1000 de oro. Una escort de dos zonas no paga 50.

Si algo falla, la quest no se muestra. Punto. Mejor mostrar una quest menos sofisticada que una imposible.

2.6 ¿Cuándo regenerar una quest activa?

Cuando deja de ser válida. El jugador acepta una quest “mata a Goblin Jefe Vrok”, pero antes de cumplirla, Vrok muere por una explosión scripteada en otra misión. La quest queda zombie: el jugador la tiene en su log y no puede completarla.

Dos opciones, una correcta:

  • Mal: silenciosamente cambiar el target a otro goblin. El jugador no entiende qué pasa, lee “mata a Vrok” en el log y nunca lo encuentra.
  • Bien: marcar la quest como expired con un mensaje narrativo (“Vrok ha caído en otra disputa; tu encargo carece de sentido”), reembolsar al jugador si aplica, y generar una nueva quest con un quest giver nuevo.

Nunca modifiques la quest en curso. Las quests son contratos: si cambian a mitad, el jugador pierde confianza en el sistema.

2.7 Narrativa coherente con el lore

El LLM puro inventa reinos, dioses, gremios y ciudades que no existen en tu juego. Sin contexto, “explícame por qué el mercader quiere estas hierbas” produce “el barón Eldric de Vellmoore las necesita para su laboratorio en la ciudadela de Krendor” — todo nombres inventados.

La solución estándar es RAG ligero: extraer 3–5 fragmentos de lore relevantes (la región del NPC, su gremio, el item involucrado) y meterlos en el system prompt antes de cada generación. El LLM se ancla en ese contexto en vez de inventar.

System prompt:
"Eres el narrador de [JUEGO]. Genera la narrativa de una quest
respetando este lore:

- La región de Veldria está en guerra con los nórdicos desde
  el invierno pasado.
- La hierba de luna se cosecha solo en las marismas del sur.
- Maerwyn es un mercader nómada, cínico, perdió a su hijo en
  la guerra.

Quest a narrar:
- Template: FETCH
- Quest giver: Maerwyn
- Item: hierba de luna
- Cantidad: 5
- Reward: 80 oro

Devuelve JSON con campos: intro_dialog, description, completion."

El LLM ya no inventa: combina los hechos que le diste. Maerwyn pedirá la hierba de luna por un motivo coherente con el lore que tú escribiste.

2.8 Rewards: mecánica > narrativa

El LLM puede sugerir rewards en el texto: “te daré 100 monedas de oro y mi anillo familiar”. El sistema decide el reward real. Siempre.

Si no separas, el LLM va a prometer una espada legendaria por traer tres hierbas y el jugador la va a esperar. Cuando aparezcan 80 de oro y un consumible común, el jugador se siente estafado por el juego, no por el NPC.

Patrón correcto: el LLM recibe el reward ya calculado como input (“recompensa: 80 oro, 1 poción menor”) y debe narrar eso exactamente. El system prompt lo deja claro: “menciona la recompensa exacta tal como te la pasamos, no inventes objetos adicionales”.

3. Pseudocódigo

class QuestTemplate
    type:        TemplateType
    weight:      Float
    fillSlots(world: WorldState) -> Dict<String, Object>
    promptForNarrative(slots: Dict, lore: List<String>) -> String

function generateQuest(world: WorldState, llm: LlmClient,
                       templates: List<QuestTemplate>) -> Quest?
    for attempt in 1..MAX_ATTEMPTS:
        tmpl   = pickWeighted(templates, world)
        slots  = tmpl.fillSlots(world)
        if slots == null: continue        # no hay entidades disponibles

        lore   = world.relevantLore(slots)
        prompt = tmpl.promptForNarrative(slots, lore)
        narrative = llm.generate(prompt)
        if not narrative.parsedOk: continue

        quest = buildQuest(tmpl, slots, narrative)
        if validate(quest, world): return quest
    return null

function validate(quest: Quest, world: WorldState) -> Bool
    if not world.npcExists(quest.target):       return false
    if not world.itemsAvailable(quest.items):   return false
    if not world.isReachable(quest.start,
                              quest.targetLoc): return false
    if not rewardInRange(quest.reward,
                          quest.estimatedEffort): return false
    return true

Tres ideas: los slots se rellenan antes de hablar con el LLM, la narrativa nunca decide mecánica, y la validación corre sobre la quest construida — no sobre el prompt. Si la quest falla validación, se descarta entera y se reintenta.

4. Implementación en Unity / C#

Patrón base: QuestTemplate como ScriptableObject (los diseñadores los crean en el inspector), Quest como POCO serializable, y un QuestGenerator que orquesta el pipeline.

using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;

public enum TemplateType { Fetch, Kill, Escort, Deliver }

[CreateAssetMenu(menuName = "Quests/Template")]
public class QuestTemplate : ScriptableObject {
    public TemplateType type;
    public float weight = 1f;
    public Vector2Int quantityRange = new(3, 8);
    [TextArea(3, 8)] public string promptTemplate;

    public Dictionary<string, object> FillSlots(WorldState world) {
        var slots = new Dictionary<string, object>();
        switch (type) {
            case TemplateType.Fetch:
                var giver = world.PickRandomNpc(npc => npc.canGiveQuests);
                var item  = world.PickItemFromBiome(world.playerBiome);
                if (giver == null || item == null) return null;
                slots["quest_giver"] = giver;
                slots["item_type"]   = item;
                slots["quantity"]    = Random.Range(quantityRange.x, quantityRange.y);
                slots["reward"]      = world.economy.RewardForFetch(item, (int)slots["quantity"]);
                break;
            // Kill, Escort, Deliver: misma idea con slots distintos
        }
        return slots;
    }
}

public class QuestGenerator : MonoBehaviour {
    public List<QuestTemplate> templates;
    public LlmClient llm;
    public WorldState world;
    const int MaxAttempts = 3;

    public async Task<Quest> GenerateAsync() {
        for (int i = 0; i < MaxAttempts; i++) {
            var tmpl  = WeightedPick(templates);
            var slots = tmpl.FillSlots(world);
            if (slots == null) continue;

            var lore     = world.RelevantLore(slots);
            var prompt   = BuildPrompt(tmpl, slots, lore);
            var response = await llm.GenerateJsonAsync(prompt);
            if (!response.ok) continue;

            var quest = BuildQuest(tmpl, slots, response.narrative);
            if (Validator.Check(quest, world)) return quest;
        }
        return null;
    }

    static bool ValidatorCheck(Quest q, WorldState w) =>
        w.NpcExists(q.targetNpcId)
        && w.ItemsAvailable(q.requiredItems)
        && w.IsReachable(q.startLocation, q.targetLocation)
        && w.economy.RewardInRange(q.reward, q.estimatedEffort);
}

[System.Serializable]
public class Quest {
    public TemplateType type;
    public string targetNpcId;
    public List<string> requiredItems;
    public string startLocation;
    public string targetLocation;
    public Reward reward;
    public int estimatedEffort;
    public string introDialog;
    public string description;
    public string completionDialog;
}

5. En otros engines

  • Godot: Resource para QuestTemplate (equivalente a ScriptableObject). Los slots se resuelven igual. El HTTP al LLM va por HTTPRequest; JSON nativo de Godot evita Newtonsoft.
  • Unreal: UDataAsset cubre el rol de ScriptableObject. Para autores no-programadores, expón los templates como Data Tables si la estructura es uniforme, o como UPrimaryDataAsset si tienes polimorfismo de slots.
  • JavaScript / web: los templates son objetos JSON puros, el slot filler es un módulo aparte, el cliente del LLM lo dan los SDKs oficiales de OpenAI o Anthropic con structured output integrado. El stack más sencillo de los tres.

Lo que no cambia: el orden del pipeline (template → slots → LLM → validator) y la regla de que la validación corre antes de mostrar la quest al jugador.

6. Quiz

Pon a prueba lo que entendiste

Responde una por una. La explicación aparece al elegir, correcta o no.

  1. Tu sistema genera una quest 'mata al dragón Aerion en las Montañas del Norte', pero tu juego no tiene dragones ni montañas. ¿Dónde está el bug?

  2. El jugador completa una quest 'trae 5 hierbas a Maerwyn', pero al volver, Maerwyn sigue diciendo 'tráelas'. ¿Qué falta?

  3. ¿Por qué es crítico separar mecánica de narrativa en quests generadas con LLM?

  4. El LLM termina su narrativa con 'y como recompensa, te daré la Espada Legendaria de Veldra'. ¿Quién decide la recompensa real?

Las quests dinámicas son una pieza dentro del tablero mayor de narrativa procedural: donde los storylets producen eventos emergentes, las quests dirigen explícitamente la acción del jugador. Combinar ambos sistemas — storylets que generan contexto, quests que canalizan ese contexto en objetivos jugables — es donde un mundo simulado deja de ser una lista de cosas que pasan y se vuelve un juego con dirección.