Skip to content

perf: Improve pathfinder#375

Open
andy5995 wants to merge 8 commits intodevelopfrom
improve/pathfinder
Open

perf: Improve pathfinder#375
andy5995 wants to merge 8 commits intodevelopfrom
improve/pathfinder

Conversation

@andy5995
Copy link
Copy Markdown
Collaborator

@andy5995 andy5995 commented Mar 22, 2026

Possible related to #277

We haven't tested this yet... I'll report back when we do.

The pathfinder's three std::map structures had significant per-iteration overhead from tree node allocation and O(log n) traversal:

  • openNodesList (map<float, vector<Node*>>): replaced with a std::vector maintained as a binary min-heap using std::push_heap / std::pop_heap. Tie-breaking by insertion sequence number preserves deterministic expansion order.

  • openPosList (map<Vec2i, bool>): replaced with std::unordered_set<Vec2i, Vec2iHash> for O(1) average membership tests instead of O(log n).

  • closedNodesList (map<float, vector<Node*>>): eliminated entirely. The only use was finding the best partial-path node when the node limit is reached; this is now tracked with a single bestClosedNode pointer updated on each expansion.

Also align pathFindNodesAbsoluteMax (pool size) with pathFindNodesMax (search limit): both are now 2000, so the extended retry path can actually find more nodes than the normal search.

@andy5995
Copy link
Copy Markdown
Collaborator Author

andy5995 commented Mar 22, 2026

It works in this example. I'm red, the AI is blue.

Before

screen50

After

screen52
(Zoom in and you will see little blue units all the way along the treeline from West to East)

But when I tested a longer game on ParaisoBR, I noticed some units would kind of move forward a few tiles, then back, then forward again, then back; with nothing actually blocking them. They kept moving though, and no units actually got stuck.

Claude says:

This PR makes two categories of improvement to the A* pathfinder.

  1. Data structure optimisation (performance)

The pathfinder maintained three std::map structures that had significant per-iteration overhead from tree-node allocation and O(log n)
traversal:

  • openNodesList (map<float, vector<Node*>>): replaced with a std::vector maintained as a binary min-heap using
    std::push_heap / std::pop_heap. Tie-breaking by insertion sequence number preserves deterministic expansion order.
  • openPosList (map<Vec2i, bool>): replaced with std::unordered_set<Vec2i, Vec2iHash> for O(1) average membership tests instead of O(log
    n).
  • closedNodesList (map<float, vector<Node*>>): eliminated entirely. The only use was finding the best partial-path node when the node
    limit is reached; this is now tracked with a single bestClosedNode pointer updated on each expansion.

pathFindNodesAbsoluteMax (pool size) is also aligned with pathFindNodesMax (search limit): both are now 2000.

  1. Obstacle-avoidance pathfinding fix (correctness)

Units were getting permanently stuck when the path to their destination required a long detour around a large obstacle (e.g. a dense tree
cluster or mountain blocking the front of a base, with the only exit at the rear). They would pile up against the obstacle instead of
routing around it.

Root cause — three compounding bugs:

a) Dead retry condition. When A* exhausted its node budget, it was supposed to trigger an "exploratory retry" with a larger search. The
retry was gated on maxNodeCount != pathFindNodesAbsoluteMax, but since this PR equalized both values to 2000, the condition was always
false. The retry never fired.

b) Frame-tolerance function hardcoded off. The retry was also gated on Unit::isLastPathfindFailedFrameWithinCurrentFrameTolerance(), which
was unconditionally returning false due to a disabled feature flag (enablePathfinderEnlargeMaxNodes = false). Even if the first condition
had been met, the retry still could not fire.

c) Insufficient node budget. The normal search budget of 2000 nodes is enough for typical paths, but covers only roughly a 25-cell
exploration radius. A detour around a large obstacle can require 40–60+ cells of travel in the wrong direction first. 2000 nodes is not
enough to discover such a route.

Fixes:

  • The dead maxNodeCount != pathFindNodesAbsoluteMax guard is replaced with an isExploratoryRetry flag that simply prevents infinite
    recursion, allowing the retry to fire whenever the node budget is exhausted on a normal (non-retry) search.
  • isLastPathfindFailedFrameWithinCurrentFrameTolerance() is re-enabled with a 60-frame cooldown (~1.5 s at 40 fps), so units get one
    exploratory retry per cooldown period rather than never.
  • A new pathFindNodesExploratoryMax = 8000 constant gives the retry a much larger search budget. The node pool is resized to match. Memory
    cost is approximately 2.3 MB across all 8 factions.
  • The retry uses a reduced heuristic weight of 0.25 (down from 1.0). Standard A* with weight 1.0 strongly biases expansion toward the
    goal, which causes it to waste all its nodes pressing against the obstacle. At weight 0.25, the heuristic pull is much weaker and the
    search expands far more uniformly — closer to BFS — giving it a realistic chance of finding exits that lie in the opposite direction from
    the goal.
  • The 200-node cap applied to repeatedly-stuck units is skipped for exploratory retries, since they need the full budget to do their job.

