- Moved the rig scene from `res://master_rig.tscn` to `res://scenes/stickman_rig.tscn`, preserving its UID. - Split the `StickmanRig` controller into three components: `StickmanLocomotion`, `StickmanRagdoll`, and `StickmanActionRunner`. - Updated the architecture to ensure `StickmanRig` acts as the public facade for the new components. - Created a new test suite `test_phase8_rig_modularization.gd` to verify the integrity of the relocated rig and its components. - Added checks for scene loading, component resolution, action queue delegation, and state authority.
23 KiB
Phase 8 — Rig Relocation and Runtime Component Modularization
Status: architecture proposed; awaiting user approval before implementation.
Source proposal: plans/PHASE_8_RIG_MODULARIZATION.md.
1. Outcome and architectural correction
Move the authored rig scene from res://master_rig.tscn to
res://scenes/stickman_rig.tscn while preserving its existing scene UID
(uid://dr5ef3l2var2s). Split the 3,159-line StickmanRig controller into three child
components, but keep StickmanRig as the only public facade used by the factory, stage,
harnesses, scene I/O, and existing gameplay code.
One boundary in the source plan should change before implementation: navigation and
walk_to() belong in StickmanLocomotion, not StickmanActionRunner. Walking, terrain
following, ledge momentum, landing, animation pause/resume, and root displacement share one
state machine and one AnimationPlayer. Splitting path following from terrain motion would
create two writers for the rig transform and require a per-frame synchronization protocol.
StickmanActionRunner should orchestrate actions and call the locomotion API; it should not
implement movement.
The extraction is structural only. It must not change movement tuning, recovery math, action payloads, signal timing, saved scene data, or editor UI.
2. Targets and ownership
| Target | Ownership after Phase 8 |
|---|---|
scenes/stickman_rig.tscn |
Renamed rig scene. Keeps the existing Master, Body, Skeleton2D, IK_Targets, AnimationTargets, AnimationPlayer, and AnimationTree paths. Adds a nonvisual RigComponents branch. |
scripts/stickman_rig.gd (StickmanRig) |
Public compatibility facade; authoritative RigState; facing profile, bend flags, IK modification resolution, body z-order, root mirror, head pin, shared node lookup, component wiring, deterministic physics scheduling, and public signal forwarding. |
scripts/stickman_locomotion.gd (StickmanLocomotion) |
Navigation agent, walking, terrain probes/following, airborne and landing states, root movement, gait/foot contacts, animated collision proxies, airborne sweeps, and impact reactions. |
scripts/stickman_ragdoll.gd (StickmanRagdoll) |
Momentum tracking, ragdoll definitions and procedural bodies/joints, rest detection, pre-ragdoll/captured-pose data, recovery snap/tween, and ragdoll impulse application. |
scripts/stickman_action_runner.gd (StickmanActionRunner) |
Authored/runtime action queues, reactive injection, action phase machine, wait timing, speech bubble and overlay arbitration, and action/queue/speech signals. |
scripts/stickman_factory.gd |
Loads the relocated scene; return type and spawn* API stay unchanged. |
scripts/stk_rig_adapter.gd |
No node-path changes. Comments reference the relocated scene. It continues to adapt the root before _ready(). |
scripts/create_animations.gd, harnesses, tests, active docs |
Update live scene-path literals/comments and component-aware tests. |
source_stk_path remains on the root facade because it is instance provenance consumed by
stage serialization, not behavior owned by a component.
No layout, theme, visible node, or user-facing UI change is required.
3. Scene structure
The existing visual and skeleton hierarchy must remain byte-for-byte equivalent apart from the new component branch and external component script resources. Adapter and harness node paths therefore remain valid.
Master: Node2D [StickmanRig]
├── RigComponents: Node
│ ├── Locomotion: Node [StickmanLocomotion]
│ ├── Ragdoll: Node [StickmanRagdoll]
│ └── ActionRunner: Node [StickmanActionRunner]
├── Body: Node2D
│ └── Body / Head / 8 limb visual containers (unchanged)
├── Skeleton2D (unchanged)
│ └── Torso/Head + 8 limb Bone2D nodes
├── RayCast_Ground (unchanged; legacy)
├── IK_Targets (unchanged)
├── AnimationTargets (unchanged)
├── AnimationPlayer (unchanged)
└── AnimationTree (unchanged)
world/stage parent
└── RagdollBodyContainer (runtime only; unchanged location)
├── 10 RigidBody2D
└── 9 PinJoint2D
The components are authored children rather than created in _ready(). Missing component nodes
produce one StickmanRig: warning per missing node and disable only that subsystem. The root must
never manufacture a fallback component silently because that would hide a broken scene.
4. Dependency direction and state authority
flowchart LR
C[StickmanRig facade/coordinator]
L[StickmanLocomotion]
R[StickmanRagdoll]
A[StickmanActionRunner]
U[Stage / Factory / Harness / Scene IO]
U -->|existing public API| C
C -->|configure + deterministic ticks| L
C -->|configure + commands| R
C -->|configure + commands| A
A -->|walk/speech-independent movement query| L
A -->|ragdoll_requested / recovery_requested| C
R -->|state_transition_requested| C
R -->|ground query + recovery reanchor| L
L -->|arrived / facing_requested| C
C -->|forwarded public signals| U
Rules:
StickmanRig.stateis the only high-level state authority. Components receive the current state during commands/ticks and never write it directly.StickmanActionRunnerhas no reference toStickmanRagdoll; it requests transitions through root-connected signals. This allows queue tests without constructing physics bodies.StickmanRagdollmay synchronously call only the narrow locomotion recovery/query API; it must not inspect locomotion private fields.StickmanLocomotionrequests facing changes by signal so the facade remains the profile and mirror authority.- The components do not implement
_physics_process(). The root is the sole scheduler. This preserves the current update order and avoids relying on Godot sibling processing order.
5. Static component contracts
The signatures below are the implementation contract. Internal helper names may be retained during mechanical extraction, but cross-component calls must use only these methods/signals.
5.1 StickmanLocomotion
class_name StickmanLocomotion
extends Node
signal arrived(target: Vector2)
signal facing_requested(profile: int)
func configure(rig: StickmanRig) -> void
func tick_movement(delta: float, rig_state: int) -> void
func tick_post_skeleton(delta: float, rig_state: int) -> void
func walk_to(target: Vector2, speed: float = -1.0) -> bool
func cancel_walk(restore_idle_pose: bool = true) -> void
func is_walking() -> bool
func consume_walk_completed() -> bool
func begin_terrain_fall() -> void
func set_terrain_following_enabled(enabled: bool) -> void
func is_action_blocked() -> bool
func prepare_for_ragdoll() -> void
func restore_after_recovery() -> void
func probe_ground_y(
world_x: float,
top_y: float,
bottom_y: float,
exclude: Array[RID]
) -> float
func reanchor_for_recovery(feet_world: Vector2, foot_offset: Vector2) -> Vector2
func set_collision_bodies_enabled(enabled: bool) -> void
func apply_collision_reaction(
part_name: String,
normal: Vector2,
impact_speed: float,
contact_position: Vector2 = Vector2.INF
) -> void
tick_movement() owns the old clear-pelvis → walking → terrain → settle sequence.
tick_post_skeleton() owns impact-reaction application and collision-proxy synchronization,
which must remain after the facade's head pin. reanchor_for_recovery() moves the root and
returns the mirror-aware rig-local shift used by StickmanRagdoll to rebase captured body data.
The locomotion component is the source of truth for TerrainMotion, navigation, terrain/air
tuning constants, collision proxy tables, and reaction constants. Compatibility aliases may
remain on StickmanRig for one phase, but they must point to component constants rather than
duplicate values.
5.2 StickmanRagdoll
class_name StickmanRagdoll
extends Node
signal state_transition_requested(next_state: int)
signal rest_state_changed(at_rest: bool)
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void
func tick_pre_movement(delta: float, rig_state: int) -> void
func enter_ragdoll() -> bool
func request_recovery() -> bool
func snap_to_standing() -> bool
func cancel_recovery() -> void
func apply_velocity_boost(velocity: Vector2) -> void
func is_at_rest() -> bool
func initialize_spawn_pose_from_skeleton() -> void
enter_ragdoll() and recovery methods return whether they actually accepted the transition.
They perform the physical handoff before emitting state_transition_requested; the root then
commits state and emits the legacy state_changed signal exactly once. The component owns the
existing RAGDOLL_BODIES, RAGDOLL_JOINTS, per-profile joint bands, recovery overrides,
captured dictionaries, tween generation, and procedural container lifetime.
The root provides narrow internal skeleton services used by recovery:
func _get_skeleton() -> Skeleton2D
func _get_body_container() -> Node2D
func _get_animation_player() -> AnimationPlayer
func _get_ik_marker(name: String) -> Marker2D
func _get_limb_bones() -> Dictionary
func _get_bend_modifications() -> Dictionary
func _get_head_bone() -> Bone2D
func _get_head_look_at() -> SkeletonModification2DLookAt
func _set_limb_mods_enabled(enabled: bool) -> void
func _restore_head_lookat_state() -> void
func _rearm_ik_stack() -> void
These methods are internal collaboration points, not new consumer APIs.
5.3 StickmanActionRunner
class_name StickmanActionRunner
extends Node
signal action_started(action: Dictionary, index: int)
signal action_finished(action: Dictionary, index: int)
signal queue_finished
signal queue_changed
signal speech_finished
signal ragdoll_requested(enabled: bool)
signal recovery_requested
var action_queue: Array[Dictionary] = []
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void
func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void
func speak(text: String, duration: float) -> void
func queue_action(action: Dictionary) -> void
func clear_queue() -> void
func get_queue() -> Array[Dictionary]
func remove_action(index: int) -> void
func insert_action(index: int, action: Dictionary) -> void
func queue_size() -> int
func enqueue_reactive(actions: Array[Dictionary]) -> void
func clear_reactive_actions() -> void
func start_queue() -> void
func stop_queue() -> void
func is_queue_running() -> bool
func sync_facing(profile: int) -> void
RunnerState and ActionPhase are component-owned. Root keeps enum aliases/definitions only
where required for source compatibility. The component owns both primary and overlay speech,
creates SpeechBubble under Master (not under RigComponents), and counter-mirrors it through
sync_facing().
6. Root compatibility facade
All current consumer-facing members remain available on StickmanRig with unchanged names,
parameters, return types, and signal cardinality.
| Existing root surface | Delegates to / remains in |
|---|---|
set_facing_profile, get_facing_profile, bend getters/setters, bend joint queries |
Root |
state, state_changed, set_ragdoll, toggle_ragdoll, is_in_ragdoll, request_recovery, snap_to_standing, apply_ragdoll_velocity_boost, is_ragdoll_at_rest, initialize_spawn_pose_from_skeleton |
Root facade → Ragdoll |
walk_to, is_walking, arrived, begin_terrain_fall, snap_to_terrain, set_terrain_following_enabled |
Root facade → Locomotion |
set_collision_bodies_enabled, apply_collision_reaction |
Root facade → Locomotion |
action_queue, queue mutation/query methods, reactive methods, start_queue, stop_queue, is_queue_running, speak |
Root facade → ActionRunner |
action_started, action_finished, queue_finished, queue_changed, speech_finished |
Child signal forwarded by root |
facing_profile, four bend exports, rest_timeout, auto_recover, walk_speed |
Remain root exports; values synchronize to the owning component during _ready() and in setters afterward. |
source_stk_path |
Root |
action_queue must be a delegating property because tests, scene loading, and older callers may
read or assign it directly:
var action_queue: Array[Dictionary]:
get:
return _action_runner.action_queue if _action_runner != null else []
set(value):
if _action_runner != null:
_action_runner.action_queue = value
else:
_pending_action_queue = value
Direct assignment keeps today's behavior and does not emit queue_changed; mutation methods do.
Pre-_ready() export/property writes are stored on the facade and pushed to components after
configuration, matching the existing pre-ready setter guard.
Private root fields are not compatibility API. Existing tests that inspect _ragdoll_bodies,
_terrain_motion, _run_queue, or other private internals must move to the owning component.
Do not keep duplicate mutable private state on the root merely to satisfy tests.
7. Physics order and state transitions
The root retains a single _physics_process() scheduler:
Ragdoll.tick_pre_movement
├── cache root momentum
└── update ragdoll rest detection
Locomotion.tick_movement
├── clear one-frame pelvis correction
├── update walk/navigation
├── update terrain/air/landing
└── settle walk markers
ActionRunner.tick
├── update speech clocks
└── update action state machine
StickmanRig core
└── pin mirrored head rotation
Locomotion.tick_post_skeleton
├── apply impact reactions
└── sync animated collision proxies
Ragdoll transition flow:
stateDiagram-v2
[*] --> ANIMATED
ANIMATED --> RAGDOLL: root.set_ragdoll(true)\nlocomotion cleanup → build physics → request state
RECOVERING --> RAGDOLL: root.set_ragdoll(true)\ncancel tween → rebuild physics
RAGDOLL --> RECOVERING: request_recovery()\ncapture → destroy physics → reanchor → snap
RECOVERING --> ANIMATED: recovery tween finished
RAGDOLL --> ANIMATED: snap_to_standing()
RECOVERING --> ANIMATED: snap_to_standing()
The facade rejects illegal transition requests before delegating. A component signal can request
a state commit, but _set_state(next_state) on the facade is the only function that mutates the
root state and emits state_changed.
8. Practical data flows
8.1 Authored queue action
rig.queue_action({"type": "walk_to", "target": Vector2(640, 320), "speed": 300.0})
rig.queue_action({"type": "speak", "text": "Made it!", "duration": 2.0})
rig.start_queue()
StickmanRig.queue_action
→ ActionRunner.action_queue
→ root forwards queue_changed
ActionRunner begins walk_to
→ Locomotion.walk_to
→ Locomotion arrived(target)
→ root forwards arrived(target)
→ ActionRunner observes consume_walk_completed() and emits action_finished
The queue dictionary shape and deep-copy behavior remain unchanged.
8.2 Ragdoll recovery landing handoff
Ragdoll captures torso world center + body pose
→ Locomotion.probe_ground_y(x, top, bottom, ragdoll_rids)
→ Ragdoll computes feet_world
→ Locomotion.reanchor_for_recovery(feet_world, FOOT_OFFSET)
moves Master and returns mirror-aware local_shift
→ Ragdoll rebases captured positions by local_shift
→ Ragdoll snap-solves skeleton and requests RECOVERING
The existing capture payload stays internal and unchanged:
{
"torso": {"pos": Vector2(...), "rot": 0.42, "half": 195.0},
"head": {"pos": Vector2(...), "rot": -0.10, "half": 100.0},
# eight limb entries...
}
9. Relocation policy
Implementation moves the scene once with its UID intact, then updates live references in the same change:
scripts/stickman_factory.gdscripts/physics_test_harness.gdtests/test_phase6a_airborne_landing.gd- any additional literal discovered by
rg --fixed-strings 'res://master_rig.tscn' - active comments in
stickman_rig.gd,stk_rig_adapter.gd,test_harness.gd, andcreate_animations.gd - implementation-facing comments in the changed scripts
After the user verifies the implementation in Godot, the documentation phase updates active
project docs (README.md, AGENTS.md, RIGGING.md, and the source plan). Historical specs
should not receive a blind search/replace because many statements describe the path that existed
when that phase shipped. Add a short relocation note where a historical file is still used as
live guidance; do not rewrite historical line references or decisions.
Acceptance requires:
rg --fixed-strings 'res://master_rig.tscn' scripts scenes tests project.godot
to return no live code/scene/test matches. Human-readable historical mentions may remain when clearly historical.
10. Migration sequence
- Characterize first. Run the current focused rig suites and record baselines before moving code. Add no behavior changes to the extraction patch.
- Add component nodes/scripts without relocating the scene. Wire configuration, root signal forwarding, and the central scheduler. The scene still loads from the old path during this step.
- Extract action/speech orchestration. It has the smallest physics surface. Keep all root queue methods as delegates and migrate private runner tests.
- Extract locomotion plus animated collision. Move navigation, terrain/air/landing, proxy, and flinch state together so only one component writes the animated root/markers.
- Extract ragdoll/recovery. Connect the narrow ground-query/reanchor API and retain the exact instant-handoff ordering.
- Slim and verify the facade. Remove duplicate mutable state/constants, leaving aliases only where compatibility requires them. Verify every root public method and signal.
- Relocate the scene last. Move to
scenes/stickman_rig.tscn, preserve the UID, update every live preload/path/comment, force one headless editor rescan for newclass_namescripts, then run static checks and all focused suites. - Stop for editor verification. Per
spec-pipeline, do not update README, AGENTS, RIGGING, the source plan, or historical documentation until the user confirms the feature works in Godot. Documentation cleanup is the manually triggered Phase 4.
11. Verification plan
Static and scene-load checks
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --editor --path "C:\Godot4\stickman" --quit
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/stickman_rig.gd" --path "C:\Godot4\stickman"
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/stickman_locomotion.gd" --path "C:\Godot4\stickman"
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/stickman_ragdoll.gd" --path "C:\Godot4\stickman"
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/stickman_action_runner.gd" --path "C:\Godot4\stickman"
Add tests/test_phase8_rig_modularization.gd covering:
- relocated scene loads by new path and retains UID/node paths;
- all three typed component children resolve;
- root public methods delegate and root signals emit once;
- missing optional/component dependency warns instead of crashing;
- queue/speech execution works while no ragdoll bodies exist;
- locomotion walking/terrain ticks work while no ragdoll bodies exist;
- ragdoll enter/recover/snap keeps state order and ten-body/nine-joint cardinality;
- root exports synchronize before and after
_ready(); - direct
action_queueassignment retains compatibility semantics; and - repeated spawn/free leaves no world-level
RagdollBodyContainerorphan.
Run the focused regressions most coupled to the extraction:
tests/test_phase3c_walk_recovery.gdtests/test_phase4_final_bugs.gdtests/test_phase4b_walk.gdtests/test_phase4b_terrain.gdtests/test_phase45_collision_bodies.gdtests/test_phase6a_airborne_landing.gdtests/test_phase5_file_features.gdtests/test_cli_scene.gd
Tests should inspect $RigComponents/Locomotion, $RigComponents/Ragdoll, or
$RigComponents/ActionRunner for subsystem internals instead of requiring stale private mirrors
on StickmanRig.
Manual Godot verification after automated checks:
- factory/harness loads an
.stkand all body geometry follows the same bones; - LEFT/RIGHT/FORWARD, head mirror pin, bend menus, and z-order remain unchanged;
- queue
walk_to → speak → wait → ragdoll → recovercompletes with one signal per event; - walking over ramps, ledge fall, hard landing, prop collision/flinch, and queue landing barrier;
- ragdoll knock-up, automatic/manual recovery, recovery interruption, and snap-to-standing;
- Sandbox EDIT/DIRECT/PLAY round trip and scene save/load provenance/queue persistence.
12. Risks and guardrails
| Risk | Guardrail |
|---|---|
| Godot child processing order changes behavior | Components have no physics callbacks; root calls ordered tick methods. |
| Walk and terrain both write the root | They live in one locomotion component and retain the current internal order. |
| State duplicated between root and ragdoll | Root is sole RigState authority; component requests transitions. |
| Signals double-fire through forwarding | Child emits subsystem signal; root forwarding is the only consumer-facing emission. Add cardinality tests. |
| Recovery reaches through locomotion internals | Only probe_ground_y() and reanchor_for_recovery() cross that boundary. |
Adapter runs before component _ready() |
Components do not cache geometry until root _ready(); adapter node paths remain unchanged. |
| Scene move breaks preload before scripts update | Relocation and all live path updates land atomically; UID is asserted. |
New class_name cache is stale in CLI |
Run one --headless --editor --quit rescan before --check-only. |
| Private-test migration masks regressions | Add behavior assertions through root API/signals plus component-local focused assertions. |
| Broad doc replacement falsifies old specs | Update active docs; annotate historical specs instead of blind replacement. |
13. Decisions requiring approval
- Approve movement ownership change:
StickmanLocomotionowns navigation andwalk_to();StickmanActionRunneronly orchestrates it. This intentionally differs from the source plan. - Approve animated collision ownership: collision proxies, airborne sweeps, and impact reactions move with locomotion, avoiding a fourth component in this phase.
- Approve compatibility scope: preserve all root public APIs/signals/exports, including direct
action_queue; migrate tests off private root fields rather than keeping duplicate private mirrors. - Approve documentation policy: after editor verification, update active documentation and preserve historical specs with relocation notes instead of blanket rewriting their historical references.
Implementation must not begin until these decisions are approved.