Vav Labs
Back to blog

Godot pathfinding / 2026-09-22 / 12 min read

Verified as of Godot 4.7.2 stable

RTS Crowd Movement in Godot: From Right-Click to Arrival

Build a Godot RTS move order with reachable destination slots, shared claims, partial allocation, and honest arrival states. Includes a tested 2D demo.

Give each unit a place to stop

For an RTS move order in Godot, use the click as the center of a destination area. Give each selected unit a separate reachable position, reserve those positions across groups, and check the body's position before reporting arrival. Keep avoidance enabled for movement between them.

If every unit receives the same target_position, you've asked several bodies to occupy one point. Changing avoidance settings doesn't change that request. Godot's documentation already separates navigation from body movement. The group order still needs rules for who stops where. Using NavigationAgents

The accompanying 2D demo makes that distinction visible. In the captured eight-unit fixture, the shared-target mode finished with all eight units stalled. With destination allocation enabled, all eight arrived. These are observations from the included scene, not a capacity claim for your RTS.

Download the source project · Read the verification record · Open the clean-extraction receipt

Eight units with separate reserved destinations, all marked arrived.
Actual Godot viewport. Four cyan units and four amber units have settled around the click. The sidebar reports eight arrivals and eight reserved positions.

The code was tested on Godot 4.7.2 stable, build ed1daf0bf. The official archive listed 4.7.2 as the latest stable release when checked on September 22, 2026. API links below use the 4.7 documentation. The fixture uses equal-size circular bodies, a static navmesh, and ordinary NavigationAgent2D movement.

What the move order owns

The controller snapshots the selected unit IDs when you click. Changing the selection later doesn't change an existing order. Each selected member receives a new revision, which follows its planning work and eventual target write.

There are two separate results to report. Allocation says how many members received destinations. Movement says what happened to each body afterward.

Member states
StateMeaning in this demo
PLANNINGCandidate pairs are still being checked
QUEUEDA position is reserved, but the agent target hasn't been written yet
MOVINGThe member is following its assigned destination
ARRIVEDThe body is within tolerance and has settled
WAITINGNo assignment was accepted, or the plan became invalid
BLOCKEDNavigation ended before the assigned destination
STALLEDThe body stopped making sufficient progress along its route

An order can be PARTIAL while its assigned members are moving. A fully allocated order can still contain a stalled member later. Keeping those facts separate makes the UI considerably easier to trust.

This controller doesn't keep a formation while units travel. If you need an anchor, named slots, and reformation after a corridor, start with formation movement in Godot.

Give arrival tolerance some room

The demo starts with 25 candidate points, arranged in two square rings around the click. That's a deliberately small search area. A click near a wall may yield fewer usable positions.

For equal-radius bodies, candidate spacing is:

spacing = 2 * body_radius + 2 * arrival_tolerance + margin

Both units can stop on the edge of their arrival tolerance, toward each other. Including both tolerances leaves the requested margin between their bodies even in that case. The fixture uses radius 10, tolerance 3, and margin 4, giving 30 units between slot centers. Those values belong to this scene.

Next, project candidates onto the navigation map. Reject a projection that moves more than 18 units, then check spacing again. Two well-separated proposals can collapse onto the same edge when snapped. Spacing before projection doesn't protect you afterward.

The projection pass greedily retains separated points. It can discard a point that a more sophisticated sampler would use. So a shortage means no full assignment in the checked candidate set, not proof that the surrounding terrain has no room.

Validate the route for each unit-slot pair

A point on the navmesh may belong to a disconnected region. map_get_closest_point() doesn't receive the unit's start position or a navigation-layer mask. Treat its result as a candidate. The subsequent path query uses the member's start and agent.navigation_layers. NavigationServer2D API

The acceptance check in rts_destination_core.gd is small:

Exact ZIP excerptscripts/articles/rts_destination_core.gd

static func accepts_path(path: PackedVector2Array, start: Vector2, slot: Vector2) -> bool:
	return not path.is_empty() \
		and path[0].distance_to(start) <= ARRIVAL_TOLERANCE \
		and path[path.size() - 1].distance_to(slot) <= ARRIVAL_TOLERANCE

