Sistemas Intermedio 20 min de lectura

Dynamic Difficulty Adjustment (DDA) con telemetría

Resident Evil 4 y Left 4 Dead ajustaban dificultad en silencio según cómo jugabas. Cómo construirlo sin que el jugador lo note ni se sienta engañado.

Publicado: · Por Juanjo "Banyo" López

0. Introducción

El selector clásico de easy / normal / hard es una decisión que el jugador toma antes de tener ninguna información. Le pides que se autoetiquete a los 30 segundos de instalar el juego, y después le obligas a vivir con esa etiqueta durante 20 horas. Lo normal es que falle: cerca del 30% se queda frustrado y deja el juego en el primer jefe, y otro 30% lo termina sin haber muerto ni una vez y siente que no pasó nada.

Dynamic Difficulty Adjustment (DDA) ataca ese problema desde otra dirección. En vez de pedirle al jugador que se clasifique, lo observas: cuántas veces muere por encuentro, cuánto tarda en limpiar una zona, qué porcentaje de tiros acierta. Con eso ajustas variables del juego en silencio para mantenerlo cómodo pero no aburrido.

El caso canónico es Resident Evil 4. Las llamadas notas de Hunk filtradas años después documentaban cómo el juego modulaba HP de enemigos, accuracy y drop rate de munición según el rendimiento. Left 4 Dead hizo lo mismo con su AI Director controlando la presión. Mario Kart 8 lo aplica al rubber-banding del CPU. Crash Bandicoot N. Sane Trilogy introdujo un Aku Aku invisible que aparece tras varias muertes seguidas en un mismo tramo.

¿Qué cubre este tutorial?

  • Qué es flow y por qué la dificultad fija lo rompe.
  • Qué métricas de telemetría medir y cuáles ignorar.
  • Cómo construir un player skill model que no oscile con cada mala racha.
  • Qué palancas puedes tocar sin que el jugador detecte el engaño.
  • Cómo evitar que un jugador avanzado explote tu DDA muriéndose a propósito.

1. Demo

DDA en tiempo real Simulación de combate con skill model adaptativo. El gráfico muestra cómo HP enemigo, accuracy y drop rate se desplazan según el rendimiento simulado del jugador.

2. Concepto

DDA monitorea el rendimiento del jugador en tiempo real y ajusta variables del juego para mantenerlo en flow. La pieza crítica no es la fórmula: es la lista de palancas y la velocidad de respuesta. Demasiado rápido se nota; demasiado lento es inútil.

Lo que sigue es la separación honesta entre las decisiones técnicas (cómo modelar el skill) y las decisiones de diseño (qué palancas tocar, cuándo intervenir).

2.1 ¿Qué es flow y por qué importa para DDA?

Mihaly Csikszentmihalyi describió flow en 1975 como el estado donde el desafío percibido y la habilidad del individuo están equilibrados. Si el desafío supera a la habilidad, aparece ansiedad. Si la habilidad supera al desafío, aparece aburrimiento. En el medio hay un canal estrecho donde la atención se mantiene sin esfuerzo.

Flow channel de Csikszentmihalyi
challengeskillansiedadaburrimientoflow channeljugador en flow

Si el desafío supera a la habilidad aparece ansiedad; al revés, aburrimiento. En el canal diagonal, el jugador fluye. El punto azul se mueve dentro del canal.

El problema del selector fijo es que asume una habilidad constante. Un jugador no es “intermedio” en abstracto: es intermedio para combate cuerpo a cuerpo, malísimo para puzzles de plataformas, y bueno para sigilo. Y eso cambia con el cansancio, con la novedad de una mecánica, con la hora del día. DDA empuja al jugador hacia el canal continuamente.

2.2 ¿Qué métricas de telemetría medir?

Tienes que medir cosas observables sin permiso del jugador. Nada de encuestas. La regla práctica: cualquier métrica útil cabe en una variable que el código del juego ya conoce.

  • Muertes por segmento: ¿cuántas veces reintentó este encuentro? Es la señal más fuerte y la más fácil de obtener.
  • Tiempo en zona: si tarda 4 minutos en limpiar una habitación pensada para 90 segundos, hay fricción.
  • Accuracy (tiros acertados / tiros totales) en un buffer reciente. Diferencia mejor a un jugador que falla porque no apunta de uno que ya está apuntando bien.
  • HP gastado o porcentaje de vida medio: un jugador que sale de cada combate al 5% no está cómodo.
  • Consumibles usados: pociones, ammo packs, granadas. Un consumo bajo puede indicar exceso de habilidad o miedo de gastar.
  • Retries por puzzle: relevante en juegos con secciones no-combate.

