Introduction

bevy_northstar is a Hierarchical Pathfinding plugin for Bevy.

The crate provides:

  • Pathfinding Grids: A grid defines the navigable area and stores precalculated neighbors, chunks, entrances, and internal paths used for pathfinding.

  • Pathfinding Systems: Bevy systems to handle pathfinding and collision avoidance for you.

  • Pathfinding Algorithms: You can call the pathfinding functions directly if you desire to handle the pathfinding logic in your own systems or just want to do a one off call.

  • Debugging Tools: Easily visualize the grid and calculated paths to troubleshoot any tilemap and pathfinding issues.

The crate is currently designed for use with 2d and 3d grid based tilemaps. It is not dependent on any specific tilemap Bevy crate, though it's been designed for ease of use with bevy_ecs_tilemap and any related crates such as bevy_ecs_tiled and bevy_ecs_ldtk.

How It Works

Hierarchical pathfinding works by dividing the map into chunks and identifying viable connections between each chunk at their edges. This creates a network of high-level graph nodes across the map.

Instead of searching the entire map at once, pathfinding can use these high-level nodes to run a simplified A* search over a much smaller set of positions, which significantly speeds up the process.

Additionally, for each high-level node, paths through the local chunk to all other chunk nodes can be precomputed and stored. After the high-level search is complete, these precomputed paths are reused, so the algorithm doesn't need to search through every chunk individually.

Once the high-level path is found, it is refined using a line tracing algorithm to make the path more optimal.

In the picture above, the high-level path is shown in blue, and the red line shows the final refined path built from it.

Who This Crate Is For

The current target for this crate is for games with large tile based maps and designed to support large sim games like Dwarf Fortress or Rimworld, RPGs with grid like movement like Fallout 1/2, and Roguelikes.

It does not support Flowfield pathfinding which is better suited in RTS games where many agents are often pathfinding to the single goal. HPA* is better suited for many agents with their own unique goals.

Nav meshes are not supported yet but are planned; they’re better suited for games with freeform movement, think Zelda-style movement, rather than grid-constrained paths. You can certainly abstract the grid-based paths to freeform movement though.

Other crates that might fit those projects better:

Flowfield

DIY A*

  • pathfinding: The general pathfinding crate is a good place to start if you just want to implement simple A*.

3D: Why and When

Even if your game is technically 2D, many 2D tilemap games feature 3D movement often referred to as 2.5D. Isometric games are a great example, but even top-down games like Zelda often include a concept of depth. There are also fully 3D games that use grid-based movement in three dimensions, such as X-COM.

If your 2D game allows regular movement between floors or terrain levels, you’ll likely want to use a 3D grid.

Quick Start

Cargo Setup

Add required dependencies to your Cargo.toml file:

[dependencies]
bevy = "0.16"
bevy_northstar = "0.3"

Features

Stats

Enable statistics reporting, includes pathfinding, collision avoidance, and grid build time stats. Useful for diagnosing frame spikes. See Debugging.

Parallel (Enabled by default)

Uses rayon to build the navigation grid in parallel. This greatly speeds up grid rebuilds, but may cause issues in WASM builds. To disable parallelism (e.g. for WASM support), disable default features:

bevy_northstar = { version = "0.3", default-features = false }

⚠️ Warning
If you disable parallel, rebuilding the grid during gameplay (e.g. mining, explosions, etc.) can be quite expensive. You will want to confine your updates in a single frame to the least amount of affected chunks.

Debug Optimization

Pathfinding and grid algorithms involve a lot of branching, which can make debug builds significantly slower. You can set the optimization settings for this crate so you can still debug your game without the performance hit.

Follow Bevy Quickstart Cargo Workspaces to add opt-level = 3 to your Cargo.toml dependencies.

Or alternatively add the following to your Cargo.toml:

[profile.dev.package."bevy_northstar"]
opt-level = 3

Crate Usage

The basic requirements to use the crate are to spawn an entity with a Grid component, adjust the navigation data, and then call Grid::build() so the chunk entrances and internal paths can be calculated.

To use the built-in pathfinding systems for the crate, add the NorthstarPlugin specifying the Neighborhood to use.

CardinalNeighborhood (North, East, South, West) is a good neighborhood to start with. See Neighborhoods for the full list and details.

use bevy::prelude::*;
use bevy_northstar::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        // Add the Northstar Plugin with a selected neighborhood to use the built-in pathfinding systems
        .add_plugins(NorthstarPlugin::<CardinalNeighborhood>::default())
        .add_systems(Startup, (startup, build_grid.after(startup)))
        .run();
}

fn startup(mut commands: Commands) {
    // Configure the grid
    let grid_settings = GridSettingsBuilder::new_2d(64, 48).chunk_size(16).build();

    // Spawn the grid used for pathfinding.
    commands.spawn(Grid::<CardinalNeighborhood>::new(&grid_settings));
}

