Vav Labs
Back to blog

Godot pathfinding / 2026-08-04 / 15 min read

Verified as of Godot 4.7.1 stable

Formation Movement in Godot: Stable Slots, Release, and Reformation

Build formation movement in Godot with a virtual anchor, stable unit-to-slot assignment, checked destinations, and deliberate release and reformation.

Chromium capture of the Godot formation-movement Web export showing actual target writes beside the write-every-tick model, eight units, named slots, both solid gate walls, and the RELEASED state.

Interactive Godot 4.7.1 proof

Order, release, and reform the squad

Drag a destination for eight units, switch line, column, and wedge layouts, and compare actual target writes with a same-run write-every-tick model as the formation releases and reforms.

Loads only after launch. Requires WebAssembly and WebGL 2.

HTML transcript

  1. The scene begins with eight CharacterBody2D members assigned to stable named slots around a virtual anchor.
  2. Dragging an order advances one checked group centerline while each required member keeps a distinct slot; the scorecards compare actual dispatched target writes with one modeled write per live member per measured physics tick.
  3. At the authored narrow gate, the controller transitions from MOVING_FORMED to RELEASED, keeps membership and slot IDs, then enters REFORMING in valid open space.
  4. The controls switch line, column, and wedge layouts, remove a member, toggle avoidance, inject required or optional stragglers, and produce a typed unreachable-slot result.
  5. Arrival latches only after the anchor, endpoint, member-position, member-speed, and stable-tick conditions all pass.

Fallback coverage

  • The poster is a real Chromium capture of this exact single-threaded Godot Web export.
  • The source ZIP contains the standalone Godot 4.7.1 project, formation controller, member controller, visible proof scene, and headless verifier.
  • The source receipt records 35 passing named checks—34 verifier checks plus the clean extracted-package rerun—and a separate green scene smoke.
  • The Web receipt binds the browser build to the exact source ZIP and records 12 passing Chromium checks, including the evidence-bound same-run write model, with zero captured browser errors.

The short answer

Giving eight units the same destination isn't formation movement. It's a meeting with poor seating.

Offset following is a useful steering primitive. The Godot-specific problem starts when every moving offset is written to NavigationAgent2D.target_position on every physics tick. The engine's 4.7.1 setter source says it is intentionally not checking the parameter for equality. The same target may need a new path after the world changes, so the setter calls _request_repath(). The same comment adds, “Revisit later when the navigation server can update the path without requesting a new path.” That is useful engine behavior, and it means the formation layer must decide when a path-target write is deserved.

Represent the formation as a virtual anchor plus stable slot IDs. Store each slot as a local offset, transform it into world space from the anchor's position and facing, and assign each required member exactly one slot. A moving slot may change every frame as steering intent; it doesn't automatically deserve a new path query. When the current footprint won't fit, preserve the logical mapping, release the spatial shape, traverse, and reform on the far side.

This article owns loose formation movement and uses arrive-in-formation as a baseline. A rigid shape through every corridor isn't a setting; sometimes there is no valid route for that footprint.

The useful split is one shared order and several local executions. The anchor owns the accepted group route. Final destination slots may still need individual paths, and a released member may need a corridor fallback, but those are consequences of the order rather than eight unrelated commands. This keeps the group's intent inspectable even when bodies temporarily take different trajectories.

Flocking isn't slot assignment either. Reynolds's boids model produces emergent group motion from separation, alignment, and cohesion, while his steering catalogue includes arrival, offset pursuit, and leader following. Those are useful ingredients, but they don't assign persistent named roles across a squad.

Three different promises hidden inside formation movement
PromiseContract
Arrive in formationFreeze distinct final slots for one order and let members reach them
Move as a loose formationTrack a moving group frame in open space, within declared error bands
Stay rigid where feasibleUse a footprint-aware group planner that can reject geometry the shape cannot cross

Give the group four owners

Formation code becomes easier to inspect when four nouns have four owners. The anchor is data, not the captain. This borrows the representation of Lewis and Tan's virtual-structure approach: relative positions live in one moving reference frame. Here it is only a game-side coordinate model; it doesn't inherit the paper's precision or robotics-control guarantees.