No midas tiempo de juego total ni nivel del personaje. Son métricas de progreso, no de skill, y pueden mentirte: un jugador con 40 horas puede estar reciclando contenido fácil.

2.3 ¿Cómo construir un player skill model?

Lo más simple que funciona: una media móvil exponencial sobre las métricas, mapeada a un valor único skillEstimate en el rango [0, 1].

Buffer circular de telemetría
s1s2s3s4s5s6s7s8más recienteskillEstimate∈ [0, 1]buffer circular (N=8)

N samples recientes giran en un buffer circular. El sample más reciente (con pulso) actualiza el skillEstimate.

Cada métrica se normaliza a [0, 1] con un baseline esperado (por ejemplo: 1 muerte por encuentro es 0.5, 0 muertes es 1.0, 3 muertes es 0.0). El skill global es un promedio ponderado de las métricas, y se mezcla con el valor anterior usando lerp con un factor pequeño (0.05–0.10). Así el modelo no salta con cada evento individual.

Versiones más serias usan Bayesian update: tratas skill como una distribución, no como un valor puntual, y cada métrica actualiza la posterior. Para un juego comercial bastante peso queda muy bien con la media móvil. Reserva Bayesian para cuando midas más de cinco métricas con varianzas distintas.

2.4 ¿Qué palancas tocar y cuáles no?

Esta es la decisión más delicada del sistema. Hay palancas invisibles (el jugador no las puede verificar) y palancas visibles (el jugador las nota si las tocas).

Palancas de DDA: knobs invisibles
INVISIBLES (modular)HP enemigo0.7×1.0×1.3×accuracy enemigo0.400.85item drop rate0.8×1.3×spawn count37VISIBLES (no tocar)daño base del armahitbox del jugadorvelocidad mov.tamaño enemigo

Cada palanca es un slider con su posición actual (marcador pulsando). Solo se modulan las invisibles; las visibles permanecen fijas.

Reglas duras:

  • Modular HP enemigo dentro de un rango (0.7×–1.3×) es invisible. El jugador no cuenta balas hasta la muerte del enemigo.
  • Modular accuracy funciona si el enemigo a veces falla a propósito. Si pasa de 90% a 40% en dos minutos, el jugador percibe “esto es muy fácil de pronto”.
  • Item drops son la palanca más segura. Subir el drop rate de munición tras dos muertes pasa por azar.
  • No toques el daño base del arma del jugador. Esa es la métrica que el jugador memoriza primero. Si tu pistola hace 35 hoy y 50 mañana, el jugador detecta el engaño y siente que su victoria no cuenta.
  • No toques el hitbox del jugador. Los speedrunners y los streamers detectan esto con frame counting y se vuelve un meme negativo.

2.5 ¿Cuándo el jugador detecta DDA y por qué duele?

El daño emocional de un DDA mal calibrado es peor que el de no tenerlo. Si tras tres muertes el enemigo “súbitamente” cae con dos tiros, el jugador no piensa “qué bien, lo logré”. Piensa “el juego me dejó ganar”. La victoria pierde valor.

Tres causas comunes de detección:

  • Cambios bruscos. Un ajuste del 30% de un frame al siguiente se siente como un interruptor. Solución: interpola las palancas con lerp durante 5–10 segundos.
  • Ajustes durante el mismo encuentro. Si el enemigo tiene 200 HP cuando empiezas a dispararle y a media pelea baja a 120 HP, el jugador lo siente. Solución: bloquea los ajustes mientras dure un encuentro activo.
  • Ajustes hacia abajo demasiado generosos. Bajar HP de 1.3× a 0.7× tras dos muertes es ofensivo. Mantén el rango estrecho.

2.6 DDA vs Director AI: ¿cuál es la diferencia?

Se confunden mucho porque ambos son meta-sistemas invisibles. La diferencia es el objetivo:

  • Director AI persigue una curva dramática. Quiere que la presión suba y baje como en una película. No le importa si el jugador es bueno: le importa el ritmo.
  • DDA persigue flow. Quiere mantener al jugador en el canal entre ansiedad y aburrimiento. No le importa el ritmo dramático: le importa que el jugador no abandone.