fn build_grid(grid: Single<&mut Grid<CardinalNeighborhood>>) {
    let mut grid = grid.into_inner();

    // Let's set the position 8, 8 to a wall
    grid.set_nav(UVec3::new(8, 8, 0), Nav::Impassable);

    // The default settings set every position as passable but for demonstration let's set one
    // Nav::Passable takes a movement cost which determines how expensive it is to move to that position.
    grid.set_nav(UVec3::new(4, 4, 0), Nav::Passable(1));

    info!("Building the grid...");

    // The grid needs to be built after setting the points.
    // Building the grid will calculate the chunk entrances and cache internal paths.
    grid.build();

    info!("Grid built successfully!");
}

Grid Generic Neighborhood Shorthand Types

The following shorthand types are also available for constructing and referencing a Grid::<N>.

  • CardinalGrid
  • CardinalGrid3d
  • OrdinalGrid
  • OrdinalGrid3d

Rather than Grid<CardinalNeighborhood>::new you can use CardinalGrid::new. Rather than querying Single<&mut Grid<CardinalNeighborhood>> you can query Single<&mut CardinalGrid>

Quick NorthstarPlugin Pathfinding System Usage

Using the example above we can create a system to look for entities without a path and give them a goal.

If you're not interested in using the built-in systems, see Pathfinding for examples.


#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_northstar::prelude::*;

fn spawn(mut commands: Commands) {
    let grid_settings = GridSettingsBuilder::new_2d(64, 48).chunk_size(16).build();
    // Store the spawned entity to relate entities to this grid.
    let grid_entity = commands.spawn(CardinalGrid::new(&grid_settings));

    // Let's spawn a pathfinding Player agent
    commands.spawn((
        Name::new("Player"),
        // AgentPos is required to position your entity in the grid.
        AgentPos(UVec3::new(4, 4, 0)),
        // Here we relate this agent to the grid we created.
        AgentOfGrid(grid_entity),
    ));
}

fn pathfind_agents(
    // The Pathfind component acts as a request to pathfind to a goal.
    query: Query<Entity, Without<Pathfind>>,
    mut commands: Commands,
) {
    for entity in &query {
        // Let's request to pathfind to 7,7.
        commands.entity(entity).insert(Pathfind::new(UVec3::new(7, 7, 0)));
    }
}

fn move_player(
    mut query: Query<(Entity, &mut AgentPos, &NextPos)>
    mut commands: Commands,
) {
    for (entity, mut agent_pos, next_pos) in &mut query {
        // NextPos contains the next valid position in the path.
        // Here we update just the AgentPos to keep it aligned with the grid,
        // but for real usage you would also likely update the transform for world positioning.
        agent_pos.0 = next_pos.0;

        // Remove the NextPos component and the pathfinding system will insert a new NextPos with the next position in the path.
        commands.entity(entity).remove::<NextPos>();
        // Once the agent reaches its goal, Pathfind will be removed.
    }
}
}

Grid as a Component

Currently the plugin Pathfinding and Debug systems expect there to be only a single copy of the Grid component which means you can't currently use them if you want to support multiple grids in your project.

Normally it would make sense for this to be Bevy Resource but this decision was made so the plugin can update to support multiple grids in the future without making breaking API changes. If your project needs to support multiple pathfinding grids you can avoid using the NorthstarPlugin and NorthstarDebugPlugin and call the pathfinding functions directly on the Grid components for the time being.

Configuring the Grid with GridSettingsBuilder

Use GridSettingsBuilder to generate GridSettings to pass to the Grid constructor. You will need to call build() to get the returned settings to pass to Grid.


#![allow(unused)]
fn main() {
use bevy_northstar::prelude::*;

let grid_settings = GridSettingsBuilder::new_2d(128, 128)
    .chunk_size(16)
    .default_impassable()
    .add_neighbor_filter(filter::NoCornerClipping)
    .enable_collision()
    .avoidance_distance(5)
    .build();

let grid: Grid<OrdinalNeighborhood> = Grid::new(&grid_settings);
}

GridSettingsBuilder Configuration Breakdown

Constructors

new_2d(width, height)

Request a 2d grid. The grid always uses UVec3 in the background so this is just a helper constructor to ignore Z. width and height are the dimensions of the grid, not pixel size.

new_3d(width, height, depth)

Request a 3d grid. depth being the z dimension.

Chunk Settings

chunk_size(size)

Default: 16

The grid is divided down into regions called chunks. You can configure the size of these regions with chunk_size(size). Larger chunks will reduce build time and pathfinding time, while smaller chunks will create more optimal paths.

chunk_depth(size)

Default: 1

Ignore if your game is fully 2d. The chunk regions depth is determined separately. 1 is fine for fake 3d maps like isometric maps with a few height layers.

enable_diagonal_connections()

By default the entrances to chunks only look for other chunks that are cardinally adjacent. Enabling diagonal connections will create entrances in corners where diagonally adjacent chunks are accessible.

Enabling this will increase build and pathfinding time but can result in better paths if you have a noisy map.

Default Navigation Settings

default_movement_cost(cost)

Default: 1

Initializes each grid cell with the set cost.