Give slots identities such as left_1 and rear_2. A coordinate is only where that role happens to be now. Keeping identity separate from position lets the same member stay on the left flank while the anchor turns, pauses, releases, and reforms. A physical leader may still affect animation or combat, but removing it mustn't erase the formation's coordinate frame.

This is also the boundary with broad RTS architecture: the controller below follows one accepted group order. Selection systems, multi-squad coordination, command arbitration, and system-scale composition remain outside it.

Formation ownership boundaries
LayerOwnsDoesn't own
Group orderDestination, final facing, required members, layoutIndividual collision response
Virtual anchorShared route progress and orientationA physical unit that can die or get blocked
Slot assignmentStable agent_id → slot_id mappingPathfinding and body movement
Member movementPath following, acceleration, physics, optional avoidanceReassigning itself to a tempting nearby slot

Transform named slots from a virtual anchor

Store layouts around the origin. In this convention local x points right and local y points forward, so a negative y sits behind the anchor. The same transform works for a line, column, wedge, or authored role layout.

Your anchor follows one accepted, endpoint-checked centerline. Its current path tangent supplies the moving facing; the order's requested facing takes over at the destination. A turn rotates world slots, but it must not regenerate their IDs or silently reassign members.

When you advance the anchor, treat it as a deterministic operation over a stored polyline: consume travel distance, skip zero-length segments, update the current tangent, and stop at the accepted endpoint. Keep the route revision beside the cursor. If the navigation map changes, revalidate the route as an explicit group event; don't let one member quietly change the coordinate frame because its path changed.

Test the transform in all canonical directions. A sign error can hide for a long time when every screenshot faces right. Treat spacing as gameplay data, not a magic value from an article. It must agree with body size, acceleration, animation, allowed compression, and navigation clearance. NavigationAgent2D.radius changes the avoidance circle; it doesn't make normal pathfinding honor the body's footprint. The clearance guide owns that setup.

An accepted anchor centerline proves only that a point has a route. It doesn't prove that a wedge fits around the line. The proof project therefore uses an authored gate and explicit release condition instead of presenting a centerline query as footprint-aware planning.

Exact ZIP excerptscripts/articles/formation_movement_core.gd · local_to_world()

static func local_to_world(
	anchor: Vector2,
	forward_hint: Vector2,
	local_offset: Vector2
) -> Vector2:
	var forward := forward_hint.normalized()
	if forward.is_zero_approx():
		forward = Vector2.UP
	var right := Vector2(-forward.y, forward.x)
	return anchor + right * local_offset.x + forward * local_offset.y

Keep assignment stable, not magically optimal

If every member independently chooses its nearest slot, two members can choose the same destination. If you rebuild the whole mapping every frame, small position changes can make roles swap repeatedly. The formation contract needs a one-to-one mapping, deterministic tie-breaking, and explicit reassignment events.

A practical small-squad baseline is conservative: preserve every valid previous pair, sort unassigned members by stable ID, then give each the nearest compatible free slot with a stable slot-ID tie-break. Call that algorithm what it is: deterministic and greedy. It doesn't minimize total travel.

Snapshot the required member set when the order begins. Reassign only after a declared event—membership change, layout change, invalid slot, or new order—so normal movement can't churn roles. If your slots have gameplay meaning, validate compatibility before distance: a medic-only slot is a hard constraint, not a large cost you hope the greedy pass interprets well. Record previous and new mappings with the formation revision so a replay can explain every swap.

If minimum total assignment cost matters, build a declared cost matrix and use Kuhn's Hungarian method. It optimizes the matrix you supply. A matrix containing only Euclidean distance says nothing about crossing paths or collision-free execution; Ma and Koenig's TAPF formulation treats target assignment and pathfinding as a coupled problem. Add role or switch penalties only when the artifact can explain and test them.

Treat a slot as requested geometry

