# Kinematic-to-Ragdoll Translation — Implementation Spec > Status: implementation-ready. This spec replaces the plan's open questions with > **verified** facts from the codebase and Godot 4.4 source. Everything below is > checked against the actual files; corrections to the plan are called out in §3. --- ## 1. Objective Add a reversible `ANIMATED ⇄ RAGDOLL` state switch to the runtime rig (`master_rig.tscn` + `scripts/stickman_rig.gd`). In `RAGDOLL` mode the kinematic `skeleton + IK` puppet is frozen and hidden, and a procedurally-built network of `RigidBody2D` + `PinJoint2D` nodes (spawned in code, never in the scene file) takes over, so the figure tumbles/falls against the terrain and props. The `physics_test_harness` scene gets an **R** key trigger. --- ## 2. Verified codebase facts (read these as ground truth) ### 2.1 Scene tree of `master_rig.tscn` (root = `Master`, script `StickmanRig`) ``` Master (Node2D, script = stickman_rig.gd, class StickmanRig) ├── Body (Node2D — sibling of Skeleton2D) │ ├── Body (Line2D 0→400, width 16 — TORSO visual) │ ├── LeftUpperLeg (Line2D 0→200 w16) │ ├── RightUpperLeg (Line2D 0→200 w16) │ ├── LeftLowerLeg (Line2D 0→200 w16) │ ├── RightLowerLeg (Line2D 0→200 w16) │ ├── LeftUpperArm (Line2D 0→175 w16) │ ├── RightUpperArm (Line2D 0→175 w16) │ ├── LeftLowerArm (Line2D 0→200 w16) │ ├── RightLowerArm (Line2D 0→200 w16) │ └── Head (Node2D + inline @tool circle script, radius 100) ├── Skeleton2D (modification_stack assigned) │ ├── Torso (Bone2D, root — NO length) │ │ ├── Head (Bone2D, pos (0,-391.5) rel Torso, length 90, bone_angle -90) │ │ │ ├── RayCast_Aim │ │ │ └── Pivot → RemoteTransform2D (→ Body/Head) │ │ ├── LeftUpperArm (Bone2D pos (0,-248), length 168) │ │ │ ├── LeftLowerArm (Bone2D pos (-168,0), length 200) │ │ │ │ └── RemoteTransform2D (→ Body/LeftLowerArm) │ │ │ └── RemoteTransform2D (→ Body/LeftUpperArm) │ │ ├── RightUpperArm (Bone2D pos (0,-248), length 168) │ │ │ ├── RightLowerArm (Bone2D pos (168,0), length 200) │ │ │ │ └── RemoteTransform2D (→ Body/RightLowerArm) │ │ │ └── RemoteTransform2D (→ Body/RightUpperArm) │ │ ├── LeftUpperLeg (Bone2D pos (0,0), length 200) │ │ │ ├── LeftLowerLeg (Bone2D pos (0,200), length 200) │ │ │ │ └── RemoteTransform2D (→ Body/LeftLowerLeg) │ │ │ └── RemoteTransform2D (→ Body/LeftUpperLeg) │ │ ├── RightUpperLeg (Bone2D, length 200) ← NOTE: current scene is 200, │ │ │ ├── RightLowerLeg (Bone2D pos (0,200), length 200) not the stale "90" │ │ │ │ └── RemoteTransform2D (→ Body/RightLowerLeg) mentioned in old docs │ │ │ └── RemoteTransform2D (→ Body/RightUpperLeg) │ │ └── RemoteTransform2D (→ Body/Body, torso driver, rotation π) ├── RayCast_Ground ├── IK_Targets (Right_Hand, Left_Hand, Right_Leg, Left_Leg, Head, Torso→RT2D, RayCasts) ├── AnimationPlayer (libraries: RESET / walk_left / walk_right) └── AnimationTree (active=false, placeholder — out of scope) ``` **Key answers to "where things live":** - `Body/*` visual nodes are **siblings of `Skeleton2D`**, both children of `Master`. They are **not** children of the bones. Each `Body/*` node is driven by a `RemoteTransform2D` that is a **child of the matching bone** (via `remote_path`). - The `RemoteTransform2D` drivers live under the bones, not on the `Body/*` nodes. - The **torso visual** is named `Body/Body` (not `Body/Torso`). The **torso bone** (`Skeleton2D/Torso`) has **no `length`** — it is the hip joint root. The actual spine segment runs from the Torso bone origin (hips) to the Head bone origin (neck), distance ≈ 391.5 px. ### 2.2 Bone semantics (for ragdoll geometry) `Bone2D` extends along its **local +X** axis by `length`. So for any bone: - **origin** = `bone.global_position` - **tip** (far end / child joint) = `bone.to_global(Vector2(bone.length, 0.0))` - **midpoint** = `(origin + tip) / 2` - **rotation** = `bone.global_rotation` (the bone points along +X locally) The `Body/*` `Line2D`s are authored along local +Y and their `RemoteTransform2D` drivers add `±π/2` rotation to align them with the bone's +X. This is why `Body/LeftUpperArm` is 175 px but its bone `length` is 168 — **use the Bone2D `length` for collision, not the Line2D points length**. ### 2.3 Harness integration points (`physics_test_harness.gd`) - Scene root `PhysicsTestHarness (Node2D)` at world origin, children `Camera2D`, `Environment` (empty Node2D). Rig is spawned in **code**, not the scene file. - `_spawn_rig()` (line ~146): `RIG_SCENE.instantiate()` → `rig.position = RIG_SPAWN_POSITION (0,-385)` → `add_child(rig)` → `_add_rig_collision_proxy()`. **It does not store the rig reference** — a `_rig` member must be added. - `_add_rig_collision_proxy()` (line ~191): creates `StaticBody2D` named **`"RigCollisionProxy"`** as a direct child of the harness root, `position = RIG_PROXY_CENTER`, with a `RectangleShape2D` child (240×1000). No removal function exists yet — add `_remove_rig_collision_proxy()`. - Key handling: `_input()` → `_handle_key(InputEventKey)` (line ~76) already matches `KEY_1/KEY_2/KEY_3` (guarding `key.pressed` and `key.echo`). **R goes here** as a new `match` arm. `R` is currently unused — no conflict. - `PhysicsTestHarness` does **not** play any animation; the rig stands in rest pose with the modification stack enabled. Momentum at toggle will therefore be ≈ 0 in this harness (the momentum path is generic and still specified). ### 2.4 `StickmanRig` (`scripts/stickman_rig.gd`) internals to reuse Existing fields/methods (do not rename): `_nodes_ready`, `_skeleton: Skeleton2D`, `_body_container: Node2D`, `_bend_joint_bones`, `_bend_modifications`; `_ready()` enables `_skeleton.modification_stack.enabled = true`; `_apply_profile()` rewrites IK flags + `Body/*` z-order; `get_bend_joint_global_position()`. The ragdoll system adds **new** members and does not change these. ### 2.5 AnimationPlayer Owned by the rig root (`Master`), direct child, named **`"AnimationPlayer"`. `test_harness.gd` confirms `const ANIMATION_PLAYER_PATH := "AnimationPlayer"`. Ragdoll code resolves it as `get_node_or_null(NodePath("AnimationPlayer"))`. ### 2.6 Collision layers No script sets `collision_layer`/`collision_mask`. `TerrainBlock` (StaticBody2D), `PropBlock` (RigidBody2D), and `RigCollisionProxy` (StaticBody2D) all use Godot defaults: **layer 1, mask 1**. Ragdoll bodies must therefore also use **layer 1, mask 1** so they collide with terrain and props (and props still bounce off them). --- ## 3. Plan corrections (verified against Godot 4.4 source) 1. **`PinJoint2D` DOES have angle limits in Godot 4.4** — the plan's "no limits, must build a custom joint" assumption is wrong. Verified properties (4.4 docs + `scene/2d/physics/joints/pin_joint_2d.{h,cpp}`): - `softness: float` (default 0) - `angular_limit_enabled: bool` (default false) - `angular_limit_lower: float` (radians, default 0, hint range −180°..180°) - `angular_limit_upper: float` (radians, default 0, hint range −180°..180°) - `motor_enabled: bool`, `motor_target_velocity: float` (rad/s) - Inherited from `Joint2D`: `node_a`, `node_b`, `bias`, `disable_collision` (default **true** — connected bodies won't self-collide, which is what we want). - The pin point = the joint node's **global position** (`joint_make_pin(joint, get_global_position(), …)`). **Angle-limit semantics (critical, read carefully)** — from `modules/godot_physics_2d/godot_joints_2d.cpp`: - On joint construction the solver stores `initial_angle = angle_from(parent_body_origin → child_body_origin)` (world space, captured **once**). - Each step it computes `dist = angle( (child_origin − parent_origin).rotated(−initial_angle) )` and clamps `dist` to `[angular_limit_lower, angular_limit_upper]`. - **Therefore the limits are measured in WORLD space**, relative to the spawn-time direction of the parent→child center vector, and **do not follow the parent body's own rotation**. - Practical consequences (documented, accepted for v1): * The limit is an *approximation* of the child's swing angle (it uses body centers, so there is a ~parent_length/2 parallax — monotonic and fine). * Because it is world-frame, the "no backward bend" guarantee holds at the spawn orientation but degrades as the whole figure tumbles. This is the standard ragdoll trade-off; a local-frame custom joint is listed as a **deferred enhancement**, not v1. * The per-limb fold sign (+/−) depends on limb side and facing — the implementer must do a one-time visual check and swap/normalize the `lower`/`upper` pair per joint (the data table in §5 makes this a one-line edit). 2. **`CapsuleShape2D` semantics** (verified in `modules/godot_physics_2d/godot_shape_2d.cpp`): `height` = **total** capsule height (tip to tip), `radius` = cap radius; the straight section length = `height − 2·radius`; AABB spans local Y `[−height/2, +height/2]`. **Recommendation: `height = bone.length`, `radius = 8.0`** so the capsule spans the bone exactly tip-to-tip (rounded caps at the joints), matching the Line2D's round caps. (`width 16 → radius 8`. The plan's "≈14" is a chunkier stability alternative; expose as a named constant.) 3. **Engine version discrepancy**: `AGENTS.md` says "Godot 4.4" but `project.godot` has `config/features=PackedStringArray("4.7", "Forward Plus")` (project last saved with 4.7). Disk has `Godot_v4.4-stable_win64{,_console}.exe` and `Godot_v4.7.1-stable_win64{,_console}.exe`. The angle-limit API verified here exists in 4.4 and later. Recommend the implementer confirm which binary is canonical (default to **4.4** per AGENTS/task, flag the 4.7 features string). 4. **`RightUpperLeg.length` is already 200** in the current `master_rig.tscn` (the "90" value in old docs/AGENTS is stale). The ragdoll builder reads `bone.length` live, so this is moot, but do not assume 90 anywhere. 5. **Momentum**: `StickmanRig` is a `Node2D` — it has no built-in velocity and no angular velocity. Track both from per-frame deltas (see §6.3). In the current harness the rig never moves, so values are ≈ 0; the mechanism is generic for future use. --- ## 4. Public API on `StickmanRig` ```gdscript enum RigState { ANIMATED, RAGDOLL } signal state_changed(new_state: int) # emits RigState value var state: RigState = RigState.ANIMATED # read-only outside; only setters change it func is_in_ragdoll() -> bool # state == RigState.RAGDOLL func set_ragdoll(enabled: bool) -> void # enter if enabled & !in_ragdoll; exit if !enabled & in_ragdoll func toggle_ragdoll() -> void # set_ragdoll(not is_in_ragdoll()) ``` `set_ragdoll()` / `toggle_ragdoll()` are the only external entry points. The harness calls `rig.toggle_ragdoll()`. --- ## 5. Ragdoll data tables ### 5.1 Body definitions (10 bodies) Ordered so parents are built before children. `bone_path` is relative to the rig root. The torso and head are special-cased in the builder (see §6.2). | key | bone_path (Skeleton2D-relative) | parent | shape | length source | radius | mass | lin damp | ang damp | |---|---|---|---|---|---|---|---|---| | `torso` | `Skeleton2D/Torso` | — | capsule | dist(Torso, Head) | 12 | 8.0 | 1.0 | 4.0 | | `head` | `Body/Head` (visual center) | `torso` | circle | radius 100 | 100| 2.0 | 0.5 | 2.0 | | `left_upper_arm` | `Skeleton2D/Torso/LeftUpperArm` | `torso` | capsule | `bone.length` (168)| 8 | 1.5 | 0.5 | 3.0 | | `left_lower_arm` | `Skeleton2D/Torso/LeftUpperArm/LeftLowerArm` | `left_upper_arm` | capsule | `bone.length` (200) | 8 | 1.0 | 0.5 | 3.0 | | `right_upper_arm`| `Skeleton2D/Torso/RightUpperArm` | `torso` | capsule | `bone.length` (168)| 8 | 1.5 | 0.5 | 3.0 | | `right_lower_arm`| `Skeleton2D/Torso/RightUpperArm/RightLowerArm` | `right_upper_arm` | capsule | `bone.length` (200) | 8 | 1.0 | 0.5 | 3.0 | | `left_upper_leg` | `Skeleton2D/Torso/LeftUpperLeg` | `torso` | capsule | `bone.length` (200)| 8 | 2.0 | 0.5 | 3.0 | | `left_lower_leg` | `Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg` | `left_upper_leg` | capsule | `bone.length` (200) | 8 | 1.5 | 0.5 | 3.0 | | `right_upper_leg`| `Skeleton2D/Torso/RightUpperLeg` | `torso` | capsule | `bone.length` (200)| 8 | 2.0 | 0.5 | 3.0 | | `right_lower_leg`| `Skeleton2D/Torso/RightUpperLeg/RightLowerLeg` | `right_upper_leg` | capsule | `bone.length` (200) | 8 | 1.5 | 0.5 | 3.0 | Named constants: `RAGDOLL_LIMB_RADIUS := 8.0`, `RAGDOLL_TORSO_RADIUS := 12.0`, `RAGDOLL_HEAD_RADIUS := 100.0`. (Tune `RAGDOLL_LIMB_RADIUS` up to ~14 if limbs tunnel at high speed.) ### 5.2 Joint definitions (9 joints) One `PinJoint2D` per non-root body. `pin_point` = the **child bone's origin** (`child_bone.global_position`), which coincides with the parent bone's tip. `node_a` = parent body, `node_b` = child body. | joint | child | parent | pin_point (world) | limits | |---|---|---|---|---| | neck | `head` | `torso` | `Head.global_position` | **free** (`angular_limit_enabled=false`) | | left_shoulder | `left_upper_arm` | `torso` | `LeftUpperArm.global_position` | shoulder/hip band | | left_elbow | `left_lower_arm` | `left_upper_arm` | `LeftLowerArm.global_position` | elbow/knee band | | right_shoulder | `right_upper_arm`| `torso` | `RightUpperArm.global_position` | shoulder/hip band | | right_elbow | `right_lower_arm`| `right_upper_arm`| `RightLowerArm.global_position` | elbow/knee band | | left_hip | `left_upper_leg` | `torso` | `LeftUpperLeg.global_position` | shoulder/hip band | | left_knee | `left_lower_leg` | `left_upper_leg` | `LeftLowerLeg.global_position` | elbow/knee band | | right_hip | `right_upper_leg`| `torso` | `RightUpperLeg.global_position` | shoulder/hip band | | right_knee | `right_lower_leg`| `right_upper_leg`| `RightLowerLeg.global_position` | elbow/knee band | Limit bands (radians, relative to spawn angle; **sign to be eyeballed per limb**): | band | angular_limit_enabled | lower | upper | |---|---|---|---| | elbow/knee | true | `-deg_to_rad(5)` | `+deg_to_rad(150)` | | shoulder/hip| true | `-deg_to_rad(160)` | `+deg_to_rad(160)` | | neck | false | (n/a) | (n/a) | If a joint folds the wrong way in testing, swap its `lower`/`upper` (or negate both) in the table. For a stricter "no hyperextension" at rest, lower elbow/knee to `-deg_to_rad(2)`. All joints: `softness = 0.0` (stiff; raise to 0.05–0.2 only if jitter/stretch), `bias` left at default 0 (uses project `default_constraint_bias`), `disable_collision` left `true` (default). --- ## 6. Implementation design ### 6.1 State & lifecycle - New members: `state: RigState`, `_ragdoll_root: Node2D` (container), `_ragdoll_bodies: Dictionary` (key → `RigidBody2D`), `_prev_global_pos: Vector2`, `_prev_global_rot: float`, `_cached_linear_velocity: Vector2`, `_cached_angular_velocity: float`, `_anim_player: AnimationPlayer`. - `_physics_process(delta)`: `_track_momentum(delta)` (only meaningful while `ANIMATED`; harmless otherwise). ### 6.2 `_enter_ragdoll()` 1. Freeze kinematic rig: `_skeleton.modification_stack.enabled = false`; `_anim_player.stop()` (null-guarded); `_body_container.visible = false`. (Leave `RemoteTransform2D` drivers as-is — hidden visuals, zero visual cost.) 2. `_build_ragdoll()`: - Create `_ragdoll_root = Node2D.new()`, name `"RagdollBodyContainer"`. - **Reparent to world space**: add as child of `get_parent()` (the harness root, at world origin). Guard: if `get_parent() == null`, fall back to `get_tree().current_scene`. Bodies/joints are placed in **world** coords (their `position == global_position` under an origin parent). Do NOT parent the container under the rig — the rig carries `RIG_SPAWN_POSITION` offset. - For each entry in the body table (parent-before-child order): * Resolve the `Bone2D` via `_skeleton.get_node_or_null` (torso/limbs) or the `Body/Head` visual via `get_node_or_null`. Null → `push_warning` + skip that body (and any joints that reference it). * Compute origin/tip/midpoint/rotation: - limbs: origin=`bone.global_position`, tip=`bone.to_global(Vector2(bone.length,0))`. - torso: origin=`Torso.global_position`, tip=`Head.global_position` (length = that distance). - head: center=`Body/Head.global_position` (the circle center). * Build `RigidBody2D` (name `"Ragdoll_" + key`): - `mass`, `linear_damp`, `angular_damp` from table. - `gravity_scale = 1.0`, `lock_rotation = false`, `freeze = false`. - `collision_layer = 1`, `collision_mask = 1` (defaults; explicit for clarity). - `CollisionShape2D` child: `CapsuleShape2D(height=length, radius=r)` for capsules, or `CircleShape2D(radius=100)` for head. - Position body at midpoint, rotation = bone `global_rotation` (capsules); head rotation irrelevant (circle). - `_ragdoll_root.add_child(body)`; record in `_ragdoll_bodies[key]`. - For each joint entry (skip if either body missing): * `var pin := PinJoint2D.new()`, name `"RagdollPin_" + child_key`. * `pin.position = pin_point` (world). Add to `_ragdoll_root`. * `pin.node_a = pin.get_path_to(parent_body)`; `pin.node_b = pin.get_path_to(child_body)`. * Apply limit band + `softness`. (Set `node_a`/`node_b` **after** adding to the tree so `initial_angle` captures the rest pose — bodies are already positioned, so the reference is correct.) - Momentum handoff: `_ragdoll_bodies["torso"].linear_velocity = _cached_linear_velocity`; `.angular_velocity = _cached_angular_velocity` (set **after** `add_child`). 3. `state = RigState.RAGDOLL`; `state_changed.emit(int(state))`. ### 6.3 `_track_momentum(delta)` ```gdscript if delta > 0.0: _cached_linear_velocity = (global_position - _prev_global_pos) / delta _cached_angular_velocity = wrapf(global_rotation - _prev_global_rot, -PI, PI) / delta _prev_global_pos = global_position _prev_global_rot = global_rotation ``` Initialize `_prev_*` in `_ready()`. ### 6.4 `_exit_ragdoll()` 1. `_destroy_ragdoll()`: if `_ragdoll_root != null && is_instance_valid`, `_ragdoll_root.queue_free()`; clear `_ragdoll_bodies`, `_ragdoll_root = null`. 2. Re-enable kinematic rig: `_body_container.visible = true`; `_skeleton.modification_stack.enabled = true`; `_anim_player.stop()` (leave pose as-is; do not attempt to restore a specific frame — the skeleton keeps its last pose, which for the harness is the rest/IK pose). 3. `state = RigState.ANIMATED`; `state_changed.emit(int(state))`. ### 6.5 `set_ragdoll(enabled)` Idempotent guards: entering while already `RAGDOLL` (or exiting while `ANIMATED`) is a no-op. Null-guard `_skeleton`/`_body_container` before use (consistent with the file's `push_warning` style). --- ## 7. `physics_test_harness.gd` changes (exact) 1. Add member: `var _rig: StickmanRig = null`. 2. In `_spawn_rig()`, change `as Node2D` → `as StickmanRig`, store `_rig = rig`. 3. Add a `match` arm in `_handle_key`: ```gdscript KEY_R: if _rig != null: _rig.toggle_ragdoll() if _rig.is_in_ragdoll(): _remove_rig_collision_proxy() else: _add_rig_collision_proxy() ``` 4. Add `_remove_rig_collision_proxy()`: find the direct child named `"RigCollisionProxy"` (iterate `get_children()` matching `name`), `queue_free()` if present. Make `_add_rig_collision_proxy()` idempotent: skip if a child named `"RigCollisionProxy"` already exists (prevents duplicates on rapid toggling). --- ## 8. Acceptance criteria - **Toggle**: pressing **R** hides the stick figure, removes `RigCollisionProxy`, and spawns a physics ragdoll at the same world pose; pressing **R** again frees the ragdoll, restores the figure, and re-adds the proxy. - **Terrain**: ragdoll rests/slides/tumbles on flat ground, ramp, and stairs (collides with `TerrainBlock` bodies); props still bounce off it. - **Integrity**: connected limbs never separate/detach (pin joints hold under gravity); elbows/knees resist hyperextension and full 360° rotation at the spawn orientation (native world-frame limits — see caveat in §3.1). - **Fidelity**: collision capsules/circle line up with the hidden bones (no floating/offset shapes). - **Cleanup**: rapid R toggling leaves no orphaned nodes (proxy + ragdoll container are freed each cycle); no leaked `RigidBody2D`/`PinJoint2D`. - **No scene edits**: `master_rig.tscn` and `physics_test_harness.tscn` are unchanged; everything is code-driven. --- ## 9. Verification commands (for the tester phase) Godot binaries live in `C:\Godot4\` (`Godot_v4.4-stable_win64_console.exe`, `Godot_v4.7.1-stable_win64_console.exe`). No shell tool is available to the Architect; the Tester runs these: ```bat :: 1. Whole-project parse/compile check (reports script errors, then quits) C:\Godot4\Godot_v4.4-stable_win64_console.exe --headless --editor --path C:\Godot4\stickman --quit :: 2. Headless runtime smoke test of the harness scene (runs N frames, then quits) C:\Godot4\Godot_v4.4-stable_win64_console.exe --headless --path C:\Godot4\stickman res://scenes/physics_test_harness.tscn --quit-after 120 ``` - Command 1 catches GDScript syntax/type errors across all scripts. - Command 2 exercises `_ready()` + `_spawn_rig()` + `_add_rig_collision_proxy()`. Headless cannot send the **R** keypress, so the toggle itself must be verified **interactively** via F6 on `physics_test_harness.tscn` (project convention): load scene, press R, observe ragdoll fall; press R again; confirm figure restored and props still collide. Rapid-tap R to check for orphans/leaks (use the editor's remote scene tree). --- ## 10. Deferred / out of scope - **Local-frame angular limits** (limits that follow the parent's rotation) — would require a custom `Joint2D` or per-frame correction; only pursue if QA deems the world-frame limits insufficient for tumbling realism. - **Disabling `RemoteTransform2D` drivers** while in RAGDOLL — skipped (hidden visuals, negligible cost). - **`AnimationTree`** — untouched placeholder (per existing convention). - **Ragdoll self-collision tuning** — v1 relies on `disable_collision=true` for joint-connected pairs + thin capsules; a dedicated collision layer for intra-ragdoll exclusions is a follow-up if jitter appears.