Vav Labs
Back to blog

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

Updated 2026-07-19 · Verified as of Godot 4.7.1 stable docs and runtime graph verifier checked on 2026-07-19

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 API has stayed recognizable across Godot 4.x, but three behavior changes are recent enough to matter when you're reading an older tutorial or rerunning an old test fixture.

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.7A disabled from point now reliably returns an empty path. Tests written around earlier behavior need to be rerun.
4.7.1Current maintenance release checked for this refresh; no AStar2D API change from 4.7.

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

Update an AStar2D graph at runtime

AStar2D has no update() call. Point and connection mutations affect the next query directly, so choose the narrowest operation that describes what changed.

The easy mistake is using add_point() as a movement setter. When the ID already exists, Godot updates both the position and the weight scale. Because the third argument defaults to 1.0, that call can silently remove an authored terrain cost. Use set_point_position() when only the position changed.

Temporary occupancy usually belongs in set_point_disabled(), not remove_point(). Disabling keeps the authored graph available for the next turn; removing a point is a topology edit and should be followed by an integrity check. If the world is a dense tile board, use the runtime AStarGrid2D update guide instead of rebuilding that board as thousands of hand-connected points.

Runtime changeOperationWhat stays intact
A waypoint movedset_point_position(id, position)ID, connections, disabled state, and weight
A door or waypoint is temporarily unavailableset_point_disabled(id, true)Point and all connections
Terrain became more expensiveset_point_weight_scale(id, value)Position and topology
One direction closeddisconnect_points(from, to, false)The reverse direction, if it exists
A waypoint was deleted permanentlyremove_point(id)Incident connections are removed; audit any project-owned references to the deleted ID
# Point 7 used to have weight_scale = 4.0.
astar.add_point(7, new_position)           # Also resets its weight to 1.0.
astar.set_point_position(7, new_position)  # Moves it without changing the weight.

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.

Audit the topology before trusting an empty path

An empty AStar2D path is often correct. The useful question is whether the graph matches the topology you intended: all required IDs exist, the start isn't disabled, and every directed edge points the right way.

get_point_connections(id) is useful for displaying the neighborhood, but a directed audit must check each expected direction explicitly. A one-way ledge can look connected in a drawing while still being wired backwards. The audit doesn't replace a path query; it tells you whether the query is testing the graph you thought you built.

For a richer report, pair these topology facts with the named failure vocabulary from the Godot pathfinding debug panel instead of logging only path.is_empty(). The downloadable verifier runs six named AStar2D mutation and topology checks on Godot 4.7.1; its machine-readable receipt records the observed values plus a clean extracted-package rerun bound to the exact ZIP.

Measurement-gap: this is a deterministic correctness proof, not a performance benchmark. It does not measure path-query time, allocations, memory use, or scaling with graph size.

static func audit_graph(
	graph: AStar2D,
	required_ids: PackedInt64Array,
	directed_edges: Array[Dictionary]
) -> Array[String]:
	var issues: Array[String] = []

	for id in required_ids:
		if not graph.has_point(id):
			issues.append("MISSING_POINT:%d" % id)

	for edge in directed_edges:
		var from_id: int = edge["from"]
		var to_id: int = edge["to"]
		if not graph.has_point(from_id) or not graph.has_point(to_id):
			continue
		if not graph.are_points_connected(from_id, to_id, false):
			issues.append("MISSING_EDGE:%d->%d" % [from_id, to_id])

	return issues

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.

How do I update an AStar2D graph at runtime?

Use set_point_position() for a moved waypoint, set_point_disabled() for a temporary blocker, set_point_weight_scale() for a cost change, and connect_points() or disconnect_points() for topology. AStar2D has no update() step; mutations affect the next query.

Does add_point() replace an existing AStar2D point?

It updates the existing point's position and weight scale while keeping the same ID. If you omit the weight argument, its default 1.0 can overwrite a previous custom weight. Use set_point_position() when only the position changed.