default_impassable()

Default: Passable

Initializes each grid cell as Nav::Impassable. Useful if you're procedurally generating your map and can speed up setting cells by only digging out what's needed.

Collision Settings

enable_collision()

Default: Collision disabled

Enables the built-in pathfinding systems to use collision avoidance so entities with the Blocking component will never occupy the same position.

avoidance_distance(distance)

Default: 4

The collision avoidance system will look ahead in the path to see if any upcoming path positions might be blocked and will re-route to avoid the collision. This will set how far the system looks ahead. Longer distances will affect CPU performance.

Neighbor Settings

add_neighbor_filter(filter)

Pass a NeighborFilter that will be applied to viable neighbors when they are precalculated. See Filters.

Cell Navigation Data

Each cell in the Grid contains navigation data that is used for pathfinding.

The primary way for setting the nav data is using Grid::set_nav(), which takes a Nav enum.

Cost

"Cost" is a common concept in pathfinding and is used throughout the crate. It represents how difficult it is for an agent to move through a given cell. All internal pathfinding systems use cost to find the lowest-cost path to a goal, rather than the absolute shortest one.

You can use cost to discourage agents from entering certain areas. For example, moving through water might be allowed but is assigned a higher cost so that agents avoid that route unless necessary. Another use case would be movement budgets like action points. Each tile could represent a certain number of action points a move costs.

Nav is the enum used to set cell navigation data. You can set it with Grid::set_nav(). For example:


#![allow(unused)]
fn main() {
// Set 1, 1, 1 to a wall
grid.set_nav(UVec3::new(1, 1, 1), Nav::Impassable)
// Set 2, 2, 2 as "walkable" with a cost of 4.
grid.set_nav(UVec3::new(2, 2, 2), Nav::Passable(4))
// Set 3, 3, 3 as a portal that warps the agent to 7, 7, 7 with a cost of 1,
// one_way is set to true which means a reverse portal at the target will not be created.
grid.set_nav(UVec3::new(3, 3, 3), Nav::Portal(Portal::to(UVec3::new(7, 7, 7), 1, true))) 
}

Marks the cell as impassable (e.g., a wall). Paths will never include this position. You can default the entire grid to impassable using GridSettingsBuilder::default_impassable.

Marks the cell as passable with a specified cost. By default, GridSettingsBuilder sets all cells to passable with a cost of 1. You can override this with GridSettingsBuilder::default_cost(u32).

This is the main variant used for standard walkable terrain.

Defines a "portal" that overrides normal neighborhood movement rules. See below for usage.

Portal: Ramps/Stairs, Elevators, Actual Portals

Portals allow movement between positions that wouldn't normally be adjacent under standard neighborhood rules. They're often used in 2.5D tilemaps to represent elevation changes (e.g., ramps or stairs), but they can also be used for actual teleporters.

Ramp/Stairs

Ramp

In the image above, the ramp transitions the agent from Z = 0 to Z = 2, which isn’t normally allowed by OrdinalNeighborhood3d. You can create this behavior by setting a portal at the ramp tile:


#![allow(unused)]
fn main() {
// Target destination for this portal, which in our case is the current tile x, y + to a height of 2.
let target_pos = UVec3::new(tile_pos.x, tile_pos.y, 2);

// Create the portal for the ramp
grid.set_nav(
    // The position of our ramp cell
    UVec3::new(tile_pos.x, tile_pos.y, 0),
    // You can set the destination to the same x,y position but change the z height
    // We'll set the cost to 2, this is arbitrary.
    Nav::Portal(Portal::to(target_pos, 2, false)),
    // As one_way is set to false, we don't need to worry about creating a reverse portal at the target
    // set_nav will handle that for you.
);
}

Alternatively, place the portals on tiles adjacent to the ramp:

RampAlternative


#![allow(unused)]
fn main() {
// Have the bottom adjacent cell to the ramp portal to the top adjacent cell to the ramp.
grid.set_nav(UVec3::new(0, 0, 0), Nav::Portal(Portal::to(2, 0, 2), 1, false))
}

Teleporting

To create a teleporter, set the portal destination to any arbitrary map position. You can make it one-way by not adding a reverse portal:


#![allow(unused)]
fn main() {
/// Let's say the current_tile is at 5, 5, 4
let current_tile = UVec3::new(5, 5, 4);

grid.set_nav(
    current_tile,
    /// This cell can be used as a warp to 45, 45, 0 at a cost of 1.
    Nav::Portal(Portal::to(UVec3::new(45, 45, 0), 1, true))
)
}

Animations For Portals

You'll likely want to animate transitions when agents use jumps, ramps, or portals.

The Path::next() method is useful for this. It returns the next position in the path, which you can compare to the agent's current tile position. This lets you determine what kind of tile the agent is moving into such as a portal, a ramp. Or just test if the Z depth changes and trigger a jump animation.

You can also get the agents current cell navigation data, for example, test if the agent is standing on a portal:


#![allow(unused)]
fn main() {
if let Some(Nav::Portal(portal)) = grid.nav(position.0) {
    // Trigger some warping particle fx.
}
}

Grid Updates

You can modify the navigation data of individual cells at runtime using Grid::set_nav(). After making changes, you must call Grid::build() to update the internal state.

When Grid::set_nav() is called, the affected chunk and its adjacent chunks are automatically marked as dirty. During the next Grid::build() call, only those dirty chunks are rebuilt. If the default parallel feature is enabled, chunk rebuilds are performed in parallel for better performance.

Calling any of the Grid pathfinding methods on a dirty grid will log an error and return None.

Example:


#![allow(unused)]
fn main() {
if input.just_pressed(MouseButton::Right) {
    if let Some(position) = clicked_tile {
        let mut grid = grid.into_inner();

        if let Some(nav) = grid.nav(position) {
            if !matches!(nav, Nav::Impassable) {
                // If the cell is passable, we set it to impassable.
                grid.set_nav(position, Nav::Impassable);
            } else {
                // If the cell is impassable, we set it to passable with a cost of 1.
                grid.set_nav(position, Nav::Passable(1));
            }
        } else {
            return;
        }
        // You must call `build` after modifying the grid to update the internal state.
        grid.build();
    }
}
}

Performance Notes

Rebuilding a single chunk takes approximately 0.2ms on modern systems. Note that updating a cell in a single chunk may require updating neighboring chunks if it touches an edge. If you enable GridSettingsBuilder::diagonal_connections() or use an ordinal Neighborhood, the number of adjacent chunks needing rebuilds may increase.

  • With the default parallel feature enabled, multiple dirty chunks can often be rebuilt in the same frame with a ~35% speedup.

  • If parallel is disabled (e.g. for WASM), rebuilding a large number of chunks sequentially may exceed your frame budget. In that case, consider:

    • Limiting updates to small, localized areas per frame.

    • Spreading updates across multiple frames.

    • Adjusting chunk size to find the best performance fo your use case.

Pathfinding With Built-In Systems

Pathfind and AgentPos Components

To use the NorthstarPlugin pathfinding systems, insert a Pathfind and AgentPos on the entity you want to pathfind. You will need to maintain the grid position in AgentPos.


#![allow(unused)]
fn main() {
commands
    .spawn((
        Name::new("Player"),
        // Request a path to 8,8
        Pathfind::new(UVec3::new(8, 8, 0)),
        // The entities current position in the grid
        AgentPos(UVec3::new(4, 4, 0))
        Blocking, // Insert the Blocking component if using collision and this entity should block others.
    ));
}

There are also shorthand constructors for creating Pathfind.


#![allow(unused)]
fn main() {
// If you're on a 2d grid you can use `new_2d()` without having to create a new UVec3. 
Pathfind::new_2d(8, 8)
// Same with 3d
Pathfind::new_3d(8, 8, 4)
}

Pathfind Configuration

Pathfind has configuration options you can set by chaining.

mode(mode)

Default: PathfindMode::Refined

Use this to set the desired algorithm to find the goal. Ex: Pathfind::new_2d(8, 8).mode(PathfindMode::AStar). See below for a list of PathfindModes and their description.

partial()

Default: Not enabled

Apply .partial() to request an incomplete path if the goal is not reachable. Ex: Pathfind::new_2d(4, 4).mode(PathfindMode::Astar).partial().

PathfindMode

The pathfinding algorithm enum. Current options are:

PathfindMode::Refined

This is the default algorithm

Gets a high level path to the goal at the chunk level. If a path is found, the path is iterated over with a line of sight / tracing algorithm to attempt to create the shortest path to the goal. The refinement is more expensive than the HPA* algorithm but not nearly as expensive as using A*.

PathfindMode::Coarse

Returns the unrefined HPA* path pulled from the cached entrance paths. This will not return the path with the least steps to the goal but is extremely fast to generate. It's great for natural paths NPCs might use to move around a building for example.

PathfindMode::AStar

This is standard A* pathfinding. It's very expensive for long distance goals on large maps but is still useful for very short distances or when you're concerned with the absolute shortest path. A good use would be movement where action points are subtracted based on number of moves.

NextPos

The pathfind system detects entities with a changed Pathfind component. It then runs the pathfinding algorithm and, if a valid path is found, inserts the next step as a NextPos component.

You should consume the NextPos component by moving the entity accordingly and then removing the component afterward. In a subsequent frame, the next_position system will insert the next NextPos if the path is not yet complete.

If collision avoidance is enabled, the next_position system will also handle local avoidance. It may adjust the path if another entity is blocking the current path within the configured avoidance_distance (as set in GridSettingsBuilder).

See Grid Settings for more information on enabling and configuring collision.

Example movement system:


