Working on the procedural player/NPC figure rig in Eleblorb (body proportions or walk/run/jump animation) — especially diagnosing or avoiding axis/rotation-sign mistakes. Use whenever adjusting how the character/villager bodies are shaped or move, or when a motion is reported as backwards, mirrored, or wrong.
Install
npx skillscat add pizzapocket/eleblorb/figure-rig Install via the SkillsCat registry.
Eleblorb procedural figure rig: getting axes right
The player and every NPC are built from scratch out of superellipsoid mesh parts (scripts/procedural_figure.gd, animated by scripts/player.gd/scripts/npc.gd) — no imported model, no skeleton, no AnimationPlayer, no live preview loop for whoever's writing the code. Every proportion and every joint rotation is hand-authored transform math, and the single most repeated failure mode on this project has been guessing an axis direction or rotation sign wrong. This skill is about how to avoid that and how to fix it fast when it happens anyway — not a frozen list of "correct" values, since those get revised as the rig keeps changing.
Why this keeps happening
Nobody writing this code can see it move while writing it (Godot can't be run headless while the user's own editor is open, and even when it can, a rotation's visual effect on a 3D figure isn't something a headless run reports). So almost every "which way does positive rotation.x go" question can't be answered by inspection — it can only be answered by testing, by working out the actual transform math, or by carrying forward something someone already tested. Treating any of these as obvious or inferable by "it seems logical" is exactly what has gone wrong, repeatedly:
- Head pitch flipped sign the instant an ancestor's 180° correction was removed elsewhere in the rig, with zero changes to the pitch code itself.
- Knee bend timing was picked (
cos(phase) > 0 = forward) without checking against the rendered motion, and was backwards. - The same thing happened to elbow timing, independently, later.
- The jump pose's shoulder/hip rotation direction was assumed to match the spine's already-confirmed sign, by analogy ("it's a similar kind of pivot") — wrong; the shoulder/hip pivot turned out to be the opposite sign from the spine.
- A derived quantity (
forward_fraction, computed from the walk swing's sign to time the elbow bend) inherited that same wrong assumption and had to be fixed separately, after the underlying joint sign was already corrected — fixing the root cause didn't automatically fix code built on top of it. - Outside the figure rig entirely, a village corner piece's rotation was guessed per-quadrant without working out Godot's actual rotation matrix, and was backwards for exactly two of the four quadrants (the two where the guess couldn't be checked by symmetry) — same failure mode, different domain.
- Wiring the arm-power-pose input actions to
_arm_left/_arm_right, "which world axis is the character's own right side" was hand-derived from the rig's ownforward = +Zconvention three separate times in one investigation (once fromright = up × forward, once from Godot's own default-basis convention translated into this project's flipped-forward frame, once from a "facing north, east is my right hand" physical analogy) — and got a different answer each time, including one derivation that directly contradicted an existing, already-shipped, workingsideconvention elsewhere in the same file. None of that derivation work actually resolved anything; only the user's direct report of which arm physically moved did. This is the clearest illustration yet of rule 1 below: a plausible, carefully-shown derivation is not the same thing as a confirmed fact, and re-deriving a geometric relationship from scratch is a genuine, repeatable way to talk yourself into being wrong. See "Confirmed control-mapping facts" below for the setting this actually produced.
The pattern in every case: a direction was chosen because it seemed reasonable, presented as if settled, and only corrected after the user caught it in play. That round trip — guess, ship, get told it's backwards, fix — is the expensive part. The goal isn't to never guess wrong; it's to make wrong guesses cheap to catch and fix, and to stop re-making the same guess after it's already been settled once.
How to actually get an axis right, in order of preference
- It's already been confirmed — reuse it, don't re-derive it. If a nearby comment says a sign was confirmed by direct observation, or the code has clearly been through a correction round already (look for words like "confirmed backwards," "per direct correction"), that value is trustworthy for that exact pivot. It is not automatically trustworthy for a different pivot, even a structurally similar one — the shoulder/hip-vs-spine mistake above is exactly this trap. Each joint's sign is its own fact.
- Work out the transform mathematically. When a visual check isn't available, derive the actual answer instead of guessing — apply the real rotation matrix (Godot's Y-rotation convention:
x' = cos(θ)x + sin(θ)z,z' = -sin(θ)x + cos(θ)z, right-handed) to a representative point or vector and read off where it actually ends up. This is how the corner-piece rotation bug was solved (not by looking at it — by computing where each quadrant's geometry actually moves under each candidate rotation) and it's always available even with no engine access at all. - Run a small headless check, only if the editor is confirmed closed.
pgrep -fl Godotfirst, always — running headless Godot while the user's own editor is open corrupts the shared import cache. If clear, a minimal script that builds a transform and prints the result for a known input can settle a sign question directly, the same way an old rig's head-pitch convention was once confirmed this way. - Otherwise, guess — but say so, out loud, in two places. In the code, a comment stating plainly that this sign is chosen but not verified. In the reply to the user, a plain statement that this part is an inferred guess and may need correction once they see it move. An unflagged guess that turns out wrong reads as a mistake; a flagged guess that turns out wrong is just information gathering that worked as intended.
When the user reports a motion is backwards
Don't restructure the formula. In the history of this rig, essentially every "that's backwards" report has been fixed by negating or swapping a single sign, not by rethinking the approach. Before touching code:
- Read the correction precisely for which specific extremum or direction is swapped. "The apex of the backswing has the most bend, should be the least" tells you exactly which two values are transposed — it's a mechanical fix once you've parsed it, not a redesign.
- Check whether anything else was built on top of the wrong sign. Fixing the root joint's convention doesn't retroactively fix a derived formula that baked in the old (wrong) assumption — go looking for other code that reads that pivot's rotation or reasons about "which direction is forward" for it.
- Update the comment, not just the code, to record what was actually confirmed — that record is what lets the next change reuse this fact instead of re-guessing it. Keep that record next to the code it describes (a comment at the point of use), not in this skill file — the code comment naturally stays in sync as the rig changes; a fact frozen in a skill file doesn't.
A couple of durable structural patterns (less likely to churn)
These are about shape, not axis direction, so they're on steadier ground — still worth knowing before touching procedural_figure.gd:
- Every size is a
Vector3of half-extents.build_scale/abdomen_width_scale(proportion-variety knobs) only ever touchx/z— never.y— because the entire vertical stacking of the rig is computed off the unscaled.ycomponents; scaling height through those knobs would desync where every part above it stands. - An asymmetric front/back (or any asymmetric pair of extents around one local axis) needs both a widened half-extent (the average of the two sides) and a position offset (half the difference) — see any of
HIP_BACK_OFFSET/abdomen_z_offset/chest_z_offsetin the current code for the derivation, and reuse that derivation rather than re-deriving it each time a new part needs the same treatment. - Joints are plain pivots with no filler mesh; a visible pinch where two rounded parts meet is either solved by overlapping one segment slightly into the other, or left alone where it's been explicitly decided to be fine (the shoulder). Don't reach for a filler sphere — that's been tried and explicitly reverted.
Confirmed hand/palm facts (for pose and animation work)
Kept here as a quick-reference summary per direct instruction, even though rule 3 above generally treats the code comment as the single source of truth — _build_arm() in procedural_figure.gd (the PalmAttach/BackAttach comments) has the full derivation and reasoning behind each of these. Check that comment before trusting anything below if the rig has changed since this was written.
- Palm vs. back of hand: on the hand's own un-rotated local axes (
hand's own space, before itsrotation.ywrist twist is applied) the palm faces +Z; the back of the hand is -Z. Confirmed backwards once already — an initial pass read an older, ambiguous comment as meaning palm = -Z, and a held item visibly emerged from the back of the hand instead of resting in the palm until this was flipped. - Wrist-to-fingers axis: within
hand's own local space, +Y is toward the wrist, -Y is toward the fingertips — matches the same "further down the chain is more negative Y" convention every other arm/leg segment already uses (elbow_y,hand_y, etc. are all negative offsets from their own parent). - Two attachment points exist on the hand mesh for this, both children of
hand(so both inherit its wrist twist for free) and both nudged 0.025 (2.5cm) toward the fingers from the hand's own vertical center rather than sitting at the wrist:PalmAttachat local(0, -0.025, +hand_size.z)andBackAttach(its mirror on the opposite face) at local(0, -0.025, -hand_size.z). Exposed fromProceduralFigure.build()'s returned dict aspalm_left/palm_right/back_left/back_right. hand's own node origin is NOT the wrist — it's the rendered segment's own center, an artifact of_build_overlapping_segment()'s placement math (part.position = top_y + extra_top - half_height, nottop_yitself). A caller that assumeshand.global_positionmeans "the wrist" ends up anchored a couple centimeters down toward the fingers instead, and anything built relative to that (a guessed fingertip offset, in turn) drifts further still — this exact chain of assumptions once put a blorb-suit sleeve's own eyes down near the fingertips instead of the back of the hand. This is a general fact about_build_overlapping_segment()itself, not just the hand: the same is true of every segment built through it (upper/lower arm, upper/lower leg) wheneverextra_top != extra_bottom— the node's own origin sits at the geometric center of the rendered (overlap-inclusive) mesh, not at either of its two logical joint boundaries. Don't assume a segment's own position is one of its ends without checking.- Two more attachment points exist specifically to give a caller the real logical boundaries instead of re-deriving them:
WristAttachat local(0, hand_size.y - JOINT_OVERLAP * 0.5, 0)andFingertipAttachat local(0, -(hand_size.y + JOINT_OVERLAP * 0.5), 0), both children ofhand. Exposed aswrist_left/wrist_right/fingertip_left/fingertip_right. The asymmetric correction (WristAttachsubtracts back outJOINT_OVERLAP,FingertipAttachdoesn't) matches the hand segment's own_build_overlapping_segmentcall usingextra_top=JOINT_OVERLAP, extra_bottom=0.0— only the wrist side was ever extended into the overlap.
Per-frame orientation must come from a live node, not carried history
blorb_suit.gd's _update_face() needs a stable (right, up) frame for the blorb-suit eyes on a piece whose facing direction changes as the character animates (the arm-power pose sweeps the hand's own backhand direction through a wide range of orientations, every physics frame). The first fix for "eyes spin like a compass before settling" replaced a threshold-based frame pick (facing.cross(UP), which snaps ~90° the instant facing crosses a fixed reference axis) with a parallel-transported frame, carried forward frame-to-frame via node metadata — the same rotation-minimizing-frame technique build_limb_tube() uses along a tube's rings.
That looked right and settled the spin, but introduced a slower, worse bug: parallel transport is path-dependent (holonomic). Carrying a frame around a closed loop in direction-space — exactly what raising an arm and lowering it back down traces out — does not generally return the frame to its own starting orientation; it leaves a small residual twist behind, every cycle. Reported as the eyes' resting orientation rotating a little further each time the arm was raised and lowered a few times. This is a fundamentally different failure mode from the usual sign/axis mistakes above (nothing was guessed wrong — the math was doing exactly what parallel transport does), so it's worth its own entry: any technique that carries orientation state forward frame-to-frame based on incremental changes will drift over a repeated back-and-forth motion, even when each individual incremental step is correct.
The real fix was to stop carrying history at all, for any piece with a real backing node to read instead: rebuild_arm() now derives the eye frame's right_hint directly from back (BackAttach)'s own live global_transform.basis every frame — back carries no rotation of its own (see "Confirmed hand/palm facts" above), so this is just the hand's current rotation, recomputed fresh from scratch each call with zero dependency on any previous frame. Returning to the same hand rotation always yields the same frame, so there's nothing left to accumulate. Parallel transport (via _transport_frame()) is still correct and still used for build_limb_tube()'s own ring basis — that's a single one-shot integration along one mesh build, not something carried across animation frames, so it has no loop to accumulate drift around. The general rule: prefer deriving orientation fresh from a real transform that's already ground truth over carrying/integrating orientation state across frames, whenever such a node is available — reach for incremental/transported state only when no such backing transform exists (as remains true for build_limb_tube()'s own rings, which aren't real nodes).
Confirmed control-mapping facts
- SUPERSEDED, kept for the history:
left_arm_power/right_arm_power(Q/E) once had to drive the OPPOSITE-named arm pivot (left_arm_power→_arm_right,right_arm_power→_arm_left) to read correctly. That swap was only correct whileProceduralFigure.build()'s ownarm_left/arm_rightnaming was itself backwards (see the entry below) — it was a second wrong turn that happened to cancel the first one out. Once the naming bug was fixed at its source, this compensating swap became wrong again and was reverted: Q now drives_arm_leftdirectly, E drives_arm_rightdirectly, the plainly-named pairing. This is exactly the general case the "check whether anything else was built on top of the wrong sign" rule (above, in "When the user reports a motion is backwards") warns about — don't assume this mapping is settled just because it was "confirmed by observation" once; it was confirmed relative to a since-changed root convention, and the very act of fixing that root convention is what silently broke it again. - Palm-down hand roll (
HAND_PALM_DOWN_ROLLinplayer.gd, added on top of the hand's own staticWRIST_INWARD_ANGLEtwist viahand.rotation.y) must be applied asrest_twist - side * HAND_PALM_DOWN_ROLL, notrest_twist + side * HAND_PALM_DOWN_ROLLand not a flat (non-side-dependent) constant. Took two rounds of direct-observation correction: a flat+HAND_PALM_DOWN_ROLLhad the right arm correctly rolling palm-down but the left arm rolling the SAME direction instead of mirrored (palm-up); making it+side * HAND_PALM_DOWN_ROLLfixed the mirroring but flipped BOTH arms to palm-up; negating the whole term (-side * ...) is what actually landed both palms down. The mirroring fix and the overall-direction fix are two separate, independent corrections — getting one right doesn't imply the other is also right. ARM_POWER_POSE_ANGLE(player.gd, the shoulder swing target for the arm-power pose) once needed to be commanded asdeg_to_rad(150.0)to visually land the arm at a true 90-degree (horizontal) raise — confirmed by direct observation twice at the time (90 commanded read as only ~45 visually), with no root cause ever found for the ~2x discrepancy. That is no longer true: the constant readsdeg_to_rad(90.0)today and the pivot turns by what it is given. Whatever else changed in the rig fixed it. This entry is kept only as a warning about itself: a "confirmed empirical fact" recorded in this file outlived the condition that made it true, and reading it later caused a whole pose (the vine swing's shoulder) to be built the long way round — aimed by constructing a basis — specifically to dodge a scaling factor that had already stopped existing. Check the constant's current value in the source before trusting any numeric claim in this file about it. The general lesson matches thearm_left/arm_rightentry below: a fact frozen here goes stale silently, while a comment at the point of use travels with the code it describes.- When the user says "his left hand" or "his right hand," they mean anatomically (the character's own left/right, as if you were the character), not screen-left/screen-right as seen by a viewer facing the character — those two are mirror images of each other whenever the character faces the camera, and the user has explicitly flagged this as their consistent convention. Do not silently reinterpret a "left"/"right" report as camera-relative, and do not ask which one they mean by default — take it as anatomical unless told otherwise.
ProceduralFigure.build()'s ownarm_left/arm_right/leg_left/leg_rightreturn-dict keys had the anatomical side backwards for an unknown but long stretch of this project's history (side=+1.0, internally read as "right" at every build-time call site, was actually building on the character's true anatomical left, and vice versa) — fixed by swapping whichsidevalue thearm_left/arm_right(andleg_left/leg_right) local variables are built from, right at the two_build_arm()/_build_leg()call sites inbuild(). Every downstream sign convention insideprocedural_figure.gditself (shoulder_x/hip_x,ARM_OUTWARD_ANGLE,TOE_OUT_ANGLE, the wrist twist direction) is expressed as a function of thesideparameter, not the external name, so it stayed internally self-consistent automatically — only the label attached to each side was wrong there. This is a distinct failure mode from every other entry in this file: not a guessed sign that was never checked, but a guessed pairing between a label and a geometric side that nothing had ever actually exercised in a way that would surface the mismatch. Confirmed backwards only once a feature existed that made a human specifically reason about "click his right leg" and check the result against a specific physical limb (InventoryUI's blorb-suit paper-doll) — every earlier animation/pose feature (walking, idle, held items) usedarm_left/arm_rightsymmetrically or by name only, never checking anatomical correctness specifically, so the mislabeling had nothing to contradict it for a long time. The general lesson: a left/right (or any other paired-label) convention can be silently backwards indefinitely if every consumer only ever treats the two sides symmetrically-by-name rather than checking one specific named side against a real-world expectation — "it's been working fine" is not evidence a label-to-geometry pairing is correct, only that nothing has tested it yet. If a different asymmetric pairing (not left/right) is ever introduced in this rig, don't assume "no bug reports yet" means it's correctly labeled.- But it was NOT true that nothing outside
procedural_figure.gdneeded to change —player.gd's_apply_arm_power_poses()hardcodes its own literalsidevalues (1.0/-1.0) alongside specific_arm_left/_arm_rightpivots, to feed_pose_extended_arm()/_rest_hand_roll()'srest_twist := -side * (...)formula — the exact same formula_build_arm()used to set that hand's restingrotation.yin the first place. That formula only reproduces the correct resting orientation if itssideargument matches the side the pivot was actually built with; once the build-time pairing flipped, this call site's own hardcoded values were instantly wrong too, even though nothing about them individually looked incorrect. Missing this on the first pass is exactly what produced the follow-up report ("the palm and back of hand mapping flipped") as a seemingly new, separate bug. Whenever a root left/right (or any signed-parameter) pairing changes, grep for every OTHER hardcoded numericside/sign literal anywhere in the codebase that was written to match the OLD pairing — not just the input-mapping or naming layer that prompted the fix — and update each one deliberately; don't assume a fix at the source automatically propagates through a formula that was tuned by observation against the old, wrong version.
- But it was NOT true that nothing outside