Movimiento Intermedio 13 min de lectura

A*: el estándar del pathfinding en juegos

Dijkstra con instinto. Aprende heurística bien implementada para llegar más rápido...o perderte en el intento.

Publicado: · Por Juanjo "Banyo" López

0. Introducción

A* (1968, Hart-Nilsson-Raphael) es el algoritmo de pathfinding por defecto en videojuegos. Age of Empires, Baldur’s Gate, prácticamente todo RTS, todo tactical, todo grid-based moderno. Es Dijkstra con un agregado: una heurística que estima cuán cerca está cada nodo del goal.

Esa heurística sesga la búsqueda hacia el destino. En vez de explorar en círculo (Dijkstra), A* prioriza nodos que parecen prometedores. En mapas grandes, eso significa explorar 5–20× menos celdas para llegar al mismo camino óptimo.

La fórmula que lo define

A* prioriza por:

f(n) = g(n) + h(n)

  • g(n) — costo real desde el origen hasta n. Es el mismo gScore de Dijkstra.
  • h(n) — estimación heurística del costo desde n hasta el goal. No lo conoces realmente; es una conjetura informada.
  • f(n) — suma. La PQ de A* extrae el nodo con menor f, que combina “cuánto te costó llegar” con “cuánto te falta”.

La elección de h es la decisión de diseño más importante de A*. Ahí entra el concepto de admisibilidad.

¿Cuándo usar A*?

  • Tienes un goal específico conocido (one-to-one search). Si necesitas multi-target, Dijkstra puede ser más simple.
  • El mapa es grande y la velocidad importa.
  • Puedes calcular una heurística razonable (en un grid, es trivial).

¿Cuándo NO?

  • Multi-target o flow fields → Dijkstra o flow field directo.
  • Mapas chiquitos (< 30×30): A* y Dijkstra terminan tan rápido que no se nota la diferencia. Usa lo que ya tengas.
  • Heurística difícil de calcular o impredecible (ej: navegación por grafos abstractos donde la distancia “real” no se puede estimar): A* sin heurística buena = Dijkstra disfrazado.

1. A* en acción

Click para alternar muros, Shift+click mueve el origen, Alt+click mueve el destino. La frontera de A* se colorea por f-cost (g+h); el verde es el camino reconstruido. Compara con la cantidad explorada vs Dijkstra: A* explora mucho menos.

Figura 1 — Click para muros, Shift+click mueve el origen, Alt+click mueve el destino. La frontera se colorea por f-cost: azul es f bajo (prometedor), rojo es f alto (alejándose).

Notarás que A* “apunta” al goal: la mancha de exploración tiene forma de gota orientada hacia el destino, no de círculo como Dijkstra.

1.1 El bucle, casi idéntico a Dijkstra

function astar(grid, start, goal, h)
    gScore = Map.fillWith(Infinity)
    fScore = Map.fillWith(Infinity)
    gScore[start] = 0
    fScore[start] = h(start, goal)
    parent = Map()
    pq = MinPriorityQueue()                # ordenada por fScore
    pq.push(start, fScore[start])

    while pq not empty
        current = pq.popMin()
        if current == goal:
            return reconstructPath(parent, goal)

        for each neighbor of walkableNeighbors(current)
            tentative_g = gScore[current] + cost(neighbor)
            if tentative_g < gScore[neighbor]
                gScore[neighbor] = tentative_g
                fScore[neighbor] = tentative_g + h(neighbor, goal)
                parent[neighbor] = current
                pq.push(neighbor, fScore[neighbor])

Diferencias mínimas con Dijkstra:

  1. La PQ se ordena por fScore, no por gScore.
  2. Al actualizar un vecino, calculas f = g + h y eso es lo que va a la PQ.

Si h(n) = 0 para todo n, A* es Dijkstra. La heurística es lo que añade el sesgo.

2. La elección de heurística

Las cuatro heurísticas más comunes sobre el mismo mapa: Manhattan suma diferencias en x e y (4-conexo), Euclidiana usa distancia recta, Chebyshev y Octile permiten diagonales (8-conexo). El número junto al nombre cuenta cuántos nodos exploró cada una; con mejor heurística, A* explora menos.

Figura 2 — Cuatro paneles A sobre el mismo mapa. Cada uno usa una heurística distinta y reporta cuántos nodos exploró. Heurística más informada = menos exploración, siempre que sea admisible.*

2.1 Las cuatro heurísticas comunes

