Imagine you are building a real-time strategy game. The player selects two hundred units and right-clicks a point on the other side of the map. Every single one of those units needs a path to the destination — right now. If you reach for A* pathfinding, you are running two hundred individual searches across the same navigation graph, and many of those paths will overlap almost entirely. The cost adds up fast, and your frame budget disappears.
Flow fields solve this problem elegantly. Instead of computing a path per agent, you compute a single vector field that covers the entire navigable space. Every cell in the field stores a direction — the best direction to move toward the target. An agent standing on any cell simply reads the vector beneath its feet and walks that way. One computation serves every agent on the map, and each agent's per-frame lookup is $O(1)$.
The Scaling Problem with Per-Agent Pathfinding
A* is the gold standard for single-agent pathfinding, but its cost is proportional to the number of nodes it explores. On a grid of $N$ cells, a single A* search is roughly $O(N \log N)$ in the worst case. For $k$ agents, you pay that cost $k$ times. When $k$ reaches hundreds or thousands — common in RTS games — the total cost becomes prohibitive.
Flow fields flip the equation. The upfront cost to build the field is comparable to a single Dijkstra search: $O(N \log N)$ with a priority queue, or $O(N)$ with a simple breadth-first flood on uniform-cost grids. After that, every agent reads its direction in constant time. The more agents you have, the better the amortized cost per agent becomes.
How Flow Fields Work: The Three-Layer Algorithm
A flow field is constructed in three passes over the grid. Each pass produces a layer of data that feeds into the next:
- Cost Field — stores the traversal cost of each cell (terrain difficulty, obstacles)
- Integration Field — stores the total accumulated cost from each cell to the target
- Flow Field — stores a unit-length direction vector pointing toward the neighbor with the lowest integrated cost
Once all three layers are computed, agents sample the flow field at their world position and move accordingly. Let's walk through each layer.
Layer 1: The Cost Field
The cost field is a 2D grid where each cell holds a value representing how expensive it is to traverse. A flat, open tile might cost 1. Mud or swamp might cost 3. A wall or impassable obstacle is marked with an infinite cost (or a sentinel value like 255).
// Initialize cost field — 0 means passable, 255 means impassable
const GRID = 32;
const cost = new Uint8Array(GRID * GRID);
// Mark some cells as walls
cost[10 * GRID + 5] = 255; // row 10, col 5
cost[10 * GRID + 6] = 255;
cost[10 * GRID + 7] = 255;The cost field rarely changes during gameplay. It is usually built once from your tilemap or navmesh data and updated only when the environment changes — a bridge is destroyed, a building is placed, or terrain is deformed.
Layer 2: The Integration Field
The integration field answers the question: what is the cheapest total cost to reach the target from this cell? It is computed using a flood-fill algorithm starting from the target cell, expanding outward. This is essentially Dijkstra's algorithm on a grid.
The target cell starts with an integrated cost of zero. For every neighboring cell $\mathbf{n}$ of a processed cell $\mathbf{m}$, the integration cost is:
$$I(\mathbf{n}) = \min\Big(I(\mathbf{n}),\; I(\mathbf{m}) + C(\mathbf{m}, \mathbf{n})\Big)$$
where $C(\mathbf{m}, \mathbf{n})$ is the movement cost from $\mathbf{m}$ to $\mathbf{n}$. For cardinal neighbors (up, down, left, right) the base movement cost is 1.0. For diagonal neighbors it is $\sqrt{2} \approx 1.414$. The cell's terrain cost from the cost field is added on top.
function computeIntegrationField(cost, target, gridSize) {
const integration = new Float32Array(gridSize * gridSize);
integration.fill(65535); // "infinity"
const ti = target.y * gridSize + target.x;
integration[ti] = 0;
// Neighbor offsets: [dx, dy, moveCost]
const dirs = [
[-1,0,1], [1,0,1], [0,-1,1], [0,1,1],
[-1,-1,1.414], [-1,1,1.414], [1,-1,1.414], [1,1,1.414]
];
const queue = [ti];
const visited = new Uint8Array(gridSize * gridSize);
while (queue.length > 0) {
// Find cell with lowest cost (simple linear scan)
let minIdx = 0;
for (let q = 1; q < queue.length; q++) {
if (integration[queue[q]] < integration[queue[minIdx]]) minIdx = q;
}
const ci = queue.splice(minIdx, 1)[0];
if (visited[ci]) continue;
visited[ci] = 1;
const cx = ci % gridSize;
const cy = (ci - cx) / gridSize;
for (const [dx, dy, moveCost] of dirs) {
const nx = cx + dx, ny = cy + dy;
if (nx < 0 || nx >= gridSize || ny < 0 || ny >= gridSize) continue;
const ni = ny * gridSize + nx;
if (cost[ni] === 255 || visited[ni]) continue;
const newCost = integration[ci] + moveCost + cost[ni];
if (newCost < integration[ni]) {
integration[ni] = newCost;
queue.push(ni);
}
}
}
return integration;
}After the flood completes, every reachable cell contains the shortest-path distance to the target. Cells that are walled off or unreachable retain their initial "infinity" value. The integration field forms a smooth distance gradient radiating outward from the target — think of it like a topographic map where the target sits at the lowest elevation.
Layer 3: The Flow Field (Gradient Descent)
The flow field is derived directly from the integration field. For each cell, we look at all of its neighbors, find the one with the lowest integration value, and store a normalized direction vector pointing toward that neighbor:
$$\mathbf{d}(\mathbf{n}) = \text{normalize}\!\left(\underset{\mathbf{m} \in N(\mathbf{n})}{\arg\min}\; I(\mathbf{m}) - \mathbf{n}\right)$$
In plain terms: each cell's flow vector is the direction of steepest descent on the integration field. This is the discrete equivalent of computing the negative gradient $-\nabla I$.
function computeFlowField(integration, cost, gridSize) {
const flowX = new Float32Array(gridSize * gridSize);
const flowZ = new Float32Array(gridSize * gridSize);
const dirs = [
[-1,0], [1,0], [0,-1], [0,1],
[-1,-1], [-1,1], [1,-1], [1,1]
];
for (let y = 0; y < gridSize; y++) {
for (let x = 0; x < gridSize; x++) {
const idx = y * gridSize + x;
if (cost[idx] === 255) continue;
let bestCost = integration[idx];
let bx = 0, bz = 0;
for (const [dx, dy] of dirs) {
const nx = x + dx, ny = y + dy;
if (nx < 0 || nx >= gridSize || ny < 0 || ny >= gridSize) continue;
const nc = integration[ny * gridSize + nx];
if (nc < bestCost) {
bestCost = nc;
bx = dx;
bz = dy;
}
}
const len = Math.sqrt(bx * bx + bz * bz) || 1;
flowX[idx] = bx / len;
flowZ[idx] = bz / len;
}
}
return { flowX, flowZ };
}The result is a grid of unit vectors. Visualized as arrows, the flow field reveals smooth, curving paths that weave around obstacles and converge on the target from every direction. This is the data structure that agents will query at runtime.
Moving Agents: Sampling with Bilinear Interpolation
An agent's world position rarely falls exactly on a grid cell center. To get smooth, continuous movement, we sample the flow field using bilinear interpolation — blending the four surrounding cell vectors based on the agent's fractional position within the cell.
Given an agent at continuous grid coordinates $(x, y)$, let $x_0 = \lfloor x \rfloor$, $y_0 = \lfloor y \rfloor$, $t_x = x - x_0$, and $t_y = y - y_0$. The interpolated flow vector is:
$$\mathbf{F}(x, y) = (1 - t_x)(1 - t_y)\,\mathbf{F}_{00} + t_x(1 - t_y)\,\mathbf{F}_{10} + (1 - t_x)\,t_y\,\mathbf{F}_{01} + t_x\, t_y\,\mathbf{F}_{11}$$
where $\mathbf{F}_{00}$, $\mathbf{F}_{10}$, $\mathbf{F}_{01}$, $\mathbf{F}_{11}$ are the flow vectors at the four surrounding grid cells. The agent then updates its position each frame:
$$\mathbf{p}_{t+\Delta t} = \mathbf{p}_t + \mathbf{F}(\mathbf{p}_t) \cdot v \cdot \Delta t$$
where $v$ is the agent's movement speed and $\Delta t$ is the time step.
function sampleFlowField(worldX, worldZ, flowX, flowZ, gridSize, cellSize) {
// Convert world position to continuous grid coordinates
const gx = worldX / cellSize + gridSize * 0.5;
const gz = worldZ / cellSize + gridSize * 0.5;
const x0 = Math.floor(gx);
const z0 = Math.floor(gz);
if (x0 < 0 || x0 >= gridSize - 1 || z0 < 0 || z0 >= gridSize - 1) {
return { x: 0, z: 0 }; // out of bounds
}
const tx = gx - x0;
const tz = gz - z0;
const i00 = z0 * gridSize + x0;
const i10 = i00 + 1;
const i01 = i00 + gridSize;
const i11 = i01 + 1;
return {
x: flowX[i00]*(1-tx)*(1-tz) + flowX[i10]*tx*(1-tz)
+ flowX[i01]*(1-tx)*tz + flowX[i11]*tx*tz,
z: flowZ[i00]*(1-tx)*(1-tz) + flowZ[i10]*tx*(1-tz)
+ flowZ[i01]*(1-tx)*tz + flowZ[i11]*tx*tz
};
}Bilinear interpolation eliminates the jerky, grid-aligned movement you would get from simply snapping to the nearest cell's vector. Agents glide smoothly through the field, curving naturally around obstacles.
Games That Use Flow Fields
Flow field pathfinding became widely known through Elijah Emerson's work on Supreme Commander 2 (2010), where it handled thousands of simultaneous unit movements across large maps. The technique was later adopted by Planetary Annihilation (2014), another large-scale RTS that needed to move massive armies efficiently.
Beyond RTS games, flow fields appear in many forms:
- Tower defense games like Kingdom Rush and Fieldrunners use flow fields to route waves of enemies around player-placed structures. When the player builds a new tower, the flow field is recomputed and all enemies seamlessly reroute.
- Crowd simulation in games like Hitman and Assassin's Creed uses flow-field-like techniques to steer large numbers of NPCs through complex environments without per-agent pathfinding.
- Particle effects in many games use noise-based flow fields (curl noise) to create organic-looking smoke, fire, and fluid motion. These artistic flow fields are not pathfinding tools, but they share the same underlying data structure — a grid of direction vectors that guide movement.
Optimizations and Extensions
The basic algorithm described above works well for moderate grid sizes, but production implementations add several optimizations to scale further:
Sector-Based Computation
Instead of computing a flow field over the entire map, divide the world into sectors (chunks). Only compute flow fields for sectors that contain active agents. When an agent crosses a sector boundary, compute the next sector on demand. This dramatically reduces memory and computation for large maps.
Hierarchical Flow Fields
Use a coarse-grained flow field for long-distance navigation and a fine-grained field for local movement. The coarse field guides agents across the map at low cost, while the fine field handles obstacle avoidance in the agent's immediate vicinity. This mirrors the hierarchical pathfinding approach used in many production games.
Caching and Temporal Coherence
If the cost field has not changed and the target is the same, the flow field does not need to be recomputed. Cache flow fields keyed by target position and cost-field version. In many RTS games, you might have only a handful of unique targets at any given time, so the cache stays small.
Local Avoidance
Flow fields handle static obstacles naturally, but agents also need to avoid each other. Combine the flow field with a local avoidance layer — such as velocity obstacles (ORCA) or simple separation forces — to prevent agents from stacking on top of each other. The flow field provides the macro direction, and local avoidance handles the micro adjustments.
When to Use Flow Fields vs. A*
Flow fields are not universally better than A*. The right choice depends on your game's requirements:
- Use flow fields when many agents share the same destination, when the map is grid-based, and when you need low per-agent cost at runtime.
- Use A* when agents have unique destinations, when the navigation graph is sparse (navmesh), or when you have few agents and the overhead of a full-field computation is not justified.
- Many games combine both: A* for unique-destination pathfinding and flow fields for group movement toward shared objectives.
Flow fields are one of those techniques that, once you understand them, change how you think about pathfinding in games. The three-layer algorithm is straightforward to implement, scales beautifully with agent count, and produces smooth, natural-looking movement. Try clicking around the interactive demo above to see the field recalculate in real time — set a target, place some walls, and watch how every particle in the field instantly adjusts its course.
Comments