# 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. ```text 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 ```mermaid 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: 1. `StickmanRig.state` is the only high-level state authority. Components receive the current state during commands/ticks and never write it directly. 2. `StickmanActionRunner` has no reference to `StickmanRagdoll`; it requests transitions through root-connected signals. This allows queue tests without constructing physics bodies. 3. `StickmanRagdoll` may synchronously call only the narrow locomotion recovery/query API; it must not inspect locomotion private fields. 4. `StickmanLocomotion` requests facing changes by signal so the facade remains the profile and mirror authority. 5. 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` ```gdscript 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` ```gdscript 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: ```gdscript 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` ```gdscript 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: ```gdscript 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: ```text 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: ```mermaid 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 ```gdscript 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() ``` ```text 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 ```text 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: ```gdscript { "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.gd` - `scripts/physics_test_harness.gd` - `tests/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`, and `create_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: ```powershell 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 1. **Characterize first.** Run the current focused rig suites and record baselines before moving code. Add no behavior changes to the extraction patch. 2. **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. 3. **Extract action/speech orchestration.** It has the smallest physics surface. Keep all root queue methods as delegates and migrate private runner tests. 4. **Extract locomotion plus animated collision.** Move navigation, terrain/air/landing, proxy, and flinch state together so only one component writes the animated root/markers. 5. **Extract ragdoll/recovery.** Connect the narrow ground-query/reanchor API and retain the exact instant-handoff ordering. 6. **Slim and verify the facade.** Remove duplicate mutable state/constants, leaving aliases only where compatibility requires them. Verify every root public method and signal. 7. **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 new `class_name` scripts, then run static checks and all focused suites. 8. **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 ```powershell & "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: 1. relocated scene loads by new path and retains UID/node paths; 2. all three typed component children resolve; 3. root public methods delegate and root signals emit once; 4. missing optional/component dependency warns instead of crashing; 5. queue/speech execution works while no ragdoll bodies exist; 6. locomotion walking/terrain ticks work while no ragdoll bodies exist; 7. ragdoll enter/recover/snap keeps state order and ten-body/nine-joint cardinality; 8. root exports synchronize before and after `_ready()`; 9. direct `action_queue` assignment retains compatibility semantics; and 10. repeated spawn/free leaves no world-level `RagdollBodyContainer` orphan. Run the focused regressions most coupled to the extraction: - `tests/test_phase3c_walk_recovery.gd` - `tests/test_phase4_final_bugs.gd` - `tests/test_phase4b_walk.gd` - `tests/test_phase4b_terrain.gd` - `tests/test_phase45_collision_bodies.gd` - `tests/test_phase6a_airborne_landing.gd` - `tests/test_phase5_file_features.gd` - `tests/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 `.stk` and 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 → recover` completes 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 1. **Approve movement ownership change:** `StickmanLocomotion` owns navigation and `walk_to()`; `StickmanActionRunner` only orchestrates it. This intentionally differs from the source plan. 2. **Approve animated collision ownership:** collision proxies, airborne sweeps, and impact reactions move with locomotion, avoiding a fourth component in this phase. 3. **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. 4. **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.