A rotated slot can land inside a wall, off the navigation surface, or on a disconnected island. Keep the requested point, the endpoint of the loaded path, and the error between them together as order evidence. Call the slot reachable only when that error is inside a declared tolerance.

Godot's path documentation explains that a disconnected request can return the closest reachable endpoint on the start island. The is_navigation_finished() reference makes the important distinction: navigation can finish at the last waypoint when the target is unreachable. Finished path following isn't proof of slot success.

Don't run one full query to preflight a slot and immediately ask the agent for the same route again. The proof controller enters VALIDATING, submits candidate targets through the shared scheduler, refreshes each agent's path state once from _physics_process(), and inspects that loaded path's endpoint before authorizing movement. If one required slot fails, it returns an explicit result instead of letting part of the group drift away.

Treat your validation batch as a transaction. Snapshot the order ID, formation revision, required members, requested slots, and navigation-map revision before the first refresh. Every endpoint in that batch must belong to the same snapshot. If membership or map state changes halfway through, discard the partial result and start a new revision; mixing old and new endpoints creates a plausible-looking formation that was never actually validated.

An empty loaded path isn't automatically failure either. If the agent parent's position is already within slot tolerance, no route is needed. Accept that case with the same requested, final, and error fields so “already there” doesn't become a special result shape downstream code must guess about.

The full layer-aware query mechanics, map synchronization, and map_get_closest_point() caveats belong to the NavigationServer2D guide. Here the formation-specific rule is simply to preserve the requested point, returned endpoint, and error.

Endpoint evidence retained for every requested slot
FieldMeaning
requested_slotWhere the authored layout asked the member to stand
final_positionThe endpoint of the path actually loaded by the agent
slot_errorDistance between the requested slot and loaded endpoint

Move each body without turning every slot into a repath

Godot's navigation system doesn't move an agent's parent. The member controller owns the body, while the formation controller owns permission and intent. In open space, a current world slot can influence arrival or offset steering around the accepted group route. Rewrite a target_position only after an order, layout, assignment, invalid slot, or thresholded displacement requires it. The existing request-scheduling article owns queue budgets, coalescing, priority, and starvation.

A spatial deadband is only a write filter. Order, layout, assignment, or an explicitly invalid slot may force a refresh. A map revision is different: Godot can update a loaded path after the map changes, so observe path_changed and revalidate the endpoint instead of reflexively writing the same target again. Count both target writes and path changes; either counter can expose an accidental feedback loop.

The proof labels its comparison as a model, not measured engine work. For one accepted order it adds the number of live member-agent pairs once per measured physics tick, then compares that sum with assignments at the real target_position dispatch site. The window closes only after ARRIVED and an empty governor queue.

The member also needs to verify a structural assumption hidden in get_next_path_position(): when no path is loaded, Godot returns the position of the agent's parent. If the NavigationAgent2D isn't a direct child of the body this controller moves, endpoint evidence can describe a different node. The packaged wrapper below fails closed.

The official path-following guide is the authority for checking is_navigation_finished() early, calling get_next_path_position() once per physics tick while active, and stopping those updates after completion to avoid jitter. The class reference, not the guide's update pattern, owns the unreachable-last-waypoint semantics used in slot validation.

Avoidance remains downstream. Godot calls its returned value safe_velocity, but it isn't proof of collision-free physics motion or route progress. RVO doesn't assign slots, choose the group route, preserve ranks, or decide when the formation has arrived. Its radius, priority, horizons, masks, and map isolation are separate avoidance concerns.

Draw the wanted formation velocity and returned avoidance velocity as separate vectors. When a unit leaves its row, that distinction shows whether the group controller requested the deviation or the local avoidance layer introduced it. The formation state machine remains authoritative either way.

Exact ZIP excerptscripts/articles/formation_member_2d.gd

