Vav Labs
Back to blog

Godot pathfinding / 2026-07-05 / 13 min read

Verified as of Godot 4.7 stable docs on 2026-07-05

AStar2D in Godot 4: complete reference

AStar2D reference for Godot 4: points, connections, one-way segments, weights, disabled points, closest-point lookup, partial paths, and custom costs.

AStar2D is the low-level one

There's no region, no baking, no update() call: you add every point and every connection yourself, and the class hands back shortest paths by point ID. The docs describe AStar2D as a wrapper for AStar3D that enforces 2D coordinates, and that's literally what the source does: every Vector2 you pass in becomes a Vector3(x, y, 0) internally.

That design has one consequence worth absorbing before any API detail: most AStar2D bugs are graph bugs. The class did exactly what you wired.

So this is the page I'd keep open in a second tab while wiring: ids, connections, one-way segments, weights, disabled points, closest-point lookup, partial paths, and the cost overrides. If your map is a uniform tile grid, stop here and use the AStarGrid2D reference instead; it wires the neighbors for you and adds solid cells and per-cell weights. If you need continuous movement, avoidance, or baked geometry, that's NavigationServer2D territory, not this class.

If something here is wrong or out of date for your Godot version, I want to know. The API has moved across Godot 4.x, and stale advice costs debugging time.

Version notes for Godot 4.x

The AStar2D core has been stable across Godot 4.x, but two additions matter, and both are recent enough that most tutorials don't mention them.

Godot versionWhat matters for AStar2D
4.0Core API: points, connections, weight scale, disabled points, closest-point queries.
4.3+get_id_path() and get_point_path() gain allow_partial_path.
4.5+neighbor_filter_enabled and _filter_neighbor() are added.
4.7 stableCurrent stable docs as of 2026-07-05. Use these for final verification.

The smallest graph that works

Three points, two connections, both query methods:

var astar := AStar2D.new()

func _ready() -> void:
    astar.add_point(1, Vector2(96, 64))
    astar.add_point(2, Vector2(320, 64))
    astar.add_point(3, Vector2(320, 256))

    astar.connect_points(1, 2)  # bidirectional by default
    astar.connect_points(2, 3)

    var ids: PackedInt64Array = astar.get_id_path(1, 3)          # [1, 2, 3]
    var points: PackedVector2Array = astar.get_point_path(1, 3)  # world positions

Ids are your contract

Two contract details hide in add_point(). Ids are integers and must be 0 or larger; you own the id scheme, and the class never invents ids for you. Calling add_point() with an id that already exists isn't an error: it updates that point's position and weight scale in place. Handy for moving platforms. Surprising when two id schemes collide and one silently rewrites the other's waypoint.

If you don't care what ids mean, get_available_point_id() returns the next free one. If you do care (tile hashes, waypoint enums, room numbers), use your scheme everywhere and guard writes with has_point(). What you shouldn't do is mix both in the same graph; sooner or later get_available_point_id() hands out an id your hashing scheme was about to use.

For iteration and debug overlays, get_point_ids() returns every id and get_point_count() says how many exist. clear() wipes points and segments in one call.

Connections and one-way segments

connect_points(a, b) creates a bidirectional segment by default. Pass false and the segment becomes directional: movement from a to b is allowed, the reverse isn't.

# A drop-down ledge: you can go from the ledge (10) to the ground (11), never back.
astar.connect_points(10, 11, false)

astar.are_points_connected(10, 11, false)  # true
astar.are_points_connected(11, 10, false)  # false: the reverse never existed

The disconnect asymmetry

One-way segments map to real game things: drop-down ledges, conveyors, one-way doors, a collapsed staircase. It's also a capability AStarGrid2D does not expose as a first-class edge-direction feature.

disconnect_points() takes the same flag, and the asymmetry can bite in both directions. Disconnecting with bidirectional = false removes one direction, and the docs say it plainly: "a unidirectional segment possibly remains". That's a feature when a door locks behind the player. It's a bug when you thought you removed the whole edge. are_points_connected() accepts the flag too, so you can assert either direction separately.

weight_scale: cost lives on the point you enter

add_point() takes an optional weight_scale, and set_point_weight_scale() changes it later. The docs' definition is precise: the weight is multiplied by the result of _compute_cost() when determining the cost of traveling across a segment into this point. In the source that's one line: a neighbor's g-score is _compute_cost(from, to) * weight_scale_of_to.

astar.add_point(7, Vector2(450, 490), 4.0)  # swamp waypoint: 4x to enter
astar.set_point_weight_scale(7, 1.0)        # drained the swamp later

The floor is 0.0, and the estimate rule

Weights live on points, not on edges. Every route entering an expensive point pays, from any direction. If you need a true per-edge cost, that's the _compute_cost() override below, or an intermediate point placed on the expensive edge.

