- Added KINEMATIC_BLENDING_AND_RECOVERY.md to outline features for smooth transitions between kinematic and ragdoll states, including visual and physical blending, and ragdoll recovery. - Introduced KINEMATIC_TO_RAGDOLL.md detailing the objectives, scope, and core architecture for transitioning the stickman from kinematic to ragdoll mode. - Created KINEMATIC_TO_RAGDOLL_SPEC.md as an implementation specification, verifying codebase facts and correcting the initial plan based on Godot 4.4 source. - Enhanced StickmanRig with state management for animated and ragdoll modes, including momentum preservation and ragdoll construction. - Updated physics_test_harness to support toggling between kinematic and ragdoll states with user input.
23 KiB
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 ofSkeleton2D, both children ofMaster. They are not children of the bones. EachBody/*node is driven by aRemoteTransform2Dthat is a child of the matching bone (viaremote_path).- The
RemoteTransform2Ddrivers live under the bones, not on theBody/*nodes. - The torso visual is named
Body/Body(notBody/Torso). The torso bone (Skeleton2D/Torso) has nolength— 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/* Line2Ds 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, childrenCamera2D,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_rigmember must be added._add_rig_collision_proxy()(line ~191): createsStaticBody2Dnamed"RigCollisionProxy"as a direct child of the harness root,position = RIG_PROXY_CENTER, with aRectangleShape2Dchild (240×1000). No removal function exists yet — add_remove_rig_collision_proxy().- Key handling:
_input()→_handle_key(InputEventKey)(line ~76) already matchesKEY_1/KEY_2/KEY_3(guardingkey.pressedandkey.echo). R goes here as a newmatcharm.Ris currently unused — no conflict. PhysicsTestHarnessdoes 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)
-
PinJoint2DDOES have angle limits in Godot 4.4 — the plan's "no limits, must build a custom joint" assumption is wrong. Verified properties (4.4 docsscene/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 clampsdistto[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/upperpair per joint (the data table in §5 makes this a one-line edit).
-
CapsuleShape2Dsemantics (verified inmodules/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.0so 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.) -
Engine version discrepancy:
AGENTS.mdsays "Godot 4.4" butproject.godothasconfig/features=PackedStringArray("4.7", "Forward Plus")(project last saved with 4.7). Disk hasGodot_v4.4-stable_win64{,_console}.exeandGodot_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). -
RightUpperLeg.lengthis already 200 in the currentmaster_rig.tscn(the "90" value in old docs/AGENTS is stale). The ragdoll builder readsbone.lengthlive, so this is moot, but do not assume 90 anywhere. -
Momentum:
StickmanRigis aNode2D— 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
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 whileANIMATED; harmless otherwise).
6.2 _enter_ragdoll()
- Freeze kinematic rig:
_skeleton.modification_stack.enabled = false;_anim_player.stop()(null-guarded);_body_container.visible = false. (LeaveRemoteTransform2Ddrivers as-is — hidden visuals, zero visual cost.) _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: ifget_parent() == null, fall back toget_tree().current_scene. Bodies/joints are placed in world coords (theirposition == global_positionunder an origin parent). Do NOT parent the container under the rig — the rig carriesRIG_SPAWN_POSITIONoffset. - For each entry in the body table (parent-before-child order):
- Resolve the
Bone2Dvia_skeleton.get_node_or_null(torso/limbs) or theBody/Headvisual viaget_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).
- limbs: origin=
- Build
RigidBody2D(name"Ragdoll_" + key):mass,linear_damp,angular_dampfrom table.gravity_scale = 1.0,lock_rotation = false,freeze = false.collision_layer = 1,collision_mask = 1(defaults; explicit for clarity).CollisionShape2Dchild:CapsuleShape2D(height=length, radius=r)for capsules, orCircleShape2D(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].
- Resolve the
- 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. (Setnode_a/node_bafter adding to the tree soinitial_anglecaptures 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 afteradd_child).
- Create
state = RigState.RAGDOLL;state_changed.emit(int(state)).
6.3 _track_momentum(delta)
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()
_destroy_ragdoll(): if_ragdoll_root != null && is_instance_valid,_ragdoll_root.queue_free(); clear_ragdoll_bodies,_ragdoll_root = null.- 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). 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)
- Add member:
var _rig: StickmanRig = null. - In
_spawn_rig(), changeas Node2D→as StickmanRig, store_rig = rig. - Add a
matcharm in_handle_key:KEY_R: if _rig != null: _rig.toggle_ragdoll() if _rig.is_in_ragdoll(): _remove_rig_collision_proxy() else: _add_rig_collision_proxy() - Add
_remove_rig_collision_proxy(): find the direct child named"RigCollisionProxy"(iterateget_children()matchingname),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
TerrainBlockbodies); 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.tscnandphysics_test_harness.tscnare 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:
:: 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 onphysics_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
Joint2Dor per-frame correction; only pursue if QA deems the world-frame limits insufficient for tumbling realism. - Disabling
RemoteTransform2Ddrivers while in RAGDOLL — skipped (hidden visuals, negligible cost). AnimationTree— untouched placeholder (per existing convention).- Ragdoll self-collision tuning — v1 relies on
disable_collision=truefor joint-connected pairs + thin capsules; a dedicated collision layer for intra-ragdoll exclusions is a follow-up if jitter appears.