HeurísticaFórmulaCuándo
Manhattan|dx| + |dy|Grid 4-conexo (sin diagonales). Es la exacta distancia mínima si no permites diagonales.
Euclidiana√(dx² + dy²)Movimiento libre en ángulos arbitrarios. Subestima en grid 4-conexo (sigue siendo admisible).
Chebyshevmax(|dx|, |dy|)Grid 8-conexo donde diagonal cuesta lo mismo que ortogonal. Distancia exacta de rey en ajedrez.
Octilemax(dx,dy) + (√2 − 1)·min(dx,dy)Grid 8-conexo donde diagonal cuesta √2 (más realista). Distancia exacta.

Regla rápida: usa la heurística que sea exacta para el costo de movimiento de tu grid. Si tu agente puede moverse en 4 direcciones a costo 1, Manhattan. Si puede hacer diagonales a costo √2, Octile. Si en cualquier diagonal cuesta 1, Chebyshev.

2.2 Admisibilidad — la regla de oro

Una heurística es admisible si nunca sobreestima el costo real al goal:

Para todo nodo n: h(n) ≤ costo_real(n, goal)

Si h es admisible, A* garantiza el camino óptimo. Si h sobreestima, A* puede encontrar un camino más rápido pero no necesariamente óptimo.

El slider multiplica la heurística por un factor. Con factor = 1 A* es óptimo (el camino es verde). Subir el factor (heurística inadmisible / "weighted A\*") hace la búsqueda más rápida pero el camino encontrado puede ser subóptimo (camino rojo). Hay un trade-off: velocidad por calidad.

Figura 3 — El slider multiplica la heurística por un factor. Con factor = 1, A* es admisible y el path es verde (óptimo). Subir el factor (weighted A*) hace la búsqueda más rápida pero puede dar un path subóptimo (rojo).

2.3 Weighted A* — comprar velocidad con calidad

A veces te conformas con un camino “casi óptimo” si A* corre 3× más rápido. La técnica clásica es multiplicar la heurística por un peso w > 1:

fScore[n] = gScore[n] + w * h(n, goal)
  • w = 1 → A* clásico, camino óptimo.
  • w = 1.5 → camino a lo sumo 50% peor que el óptimo, búsqueda mucho más rápida.
  • w = ∞ → puro greedy best-first, ignora g por completo. Muy rápido, pero el camino puede ser horrible.

Es trade-off de calidad vs velocidad. Para juegos action donde el agente recalcula path 10 veces por segundo, w = 1.5–2 suele ser ideal: nadie nota un path 30% más largo en una persecución.

3. A* vs Dijkstra — la prueba dura

Mismo mapa, mismo destino. Dijkstra explora en círculo (sin saber hacia dónde va); A* sesga la búsqueda hacia el goal y explora mucho menos. La proporción aparece abajo.

A* explora 0% de lo que explora Dijkstra.

Figura 4 — Mismo mapa, mismo goal. Dijkstra explora “en círculo” porque no sabe a dónde va. A* “apunta” al goal y explora una gota dirigida. La proporción de nodos explorados aparece debajo.

En mapas abiertos sin obstáculos, A* explora típicamente 10–20% de lo que explora Dijkstra. En mapas con muchos obstáculos esa ventaja se reduce porque la heurística engaña — A* sigue siendo correcto, pero la heurística “miente” sobre el costo real porque no sabe de obstáculos.

3.1 Optimizaciones obvias

  • Tie-breaking — cuando dos nodos tienen igual fScore, prefieres el de menor h (más cerca del goal). Reduce ramificaciones simétricas.
  • closed set — marca nodos ya extraídos. No los procesas de nuevo aunque aparezcan en la PQ con f menor (puede pasar si la PQ no soporta decrease-key).
  • Buckets en lugar de PQ — si los costos son enteros pequeños, una pq de buckets (Bucket[fScore]) es O(1) por operación. Factorio lo hace así.

3.2 Cuándo A* deja de ser ideal

  • JPS sobre grids uniformes 8-conexos: 10× más rápido que A*. Lo verás en Pathfinding avanzado.
  • Theta* cuando los paths con esquinas duras se ven feos: añade line-of-sight checks y produce paths suavizados.
  • HPA* para mapas enormes (10000×10000): jerarquía de clusters.
  • NavMesh poligonal en vez de grid: A* sobre triángulos. Lo verás en A* sobre NavMesh.

4. Pseudocódigo completo

