Axel Meyer f7fa24965b docs(puppet): README sync to v0.4.3 + multi-target/slerp/inherit_scale/virtual/sprite_rot APIs
Updates README to reflect the Subterrain-runtime-control model adopted
across v0.3.1–v0.4.3. Previous README described the original v0.3.0
substrate-port state and omitted all v0.4.x features.

Added to API section: set_look_target (multi-target, named target_id),
clear_look_target (per-target or all), write_bone (per-channel conflict
guard description). Updated description of bone_world_transform (returns
radians, not angle). Added conventions note on camera.begin/finish
requirement for render.

CHANGELOG entries added for v0.3.1, v0.3.2, v0.3.3, v0.4.0, v0.4.1,
v0.4.2, v0.4.3 covering all intermediate fixes and the model pivot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:00:38 +02:00

lib-core.puppet

Skeletal animation primitive for top-down 2D characters. Manages a bone hierarchy with cascaded world-transforms, keyframe animation playback (multi-channel: rot/pos/scl), multi-target look-at constraints (slerp or rate-limit or instant-snap), procedural animation callbacks, and a per-channel conflict guard that allows animation and procedural layers to coexist on the same bones.

Version: 0.4.3 Lib-ID: lib-core.puppet Requires: (none — pure Lua + engine.render.* + engine.asset.*) Tags: animation, skeleton, puppet, character

Topology

API

puppet.build_rig(rig_table)

Validates a rig table (parsed from *.rig.json) and returns an internal representation with bones_by_id, parent-resolved object references, track-membership maps, z-sorted bone list, and optional locomotion config. Raises on invalid data (missing parents, bones in 2 tracks, legacy rest.angle field, etc.).

puppet.build_animation(anim_table, rig)

Validates an animation table against a built rig. Bones referenced in keyframes must exist and be in the animation's declared track. Raises on unknown bones, wrong-track bones, or legacy angle channel name.

puppet.sample_animation(anim, t)

Returns a frame { <bone_id> = { rot?, pos?, scl? }, ... } sampling the animation at time t with linear interpolation per channel. Wraps on t > duration if animation.loop is true. Each channel is sampled independently; absent channel means no override (hold-on-single-side semantics).

puppet.spawn(rig, {x, y}), puppet.despawn(handle)

Spawn a new puppet instance at world position (x, y). Returns an opaque handle. despawn removes the instance and all its state.

puppet.update(dt), puppet.render(handle)

update(dt) runs the full pipeline for ALL spawned puppets (rest-reset → keyframes → look-at → procedural → compute_world → apply_locomotion). render(handle) draws ONE puppet via engine.render.draw_sprite_transform per non-virtual bone. Call render inside a camera.begin()/finish() block.

puppet.set_look_target(handle, target_id, x, y), puppet.clear_look_target(handle, target_id?)

Set or clear a named look-at target. Bones with look_at: "target_id" in the rig rotate to face the matching target position each frame. The 2-arg legacy form set_look_target(handle, x, y) sets the "default" target. clear_look_target(handle) without a target_id clears all targets.

puppet.set_procedural(handle, name, callback), puppet.clear_procedural(handle, name)

Register or unregister a per-frame procedural callback. Callback signature: function(handle, dt). Inside the callback, mutate bone transforms via puppet.write_bone. Callbacks are invoked once per puppet per frame, at root-visit time (before compute_world for that frame).

puppet.write_bone(handle, bone_id, {rot?, pos?, scl?})

Write one or more channels of a bone's local transform from a procedural callback. Per-channel conflict guard: raises if the written channel is also written by a keyframe in any active animation on that bone's track. Allows procedural to write rot while an animation writes scl on the same bone.

puppet.play(handle, anim_id, {loop?, speed?}), puppet.stop(handle, anim_id), puppet.stop_all(handle), puppet.is_playing(handle, anim_id)

Keyframe-layer controls. Animation must be registered via puppet.register_animation(handle, anim) first. speed defaults to 1.0; pass a value like 0.45 for a slower cadence.

puppet.register_animation(handle, anim)

Binds a built animation (from build_animation) to a puppet instance, making it available to play.

puppet.load_rig(path, asset_aliases?)

Loads a rig from a JSON file and optionally resolves bone textures via puppet.load_textures. Pass engine.module.asset_aliases() to enable sprite-mode rendering; omit for colored-rect-only rigs.

puppet.load_textures(rig, asset_aliases)

For each bone with a texture field, resolves the atlas-id to a texture-handle via the asset-lib indirection. Also resolves bone anchors from the atlas (unless the bone has an explicit anchor field). Caches texture dimensions for locomotion sprite_reach computation.

puppet.load_animation(path, rig)

Convenience wrapper: reads a JSON file and calls build_animation.