And the floor is 0.0, not 1.0. Values between 0 and 1 make a point cheaper than its distance, which sounds like a nice "prefer the road" dial until you remember the estimate rule: A* stays optimal only while the estimated remaining cost never overestimates the real one. Make points cheaper than distance and the default Euclidean estimate can overestimate, and discounted routes come back slightly wrong. The boring fix: keep roads at 1.0 and make everything else more expensive.

Disabled points: temporary blockers

set_point_disabled(id, true) removes a point from pathfinding without touching the graph structure. The docs call it "useful for making a temporary obstacle", and that's the right mental model: the wiring stays, the point just stops participating.

astar.set_point_disabled(7, true)             # occupied: nothing routes through 7
var id := astar.get_closest_point(click_pos)  # skips disabled points by default

Two behaviors worth memorizing

A disabled start point returns an empty path even when from_id == to_id; both path methods state this explicitly, so a unit standing on a just-disabled waypoint gets an empty route to itself. And get_closest_point() skips disabled points unless you pass include_disabled = true, which keeps snapping and pathfinding agreeing with each other by default.

If your blockers are grid-shaped and frequent, dynamic blockers without a full rebuild covers the same problem on the grid side.

From world position to graph

Gameplay hands you positions; AStar2D wants ids. Two methods bridge the gap.

get_closest_point(to_position) returns the id of the nearest point, or -1 when the pool is empty. Ties are documented and deterministic: the smallest id wins. That determinism matters more than it looks; replays and lockstep simulations get the same answer on every machine.

get_closest_position_in_segment(to_position) snaps a position onto the nearest connected segment rather than the nearest point. The docs' example: points at (0, 0) and (0, 5), query (3, 3), result (0, 3). In game terms, clicking next to a road selects the spot on the road, not the nearest intersection.

Closest-point lookup is a linear scan

This one isn't in the docs, but it's one loop in the source: get_closest_point() walks the entire point pool on every call (core/math/a_star.cpp). At waypoint counts, irrelevant. At grid-scale counts with one lookup per agent per frame, that's points × agents distance checks you didn't plan for. Cache lookups, quantize them, or keep your own spatial index next to the graph. Why your Godot pathfinding causes frame spikes is the same discipline applied to query shape.

Why is the path empty?

Four boring causes cover almost every empty result:

  • A missing id fails with an error in the debugger output and returns nothing useful. has_point() guards are cheap.
  • A disabled start returns an empty path, even when start and goal are the same id.
  • A one-way segment pointing the wrong way means the route exists in one direction only. are_points_connected(a, b, false) checks a single direction.
  • Two healthy-looking subgraphs with no segment between them return empty for every cross-graph query. get_point_connections() on both ends is the fastest sanity check.
  • Navmesh-side symptom instead: agents, regions, baking? Start from Why is my path failing in Godot?, not this class.

Partial paths (4.3+)

Since Godot 4.3, both path methods accept allow_partial_path. With it true, an unreachable goal returns the path to the closest reachable point instead of an empty array.

The docs ship a warning with it: when the target point is disabled and allow_partial_path is true, the search "may take an unusually long time to finish". Same stance as the grid version of this feature: validate first, including existence, disabled state, and direction. Use partial paths as a design policy ("walk toward the door even if it's locked"), not as a substitute for validation.

var route := astar.get_point_path(start_id, goal_id, true)  # closest reachable, 4.3+

Custom costs: subclass and override

The two virtuals, _compute_cost(from_id, to_id) and _estimate_cost(from_id, end_id), are "hidden in the default AStar2D class", to use the docs wording. You can't call them from outside; you extend AStar2D and override them. The default for both is Euclidean distance between the points.

class_name DangerAStar2D
extends AStar2D

# Extra cost per point id; 1.0 means normal ground.
var danger: Dictionary = {}

func _compute_cost(from_id: int, to_id: int) -> float:
    var base := get_point_position(from_id).distance_to(get_point_position(to_id))
    var mult: float = danger.get(to_id, 1.0)
    return base * mult

func _estimate_cost(from_id: int, end_id: int) -> float:
    # Plain distance stays a lower bound while danger only makes points pricier.
    return get_point_position(from_id).distance_to(get_point_position(end_id))

Two rules keep overrides honest

First, weight_scale still multiplies whatever _compute_cost() returns; the two stack, so pick one cost mechanism per graph unless you genuinely need both. Second, the docs' estimate rule: _estimate_cost(u, v) should return a lower bound, <= _compute_cost(u, v). If you can't guarantee the bound, the docs' own suggestion is to make _estimate_cost() return the same value as _compute_cost(), trading speed for exact information.

Neighbor filter (4.5+)