#![allow(unused)]
fn main() {
fn movement(
    mut commands: Commands,
    mut query: Query<(Entity, &mut AgentPos, &NextPos)>,
) {
    for (entity, mut agent_pos, next_pos) in &mut query {
        // Set the entities GridPos to the NextPos UVec3.
        agent_pos.0 = next_pos.0;

        // Update the entities translation
        let translation = Vec3::new(
            next.0.x as f32 * 32.0, // Assuming tiles are 32x32
            next.0.y as f32 * 32.0,
            0.0
        );

        commands.entity(entity)
            .insert(Transform::from_translation(translation))
            .remove::<NextPos>();
    }
}
}

Pathfinding/Collision Marker Components

PathfindingFailed

This component is inserted into an entity if a path to the desired goal cannot be found. You will want to create a system that determines how to handle the failure in a way unique to your game.

AvoidanceFailed

This component is inserted when collision avoidance is enabled and the entity cannot find a path around a local Blocking entity.

The reroute_path system will automatically attempt to compute a new full HPA* path to resolve the issue in the next frame. You may also choose to handle this yourself in a custom system.

RerouteFailed

This component is added when all attempts to resolve a collision-related pathing issue have failed, meaning no viable path to the goal exists at the moment or the entity is stuck.

You must handle this case in your own system — for example, by:

  • Selecting a new goal
  • Waiting and retrying after a delay
  • Alerting the player/user

#![allow(unused)]
fn main() {
fn handle_pathfind_failed(
    mut commands: Commands,
    mut query: Query<(Entity, &Name, &Pathfind), With<PathfindingFailed>>,
) {
    for (entity, name, path) in &mut query {
        log::warn!("{} cannot find a route to {}!", name, path.goal);

        let new_goal = locate_new_cheese();

        commands
            .entity(entity)
            .insert(Pathfind::new(new_goal))
            .remove::<PathfindingFailed>();
    }
}

fn handle_reroute_failed(
    mut commands: Commands,
    mut query: Query<Entity, With<RerouteFailed>>,
) {
    for entity in &mut query {
        let some_new_goal = UVec3::new(3, 30, 0); // Just some random new goal

        commands
            .entity(entity)
            .insert(Pathfind::new(some_new_goal))
            .remove::<RerouteFailed>();
    }
}
}

PathingSet

The NorthstarPlugin pathfinding systems run in their own system set named PathingSet.

You can use the set to ensure that your systems dealing with pathfinding and entity movement happen before or after the pathing systems.


#![allow(unused)]
fn main() {
app.add_systems(Update, move_pathfinders.before(PathingSet));
}

Staggering Pathfinding in the NorthstarPlugin Systems

The systems provided by NorthstarPlugin are designed to stagger how many agents can process pathfinding and collision avoidance in a single frame.

By default, both limits are set to 128 agents per frame. This may be too high if you actually have that many active agents. On the other hand, setting it too low can cause noticeable delays, with agents appearing to sit idle. You’ll want to tune these values based on your game’s performance needs.

Currently the reroute_path system that attempts to reroute agents that have a failed path that local collision avoidance is unable to resolve can still cause stutter. In a future update it will be moved into any async call.

To override the default settings, insert the NorthstarPluginSettings resource into your app:


#![allow(unused)]
fn main() {
App::new()
    .insert_resource(NorthstarPluginSettings {
        max_pathfinding_agents_per_frame: 16,
        max_collision_avoidance_agents_per_frame: 16,
    })
}

Path

The Path component will be inserted to an entity when after a path is found. It is also returned when calling the manual pathfinding methods on Grid.

You can use it draw paths in your game, for example when highlighting tiles for a path in a turn based game.

There are a few notable methods on Path.

path() -> &[UVec3]

Returns an array of all positions in the path.

cost() -> u32

The full cost of the path. It is the sum of all movement costs for each cell in the grid that the path crosses over.

len() -> usize

The total number of positions currently in the path.

next() -> Option<UVec3>

Returns the next position in the path without removing it. Very useful in animation systems to play a different animation based on tile transition for example, playing a jumping animation when the next position is a higher z depth.

pop() -> Option<UVec3>

Removes and returns the next position of the path.

See the crate docs for more. A couple of notable ones are reverse() and translate_by(offset)

Manual Pathfinding

You don't need to use the pathfinding systems in the NorthstarPlugin in order to take advantage of this crate.

You can use both, or choose to not add the NorthstarPlugin and call the pathfinding functions completely manually.

If you don't use NorthstarPlugin you'll need to maintain your own BlockingMap or HashMap<UVec3, Entity> to pass to the pathfind function to provide it a list of blocked positions.

All of the pathfinding calls can be done on the Grid component.


#![allow(unused)]
fn main() {
fn manual_pathfind(
    mut commands: Commands,
    player: Single<(Entity, &AgentPos, &MoveAction), With<Player>>,
    grid: Single<&CardinalGrid>,
    // If using the plugin you can use the BlockingMap resource for an auto-updated blocking list.
    blocking: Res<BlockingMap>,
) {
    let grid = grid.into_inner();
    let (player, grid_pos, move_action) = player.into_inner();

    let path = grid.pathfind(grid_pos.0, move_action.0, blocking, false);

    // Setting use_partial to true will allow the pathfinding to return a partial path if a complete path isn't found.

    // If you're not using collision you can pass an empty hashmap for the blocking map.
    let path = grid.pathfind(grid_pos.0, move_action.0, HashMap::new(), true);

    // There are also Coarse and AStar methods
    let path = grid.pathfind_coarse(grid_pos.0, move_action.0, blocking, false);
    let path = grid.pathfind_astar(grid_pos.0, move_action.0, blocking, false);
}
}

