01 / The idea
Point the search at the goal.
The Dijkstra lesson settled the nearest place first and never guessed. A* calls taking a tile out of the frontier expanding it; Dijkstra called the same step settling. On a tile map that means spreading out in every direction, even away from the goal. On our arena it expands 131 of the 152 open tiles to find a 15-move route.
A* expands the waiting tile with the smallest moves-so-far plus an estimate of the moves still to go. Tiles that look closer to the goal come out sooner, so the search leans toward it. With a good estimate it still returns the shortest route.
Watch three searches on the same arena: no estimate, the Manhattan distance, and that distance doubled.
Aim the search. Don’t overshoot.
Expanded 1 tiles.
expanded waiting wall · numbers are f = g + h
Search without an estimate.
With no estimate, a tile’s f is just the moves taken to reach it, so this is Dijkstra’s search. It spreads out from the start in every direction and expands 131 of the arena’s 152 open tiles before the goal comes out of the frontier. The route is 15 moves.
Reduced motion: choose a scene to see its completed state.
Read this scene
With no estimate, a tile’s f is just the moves taken to reach it, so this is Dijkstra’s search. It spreads out from the start in every direction and expands 131 of the arena’s 152 open tiles before the goal comes out of the frontier. The route is 15 moves.
Estimate: no estimate. Expanded 1 tiles.
Watch and Step through replay three searches on the same arena. Try it runs the same TypeScript on walls, a start, and a goal you place.
The numbers on the tiles are each tile’s f. The Manhattan search expanded 35 tiles and still found 15 moves. The doubled one expanded 27 and came back with 17.
02 / Name the rule
Expand the smallest g + h.
Every waiting tile carries two numbers, and the search orders tiles by their sum:
Moves so far
The fewest moves found from the start to this tile, as in Dijkstra.
Estimate to go
How many moves the goal still looks. Here, the Manhattan distance: across plus down.
g + h
Expand the smallest f next. Equal f goes to the smaller h, then to reading order.
With h always 0, f is just g, and the search is exactly Dijkstra’s. Everything the estimate changes is which tile comes out next.
The estimate has one job to do safely: never claim more moves than are really left. Walls only make routes longer, so on a four-way grid the Manhattan distance is never more than the true number of moves. It also changes by exactly 1 with each move, so along any route g + h never goes down. That is what makes the first time a tile comes out its best g, and the first time the goal comes out its shortest route. A tile never has to be expanded twice.
What about diagonal moves?The estimate has to match the moves
This lesson’s character moves in four directions. If a diagonal step also counts as a move, one step can shrink the Manhattan distance by 2, so the estimate can claim more moves than are left, and the route stops being guaranteed shortest.
Engines let you choose both. Godot’s AStarGrid2D’s diagonal_mode defaults to DIAGONAL_MODE_ALWAYS, and its default
heuristic is Euclidean; it also offers Manhattan, Octile, and Chebyshev, and a DIAGONAL_MODE_NEVER mode where “the way will be always orthogonal.”
03 / Read the shape
Dijkstra’s loop, ordered by one more number.
Basic form is the whole search: findPath pops the waiting tile
with the smallest f, offers each open neighbor a g, and walks the route back when the goal
comes out. In the wild is a tower-defense rule that uses it. At the call site runs the arena with each estimate and checks two walls. Both
languages print the same five lines.
The heap and the stale entries work exactly as in the Dijkstra lesson: a better offer pushes a new entry, and the older one is skipped when it comes out, because that tile is already expanded. The heap itself is the priority queue from the Binary heap lesson.
findPath expands the waiting tile with the smallest f = g + h, ties to the smaller h, then reading order. A better offer pushes a new heap entry; a stale one is skipped, and an expanded tile is never expanded again.
// A* on a tile map. A character moves up, right, down, or left; every move costs 1.
// '#' is a wall and '.' is open floor.
export const MAX_WIDTH = 32;
export const MAX_HEIGHT = 24;
export type Tile = { x: number; y: number };
// How far the goal still looks: nothing (plain Dijkstra), Manhattan distance, or twice that.
export type Estimate = 'none' | 'manhattan' | 'double';
export type Search = { cost: number | null; path: Tile[]; expanded: Tile[] };
export type TileErrorCode = 'bad-map' | 'bad-estimate' | 'off-map' | 'on-wall';
export class TileError extends Error {
readonly code: TileErrorCode;
constructor(code: TileErrorCode, message: string) {
super(message);
this.name = 'TileError';
this.code = code;
}
}
type Entry = { f: number; h: number; at: number };
// Smaller f first; then smaller h, the tile that looks closer; then reading order, row by row.
const ahead = (a: Entry, b: Entry) =>
a.f < b.f || (a.f === b.f && (a.h < b.h || (a.h === b.h && a.at < b.at)));
// A binary min-heap: the frontier of tiles reached but not yet expanded.
class Frontier {
#items: Entry[] = [];
get size() {
return this.#items.length;
}
push(entry: Entry) {
const items = this.#items;
items.push(entry);
for (let i = items.length - 1; i > 0;) {
const parent = (i - 1) >> 1;
if (!ahead(items[i], items[parent])) break;
[items[i], items[parent]] = [items[parent], items[i]];
i = parent;
}
}
pop(): Entry {
const items = this.#items;
const top = items[0];
const last = items.pop()!;
if (items.length > 0) {
items[0] = last;
for (let i = 0; ;) {
let best = i;
for (const child of [2 * i + 1, 2 * i + 2])
if (child < items.length && ahead(items[child], items[best])) best = child;
if (best === i) break;
[items[i], items[best]] = [items[best], items[i]];
i = best;
}
}
return top;
}
}
const MOVES = [
[0, -1],
[1, 0],
[0, 1],
[-1, 0]
];
export function estimate(kind: Estimate, from: Tile, goal: Tile): number {
const distance = Math.abs(from.x - goal.x) + Math.abs(from.y - goal.y);
return kind === 'none' ? 0 : kind === 'manhattan' ? distance : 2 * distance;
}
export function readMap(rows: readonly string[]): { width: number; height: number } {
const height = rows.length;
const width = rows[0]?.length ?? 0;
if (height < 1 || height > MAX_HEIGHT || width < 1 || width > MAX_WIDTH)
throw new TileError('bad-map', `maps are 1 to ${MAX_WIDTH} wide and 1 to ${MAX_HEIGHT} tall`);
for (const row of rows)
if (row.length !== width || !/^[.#]+$/.test(row))
throw new TileError('bad-map', 'every row has the same width and uses only . and #');
return { width, height };
}
// The tile must be a whole-number position on the map, on open floor.
export function openTile(rows: readonly string[], tile: Tile, name: string): void {
const { width, height } = readMap(rows);
const { x, y } = tile;
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= width || y >= height)
throw new TileError('off-map', `the ${name} is off the map`);
if (rows[y][x] === '#') throw new TileError('on-wall', `the ${name} is on a wall`);
}
function check(rows: readonly string[], kind: Estimate, start: Tile, goal: Tile): number {
const { width } = readMap(rows);
if (kind !== 'none' && kind !== 'manhattan' && kind !== 'double')
throw new TileError('bad-estimate', 'the estimate is none, manhattan, or double');
openTile(rows, start, 'start');
openTile(rows, goal, 'goal');
return width;
}
// Expand tiles in order of f = g + h: g is the moves taken so far, h the estimate still to go.
// An expanded tile is never expanded again, so its g must already be its best.
export function findPath(
rows: readonly string[],
start: Tile,
goal: Tile,
kind: Estimate = 'manhattan'
): Search {
const width = check(rows, kind, start, goal);
const tile = (at: number): Tile => ({ x: at % width, y: Math.floor(at / width) });
const first = start.y * width + start.x;
const target = goal.y * width + goal.x;
const g = new Map<number, number>([[first, 0]]);
const via = new Map<number, number>();
const done = new Set<number>();
const frontier = new Frontier();
const h0 = estimate(kind, start, goal);
frontier.push({ f: h0, h: h0, at: first });
const expanded: Tile[] = [];
while (frontier.size > 0) {
const { at } = frontier.pop();
if (done.has(at)) continue; // a stale entry: this tile was already expanded
done.add(at);
const here = tile(at);
expanded.push(here);
if (at === target) {
const path = [here];
for (let step = at; via.has(step); step = via.get(step)!) path.push(tile(via.get(step)!));
return { cost: g.get(at)!, path: path.reverse(), expanded };
}
for (const [dx, dy] of MOVES) {
const next = { x: here.x + dx, y: here.y + dy };
if (next.x < 0 || next.y < 0 || next.x >= width || next.y >= rows.length) continue;
if (rows[next.y][next.x] === '#') continue;
const n = next.y * width + next.x;
const offer = g.get(at)! + 1;
// Only a strictly shorter offer wins, and an expanded tile gets no offers at all.
if (!done.has(n) && offer < (g.get(n) ?? Infinity)) {
g.set(n, offer);
via.set(n, at);
const h = estimate(kind, next, goal);
frontier.push({ f: offer + h, h, at: n }); // an older, worse entry stays and is skipped
}
}
}
return { cost: null, path: [], expanded };
} // A* on a tile map. A character moves up, right, down, or left; every move costs 1.
// '#' is a wall and '.' is open floor.
const (
MaxWidth = 32
MaxHeight = 24
)
type Tile struct{ X, Y int }
// Estimate is how far the goal still looks: "none" (plain Dijkstra), "manhattan", or "double".
type Estimate string
type Search struct {
Found bool
Cost int
Path []Tile
Expanded []Tile
}
type TileError struct{ Code, Message string }
func (e *TileError) Error() string { return e.Message }
type entry struct{ f, h, at int }
// frontier is a min-heap for container/heap: smaller f first; then smaller h, the tile that
// looks closer; then reading order, row by row.
type frontier []entry
func (q frontier) Len() int { return len(q) }
func (q frontier) Less(i, j int) bool {
if q[i].f != q[j].f {
return q[i].f < q[j].f
}
if q[i].h != q[j].h {
return q[i].h < q[j].h
}
return q[i].at < q[j].at
}
func (q frontier) Swap(i, j int) { q[i], q[j] = q[j], q[i] }
func (q *frontier) Push(x any) { *q = append(*q, x.(entry)) }
func (q *frontier) Pop() any {
old := *q
last := old[len(old)-1]
*q = old[:len(old)-1]
return last
}
var moves = [4][2]int{{0, -1}, {1, 0}, {0, 1}, {-1, 0}}
func estimateOf(kind Estimate, from, goal Tile) int {
distance := abs(from.X-goal.X) + abs(from.Y-goal.Y)
switch kind {
case "none":
return 0
case "manhattan":
return distance
}
return 2 * distance
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
func readMap(rows []string) (width, height int, err error) {
height = len(rows)
if height > 0 {
width = len(rows[0])
}
if height < 1 || height > MaxHeight || width < 1 || width > MaxWidth {
return 0, 0, &TileError{"bad-map", fmt.Sprintf("maps are 1 to %d wide and 1 to %d tall", MaxWidth, MaxHeight)}
}
for _, row := range rows {
if len(row) != width {
return 0, 0, &TileError{"bad-map", "every row has the same width and uses only . and #"}
}
for i := 0; i < len(row); i++ {
if row[i] != '.' && row[i] != '#' {
return 0, 0, &TileError{"bad-map", "every row has the same width and uses only . and #"}
}
}
}
return width, height, nil
}
// openTile checks that a tile is on the map and on open floor.
func openTile(rows []string, t Tile, name string) error {
width, height, err := readMap(rows)
if err != nil {
return err
}
if t.X < 0 || t.Y < 0 || t.X >= width || t.Y >= height {
return &TileError{"off-map", "the " + name + " is off the map"}
}
if rows[t.Y][t.X] == '#' {
return &TileError{"on-wall", "the " + name + " is on a wall"}
}
return nil
}
func check(rows []string, kind Estimate, start, goal Tile) (int, error) {
width, _, err := readMap(rows)
if err != nil {
return 0, err
}
if kind != "none" && kind != "manhattan" && kind != "double" {
return 0, &TileError{"bad-estimate", "the estimate is none, manhattan, or double"}
}
if err := openTile(rows, start, "start"); err != nil {
return 0, err
}
if err := openTile(rows, goal, "goal"); err != nil {
return 0, err
}
return width, nil
}
// FindPath expands tiles in order of f = g + h: g is the moves taken so far, h the estimate
// still to go. An expanded tile is never expanded again, so its g must already be its best.
func FindPath(rows []string, start, goal Tile, kind Estimate) (Search, error) {
width, err := check(rows, kind, start, goal)
if err != nil {
return Search{}, err
}
tile := func(at int) Tile { return Tile{at % width, at / width} }
first, target := start.Y*width+start.X, goal.Y*width+goal.X
g := map[int]int{first: 0}
via := map[int]int{}
done := map[int]bool{}
h0 := estimateOf(kind, start, goal)
q := &frontier{{h0, h0, first}}
var expanded []Tile
for q.Len() > 0 {
at := heap.Pop(q).(entry).at
if done[at] {
continue // a stale entry: this tile was already expanded
}
done[at] = true
here := tile(at)
expanded = append(expanded, here)
if at == target {
path := []Tile{here}
for step, ok := via[at]; ok; step, ok = via[step] {
path = append([]Tile{tile(step)}, path...)
}
return Search{true, g[at], path, expanded}, nil
}
for _, m := range moves {
next := Tile{here.X + m[0], here.Y + m[1]}
if next.X < 0 || next.Y < 0 || next.X >= width || next.Y >= len(rows) || rows[next.Y][next.X] == '#' {
continue
}
n := next.Y*width + next.X
offer := g[at] + 1
// Only a strictly shorter offer wins, and an expanded tile gets no offers at all.
if current, reached := g[n]; !done[n] && (!reached || offer < current) {
g[n] = offer
via[n] = at
h := estimateOf(kind, next, goal)
heap.Push(q, entry{offer + h, h, n}) // an older, worse entry stays and is skipped
}
}
}
return Search{false, 0, nil, expanded}, nil
} Reading the TypeScriptA small heap, a tile index, and null
Frontier is a binary min-heap written in the file, ordered by ahead: f, then h, then the tile’s index in reading order, y × width + x. g and via are Maps keyed by that index. A search with no route returns cost: null.
Errors are a TileError whose code matches the Go version.
Reading the Gocontainer/heap and a Found flag
frontier implements heap.Interface with the same order. Search has a Found flag instead of a null cost, and PlaceWall returns a nil placement when it refuses a wall.
What is refusedMaps, estimates, and tiles
A map is 1 to 32 tiles wide and 1 to 24 tall, every row the same width, using only . and # (bad-map). The estimate is none, manhattan, or double (bad-estimate). A start or goal outside the map is off-map, and one on a wall is on-wall, checked in that order.
Both languages run the same cases, and both compare every route with a breadth-first search on 300 generated maps: no estimate and Manhattan distance always match it, and the doubled estimate never beats it.
04 / Try a decision
What did the game give up?
Fewer tiles is tempting. Decide what it costs before you ship it.
05 / Follow the cost
A good estimate changes the count, not the bound.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Expand the next tile | O(log M) | O(1) | One pop from the heap. A stale entry is popped and skipped, which is one more pop. |
| Offer a neighbor a g | O(log M) | O(1) | Work out its estimate, then push a new entry when the offer is strictly better. |
| Search to the goal | O(V log V) on a grid | O(V) | V open tiles, M neighboring pairs of them. An expanded tile only offers to tiles not yet expanded, so each pair is offered at most once: at most M + 1 pushes, and M is at most 2V on a grid. |
| No route at all | O(V log V) on a grid | O(V) | The goal never comes out, so every reachable tile is expanded before the frontier runs out. |
| Walk the route back | O(V) | O(V) | Follow each tile to the tile it was reached from. |
| Check a new wall | One search | O(V) | placeWall copies the map with the wall added and searches it once. |
The worst case is Dijkstra’s. An expanded tile offers only to neighbors that haven’t been expanded, so each neighboring pair of open tiles is offered at most once, and on a grid there are at most 2V such pairs. That is at most 2V + 1 pushes and as many pops, each O(log V).
What the estimate changes is how many tiles come out before the goal: 131 with none, 35 with Manhattan distance, 27 with it doubled, on the same arena. When the goal can’t be reached, no estimate helps. The Walled-in goal preset expands all 151 reachable tiles before the search gives up, with or without one.
06 / Give it a real job
Don’t let a wall cut off the path.
In a tower-defense game, enemies walk from a spawn tile to a goal while the player builds
walls. The usual rule: you may build anywhere, as long as a route is left. placeWall in In the wild copies the map with the new wall, searches
it once, and refuses the wall if the goal can’t be reached.
On the arena, a wall at (9, 2) is fine: the route still takes 15 moves. In the corridor, a wall in the only gap at (5, 2) is refused. Try both in the lab with Build, keeping a route, and compare Draw walls, which lets you seal the goal in.
This runs in the game’s own loop or engine, even when the game is in a browser; nothing in a component computes it.
07 / Make the call
One goal, and an estimate that never overshoots.
Reach for A* when you want one route to one goal, and you can estimate the distance left without ever claiming too much: straight-line or Manhattan distance on a map, matched to how the character moves.
Use plain Dijkstra when there is no useful estimate, or when you want the distance to every place at once. When every move costs the same, a breadth-first walk with a queue gives the same shortest routes; A* earns its keep when the estimate lets it skip most of a big map. Inflate the estimate only when a slightly longer route is acceptable and the search is too slow.
On big open grids, Godot adds shortcuts on top. Godot’s jumping_enabled option “enables
or disables jumping to skip up the intermediate points and speeds up the searching algorithm.”
SourcesThe paper and an engine’s reference
- Peter Hart, Nils Nilsson, and Bertram Raphael, “A Formal Basis for the Heuristic Determination of Minimum Cost Paths”, IEEE Transactions on Systems Science and Cybernetics 4(2), 1968, 100–107.
- Godot 4.4 class reference:
AStarGrid2D, its description, heuristics, diagonal modes, defaults, andjumping_enabled.
All checked 13 September 2026.
08 / Take the idea with you
Explain a route without saying “A*.”
“Keep a list of tiles you can reach. Each has the moves it took and a guess of the moves left. Always take the one whose total is smallest. As long as the guess never says more than the truth, the first time you reach the goal, you got there the shortest way.”
Before moving on, open the lab, choose Double Manhattan on the arena, draw one wall, and predict whether its route stays 2 moves longer than Manhattan distance finds before you check.
Connections to follow nextRelated lessons
- Dijkstra’s shortest path is this search with no estimate.
- Binary heap is the priority queue that picks the next tile.
- Queue and deque is all you need when every move costs the same and you don’t steer.