Checking both ends rejects a route that begins too far from the body as well as one that stops short. The start check is intentionally conservative. A unit far outside the walkable area needs a recovery policy before this controller will assign it a destination.

An unreachable destination can produce a nonempty path ending at an accessible point. In the disconnected fixture, that endpoint was 162 units from the requested target. A simple not path.is_empty() check would have accepted it. Using navigation paths

Before making any queries, the map must be synchronized. The demo waits for a nonzero map iteration. It also records that iteration with the plan and compares it again at commit. For a runtime mutation, the map owner must lower the controller's navigation_ready flag before changing the map and raise it after the change has synchronized. A nonzero iteration alone only tells you that some synchronization has happened.

Match the feasible pairs

Suppose A can reach slots 1 and 2, while B can only reach slot 1. Assigning A to 1 and stopping there creates a false shortage. Move A to 2 and both members have a destination.

The artifact uses maximum-cardinality bipartite matching. Members are on one side, candidate positions on the other, and each accepted path adds an edge. An augmenting search can move an earlier assignment to make room for a constrained member. It prefers a free feasible slot before displacing an existing assignment.

Here's the entry point from rts_destination_core.gd:

Exact ZIP excerptscripts/articles/rts_destination_core.gd

static func maximum_matching(edges: Array) -> Dictionary:
	# Augmenting paths can move a flexible member to free a constrained slot.
	var slot_to_member: Dictionary = {}
	for member in range(edges.size()):
		_augment(member, edges, {}, slot_to_member)
	var member_to_slot: Dictionary = {}
	for slot in slot_to_member:
		member_to_slot[slot_to_member[slot]] = slot
	return member_to_slot

The complete helper is in the ZIP. Its cardinality was checked against an independent brute-force search for all 512 possible three-by-three graphs. That checks the small matching routine. It doesn't prove that the candidate generator found every useful position.

Matching also doesn't minimize travel distance or prevent crossing routes. Those are additional objectives. For the one-slot, four-member fixture, the controller reserves that slot once and leaves three members waiting. It doesn't duplicate the assignment to make the count look better.

The default budget allows four direct validation queries per physics tick. Unchecked pairs stay pending. Only after the finite graph has been checked does the controller decide whether the order is fully allocated or partial.

Agent target writes have a separate cap of two per tick. The validation counter doesn't include NavigationAgent2D's internal path searches, and neither cap is a millisecond limit. The broader scheduling problem belongs in the many-agent request guide.

Share destination claims across groups

A group's local slot IDs aren't enough. Two groups can generate different IDs for almost identical world positions.

The demo keeps one spatial registry for all groups. A candidate must clear other members' reserved destinations and their current bodies. The current-body check remains conservative even when another unit is expected to leave shortly. A future intention doesn't remove its collider.

Planning takes several ticks, so the controller checks the world again before inserting claims. The beginning of _commit() in rts_order_controller.gd includes:

Exact ZIP excerptscripts/articles/rts_order_controller.gd

	if not navigation_ready or job.map_iteration != NavigationServer2D.map_get_iteration_id(nav_map) \
		or job.claim_revision != claim_revision:
		_restart_or_wait(job, "WORLD_CHANGED")
		return

It also checks member revisions, start-position drift, and current occupancy. Claim insertion then happens synchronously on the main thread, with no await between the checks and insertion. This is the concurrency boundary of this demo. A worker-based planner would need an explicit handoff protocol.

A changed plan gets at most two retries. After that, its remaining members wait for another order. The dispatch queue revalidates its requests too, because a valid commit can become stale before a later target write.

Arrival keeps the claim. Blocked and stalled members keep theirs as well. A new order releases only that member's old claim, and removal releases the removed member's claim. The body still participates in physical occupancy checks until it's gone.

One verification fixture parks the first group, then sends another group to the same area. Both groups arrive with four separate claims. Another starts both plans together and checks that the shared registry prevents overlapping reservations.

Replace the whole order, including pending movement

A second click can arrive while an old plan is incomplete or while a target write is queued. Every member revision must be checked at the point where that work would affect the body.

Starting a replacement order increments the revision and stops motion immediately. An old job or dispatch request can remain in its queue briefly, but it can't write a target for the new revision.