function astar(grid, start, goal, heuristic)
    closedSet = Set()
    gScore = Map.fillWith(Infinity)
    fScore = Map.fillWith(Infinity)
    parent = Map()

    gScore[start] = 0
    fScore[start] = heuristic(start, goal)
    pq = MinPriorityQueue()
    pq.push(start, fScore[start])

    while pq not empty
        current = pq.popMin()
        if current == goal:
            return reconstructPath(parent, goal)
        if current in closedSet: continue
        closedSet.add(current)

        for each neighbor of walkableNeighbors(current)
            if neighbor in closedSet: continue
            tentative_g = gScore[current] + stepCost(current, neighbor)
            if tentative_g < gScore[neighbor]
                parent[neighbor] = current
                gScore[neighbor] = tentative_g
                fScore[neighbor] = tentative_g + heuristic(neighbor, goal)
                pq.push(neighbor, fScore[neighbor])

    return null    // unreachable

5. Implementación en Unity / C#

using System;
using System.Collections.Generic;
using UnityEngine;

public static class AStar {
    public delegate float Heuristic(Vector2Int a, Vector2Int b);

    public static readonly Heuristic Manhattan = (a, b) => Mathf.Abs(a.x - b.x) + Mathf.Abs(a.y - b.y);
    public static readonly Heuristic Euclidean = (a, b) => Vector2Int.Distance(a, b);
    public static readonly Heuristic Octile = (a, b) => {
        int dx = Mathf.Abs(a.x - b.x), dy = Mathf.Abs(a.y - b.y);
        const float F = 0.41421356f; // sqrt(2) - 1
        return Mathf.Max(dx, dy) + F * Mathf.Min(dx, dy);
    };

    static readonly Vector2Int[] Dirs = {
        new(1, 0), new(-1, 0), new(0, 1), new(0, -1),
    };

    public static List<Vector2Int> Find(
        bool[,] walkable, Vector2Int start, Vector2Int goal, Heuristic h
    ) {
        int W = walkable.GetLength(0), H = walkable.GetLength(1);
        int N = W * H;
        var gScore = new float[N];
        var fScore = new float[N];
        var parent = new int[N];
        for (int i = 0; i < N; i++) { gScore[i] = float.PositiveInfinity; fScore[i] = float.PositiveInfinity; parent[i] = -1; }

        int Idx(Vector2Int c) => c.y * W + c.x;
        int sIdx = Idx(start);
        gScore[sIdx] = 0;
        fScore[sIdx] = h(start, goal);

        // .NET 6+: PriorityQueue<int, float>
        var pq = new PriorityQueue<int, float>();
        pq.Enqueue(sIdx, fScore[sIdx]);
        var closed = new bool[N];

        while (pq.Count > 0) {
            int cur = pq.Dequeue();
            int cx = cur % W, cy = cur / W;
            if (cx == goal.x && cy == goal.y) return Reconstruct(parent, W, goal);
            if (closed[cur]) continue;
            closed[cur] = true;

            foreach (var d in Dirs) {
                int nx = cx + d.x, ny = cy + d.y;
                if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
                if (!walkable[nx, ny]) continue;
                int ni = ny * W + nx;
                if (closed[ni]) continue;

                float tentative = gScore[cur] + 1f;
                if (tentative < gScore[ni]) {
                    parent[ni] = cur;
                    gScore[ni] = tentative;
                    fScore[ni] = tentative + h(new Vector2Int(nx, ny), goal);
                    pq.Enqueue(ni, fScore[ni]);
                }
            }
        }
        return null;
    }

    static List<Vector2Int> Reconstruct(int[] parent, int cols, Vector2Int goal) {
        var path = new List<Vector2Int>();
        int cur = goal.y * cols + goal.x;
        while (cur != -1) {
            path.Add(new Vector2Int(cur % cols, cur / cols));
            cur = parent[cur];
        }
        path.Reverse();
        return path;
    }
}

6. En otros engines

  • Godot: AStarGrid2D y AStar2D traen A* completo, con heurísticas configurables por callback. Cuesta cero usarlos en gameplay grid-based.
  • Unreal: FNavigationSystem usa A* sobre NavMesh internamente. Para grid manual, TArray + min-heap manual o usar plugins como NavGrid.
  • JavaScript / TypeScript: la lib del sitio implementa A* con heurística pluggable como generador para que las visualizaciones puedan animar paso a paso.

7. Quiz

Pon a prueba lo que entendiste

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

  1. Si pones h(n) = 0 para todo n, ¿qué obtienes?

  2. Tu grid permite movimiento en 4 direcciones a costo 1. ¿Qué heurística da el menor número de nodos explorados sin perder optimalidad?

  3. Multiplicas la heurística por 2 (weighted A*). ¿Qué pasa?

  4. Un mapa de 200x200 con 80% de espacio libre. ¿Qué algoritmo es típicamente más rápido?

  5. Tu mapa tiene 50% de muros distribuidos aleatoriamente. ¿Cómo cambia el comportamiento de A*?

8. Siguientes pasos