Companion Locomotion

The key words MUST, MUST NOT, REQUIRED, SHOULD, MAY, and OPTIONAL below are used as in RFC 2119.

A companion NPC (CompanionNpc, a CharacterBody3D) follows the player through field exploration. It steers toward a delayed copy of the player's recorded path — a trail sampled every physics frame, read TRAIL_DELAY (0.3 s) behind — using an arrival controller: it holds roughly FOLLOW_DISTANCE behind the player and moves at a speed that ramps continuously with how far outside that follow-ring it is. This page defines the contract for which locomotion animation the companion plays while following. The trail-follow behavior defined here is the FOLLOW state of the combat FSM — see Companion Combat for the ENGAGE/ATTACK/REGROUP states layered on top; this page's animation invariants hold in every state where a locomotion clip plays.

The position / animation split

There are two separate decisions every follow frame:

Driving the FOLLOW clip from the commanded speed rather than raw per-frame displacement is what removes the run/walk/wait flap: because follow_speed is continuous in distance (no dead zone, monotonic), the gait eases wait → walk → run and back through a real walk band instead of snapping between stand and sprint as the companion caught up and stalled.

FOLLOW arrival speed (#463)

Let d be the companion's planar distance to the player. The pure ramp CompanionCombat.follow_speed(d, ring, ramp, cap) MUST return 0 for d ≤ ring, cap for d ≥ ring + ramp, and a linear interpolation cap · (d − ring) / ramp between — clamped to [0, cap], and cap for d > ring when ramp ≤ 0 (no divide-by-zero). It MUST be monotonic non-decreasing in d; that continuity is the anti-flap property. cap (FOLLOW_MAX_SPEED, 6.5 m/s) sits just above the player's run (MOVE_SPEED 6.0) so the trailing gap holds steady rather than growing into visible lag; ring (FOLLOW_DISTANCE, 2.5 m) and ramp (FOLLOW_SLOW_RADIUS, 1.5 m) are feel knobs. A TELEPORT_DISTANCE (20 m) backstop reels the companion in if it is ever separated outright.

Intent × speed → clip table

Let speed be the companion's speed for the frame (the commanded arrival speed in FOLLOW; else planar_speed = Vector2(moved.x, moved.z).length() / delta for moved = global_position − prev_pos, the vertical component excluded so gravity and the height-snap never read as locomotion). Let intent_moving be the frame's movement intent and current_clip the clip currently playing. The pure selector CompanionCombat.locomotion_clip(intent_moving, speed, current_clip) MUST return:

ConditionClip
intent_moving == false (any speed)wait
speed < IDLE_EPS (0.15 m/s)wait (the #420 veto)
current_clip == "run" and speed < RUN_EXIT (3.0 m/s)walk (exit run)
current_clip == "run" and speed ≥ RUN_EXITrun (hold run)
otherwise, speed > RUN_ENTER (4.0 m/s)run (enter run)
otherwise (IDLE_EPS ≤ speed ≤ RUN_ENTER)walk

Walk↔run hysteresis (#463)

The walk↔run selection MUST use a hysteresis band: the selector switches walk → run only when speed rises above RUN_ENTER (4.0 m/s) and switches run → walk only when it falls below RUN_EXIT (3.0 m/s); for a speed inside the RUN_EXIT … RUN_ENTER band the selector MUST return current_clip unchanged (hold). This is a backstop: with the FOLLOW clip now driven by the continuous commanded speed, the band is rarely straddled, but it still guards the combat states (whose input is the noisier measured displacement) against boundary flap. The IDLE_EPSwait veto is unconditional and takes precedence over the hold. Coming out of wait (or any non-run clip) the enter threshold applies, so a body merely drifting through the band picks walk, never a phantom run.

The clip is resolved once per frame, centrally — the companion's equivalent of the player's single per-frame animation update — then handed to _play_companion_anim, whose ANIM_HOLD_TIME (0.30 s) debounce applies only to the walkrun borderline. Transitions to or from wait (and into the swing clip atk1) MUST apply immediately — the debounce MUST NOT hold a wait↔locomotion switch, because that lag is exactly what let the clip fall out of sync with the body during fast combat-state transitions (the sliding regression).

Invariants

Why commanded speed in FOLLOW, measured speed in combat

In FOLLOW the companion's motion is fully authored by the arrival controller, so its commanded speed is the truest, least noisy signal of what the body is doing — feeding it to the clip selector gives a smooth run → walk → wait gait with no flap. In the combat states the body is steered by approach/regroup logic and can be repositioned abruptly, so there the clip reads the measured displacement — honest about where the body actually went, so a slide or a teleport can never leave a locomotion pose playing over a still body.

Autopilot oracle

Under PSZ_AUTOPILOT, _autopilot_anim_tripwire watches for the regression: if the companion holds a walk/run clip while its measured planar speed stays below IDLE_EPS for longer than the ANIM_HOLD_TIME window, it pushes a [sanity] FAIL: companion '<id>' holds '<clip>' while stationary … error. It is silent in normal play. The companion is exercised end-to-end by the PSZ_AUTOPILOT_COMPANION_COMBAT probe and by any full-quest run (Search and Rescue spawns a following companion in every field cell).

Implemented by

Pinned by test_companion_anim_from_measured_speed (the measured-speed + intent-gate cases via _select_locomotion_anim), test_companion_anim_walk_run_hysteresis (enter above RUN_ENTER, exit below RUN_EXIT, hold in the band, and an oscillating band-straddling sequence that must stop flapping once settled in run), and test_companion_follow_speed_ramp (the arrival ramp: 0 at/inside the ring, linear across, capped, monotonic, divide-by-zero-safe) in scripts/tools/test_runner.gd — all against the pure statics, no scene or AnimationPlayer needed.