Guard the avoidance callback as well. This is the actual movement entry point in rts_order_member.gd:

Exact ZIP excerptscripts/articles/rts_order_member.gd

func _on_safe_velocity(safe_velocity: Vector2) -> void:
	if not _pending_velocity or _submitted_revision != revision or state != "MOVING":
		rejected_callbacks += 1
		return
	_pending_velocity = false
	velocity = Vector2.ZERO if frozen else safe_velocity
	move_and_slide()
	moved_steps += 1

stop_motion() clears the pending submission and sets velocity to zero. The verifier injects a callback during replacement and checks that it can't move the planning body. It also checks a callback against an arrived body.

This guard follows Godot's physics-loop callback lifecycle. It doesn't attach a unique request token to the engine signal. Don't reuse it as a claim that arbitrary out-of-order worker responses are safe.

Measure arrival on the body

is_navigation_finished() can mean that an unreachable route reached its final waypoint. It isn't sufficient evidence that the assigned destination was reached. The controller distinguishes that from actual body arrival. NavigationAgent2D completion methods

In the demo, the body must be within 3 units of its assigned slot and remain below 2 units per second for 0.15 seconds. A changed order resets that settling timer. Those checks run while the current order is moving.

The first implementation exposed a mundane problem near the goal. A member kept approaching too quickly and oscillated under avoidance. The final loop reduces requested speed as it approaches the slot:

Exact ZIP excerptscripts/articles/rts_order_member.gd

	var wanted := Vector2.ZERO
	if not _near_slot:
		wanted = global_position.direction_to(next_position) * minf(speed, distance * 6.0)

If avoidance displaces a body after its route has finished, the loop can continue toward the assigned slot when the route endpoint matches it. If the route ends elsewhere, the member becomes BLOCKED. The disconnected baseline fixture exercises that distinction with a real agent.

Stall detection uses distance remaining along the current route. Straight-line distance to the goal can increase during a perfectly valid wall detour. The fixture resets its timer after at least 0.5 units of route progress and reports STALLED after three seconds without that improvement. A changed path resets the timer too. For the live stuck watchdog and recovery diagnostics, see why NavigationAgent gets stuck in Godot.

Three seconds without progress doesn't prove that a destination is unreachable. It gives the player an observable failure state and leaves room for another order. This static demo latches ARRIVED once it settles. Knockback, teleports, and moving platforms would need an explicit policy to invalidate arrival and update occupancy.

What the artifact proves

The verifier passed 31 named checks in three runs inside the canonical project, then passed again from a clean extraction of the source ZIP. These are correctness fixtures with fixed 60 Hz simulation steps. These checks don't measure frame times.

Observed verification outcomes
FixtureRecorded result
Four units, open yardAll arrived within the 3-unit tolerance
Four members, one candidateOne assignment, three waiting members
Disconnected destinationPartial path rejected before dispatch
Disconnected shared-target baselineAgent finished its route, body reported blocked
Parked group followed by another groupFour arrivals, separate retained claims
Replacement orderOld planning and dispatch work couldn't restore the old target
Wall detourArrival without a false straight-line-distance stall
Two opposing groups at a narrow gateThree arrived, one stalled in each canonical run

The narrow-gate result is useful. Destination reservations don't reserve passage through the gate. You'll need a traffic policy, such as waiting areas or directional priority, if your game requires guaranteed passage. The demo doesn't provide one.

The screenshots use a separate eight-unit capture. Here's the shared-target result with the same bodies and click:

Shared-target baseline with eight stalled units and no arrivals.
Eight stalled bodies remain around the shared point. No destination claims are created in this mode. Avoidance outcomes can vary between runs, so the capture receipt records the observed counts.

To reproduce the checks after extracting the ZIP, run:

godot --headless --path . --fixed-fps 60 --script res://tools/verify_rts_group_orders.gd

Use the Godot console executable available on your machine. The verifier writes dist/rts-group-orders-verification.json and exits with a failing status if a check fails. The README includes capture commands and the exact fixture settings.

For interactive inspection, open project.godot, run the project, select A, B, or both, then right-click the yard. Try the disconnected layout and replace a move while units are still planning. Watch which state changes, and whether the old target ever comes back.