Left 4 Dead 2 los combina: el Director decide cuándo soltar una horda, DDA decide qué tan dura pega cada zombie. Son ortogonales. Si solo tienes presupuesto para uno y tu juego es de acción cooperativa, empieza con Director. Si es un single-player con curva de aprendizaje, empieza con DDA.

2.7 ¿Cómo evitar que los jugadores exploten el sistema?

Los jugadores avanzados detectan DDA y lo explotan. El abuso clásico: morirse a propósito al inicio de un bossfight para que el jefe tenga 0.7× HP cuando vayan en serio.

Tres mitigaciones que funcionan:

  • Ajustes solo hacia arriba dentro de un encuentro. Cuando el jefe spawnea, su HP se fija. Bajar el skillEstimate durante la pelea no afecta a ese boss; afectará al próximo.
  • Persistencia entre sesiones con regresión a la media. El skill estimado se guarda en el save, pero relaja un 10% hacia 0.5 cada vez que arranca una sesión nueva. Evita que el jugador deje un save “envenenado” con muertes a propósito.
  • Threshold mínimo de muestras. No actualices el skill model hasta tener al menos 5–10 samples. Una muerte aislada no debería mover nada.

2.8 Honestidad opcional: ¿exponer DDA al jugador?

Algunos jugadores quieren resistencia, no asistencia. Si tu juego apunta a un público que valora el reto puro (souls-likes, roguelikes hardcore), considera exponer un toggle “Adaptive difficulty: OFF” en el menú avanzado.

Tres razones para hacerlo:

  • Speedrunners necesitan una corrida determinista para comparar tiempos.
  • Reviewers quieren saber si están jugando “el juego” o una versión asistida.
  • Players con orgullo prefieren morir 40 veces que recibir ayuda invisible.

Tres razones para no exponerlo:

  • Romper la inmersión: una vez que sabe que existe, el jugador se obsesiona con detectarlo.
  • Pruebas A/B muestran que los jugadores que apagan DDA abandonan más.
  • En juegos casuales el toggle no aporta nada y confunde.

La decisión depende de tu público. Lo que no debes hacer es esconderlo y después negar que existe. Si un dataminer encuentra enemyHpMultiplier = 0.7f en el save, la prensa lo va a publicar como “el juego que te dejaba ganar”. Mejor controlar el mensaje.

3. Pseudocódigo

class Sample
    deaths: Int
    accuracy: Float        # 0..1
    hpAtEndOfEncounter: Float
    consumablesUsed: Int

class PlayerModel
    skillEstimate: Float = 0.5    # 0..1
    recentMetrics: CircularBuffer<Sample>    # tamano N=10
    locked: Bool = false          # true durante un encuentro

function pushSample(model: PlayerModel, s: Sample)
    if model.locked: return
    model.recentMetrics.push(s)
    if model.recentMetrics.length < 5: return  # threshold minimo

    target = computeSkillFromMetrics(model.recentMetrics)
    model.skillEstimate = lerp(model.skillEstimate, target, 0.07)

function computeSkillFromMetrics(buffer) -> Float
    avgDeaths   = mean(buffer.map(s -> s.deaths))
    avgAcc      = mean(buffer.map(s -> s.accuracy))
    avgHpLeft   = mean(buffer.map(s -> s.hpAtEndOfEncounter))
    # 0 muertes y 100% hp -> skill ~ 1.0
    # 3+ muertes y 5% hp  -> skill ~ 0.0
    deathScore  = clamp(1 - avgDeaths / 3, 0, 1)
    return 0.4 * deathScore + 0.3 * avgAcc + 0.3 * avgHpLeft

function applyDifficulty(world, model: PlayerModel)
    s = model.skillEstimate
    world.enemyHpMultiplier = lerp(world.enemyHpMultiplier,
                                   lerp(0.7, 1.3, s), 0.05)   # suavizado temporal
    world.enemyAccuracy     = lerp(world.enemyAccuracy,
                                   lerp(0.4, 0.85, s), 0.05)
    world.itemDropRate      = lerp(world.itemDropRate,
                                   lerp(1.3, 0.8, s), 0.05)
    world.spawnCount        = round(lerp(3, 7, s))