The Grid pathfinding methods return an Option<Path>. None will be returned if no viable path is found.

Neighborhoods

CardinalNeighborhood

Allows movement in the four cardinal directions on a 2D plane: North, East, South, and West.

Cardinal Directions

CardinalNeighborhood3d

Allows movement in all six cardinal directions in 3D space: North, East, South, West, Up, and Down. This 3D neighborhood is also well-suited for 2D tilemap games with height layers, such as isometric maps.

Cardinal3d Directions

OrdinalNeighborhood

Extends cardinal movement by allowing diagonal movement in addition to the standard directions.

Ordinal Directions

OrdinalNeighborhood3d

Extends CardinalNeighborhood3d by allowing diagonal movement in all three dimensions—e.g., North-East-Up—for a total of 26 possible directions.

Like CardinalNeighborhood3d, this is also useful for 2D tilemap games with height layers, such as isometric maps.

A NeighborFilter is available to restrict diagonal movement to only within the same depth level, reducing the number of directions from 26 to 14. See Filters for more details.

Implementing the Neighborhood Trait

While you can implement your own Neighborhood, it is not currently recommended.

To support neighbor caching, each grid cell stores its neighbors in a u32 bitmask, which limits the system to 26 directions. The provided built-in neighborhoods already cover all of these directions.

Switching to a per-cell Vec or HashMap of neighbors would drastically increase memory usage—potentially several gigabytes on large maps (e.g., 1024×1024x4 grids).

Before implementing a custom Neighborhood, check whether a built-in Filter already provides the neighbor generation behavior you need, or consult the documentation for creating a custom filter.

Filters

NeighborFilters can be applied during the stage when neighbors are calculated and cached. There are a few built-in ones but you can also implement the NeighborFilter trait yourself to create new ones.

Filters can be chained and they will be applied in the order that they are added to GridSettingsBuilder.


#![allow(unused)]
fn main() {
let grid_settings = GridSettingsBuilder::new_3d(64, 64, 4)
    .chunk_size(4)
    .add_neighbor_filter(filter::NoCornerClipping)
    .add_neighbor_filter(filter::DisallowZDiagonalMovement)
    .build();
}

NoCornerCutting

Forces cardinal movement around impassable (wall) cells by preventing diagonal movement around ANY adjacent impassable (wall) cell. Don't use with NoCornerClipping as this filter already covers clipping through corners.

Apply to GridSettingsBuilder with .add_neighbor_filter(filter::NoCornerCutting).

NoCornerCutting

NoCornerCuttingFlat

Works the same as NoCornerCutting with the exception that paths can jump z depth diagonally around corners. This is an ideal filter to use in 2.5D games. You may want to disable agents from cutting diagonally around corners so your sprites don't clip through wall corners causing y sorting glitches, but retain the ability for your agents to jump to higher heights.

No corner cutting is allowed in the same depth Corner cutting is allowed to different depths

NoCornerClipping

Prevents diagonal movement from clipping through impassable (wall) corners. Don't use with NoCornerCutting as NoCornerCutting already handles this case.

Apply to GridSettingsBuilder with .add_neighbor_filter(filter::NoCornerClipping).

NoCornerClipping

DisallowZDiagonalMovement

The OrdinalNeighborhood3d neighborhood allows diagonal movement along the Z axis. If this isn't desired you can apply the DisallowZDiagonalMovement filter and it will limit diagonal movement to only cells on the same depth while still allowing Up and Down movement.

Implementing Your Own Filter

TODO, for now please see filters.rs in the source.

Debugging the Grid component

The NorthstarDebugPlugin adds systems that can be enabled to draw gizmos to display the following:

  • Chunk grid: Grid outline of where the chunks are
  • Entrances: Calculated entrances at the boundaries of the chunks
  • Cached internal paths: Cached paths inside the chunk between its own entrances.
  • Cells: Each individual cell on the grid and its passable status
  • Paths: Draws the current calculated path components

First, add the NorthstarDebugPlugin to your app.

Then insert the DebugGrid component a child of the entity that the Grid you want to debug is attached to.

You will likely also want to add a DebugOffset component to the same entity as DebugGrid to align the gizmos with your world tilemap position. In this example we'll be using components from bevy_ecs_tilemap to assist with determining our offset.

use bevy::prelude::*;
use bevy_ecs_tilemap::prelude::*;
use bevy_northstar::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(NorthstarPlugin::<CardinalNeighborhood>::default())
        // Adding the NorthstarDebugPlugin
        // You need to specify the neighborhood here as well
        .add_plugins(NorthstarDebugPlugin::<CardinalNeighborhood>::default())
        .add_systems(Startup, startup)
        .run();
}

