Skip to content

Flow Fields

A flow field stores a direction for every reachable cell. Many agents can sample the same field, so it is a good fit for crowds moving toward a shared objective.

SpatialPlugin automatically tracks FlowFieldTarget entities and builds the fields needed by FlowFieldAgent entities.

ComponentImportant fieldsPurpose
FlowFieldAgentactive, group, profile, speedSelects a field and steers the entity’s Velocity
FlowFieldTargetgroupMask, influencePublishes a destination to one or more agent groups
GridCellx, yPlaces an entity in the navigation and spatial grids
Positionx, ySupplies the current world position
Velocityx, yReceives steering and is integrated by kinematics

Position comes from @shell/transform and Velocity comes from @shell/kinematics. Applications using @shell/engine-2d can import both from that aggregate.

import { Define, Position, Velocity } from '@shell/engine-2d';
import { FlowFieldAgent, FlowFieldTarget, GridCell } from '@shell/spatial';
export const Exit = Define.prefab().with(Position, { x: 960, y: 540 }).with(FlowFieldTarget);
export const Enemy = Define.prefab()
.with(Position)
.with(Velocity)
.with(GridCell)
.with(FlowFieldAgent, {
speed: 80,
active: true,
});

Set active to false to stop an agent from following its field. Its planar velocity decays rather than stopping instantly. A direction of zero, such as at the target or in an unreachable cell, has the same deceleration behavior.

Targets with Position are kept synchronized automatically. For a fixed destination that does not need a world position, assign its cell directly:

const CellTarget = Define.prefab().with(GridCell, { x: 12, y: 8 }).with(FlowFieldTarget);

Cell coordinates must be integer coordinates inside the configured grid.

Groups let different agents follow different objectives. Define each group as a unique, single-bit flag:

export const NavigationGroup = {
Player: 1,
Fortress: 2,
Resources: 4,
};

An agent selects exactly one group. A target mask can publish the same target to several groups:

const PlayerTarget = Define.prefab()
.with(Position)
.with(FlowFieldTarget, {
groupMask: NavigationGroup.Player | NavigationGroup.Fortress,
});
const Chaser = Define.prefab()
.with(Position)
.with(Velocity)
.with(GridCell)
.with(FlowFieldAgent, { group: NavigationGroup.Player, speed: 100 });

The defaults are group 1 for both components. A target with groupMask: 0 is ignored. Agent groups must contain exactly one bit; do not pass a combined mask to FlowFieldAgent.group or to flowFields.get().

When a group has multiple targets, agents are directed toward the least expensive destination. Increasing a target’s positive influence makes it more attractive and gives it a larger region of the field. Moving, removing, regrouping, or changing the influence of a target automatically rebuilds the affected fields.

Profiles let agents in the same target group use different movement maps. For example, profile 0 can represent ground movement while profile 1 ignores ground obstacles for flying agents:

const flowFields = world.get('flowFields');
const ground = flowFields.getProfile(0);
ground.markBlocked(10, 5);
const GroundAgent = Define.prefab()
.with(Position)
.with(Velocity)
.with(GridCell)
.with(FlowFieldAgent, { group: NavigationGroup.Player, profile: 0 });
const FlyingAgent = Define.prefab()
.with(Position)
.with(Velocity)
.with(GridCell)
.with(FlowFieldAgent, { group: NavigationGroup.Player, profile: 1 });

Profiles are created lazily and share the configured dimensions and cell size. Entity-based Obstacle and TerrainCost footprints update only profile 0; edit other profiles through their NavigationProfile API when they need a different map.

Use the flowFields resource to inspect or manually sample a field:

const manager = world.get('flowFields');
const field = manager.get(0, NavigationGroup.Player);
const direction: [number, number] = [0, 0];
field.sampleDirectionInto(position.x, position.y, direction);

sampleDirectionInto() is the allocation-free choice for an update loop. The convenience method sampleDirection(x, y) returns a new tuple. getDirection(cellX, cellY) reads one cell without world-space interpolation.

The flowField resource is an alias for flowFields.get(0, 1). Prefer flowFields in code that uses custom groups or profiles.

Normally the plugin owns building and invalidation. If gameplay changes objectives outside the ECS target tracker, the manager also exposes markDirty(profile, group), markObjectiveDirty(group), markProfileDirty(profile), and markAllDirty().

Flow fields rebuild incrementally during Update. Tune the aggregate options when field changes cause visible latency or update spikes:

world.addPlugin(
SpatialPlugin.config({
cellSize: 32,
width: 4096,
height: 4096,
flowFieldMaxHeapPopsPerUpdate: 8192,
flowFieldMaxFieldsStartedPerUpdate: 8,
}),
);

Larger budgets publish changed routes sooner but allow more work in one update. Construction is shared fairly across fields currently used by agents.