Pathfinding
Implement A* and Dijkstra pathfinding for grid and graph-based worlds.
Introduction
A* is the industry-standard pathfinding algorithm used in everything from RTS games to navigation apps. This guide explains how A* works, how to optimize it with heuristics and binary heaps, and how to implement it in Appley for grid-based and graph-based worlds.
Advanced Guide 13 of 25
Screenshot
How Professional Games Do It
U
n
A
*
C
h
F
o
U
s
A
*
H
a
D
i
O
p
F
o
C
a
I
f
FluxScript Implementation
Here is how to build this system in Appley using FluxScript (Appley Scripting):
// A* pathfinding with binary heap priority queue
const GRID_W = 100
const GRID_H = 100
grid = null // 0=walkable, 1+=cost multiplier, -1=wall
on start {
grid = new Int8Array(GRID_W * GRID_H)
// Populate grid from terrain
for x = 0 to GRID_W {
for y = 0 to GRID_H {
terrainType = terrain.getTile(x, y)
if terrainType == "water" grid[x + y * GRID_W] = -1
else if terrainType == "mud" grid[x + y * GRID_W] = 4
else if terrainType == "grass" grid[x + y * GRID_W] = 2
else grid[x + y * GRID_W] = 1 // road/walkable
}
}
}
func findPath(startX, startY, endX, endY) {
// A* with binary heap
open = new PriorityQueue((a, b) -> a.f < b.f)
closed = new Set()
gScore = {}
cameFrom = {}
startKey = startX + "," + startY
gScore[startKey] = 0
open.enqueue({
x: startX, y: startY, f: heuristic(startX, startY, endX, endY)
})
while open.size() > 0 {
current = open.dequeue()
currentKey = current.x + "," + current.y
// Goal reached
if current.x == endX && current.y == endY {
return reconstructPath(cameFrom, currentKey)
}
closed.add(currentKey)
// Check 8 neighbors
neighbors = [
[1,0], [-1,0], [0,1], [0,-1], // 4-connected
[1,1], [1,-1], [-1,1], [-1,-1] // diagonals
]
for n = 0 to neighbors.length {
nx = current.x + neighbors[n][0]
ny = current.y + neighbors[n][1]
// Bounds check
if nx < 0 || nx >= GRID_W continue
if ny < 0 || ny >= GRID_H continue
nKey = nx + "," + ny
if closed.has(nKey) continue
// Terrain cost
tileCost = grid[nx + ny * GRID_W]
if tileCost < 0 continue // Wall
// Diagonal movement costs more
moveCost = (neighbors[n][0] != 0 && neighbors[n][1] != 0) ? 1.414 : 1.0
tentativeG = gScore[currentKey] + moveCost * tileCost
// Check if this is a better path
if gScore[nKey] == null || tentativeG < gScore[nKey] {
cameFrom[nKey] = currentKey
gScore[nKey] = tentativeG
f = tentativeG + heuristic(nx, ny, endX, endY)
open.enqueue({ x: nx, y: ny, f: f })
}
}
}
return null // No path found
}
// Manhattan heuristic (never overestimates on 4-connected grid)
func heuristic(x1, y1, x2, y2) {
return math.abs(x1 - x2) + math.abs(y1 - y2)
}
// Reconstruct path from cameFrom map
func reconstructPath(cameFrom, endKey) {
path = [endKey]
current = endKey
while cameFrom[current] != null {
current = cameFrom[current]
path.unshift(current)
}
// Convert keys to positions
result = []
for key in path {
parts = string.split(key, ",")
result.push(vec3(math.parse(parts[0]), 0, math.parse(parts[1])))
}
return result
}
// Path caching for shared destinations
pathCache = {}
func findPathCached(start, end) {
cacheKey = start.x + "," + start.z + "->" + end.x + "," + end.z
if pathCache[cacheKey] != null {
return pathCache[cacheKey]
}
path = findPath(start.x, start.z, end.x, end.z)
pathCache[cacheKey] = path
return path
}
// Invalidate cache when terrain changes
on event("terrainChanged", data) {
pathCache = {} // Clear all cached paths
}Node Graph Equivalent
The same system implemented as a Node Graph in Appley Node Scripting:
[A* Algorithm]
--> [Init: open=PriorityQueue, closed=Set, gScore={}] --> [Start: g=0, f=heuristic]
--> [While: open not empty]
--> [Dequeue: lowest-f node]
--> [Branch: goal reached?]
+-- True --> [Reconstruct path] --> [Return]
--> [Add: to closed set]
--> [For Each: 8 neighbors]
--> [Bounds + wall check]
--> [Calculate: tentative g = currentG + moveCost * tileCost]
--> [Branch: better than known g?]
+-- True --> [Update: cameFrom, gScore, f]
--> [Enqueue: into open]
--> [Return: null (no path)]
[Function: heuristic] --> [Manhattan: |x1-x2| + |y1-y2|]
[Path Caching]
--> [Check: cache key exists] --> [Return cached]
--> [Else: compute + cache]
--> [On terrainChanged: clear cache]Professional Optimization Tips
How AAA studios optimize these systems — applicable to your Appley project:
- Use a binary heap for the open set. A simple array gives O(n) extract-min. A binary heap gives O(log n). For a 100x100 grid, this is the difference between 10ms and 500ms per pathfinding call.
- Use Manhattan heuristic for 4-connected grids, Euclidean for 8-connected. The heuristic must never overestimate real cost (admissible). Overestimating produces fast but non-optimal paths.
- Cache paths for shared destinations. If 10 enemies path to the same target, compute once and share. Cache invalidation on terrain changes is cheaper than recomputing 10 paths.
- Use Jump Point Search for uniform grids. JPS is 10-20x faster than A* on grids with uniform movement costs. It jumps in straight lines, skipping symmetric path equivalents.
Common Mistakes
Using a sorted array instead of a heap
Array-based priority queues are O(n) for extract-min. For large grids, this makes A* 50x slower. Always use a binary heap or Fibonacci heap.
Heuristic overestimates real cost
If the heuristic is higher than the actual cost, A* finds a path but not the optimal one. Use admissible heuristics (Manhattan for grids, straight-line for graphs).