fn startup(mut commands: Commands, asset_server: Res<AssetServer>) {
    // Query or create your bevy_ecs_tilemap TilemapAnchor
    let anchor = TilemapAnchor::Center;

    // Query or create your bevy_ecs_tilemap TilemapSize, TilemapGridSize, and TilemapTileSize. They are required for calculating the anchor offset.
    let tilemap_size = TilemapSize { x: 64, y: 64 };
    let tilemap_gridsize = TilemapGridSize { x: 8.0, y: 8.0 };
    let tilemap_tilesize = TilemapTileSize { x: 8.0, y: 8.0 };

    // Store our offset
    let offset = anchor.as_offset(
        &tilemap_size,
        &tilemap_gridsize,
        &tilemap_tilesize,
        &TilemapType::Square,
    );

    // Let's pretend we loaded a tilemap asset and stored it in a handle and now we're spawning the entity for it.
    let mut map_entity = commands.spawn(anchor);

    // Call `build()` to return the component.
    let debug_grid = DebugGridBuilder::new(64, 64)
        .enable_chunks()
        .enable_entrances()
        .build();


    // Spawn an entity with the Grid and DebugMap component as a child of the map entity.
    let grid_entity = map_entity.with_child(
        CardinalGrid::new(&GridSettingsBuilder::new_2d(64, 64).build())
    );

    grid_entity.with_child((
        debug_grid,
        DebugOffset(offset)
    ));
}

Debugging Paths

Debugging paths for an entity requires you to add a DebugPath component to the entity or entities of your choosing. This component allows you to selectively debug specific paths.

Drawing the path gizmos also requires the NorthstarDebugPlugin to add the gizmo drawing system.


#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_northstar::prelude::*;

commands.spawn((
    Name::new("Player"),
    DebugPath::new(Color::srgb(1.0, 0.0, 0.0)),
));
}

If you would like to debug a directly created path (returned from grid::pathfind()) make sure you attach the returned Path component to your entity. If you're not using NorthstarPlugin you will also need to make sure the entity has an AgentPos component. This is the query filter used to debug paths Query<(&DebugPath, &Path, &AgentOfGrid)>.

DebugGridBuilder Settings

isometric()

Sets the debug gizmos to draw in isometric perspective.

enable_chunks()

Outline the grid chunk regions.

enable_entrances()

Highlights the entrances created between each chunk. Very useful for debugging HPA* issues.

enable_cells()

Overlay over each tile whether it's passable or impassable. Useful for debugging if you're calling set_nav correctly for your tilemap.

enable_cached_paths()

Grid precaches paths between all entrances inside each chunk. Noisy, but can help debug HPA* pathing issues.

enable_show_connections_on_hover()

As you can see above, viewing all the connections at once is quite noisy. Enabling show_connections_on_hover will only draw chunk entrance cached connections when the DebugCursor position is over the node. Very useful for debugging possible failed node connections without all of the other connections overlapping.

DebugCursor Component

In order for the debug plugin to determine which node your cursor is hovering over, you need to manually update the DebugCursor component. While tedious this is required because not every game will have the same windowing and camera setups. DebugCursor is inserted along with DebugGrid so there is no need to insert it manually.

For most cases you can just dump the raw world cursor position in your input systems or just add a simple system to handle it:


#![allow(unused)]
fn main() {
fn update_debug_cursor(
    window: Single<&Window>,
    camera: Single<(&Camera, &GlobalTransform)>,
    debug_cursor: Single<&mut DebugCursor>,
) {
    let window = window.into_inner();
    let (camera, camera_transform) = camera.into_inner();
    let mut debug_cursor = debug_cursor.into_inner();

    if let Some(cursor_position) = window
        .cursor_position()
        .and_then(|cursor| camera.viewport_to_world_2d(camera_transform, cursor).ok())
    {
        debug_cursor.0 = Some(cursor_position);
    } else {
        debug_cursor.0 = None;
    }
}
}

2.5D Debugging

Drawing all 3D grid debug gizmos over a 2d tilemap render would get pretty noisy so the debug drawing generally only draws information for one z depth at a time. The exception for this is that all entrance nodes will be drawn and y offsets for height will be applied.

You can set the depth with an inspector or use DebugGrid::set_depth(depth: u32) to change the depth that is being drawn.

In 2.5D maps each depth layer will generally have a Y offset applied so the tile appears higher to the player. The DebugDepthYOffsets component is used so the debugging systems know which offsets to apply when drawing at that z depth. DebugDepthYOffsets takes a HashMap<u32, f32> where the key is the z depth and the value is the offset.

For example:


#![allow(unused)]
fn main() {
DebugDepthYOffsets(
    HashMap::from([
        (0, 0.0),
        (1, 16.0),
        (2, 32.0),
    ])
)
}

Stats

Enabling the stats feature on the crate will allow the NorthstarPlugin pathfinding systems to calculate the average time spent on pathfinding and collision calls.