func _ready() -> void:
	if agent == null:
		_reject_binding(CODE_AGENT_MISSING)
		return
	if not (agent.get_parent() is CharacterBody2D):
		_reject_binding(CODE_AGENT_PARENT_NOT_CHARACTER_BODY)
		return
	if agent.get_parent() != self:
		_reject_binding(CODE_AGENT_PARENT_MISMATCH)
		return
	_binding_ready = true
	binding_result = {"ok": true, "code": CODE_OK}
	agent.max_speed = move_speed
	agent.velocity_computed.connect(_on_velocity_computed)


func _physics_process(_delta: float) -> void:
	if not _binding_ready or not movement_authorized or agent.is_navigation_finished():
		_stop_motion()
		return
	var next_position := agent.get_next_path_position()
	var wanted := global_position.direction_to(next_position) * move_speed
	if agent.avoidance_enabled:
		agent.velocity = wanted
	else:
		velocity = wanted
		move_and_slide()
		movement_step_count += 1


func _on_velocity_computed(safe_velocity: Vector2) -> void:
	if not _binding_ready or not movement_authorized or not agent.avoidance_enabled:
		_stop_motion()
		return
	velocity = safe_velocity
	move_and_slide()
	movement_step_count += 1


func _stop_motion() -> void:
	velocity = Vector2.ZERO
	if agent != null and agent.avoidance_enabled:
		agent.velocity = Vector2.ZERO


func _reject_binding(code: StringName) -> void:
	_binding_ready = false
	binding_result = {"ok": false, "code": code}
	push_error(String(code))
	set_physics_process(false)

Release and reform through constrained space

Formation shape is a controlled objective in open space, not a rigid body that can be forced through every corridor. Preserve membership and slot IDs when a wide wedge reaches a narrow gate, but suspend slot-position error until the group has crossed.

This transition contract isn't a universal bottleneck detector. The proof uses clearly labeled authored release and reform zones around a known gate. Start with explicit geometry; automatic corridor-width inference is a separate system. Use asymmetric conditions too: release when the authored condition becomes active, but require valid open space for a hold interval before reforming. One boundary can flap at the doorway edge.

When you enter RELEASED, members may follow individual rendezvous paths, an authored queue, or shared corridor intent. A large common-goal group may read a flow field, but the flow-field guide owns its construction and a field doesn't replace slot assignment.

Release needs evidence of progress, not just a state label. Record the trigger, which required members cleared the gate, the time since meaningful forward movement, and the selected timeout result. Keep old slot IDs dormant inside the choke. On the far side, reactivate them once or create one explicit reassignment revision; rebuilding the mapping throughout traversal turns geometric freedom into role churn.

Li et al.'s SWARM-MAPF formalizes a stronger planning problem: open segments preserve formation, while congested segments temporarily compromise it and refine paths jointly. That supports release and reformation as a lifecycle. It doesn't make this authored Godot state machine complete, optimal, or generally deadlock-free.

One explicit formation lifecycle
StateActive ruleExit condition
FORMINGMembers acquire assigned slotsRequired members hold tolerance
MOVING_FORMEDAnchor advances; members track world slotsRelease condition or final approach
RELEASEDOwnership stays; spatial error is ignoredRequired members clear the constrained segment
REFORMINGMembers reacquire original or explicitly revised slotsFormation tolerance holds again
ARRIVEDCompletion and stopped movement are latchedNew order or membership policy
Formation lifecycle diagram showing stable named slots around a virtual anchor, release through a narrow gate, and reformation with the same assignments.
Membership and slot identity persist across the authored release zone; only the spatial-shape objective is suspended.

Run one group lifecycle

Pacing, membership, and arrival aren't separate patches. They're events in the same group order.

While the shape is active, compare each required member with its current slot. Slow or pause anchor progress when required-member error crosses a soft band; release or return an explicit timeout after a larger bound. Optional cosmetic followers shouldn't freeze the squad. These thresholds are engineering policy, so the artifact exposes them and injects both a required and optional straggler.

When membership changes, increment one formation revision. Preserve still-valid agent_id → slot_id pairs, remove or add slots according to the layout policy, assign only the remaining pairs, and enqueue resulting path changes through the shared scheduler. A movement controller must never reassign itself. If a commander is required for gameplay, its loss may fail the order, but it still shouldn't become a null coordinate frame.

