Neural Cellular Automata for Pathfinding
Cellular Automata From First Principles 44: Neural Cellular Automata for Pathfinding
Until now our neural cellular automata have learned to make and maintain shapes.
Now we will ask them to compute something.
Given:
walls
start
finish
can a shared local update rule discover a path?
This is an unusually good task for an NCA because classical pathfinding already has a local interpretation.
Breadth-first search expands a frontier:
start
↓
nearby reachable cells
↓
next layer of reachable cells
↓
...
↓
goal
That looks remarkably like information propagating through a cellular system.
Represent the maze as channels
Instead of one integer grid, keep semantics separate:
channel 0 = wall mask
channel 1 = start mask
channel 2 = goal mask
channel 3 = predicted reachable field
channel 4 = predicted path field
channels 5+ = hidden state
In PyTorch:
import torch
CHANNELS = 16
def make_state(walls, start, goal):
h, w = walls.shape
state = torch.zeros(1, CHANNELS, h, w)
state[:, 0] = torch.as_tensor(walls, dtype=torch.float32)
state[:, 1] = torch.as_tensor(start, dtype=torch.float32)
state[:, 2] = torch.as_tensor(goal, dtype=torch.float32)
return state
The first three channels are immutable problem inputs.
The other channels are working memory.
Preserve the maze while updating state
Our learned rule should not rewrite the walls.
def clamp_inputs(next_state, original_state):
next_state[:, :3] = original_state[:, :3]
return next_state
That gives the model a stable environment while its hidden channels evolve.
A wavefront is already local computation
Classical BFS can be expressed as repeated local propagation.
Suppose frontier marks cells reached on the previous step.
import torch.nn.functional as F
CROSS = torch.tensor(
[[0.0, 1.0, 0.0],
[1.0, 1.0, 1.0],
[0.0, 1.0, 0.0]]
)[None, None]
def expand(frontier, blocked):
neighbors = F.conv2d(frontier, CROSS, padding=1)
reachable = (neighbors > 0).float()
return reachable * (1.0 - blocked)
Repeated expansion spreads information one local neighborhood at a time.
An NCA does not need to invent locality.
It needs to learn what local information should propagate and how to store enough history to reconstruct a useful solution.
Train on distance-to-goal first
Asking for a thin exact path immediately is difficult.
A smoother training target is a distance field.
For every reachable cell, precompute its shortest-path distance to the goal with BFS.
Normalize it:
0.0 = goal
1.0 = farthest reachable cell
Then train one output channel to reproduce this field.
def distance_loss(state, target_distance, reachable_mask):
prediction = state[:, 3:4]
error = (prediction - target_distance).pow(2)
return (error * reachable_mask).sum() / reachable_mask.sum().clamp_min(1)
Why is this useful?
Because a shortest path can later be recovered by descending the distance field.
Instead of learning:
which exact one-cell-wide route should I draw?
we first learn:
how far is each location from the goal?
That is a more local, redundant representation.
Roll out until information has time to travel
A maze cell cannot instantly know about a goal forty cells away.
With a radius-one neighborhood, information can move only a limited distance per update.
So training must respect the computational diameter of the problem.
def rollout(model, state, steps, frozen_inputs):
for _ in range(steps):
state = model(state)
state = clamp_inputs(state, frozen_inputs)
return state
For a 32×32 maze we might sample:
steps = torch.randint(32, 65, ()).item()
For larger mazes, allow longer rollouts.
This is not merely a training hyperparameter.
Iteration count is computational depth.
Extract a path by local descent
Once a distance-like field exists, path extraction can be completely deterministic.
def descend_path(distance, start, goal, walls):
y, x = start
path = [(y, x)]
for _ in range(distance.size):
if (y, x) == goal:
break
choices = []
for dy, dx in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
ny, nx = y + dy, x + dx
if 0 <= ny < distance.shape[0] and 0 <= nx < distance.shape[1]:
if not walls[ny, nx]:
choices.append((distance[ny, nx], ny, nx))
if not choices:
break
_, y, x = min(choices)
path.append((y, x))
return path
This deliberately separates:
learn distributed value propagation
↓
use a simple deterministic decoder
We do not force the NCA to learn machinery that ordinary code already handles reliably.
Compare against BFS
The baseline is not optional.
For every test maze record:
BFS reachable?
BFS shortest distance
NCA reachable prediction
NCA extracted path length
NCA valid path?
NCA excess path length
A useful metric is:
path_ratio = nca_path_length / bfs_path_length
with 1.0 meaning shortest-path performance.
Also measure outright failures separately.
A mean ratio that ignores unsolved mazes can be badly misleading.
Why learn BFS-like computation at all?
For ordinary mazes, you should simply use BFS, Dijkstra or A*.
They are explicit, efficient and exact.
The purpose of this experiment is different.
We want to discover whether a single shared local learned rule can acquire an iterative algorithm whose computation scales across a grid.
That makes pathfinding a laboratory for:
algorithmic learning
local communication
recurrent computation
generalization across spatial size
hidden-state analysis
The critical test is not the training maze
A network can memorize distributions in subtle ways.
So the important experiment begins after training.
In the next chapter we will train on small mazes and evaluate on larger, denser and structurally different mazes.
That will tell us whether the NCA learned a transferable local procedure or merely adapted to the geometry of its training set.