Godot 4.5 added neighbor_filter_enabled and _filter_neighbor(from_id, neighbor_id). With the property on, the search calls your filter for each neighbor it's about to process; return true and that neighbor is skipped for this search. It's search-time pruning without rewiring the graph: faction-locked doors, temporary no-go zones, "this unit can't swim".

extends AStar2D

var closed_doors: Dictionary = {}  # point id -> true

func _filter_neighbor(_from_id: int, neighbor_id: int) -> bool:
    # Return true to skip this neighbor for the current search.
    return closed_doors.has(neighbor_id)

# Elsewhere: the filter reads object state, not query arguments.
# astar.neighbor_filter_enabled = true
# astar.closed_doors = doors_locked_for(agent)
# var path := astar.get_point_path(from_id, to_id)

The filter is object state

The catch is in those commented lines: get_point_path() has no idea which agent is asking. Set the filter context before each query, or keep one AStar2D instance per movement class and skip the bookkeeping.

Capacity and threads

reserve_space(num_nodes) pre-allocates the point pool; the docs recommend it when adding a known large number of points at once, "such as points on a grid". get_point_capacity() reads the allocation back.

And one docs note that tends to surprise people late in a project: get_point_path() is not thread-safe. One thread at a time, mutex if you must share. If pathfinding is heavy enough that threads look tempting, measure the query shape first; moving 10,000 agents in Godot is the measured version of that argument.

AStar2D vs AStarGrid2D: which one to use

Pick by the shape of the world, not by which class you met first.

You have...UseWhy
Waypoints, roads, hand-placed nodes, one-way linksAStar2DYou define the topology. Edges can be directional; costs can be overridden.
A uniform tile grid, whole-cell movementAStarGrid2DThe grid is the graph: solids, per-cell weights, diagonal modes, jumping.
Continuous movement, baked geometry, avoidanceNavigationServer2DA different layer: regions, agents, navmesh.

The honest asymmetry

AStarGrid2D does not expose directed connect_points() topology; its graph is still grid-generated. AStar2D gives you arbitrary topology and one-way edges, but makes you build and maintain every point and connection yourself, and knows nothing about corner cutting or diagonal rules. Rebuilding a 64x64 grid with connect_points() calls means hand-writing everything the grid class gives you for free. If the map is a grid, use the AStarGrid2D reference. If the choice is between a grid and a navmesh, that's the grid or navmesh decision guide. And when neither built-in survives your production requirements (multi-size agents, dynamic blockers at scale, diagnostics), that's the limitations and alternatives discussion.

Reference boundary: API behavior, not replacement strategy

Everything above is the class working as designed: what to call, in what order, and which flags change the shape of the returned path. Replacement strategy lives in the limitations guide, crowd-scale query scheduling lives in 10,000 agents, and the navmesh side of the house starts at the NavigationServer2D guide.

If any of this behaves differently in your Godot version, tell me. The page is pinned to the 4.7 stable docs and I'd rather fix it than have it quietly wrong.

Frequently asked questions

What is AStar2D in Godot?

AStar2D is Godot's general-purpose A* pathfinding class for 2D. You add points and connections yourself and query shortest paths by point ID. Under the hood it's a wrapper for AStar3D that enforces 2D coordinates.

When should I use AStar2D instead of AStarGrid2D?

When the map isn't a uniform grid: waypoint graphs, road networks, hand-placed nodes, one-way links, or custom cost functions. For tile grids, AStarGrid2D wires neighbors, solids, and per-cell weights for you.

Why does get_id_path return an empty array?

The usual causes: a disabled start point (empty even when start equals goal), a missing id, a one-way segment pointing the wrong way, or two subgraphs with no connection between them.

How do I make a one-way connection in AStar2D?

connect_points(a, b, false). Movement from a to b is allowed, the reverse isn't. are_points_connected() and disconnect_points() take the same bidirectional flag.

How do terrain costs work in AStar2D?

weight_scale sits on the destination point and multiplies the cost of entering it. The floor is 0.0, the default is 1.0. Per-edge costs need a _compute_cost() override or an intermediate point.

Does AStar2D support partial paths?

Yes, since Godot 4.3: pass allow_partial_path = true to get_id_path() or get_point_path() to get a path to the closest reachable point. A disabled target can make a partial search unusually slow.

Is AStar2D thread-safe?

get_point_path() is documented as not thread-safe. Restrict it to a single thread at a time, or guard it with a mutex.

How many points can AStar2D handle?

There's no documented limit. Use reserve_space() when adding large batches. The practical hot spot is usually get_closest_point(), which scans every point per call, not the search itself.

Can I customize the AStar2D cost function?

Yes: extend AStar2D and override _compute_cost() and _estimate_cost(). Keep the estimate a lower bound of the real cost, and remember weight_scale still multiplies the compute result.

Does AStar2D work for hex or isometric maps?

Yes, by construction: it has no grid assumptions. You define the points and the neighbor topology yourself, so any coordinate system works.