Flow field steering from distance labels
When many agents share one destination, flow field steering lets them reuse
global guidance instead of calculating a complete path for each agent. This
tutorial builds that steering from a single DistanceFieldProduct, which
labels every reachable tile with its unit-cost distance to the goal. Each
agent chooses its next step on demand.
Tess retains distance labels here, not the per-tile directions of a conventional flow field. The result demonstrates the same shared-goal steering pattern while keeping tie-breaking visible and configurable.
This tutorial uses a dense, unit-cost, orthogonal 32×24 world. The native self-check and the browser view run the same C++ model. The presentation starts paused; choose Start, a goal preset, or a passable tile in the grid.
API used
DistanceFieldProduct
retains public distance labels for dense worlds. The model builds it with
build_distance_field_product() and reads labels with distance_at().
Open the flow field steering example in a separate page.
The two agents starting on the same tile overlap deliberately. A shared field provides global guidance; it does not coordinate occupancy.
Build once, read at each step
The model gives build_distance_field_product() one goal. Changing the goal
rebuilds the product synchronously before another agent step is allowed. A
label of zero means the agent is at the goal. The unreachable sentinel means
there is no route through the currently passable topology. Both states hold
the agent and appear in the textual status beneath the canvas.
For a unit-cost orthogonal world, a legal move must have a distance exactly one less than the current tile. Merely choosing the smallest neighbouring number would hide the invariant this example is meant to teach.
The compiled model uses north, east, south, then west as its fixed direction order. The first legal descent wins, giving deterministic tie-breaking when several shortest continuations exist:
for (auto& agent : impl_->agents) {
const auto current_distance =
impl_->product.distance_at<World>(agent.position);
if (current_distance == 0) {
agent.state = AgentState::AtGoal;
continue;
}
if (current_distance == tess::DistanceFieldProduct::unreachable_distance) {
agent.state = AgentState::Unreachable;
continue;
}
auto descended = false;
for (const auto direction : kDirectionOrder) {
const auto neighbor = tess::Coord2{
agent.position.x + direction.x,
agent.position.y + direction.y,
};
if (!in_bounds(static_cast<int>(neighbor.x),
static_cast<int>(neighbor.y)) ||
impl_->world.field<PassableTag>(neighbor) == 0) {
continue;
}
const auto neighbor_distance =
impl_->product.distance_at<World>(neighbor);
if (neighbor_distance == current_distance - 1) {
agent.position = neighbor;
agent.state =
neighbor_distance == 0 ? AgentState::AtGoal : AgentState::Moving;
++moved;
descended = true;
break;
}
}
if (!descended) {
agent.state = AgentState::Unreachable;
}
}
This is on-demand next-step selection, not complete-path reconstruction. The agent stores only its current tile and state. A complete path remains useful when a consumer needs to inspect, reserve, serialize, or compare the whole route before movement begins.
Distance labels are not a retained flow field
A retained direction field stores the chosen outgoing direction at every tile. That saves neighbour reads during movement, but consumes additional memory and bakes one tie policy into the product. Retaining directions becomes worthwhile when very large agent counts repeatedly read an unchanged field and profiling shows that next-step selection matters.
DistanceFieldProduct deliberately retains distance labels instead. Its
retained-product boundary is dense worlds: the product reserves storage for
the whole shape. Sparse or streamed worlds should not treat this example as a
promise that all distant labels can remain materialized.
Weighted costs change the equality
The “one less” rule depends on every move costing one. In the general weighted case, a valid descent satisfies the transition-cost Bellman equality:
current_distance = transition_cost(current, neighbour) + neighbour_distance
For the default orthogonal entry-cost model,
transition_cost(current, neighbour) is entry_cost(neighbour), so the rule
specializes to the entry-cost Bellman equality.
Use the same cost convention that built the product; do not simply choose the smallest label. Diagonal step multipliers and provider-defined edges are part of that transition cost. The equality proves the selected edge lies on a minimum-cost continuation.
Guidance is only one layer
This example moves independent agents and permits overlap. Production movement may add reservations for future occupancy, congestion costs for route choice, collision avoidance for simultaneous moves, and local steering for continuous motion. Those systems can consume the same global guidance, but none is supplied by a distance product itself.