It will also log debug level stats for Grid::build() times. Useful making sure your grid rebuilds are staying within your frame budget.

[dependencies]
bevy_northstar = { version = "0.2.0", features = ["stats"]}

You can access the statistics from the Stats Resource.


#![allow(unused)]
fn main() {
fn print_stats(stats: Res<Stats>) {
    debug!("Collision Stats: {:?}", stats.collision);
    debug!("Pathfinding Stats: {:?}", stats.pathfinding);
}
}

To get the Grid::build() timing stats dumped to your log you'll need to enable debug level logging for the crate.

RUST_LOG=bevy_northstar=debug,bevy=info cargo run

Or alternatively configure it in code when setting up your Bevy logging.

Migrating From v0.2.X to v0.3.X

GridSettings → GridSettingsBuilder

GridSettings has been replaced with GridSettingsBuilder.

Replace


#![allow(unused)]
fn main() {
let grid_settings = GridSettings {
    width: 16,
    height: 16,
    chunk_size: 4,
    collision: true,
    ..Default::default()
}

let grid = CardinalGrid::new(&grid_settings);
}

with


#![allow(unused)]
fn main() {
let grid_settings = GridSettingsBuilder::new_2d(16, 16)
    .chunk_size(4)
    .enable_collision()
    .build()

let grid = CardinalGrid::new(&grid_settings);
}

GridPos → AgentPos

GridPos has been renamed to AgentPos. This is just a simple rename for clarity.

Point → NavCell, Nav | Wall -> Passable, Impassable

Point has been reworked for clarity. Point has been renamed to NavCell but the API has been updated to remove user's need to interact with NavCell for normal use.

Grid functions now take the Nav enum to definite mobility and movement cost for a Grid cell.

Replace


#![allow(unused)]
fn main() {
// Creates a wall at grid cell 8,8,0
grid.set_point(UVec3::new(8, 8, 0), Point::new(u32::MAX, true));
// Set the cost for a passable grid cell
grid.set_point(UVec3::new(4, 4, 0), Point::new(4, false));
}

with


#![allow(unused)]
fn main() {
grid.set_nav(UVec3::new(8, 8, 0), Nav::Impassable);
grid.set_nav(UVec3::new(4, 4, 0), Nav::Passable(4));
}

Pathfind Component Rework, New PathfindMode

The Pathfind component has been reworked with chaining setup functions to ensure configuration can be added in the future without breaking changes.

PathfindMode enum was added set which pathfinding method is desired.

Before


#![allow(unused)]
fn main() {
// You may have forced an A* path like this before
.insert(Pathfind {
    goal: UVec3::new(8, 8, 0),
    use_astar: true,
})
// or the shorthand for regular HPA*
.insert(Pathfind::new(UVec3::new(8, 8, 0)))
// shorthand A*
.insert(Pathfind::new_astar(UVec3::new(8, 8, 4)))
}

Is now


#![allow(unused)]
fn main() {
.insert(Pathfind::new(UVec3::(8, 8, 0).mode(PathfindMode::AStar)))

// You can use the new_2d and new_3d constructors without creating a UVec as well
.insert(Pathfind::new_2d(8, 8))
.insert(Pathfind::new_3d(8, 8, 4).mode(PathfindMode::AStar))
}

DebugMap → DebugGrid, DebugGridBuilder, & DebugOffset

DebugMap has been renamed to DebugGrid for clarity and a builder pattern has been added to make it cleaner to configure.

It was recommended before to put DebugMap as the child of your Grid entity, but it is now required.

Change


#![allow(unused)]
fn main() {
grid_entity.with_child((
    DebugMap {
        tile_width: 8,
        tile_height: 8,
        map_type: DebugMapType::Square,
        draw_chunks: true,
        draw_points: false,
        draw_entrances: true,
        draw_cached_paths: false,
    },
    Transform::from_translation(offset.extend(0.0)),
));
}

to


#![allow(unused)]
fn main() {
grid_entity.with_child((
    DebugGridBuilder::new(8, 8)
        .enable_chunks()
        .enable_entrances()
        .build(),
    // Add the offset to the debug gizmo so that it aligns with your tilemap.
    DebugOffset(Vec2::new(-360.0, -500.0)),
));
}

DebugMapType has been renamed to DebugTilemapType. You only need to set it if you're using isometric. DebugGridBuilder::new(8, 8).isometric().build().

DebugPath

tile_width, tile_height, and map_type has been removed from the DebugPath component.

These fields are now determined by getting the attached DebugGrid based on the entities relationship to the Grid component. Ensure that you add the AgentOfGrid relationship to your pathfinding (player/npc etc) entities.

Replace


#![allow(unused)]
fn main() {
.insert(DebugPath {
    tile_width: 8,
    tile_height: 8,
    map_type: DebugMapType::Square,
    color,
    draw_unrefined: false,
})
}

with


#![allow(unused)]
fn main() {
.insert(DebugPath::new(color))
.insert(AgentOfGrid(grid_entity))
}