Make that event atomic too: pause anchor advancement, rebuild layout and mapping from one member snapshot, validate changed targets, then resume. A unit that disappears during validation should supersede the revision, not produce an “unreachable slot” blamed on a body that no longer belongs to the order.

Your group has arrived only when all five conditions below pass together. Then latch ARRIVED until a new order or membership policy says otherwise. Reynolds's arrival behavior helps one member brake near a target; stable ticks and an aggregate latch are this formation controller's policy. Keep target_desired_distance, body stopping distance, and formation tolerance separate so one agent's path state can't silently redefine group success.

  1. The virtual anchor reached the accepted order endpoint.
  2. Every required requested slot passed endpoint validation.
  3. Every required member is inside formation tolerance.
  4. Every required member is below the arrival-speed threshold.
  5. All four conditions held for the declared stable ticks.

Make the proof observable

The focused Godot 4.7.1 project runs eight CharacterBody2D members and NavigationAgent2D nodes, line, column, and wedge layouts, a virtual anchor, named slots, loaded paths, and an authored narrow gate. Its runtime scene records FORMING → MOVING_FORMED → RELEASED → REFORMING → MOVING_FORMED → ARRIVED. That is an explicit zone policy, not automatic bottleneck detection.

The source receipt passes 35 named checks—34 verifier checks plus the clean extracted-package rerun—and a separate scene smoke. These are correctness results, not a speed or scalability benchmark.

The redesigned proof makes the claim visible: paired scorecards compare same-run actual target writes with the labeled write-every-tick model, a live strip advances through all five states, member-colored trails preserve the crossing history, and two labeled solid walls, thin authored-zone boundaries, collision-aware slot labels, outlined bodies, and a destination-aware legend keep the gate readable.

Three fixtures instantiate the packaged member wrapper itself. They verify exact typed results and captured push_error() branches for a missing agent, a non-body parent, and a different body; each must disable member motion. The per-tick guard also rejects a second path refresh.

The Web receipt passes 12 checks in Chromium 149 at 1280×720 with zero browser errors. Its gate check binds contiguous frame limits, state-bucket sums, actual-versus-modeled writes, the exact avoided-write difference, and an empty queue at completed ARRIVED. Automation also checked embed mode, trails, all layouts, membership, avoidance, both straggler policies, typed failure, reset, the ten-file allowlist, and the raw/Brotli WASM round trip.

Measurement-gap: within the named fixtures, the receipts establish invariants, transitions, packaging, and browser interaction. They don't measure large-crowd throughput or prove optimal multi-agent planning, general deadlock freedom, or subjective motion quality.

Frequently asked questions

Does Godot have built-in formation movement?

Godot 4.7 documents per-agent pathfinding, path-following state, and optional RVO avoidance. It doesn't document a formation manager, slot assignment, group arrival, or release/reform policy. Build that group layer above the agents.

Should moving slots update every physics frame?

Their world positions may. Their target_position values shouldn't. Treat the slot as steering intent, and write a new path target only after a declared invalidation or meaningful displacement through the shared scheduler.

Does NavigationAgent avoidance keep ranks intact?

No. Avoidance modifies local preferred velocity around registered agents and obstacles. It doesn't own slot IDs, rows, the group route, or completion. Draw desired and returned velocities separately when debugging drift.

What should happen at a narrow doorway?

Release or change the spatial layout before the doorway, traverse with a policy that fits, then reform after valid open space holds long enough. Start with authored zones unless you've independently verified a corridor-width detector.

Is is_navigation_finished() enough for group arrival?

No. For an unreachable target it can become true at the path's last waypoint. Validate the requested slot against the loaded endpoint, then require every required member's position, speed, and stable-tick conditions.

Should slot assignment use the Hungarian algorithm?

Use it when a declared minimum total cost matters. It optimizes the supplied matrix, not path conflicts, collision freedom, role churn, or deadlock. A stable greedy mapping is often enough for a small squad if you name and test that limitation.