Skip to content

Navigation and Pathfinding

AzureDoom edited this page Aug 29, 2026 · 1 revision

Navigation and Pathfinding

AzureCortex ships its own grid-based A* pathfinding and movement steering, independent of vanilla's PathNavigation. This page covers the plain ground-walking model; see Wall Crawling for the crawl-aware variant.

Pathfinder, the entry point

public interface Pathfinder {
    List<BlockPos> findPath(Mob mob, BlockPos start, BlockPos goal, int maxRange, int goalRadius);
}

Two built-in implementations, both usable as shared, stateless singletons:

  • AStarPathfinder.INSTANCE, plain ground-walking movement. Searches up to 2,000 nodes; returns the best partial path found if the full path isn't reached within budget.
  • CrawlTraversalEvaluator.INSTANCE, the wall-crawl-aware variant (searches up to 6,000 nodes). See Wall Crawling.

Both return foot-level BlockPos waypoints, and both fall back to the best partial path (closest to the goal) rather than an empty list when the full route can't be found, useful for "get as close as possible" behaviors like chasing.

var path = AStarPathfinder.INSTANCE.findPath(mob, mob.blockPosition(), target.blockPosition(), 64, 1);

Most of the time you won't call findPath directly, Built-in Actions like MoveToDestinationAction and InvestigateLastSeenTargetAction already wrap this for you.

Spreading search cost across ticks

A single synchronous findPath call can expand several thousand nodes in one server tick. That's fine for one mob, but if every mob's repath cooldown happens to expire on the same tick, you get the classic "N pathfinders all wake up on the same frame" hitch.

IncrementalPathSession

A resumable search that spends a fixed, small node budget per call to step() instead of running to completion in one burst:

var session = IncrementalPathSession.normal(mob, start, goal, 64, 1);
// once per tick, until it stops returning RUNNING:
var status = session.step(300); // node budget for this call
if (status == IncrementalPathSession.Status.DONE) {
    var path = session.result();
} else if (status == IncrementalPathSession.Status.FAILED) {
    // no path exists within range
}

IncrementalPathSession.crawling(...) is the equivalent wiring over the crawl-aware model, taking a PathNodeCache whose lifetime should match the session (fresh at session start, discarded at session end, .normal() doesn't need one, since the plain ground model doesn't use a cache).

PhasedPathSession, incremental fallback chains

Chains several IncrementalPathSession attempts (e.g. "try a crawl route, then a relaxed-radius crawl route, then a plain ground route, then give up") while sharing one per-tick node budget across the whole chain, so a fallback stage doesn't get to run a full synchronous search the moment the primary stage fails.

var phases = List.of(
    new PhasedPathSession.Phase("PRIMARY_CRAWL",
        () -> IncrementalPathSession.crawling(mob, start, crawlGoal, 96, radius, cache)),
    new PhasedPathSession.Phase("NORMAL_ASTAR",
        () -> IncrementalPathSession.normal(mob, start, target.blockPosition(), 64, Math.max(radius, 1)))
);

var session = new PhasedPathSession(phases);
// once per tick:
var status = session.step(budget);

If a phase fails outright, the chain advances to the next phase immediately with whatever budget is left over this same tick, so it can still resolve in one tick when possible; if a phase is still working when the budget runs out, the whole chain reports RUNNING and resumes next tick exactly where it left off.

Enabling incremental pathfinding

MoveToDestinationAction (see Built-in Actions) automatically uses IncrementalPathSession instead of a synchronous findPath call when CortexConfig.enableIncrementalPathfinding is on (the default), see Configuration for the node-budget setting.

PathNodeCache

A short-lived memoization cache for the expensive per-position classifications the pathfinders repeat constantly (solidity, walkability, tunnel/shaft/climb fit). Adjacent A* nodes and per-tick AI checks re-classify the same positions heavily, memoizing by BlockPos.asLong() collapses those repeats into single lookups.

  • Call .clear() at the start of each tick/scan/pathfind if reusing one instance across ticks (backing maps keep their capacity, steady-state reuse is allocation-free).
  • Call .invalidate(pos) to drop only cached classifications near a specific position (and its neighbors, since the classifiers query up to one block away) when something specific is known to have changed, keeping the rest of a long-lived session-scoped cache warm.
  • Not thread-safe; server thread only. Never share an instance across mobs or dimensions without clearing, results are implicitly keyed to one mob's footprint/crawl-height and one dimension.

Movement steering: MovementController

Implements NavigationHandler, the interface responsible for turning "the agent wants to move toward point P" into an actual per-tick velocity:

public interface NavigationHandler {
    Vec3 computeMovement(Mob mob, Vec3 desiredMovement);
    default void tick(Mob mob, Vec3 movement) {}
}

MovementController.INSTANCE is the ground-walking implementation: obstacle look-ahead steering (tries progressively larger angles around obstacles, biased toward whichever direction worked last time) plus danger-entity repulsion. CrawlController is the wall-crawl-aware counterpart (see Wall Crawling).

Vec3 dangerRepulsion = MovementController.dangerEntityRepulsion(mob); // pushes away from nearby hazard entities

Classifying terrain and entities per mob: MovementCapability

The traversal/collision queries have no built-in concept of "this block is dangerous" or "this block is passable despite having a collision shape", that's always mod-specific. Rather than hard-coding any tag set, every relevant query checks mob instanceof MovementCapability and defers to it:

public interface MovementCapability {
    default boolean isHazardBlock(Level level, BlockPos pos, BlockState state) { return false; }
    default boolean isHazardFluid(Level level, BlockPos pos, FluidState fluid) { return false; }
    default boolean isPassableSolid(Level level, BlockPos pos, BlockState state) { return false; }
    default boolean isHazardEntityType(EntityType<?> type) { return false; }
}

Implement this on your mob's entity class (typically via mixin, mirroring how CrawlCapability is applied, see Wall Crawling) to mark, e.g., a mod-specific "acid pool" block as a hazard, or a "resin" block as passable-despite- solid for your creature specifically. A mob that doesn't implement the interface gets the permissive MovementCapability.DEFAULT, nothing is hazardous, nothing is specially passable.

Movement-type queries: NavigationQueries

Decision-level helpers for "what kind of movement does reaching this position require":

NavigationQueries.requiredMovementAt(mob, pos); // WALK, JUMP, or CLIMB
NavigationQueries.needsWallCrawl(mob, wantedPos);
NavigationQueries.computeWallCrawlVelocity(mob, wantedPos, speed);

Debug visualization

Set enablePathfindingDebug in azurecortex.json (see Configuration) to have CortexDebug draw particle segments along every computed path, with markers classifying each node as a climb node, a walk node, or neither/both, purely a development aid with no effect on AI decisions.

Next

  • Wall Crawling, the crawl-aware pathfinding/movement/physics model.
  • Built-in Actions, MoveToDestinationAction, InvestigateLastSeenTargetAction, and friends, which wrap this package so you rarely call it directly.

Clone this wiki locally