puppet.bone_world_transform(handle, bone_id)

Returns x, y, rot — the bone's cached world-transform from the last puppet.update tick. World rot is in radians. Used by procedural callbacks to read parent bone state (e.g., body.world.rot for leg orientation math).

puppet.bone_world_scale(handle, bone_id)

Returns scl_x, scl_y — the bone's cached world scale. Accounts for inherit_scale flag (false → returns local.scl, not parent.scl * local.scl).

puppet.position(handle), puppet.facing(handle), puppet.move_to(handle, x, y)

Puppet world position management. move_to sets position instantly and updates facing (last-movement-direction angle, radians). facing returns the last movement direction.

puppet.write_bone_test_only(handle, bone_id, angle_rad)

Test-setup helper: directly write a bone angle bypassing the per-channel conflict guard. Stored in test_overrides; applied after rest-reset and keyframe sampling. Not for production use.

puppet.set_foot_sprite_reach_test_only(handle, leg_index, value), puppet.foot_state_test_only(handle, leg_index), puppet.bone_animated_test_only(handle, bone_id)

Test-only access to locomotion internal state and per-frame keyframe flags. Not for production use.

Conventions

  • World-coords + pixel-units throughout (per ADR-0031).
  • Bone angles in radians internally. JSON rest.rot and keyframe rot are in degrees; converted on load via math.rad.
  • look_at_speed is degrees/s in JSON; stored as radians/s.
  • look_at_slerp is a unitless rate (not converted).
  • sprite_rot is degrees in JSON; stored as radians.
  • rest.rot_min / rest.rot_max are degrees in JSON; stored as radians.
  • Update pipeline runs once per frame (puppet.update(dt)) for ALL spawned puppets. render is per-puppet so callers can interleave with other rendering.
  • render must be called inside a camera.begin() / camera.finish() block to render in world-space.

CHANGELOG

v0.4.3

  • inherit_scale: false bone flag: when false, world.scl = local.scl (parent.world.scl not multiplied). Position cascade still uses parent.scl. Primary use case: feet that track leg-end positionally without Y-flip inheritance when leg.scl_y inverts during walk cycle.

v0.4.2

  • Per-channel write_bone conflict check (was per-track). Procedural can now write channels (e.g., rot) that no active animation writes on the same bone (e.g., animation writes scl). Enables animation + procedural on same bone without conflict.

v0.4.1

  • target_local normalization to (-π, π] in look-at block. Fixes head-flick when body world.rot crosses ±π (south-facing direction).

v0.4.0

  • Multi-look-target API: puppet.set_look_target(handle, target_id, x, y). Bones declare look_at: "target_id" (string). Legacy look_at: true maps to "default" target.
  • Slerp smoothing: look_at_slerp field (unitless rate, exponential approach). look_at_speed (linear rate-limit) kept for backward-compat.
  • Virtual bones: virtual: true — participates in compute_world, skipped in render.
  • Legs re-parented to body (was root in v0.3.0).

v0.3.3

  • Per-bone sprite_rot (degrees in JSON): render-time rotation offset applied as world.rot + sprite_rot. Matches substrate puppet.c:2101 convention.

v0.3.2

  • Persistent look_at_state per bone. Fixes rate-limit reset-bug where each frame's rest-reset wiped the accumulated look-at rotation.

v0.3.1

  • Look-at math fix: world_angle = atan(dx, -dy) (sprite-top convention: rot=0 = top faces target, not game-forward).

v0.3.0

  • BoneState multi-channel (rot/pos/scl) with world.scl_x/y
  • Cascaded world-transform with scale propagation (compute_world)
  • Procedural locomotion layer via rig.locomotion block (apply_locomotion)
  • Schema: rest.rot (renamed from rest.angle), rest.scl added
  • New APIs: bone_world_scale, set_foot_sprite_reach_test_only, foot_state_test_only, bone_animated_test_only

v0.2.0

  • Rig-format extension (texture, z_order, anchor, asset_pack)
  • Multi-level parent-chain transform + sprite-mode render
  • New API: load_textures, bone_world_transform, write_bone_test_only

v0.1.0

  • Initial release: skeleton + bones + tracks + rest + keyframe + procedural + look-at constraint. Footplant IK deferred.

References

  • architecture/puppet.md (Reference — updated to v0.4.3)
  • superpowers/specs/2026-05-18-puppet-subterrain-model-design.md (Baseline Spec v0.4.3)
  • docs/adrs/0040-puppet-subterrain-model-pivot.md (Pivot rationale)
  • ADR-0037 (Tests-as-Libs)
  • ADR-0038 (API-Doc-Convention)
Description
Skeletal animation primitive: bones + tracks + keyframes + procedural + look-at
Readme 319 KiB
Languages
Lua 100%