@andy5995
Copy link
Copy Markdown
Collaborator Author

But when I tested a longer game on ParaisoBR, I noticed some units would kind of move forward a few tiles, then back, then forward again, then back; with nothing actually blocking them. They kept moving though, and no units actually got stuck.

This is fixed now with the last commit, as far as I could tell from the testing I did.

@andy5995 andy5995 marked this pull request as ready for review March 22, 2026 07:27
@andy5995 andy5995 changed the title perf: Replace A* open/closed list maps with heap and unordered set perf: Improve pathfinder Mar 22, 2026
@andy5995 andy5995 force-pushed the improve/pathfinder branch from 6b45c38 to 77a4dcc Compare March 25, 2026 14:18
andy5995 and others added 8 commits March 25, 2026 09:26
The pathfinder's three std::map structures had significant per-iteration
overhead from tree node allocation and O(log n) traversal:

- openNodesList (map<float, vector<Node*>>): replaced with a
  std::vector<OpenListEntry> maintained as a binary min-heap using
  std::push_heap / std::pop_heap.  Tie-breaking by insertion sequence
  number preserves deterministic expansion order.

- openPosList (map<Vec2i, bool>): replaced with
  std::unordered_set<Vec2i, Vec2iHash> for O(1) average membership
  tests instead of O(log n).

- closedNodesList (map<float, vector<Node*>>): eliminated entirely.
  The only use was finding the best partial-path node when the node
  limit is reached; this is now tracked with a single bestClosedNode
  pointer updated on each expansion.

Also align pathFindNodesAbsoluteMax (pool size) with pathFindNodesMax
(search limit): both are now 2000, so the extended retry path can
actually find more nodes than the normal search.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When A* hits its node limit and the unit has been failing to find a
path for 2+ consecutive frames, the direct route is likely blocked by a
large obstacle (e.g. trees covering the front of a base with only a
rear opening).  In that case the unit should explore around the
obstacle rather than keep pressing toward the blocked front.

Introduce a heuristicWeight field on FactionState and an optional
parameter on aStar().  Normally weight=1.0 (standard A*).  On the
extended retry triggered by repeated node-limit failures, weight=0.25
is used, which strongly reduces the heuristic's pull toward the goal
and causes the search to expand much more uniformly — effectively
closer to BFS — so it can discover routes that initially move away
from the target before circling around.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The 200-node cap applied to units failing 3+ consecutive frames was
also being applied inside the exploratory retry call (heuristicWeight
< 1.0), neutralising the wider search.  Skip the cap when in
exploratory mode so the retry actually gets the full node budget to
search around large obstacles.

Also trigger exploratory mode after just 1 consecutive failure instead
of 2, so it kicks in on the first retry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The retry was gated on (maxNodeCount != pathFindNodesAbsoluteMax), but
this PR equalised both to 2000, making the condition permanently false.
The exploratory search never ran.

Replace the dead guard with an isExploratoryRetry flag that prevents
infinite recursion while allowing the retry to fire whenever the node
budget is exhausted on a normal search.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
isLastPathfindFailedFrameWithinCurrentFrameTolerance() was hardcoded
to return false, permanently disabling the exploratory retry path.
Enable it with a 60-frame cooldown (~1.5 s at 40 fps) so units that
exhaust their normal node budget get one retry per cooldown period.

Also increase the node pool and exploratory budget from 2000 to 8000
nodes.  With weight=0.25, 8000 nodes covers roughly a 50-cell radius
in open terrain, giving the search a realistic chance of finding paths
that require long detours around large obstacles like tree clusters
or mountains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
With a 60-frame cooldown, the 5-waypoint partial path from each
exploratory retry was exhausted in ~10 frames, leaving 50 frames where
only normal A* ran.  Normal A* bestClosedNode points in a different
direction each time, causing visible back-and-forth oscillation at 1x
speed.

10 frames matches the typical partial-path lifetime so the exploratory
retry fires again just as the previous path is consumed, keeping units
consistently moving in the same direction rather than oscillating.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ldown

When normal A* exhausts its node budget and triggers an exploratory retry,
a short partial path is built toward the goal.  Once that path is consumed
(typically in 1-2 frames), the next A* call during the cooldown window
previously fell through to bestClosedNode, which pointed in a different
direction (toward open space), causing visible 1-tile back-and-forth
oscillation in congested narrow corridors.

Now, when we are still in cooldown for the same destination, we return
tsBlocked instead of emitting a bestClosedNode path.  The unit waits
until the cooldown expires and a fresh exploratory retry fires.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@andy5995 andy5995 force-pushed the improve/pathfinder branch from 77a4dcc to b3b9078 Compare March 25, 2026 14:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant