Decisiones Avanzado 13 min de lectura

Utility AI: decisiones por puntuación, no por ramas

El motor de The Sims y muchos juegos modernos. Cada acción tiene un score; gana la mayor. Curvas de respuesta, considerations multiplicativas, transiciones suaves.

Publicado: · Por Juanjo "Banyo" López

0. El problema que ni FSM ni BT resuelven bien

FSM y Behavior Trees son herramientas potentes, pero comparten una limitación: la decisión es discreta. Si tu agente está en Wandering y playerNearby, transiciona a Attacking. Es binario — o lo está o no. No hay “está parcialmente cerca, atacaría con 70% de ganas”.

¿Qué pasa cuando hay 4 needs que cambian gradualmente? ¿Cuando “comer” depende de hambre + cercanía a comida + seguridad? Con BT terminás escribiendo árboles inmensos con thresholds arbitrarios. Funciona, pero se siente robótico — el NPC cambia de comportamiento abruptamente cuando un valor cruza un umbral.

Utility AI ataca el problema desde otro lado: cada acción tiene una función de utility que produce un score continuo. Cada frame, calculás el score de todas las acciones disponibles y elegís la mayor. Las transiciones son suaves porque los scores varían suave.

acción actual: ... · utility 0.00

Cada frame se calcula la utility de cada acción y se elige la mayor. Las needs (hambre, cansancio, soledad, aburrimiento) suben con el tiempo; la cercanía a cada objeto modula. Notá cómo las decisiones cambian gradualmente — no hay ramas explícitas como en BT, todo es scoring continuo.

Figura 1 — Sim 2D con 4 needs (hambre, cansancio, soledad, aburrimiento) que suben gradualmente. Cada frame se evalúa la utility de cada acción y se ejecuta la ganadora. El sim camina hacia el objeto y lo usa hasta que otra acción gana — sin transiciones explícitas, sin thresholds.

The Sims es el caso canónico — sus motivos (motives) son needs y cada interacción con un objeto tiene un advertised score. Pero también lo usan F.E.A.R. (parcialmente), Spore, RimWorld, y la mayoría de simulaciones modernas con NPCs autónomos.

1. Anatomía: actions, considerations, curves

class Consideration:
    read(ctx) → [0, 1]      # qué tan presente está el factor
    curve: CurveConfig       # cómo se mapea a contribución

class Action:
    label, color
    considerations: List<Consideration>
    score(ctx) → [0, 1]      # producto multiplicativo de las contribuciones

function decide(actions, ctx) → Action:
    return argmax(action.score(ctx) for action in actions)

Tres conceptos clave:

  • Consideration — un factor relevante para la acción. Lee un valor del contexto (hambre, distancia, hp), normaliza a [0, 1], y lo pasa por una curva de respuesta.
  • Curva de respuesta — cómo se transforma el input en utility. Lineal, cuadrática, sigmoide, invertida…
  • Acción — combina sus considerations en un score multiplicativo. La mayor entre todas las acciones disponibles gana.

2. Las curvas de respuesta

sigmoide centrada en bias. Crea umbrales. Pasa el cursor sobre el gráfico para ver el mapeo input → utility en cualquier punto.

Figura 2 — Pasa el cursor sobre el gráfico para ver el mapeo. Cada curva expresa una “actitud” distinta del NPC sobre cómo un input debe traducirse en deseabilidad.

2.1 Lineal: f(x) = x

La más simple. “El doble de hambre = el doble de utility”. Default razonable cuando no sabés qué curva usar.

2.2 Quadratic: f(x) = x²

“Cuanto más alto, mucho más alto”. Curva acelerada — no se siente la diferencia entre 0.1 y 0.3, pero entre 0.7 y 0.9 es enorme. Útil para cosas con efecto exponencial: pánico, urgencia, opportunity.

2.3 Inverted: f(x) = 1 - x

Lo opuesto. “Cuanto menos hp tengo, más quiero curarme”. Lectura inversa. La consideration “hp bajo” se modela como read=hp con curve inverted.

2.4 Logística (sigmoide)

La más versátil. Tiene un umbral (bias) y una pendiente (steepness). Por debajo del umbral, contribución cerca de 0; por encima, cerca de 1; cerca del umbral, transición rápida.

logistic(x, bias=0.5, k=8) = 1 / (1 + exp(-k * (x - bias)))

Es lo que querés cuando tenés una need con “umbral de tolerancia”: “no me importa tener un poco de hambre, pero arriba de cierto punto, tengo que comer ya”. Bias=0.4 con steepness=8 es un default común.

3. Multiplicación: por qué un veto baja todo