Lo importante: la palanca enemyHpMultiplier no salta al valor objetivo, hace lerp cada frame. Es lo que mantiene los ajustes por debajo del umbral de detección.

4. Implementación en Unity / C#

using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public struct DDASample {
    public int   deaths;
    public float accuracy;
    public float hpLeftPct;
}

public class PlayerModel {
    public float skillEstimate = 0.5f;
    public bool  locked = false;
    readonly Queue<DDASample> buffer = new();
    const int BUFFER_SIZE = 10;
    const int MIN_SAMPLES = 5;

    public void PushSample(DDASample s) {
        if (locked) return;
        if (buffer.Count >= BUFFER_SIZE) buffer.Dequeue();
        buffer.Enqueue(s);
        if (buffer.Count < MIN_SAMPLES) return;

        float deaths = 0, acc = 0, hp = 0;
        foreach (var x in buffer) { deaths += x.deaths; acc += x.accuracy; hp += x.hpLeftPct; }
        int n = buffer.Count;
        float deathScore = Mathf.Clamp01(1f - (deaths / n) / 3f);
        float target = 0.4f * deathScore + 0.3f * (acc / n) + 0.3f * (hp / n);
        skillEstimate = Mathf.Lerp(skillEstimate, target, 0.07f);
    }
}

public class DDAManager : MonoBehaviour {
    public EnemySettings settings;
    public PlayerModel model = new();
    int deathsThisEncounter = 0;
    int shotsFired = 0, shotsHit = 0;

    void Update() {
        float s = model.skillEstimate;
        settings.hpMultiplier  = Mathf.Lerp(settings.hpMultiplier,  Mathf.Lerp(0.7f, 1.3f, s), 0.05f * Time.deltaTime * 60f);
        settings.accuracy      = Mathf.Lerp(settings.accuracy,      Mathf.Lerp(0.4f, 0.85f, s), 0.05f * Time.deltaTime * 60f);
        settings.itemDropRate  = Mathf.Lerp(settings.itemDropRate,  Mathf.Lerp(1.3f, 0.8f, s), 0.05f * Time.deltaTime * 60f);
        settings.spawnCount    = Mathf.RoundToInt(Mathf.Lerp(3f, 7f, s));
    }

    public void OnEncounterStart() { model.locked = true; deathsThisEncounter = 0; shotsFired = shotsHit = 0; }
    public void OnPlayerShot(bool hit) { shotsFired++; if (hit) shotsHit++; }
    public void OnPlayerDamaged() { /* opcional: registrar dano recibido */ }
    public void OnPlayerDied()    { deathsThisEncounter++; }

    public void OnEncounterEnd(float hpLeftPct) {
        model.locked = false;
        model.PushSample(new DDASample {
            deaths    = deathsThisEncounter,
            accuracy  = shotsFired > 0 ? (float)shotsHit / shotsFired : 0.5f,
            hpLeftPct = hpLeftPct
        });
    }
}

EnemySettings es un ScriptableObject compartido que los spawners y las IAs leen para construir cada enemigo. Modular el ScriptableObject en runtime es la forma más limpia de propagar los cambios a todo el sistema sin acoplar DDAManager con cada enemigo individual.

5. En otros engines

  • Godot: define un Autoload DDAManager.gd con un buffer Array y emite signal skill_changed(value) cuando el skillEstimate cambia más de 0.05. Los enemigos se suscriben y reescriben sus stats en _ready().
  • Unreal: implementa el modelo en el UGameInstance (sobrevive a cambios de nivel) y expón los multiplicadores como UPROPERTY para que los BehaviorTree los lean vía BlackboardComponent.
  • JavaScript / Canvas: middleware del game loop. Antes del update de los enemigos, llama a dda.tick(dt) y aplica los multiplicadores sobre el world mutable.

6. Quiz

Pon a prueba lo que entendiste

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

  1. Tras 2 muertes seguidas en el mismo bossfight, los enemigos hacen mucho menos daño de un frame al siguiente. El jugador lo nota y le molesta. ¿Qué cambias?

  2. ¿Por qué no debes modular el daño base del arma del jugador con DDA?

  3. Tu skillEstimate fluctúa cada vez que el jugador tiene una mala racha de 30 segundos. ¿Cómo lo estabilizas?

  4. ¿Cuándo conviene exponer DDA al jugador como un toggle 'adaptive difficulty: OFF'?