Cada barra muestra una consideration. raw: lo que se lee del contexto. curved: pasado por la curva de respuesta. contribution: lo que aporta al producto final (con weight aplicado). Si una sola contribución cae cerca de 0, el total se aplasta — eso es multiplicative scoring: cualquier veto baja todo.

Figura 3 — Una acción con 4 considerations. Cambia los sliders y mirá cómo el score final varía. Cuando una consideration cae cerca de 0, el total se aplasta — eso es la firma de Utility AI.

El score de una acción es multiplicativo:

score(action) = ∏ contribution(consideration)

Lo que significa que cualquier consideration cerca de 0 mata la acción. No importa que las otras tres estén en 1.0 — si una está en 0.05, el producto es < 0.05.

Eso es genuinamente útil:

  • “Comer manzana” requiere hambre Y cerca de manzana Y sin enemigo cerca. Si el enemigo está cerca, no comés aunque te mueras de hambre.
  • “Atacar” requiere tiene ammo Y enemigo a la vista. Sin ammo: no atacás aunque el enemigo esté ahí.

Las suaves contradicciones se modelan naturalmente. El BT equivalente requeriría chequeos explícitos por rama.

3.1 El compensation factor

El producto puro tiene un problema: una acción con 5 considerations, cada una en 0.8, da 0.8⁵ = 0.32. Pareciera que está poco motivada, pero todas sus considerations están altas. Una acción con 1 sola consideration en 0.8 ganaría injustamente.

Compensation: aplicar pow(score, 1/N_effective) donde N_effective depende del número de considerations. Eso “desinfla” el decay multiplicativo. Sin compensation, las acciones con muchas considerations siempre pierden.

finalScore = pow(productOfContributions, 1 / max(1, N * 0.5))

(La fórmula exacta varía. Mark/Dill en Game AI Pro proponen una versión más sofisticada.)

4. Utility vs Behavior Tree

BT clásico — thresholds fijos
Utility AI — scoring multiplicativo

Ambos paneles ven exactamente las mismas needs (cambian con el tiempo). El BT salta abruptamente entre acciones cuando los thresholds se cruzan; Utility AI transita suavemente porque las contribuciones se mueven gradualmente. Lo que en BT necesitarías 6+ ramas anidadas, en Utility son 4 acciones con 2-3 considerations cada una.

Figura 4 — Mismas needs (hp, ammo, amenaza) variando con el tiempo. Izquierda: BT con thresholds explícitos. Derecha: Utility AI. El BT salta abruptamente; el Utility transiciona suave.

Cuándo usar Utility

  • Sim games (The Sims, RimWorld, Dwarf Fortress): muchas needs simultáneas, comportamiento autónomo prolongado.
  • NPCs con personalidad que deben sentirse coherentes (no robóticos).
  • Cuando los pesos de decisión necesitan ajustarse en runtime (designers cambiando weights sin tocar código).
  • Decisiones tácticas con muchos factores: combate con cover, cobertura, salud, ammo.

Cuándo usar BT

  • Comportamiento scripted (cinemáticos, secuencias de quest, boss patterns).
  • Cuando la lógica es genuinamente discreta: “si el jugador está en Phase 2, ataca con magia”.
  • Equipos con designers que vienen de QA / level-design: BT es más parecido a un flowchart visual.

Cuándo combinar

Lo común en producción: BT global como skeleton (estructura de fases, bosses, cinemáticas) + Utility en hojas para “elegir el siguiente ataque entre 6 opciones”. Cada herramienta donde es buena. Los AI architectures modernos casi siempre son híbridos.

5. Pseudocódigo limpio

class Consideration:
    read(ctx) → float in [0, 1]
    curve: CurveConfig
    weight: float in [0, 1]    # 1 = pleno, 0 = neutral

class CurveConfig:
    kind: linear | quadratic | logistic | inverted
    bias, steepness: float

function evalCurve(x, cfg):
    match cfg.kind:
        linear:    return x
        quadratic: return x * x
        inverted:  return 1 - x
        logistic:  return 1 / (1 + exp(-cfg.steepness * (x - cfg.bias)))

class Action:
    considerations: List<Consideration>

    function score(ctx):
        product = 1
        for c in considerations:
            raw = clamp(c.read(ctx), 0, 1)
            curved = evalCurve(raw, c.curve)
            contrib = curved * c.weight + (1 - c.weight)  # weight=0 → no afecta
            product *= contrib
        # compensation factor
        N = considerations.length
        return pow(product, 1 / max(1, N * 0.5))

function decide(actions, ctx):
    bestScore = -1
    bestAction = null
    for action in actions:
        s = action.score(ctx)
        if s > bestScore:
            bestScore = s
            bestAction = action
    return bestAction

6. Trucos de diseño

6.1 Cooldowns implícitos

Para evitar que el NPC oscile entre dos acciones cercanas, agregás un bias temporal que decae:

score(action) -= recencyPenalty(action)

Si la acción se ejecutó hace 0.5s, su score se reduce un poco. Después de 5s la penalización es 0. Resultado: el NPC tiende a “comprometerse” con su acción actual unos segundos antes de cambiar.

6.2 Inertia / momentum

Similar pero distinto: dale un bonus al score de la acción actualmente en ejecución. Esto previene flickering cuando dos acciones tienen utility casi idéntica:

score(currentAction) *= 1.15

Pequeño boost (10–20%). Sin esto, una diferencia de 0.001 entre dos scores hace al NPC switchear cada frame.

6.3 Personalidad

Los weights y biases son los hyperparámetros de la personalidad. Un NPC con wHunger = 1.5 (más sensible al hambre) y wSocial = 0.5 (poco sensible) se comportará distinto. Designers pueden expresar arquetipos completos solo con tablas de weights — sin escribir código nuevo.

6.4 Visualización en debug

Renderizá las top 3 actions y sus utilities sobre el sprite del NPC. En 5 segundos de gameplay se ve si las decisiones son razonables. Tier S de productividad.

7. Implementación en Unity / C#

public abstract class Consideration {
    public abstract float Read(IAgent agent);
    public AnimationCurve curve;  // Unity built-in!
    public float weight = 1f;

    public float Score(IAgent agent) {
        float raw = Mathf.Clamp01(Read(agent));
        float curved = curve.Evaluate(raw);
        return curved * weight + (1f - weight);
    }
}

public class HungerConsideration : Consideration {
    public override float Read(IAgent a) => a.Hunger;  // 0..1
}

public abstract class UtilityAction {
    public string id;
    public Color color;
    public List<Consideration> considerations;

    public virtual float Score(IAgent agent) {
        float product = 1f;
        foreach (var c in considerations) product *= c.Score(agent);
        int n = considerations.Count;
        return Mathf.Pow(product, 1f / Mathf.Max(1f, n * 0.5f));
    }

    public abstract void Execute(IAgent agent);
}

public class UtilityAI {
    public List<UtilityAction> actions;
    public UtilityAction currentAction;
    public float momentum = 0.15f;

    public UtilityAction Decide(IAgent agent) {
        UtilityAction best = null;
        float bestScore = -1f;
        foreach (var a in actions) {
            float s = a.Score(agent);
            if (a == currentAction) s *= (1f + momentum);
            if (s > bestScore) { bestScore = s; best = a; }
        }
        currentAction = best;
        return best;
    }
}

AnimationCurve de Unity es ideal para curvas de respuesta — los designers pueden editarlas en el inspector sin tocar código.

8. En otros engines

  • Godot: Curve resource es equivalente a AnimationCurve. Implementación trivial en GDScript.
  • Unreal: Behavior Trees nativos no son Utility, pero el plugin Mass AI soporta scoring continuo. También hay assets de Utility AI en el marketplace.
  • JS/Web: cualquier game loop sirve; lo mismo del Unity sin tipos.

9. Errores comunes

  1. Olvidar normalizar el read — si Read() devuelve currentHP (50) en vez de currentHP / maxHP, las curvas se rompen porque esperan [0, 1].
  2. Curvas sin sentido — usar quadratic para “no quiero pelear si ya estoy peleando” — eso es inverted-quadratic. Pensá la dirección correcta.
  3. Sin compensation factor — acciones con muchas considerations siempre pierden contra acciones con 1.
  4. Sin momentum / cooldown — flickering entre acciones similares cada frame.
  5. Demasiadas considerations por acción — más de 5 y la signal-to-noise se va al diablo. Cada nueva consideration debería tener una razón clara para estar.
  6. Hardcodear bias/steepness — exponé esos valores como variables de personalidad. Ese es el verdadero poder de Utility.

10. Quiz

Pon a prueba lo que entendiste

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

  1. Tu NPC con BT salta abruptamente entre `flee` y `attack` cuando hp=0.3 (umbral). ¿Por qué Utility AI lo arreglaría?

  2. Una acción tiene 4 considerations. Tres están en 0.9, una está en 0.05. ¿Score?

  3. ¿Qué problema soluciona el `compensation factor` (`pow(product, 1/N)`)?

  4. Tu NPC oscila entre 'recargar' y 'atacar' cada frame cuando ammo es ~0.3. ¿Qué agregás?

11. Siguientes pasos

Utility responde “qué hacer ahora”. Pero a veces la decisión no es 1 acción sino una secuencia: para llegar a “matar al jugador” tengo que primero conseguir el arma, después acercarme, después disparar. Eso requiere planificación, no scoring instantáneo. Lo que sigue: GOAP — Goal-Oriented Action Planning, A* sobre estados del mundo.