Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.5.0 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), puppet.set_play_speed(handle, anim_id, speed) (v0.4.4)
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. set_play_speed mutates the
speed of a currently-playing anim without resetting its cycle position
— useful for mid-anim speed changes (e.g., sprint toggle). Silent no-op
if the anim is not currently playing.
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)
Lädt den Atlas des Rigs (asset_pack) und bindet jeden Bone mit
texture-Feld an dessen UV-Rect im Atlas. Seit v0.5.0 (M.2-Rewrite) hält
das Rig einen Atlas-Texture-Handle (rig.atlas.diffuse_texture_handle);
die Bones referenzieren Atlas-Index + UV-Rect statt eines per-Bone-Handles
über die alte Asset-Alias-Indirection. Löst zudem Bone-Anchors aus dem Atlas
auf (sofern der Bone kein explizites anchor-Feld hat) und cacht die
Texture-Höhe für die Locomotion-sprite_reach-Berechnung.
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.rotand keyframerotare in degrees; converted on load viamath.rad. look_at_speedis degrees/s in JSON; stored as radians/s.look_at_slerpis a unitless rate (not converted).sprite_rotis degrees in JSON; stored as radians.rest.rot_min/rest.rot_maxare degrees in JSON; stored as radians.- Update pipeline runs once per frame (
puppet.update(dt)) for ALL spawned puppets.renderis per-puppet so callers can interleave with other rendering. rendermust be called inside acamera.begin()/camera.finish()block to render in world-space.
CHANGELOG
v0.5.0
- Atlas-Handle Bone-Draw:
renderzeichnet jeden Bone über den einzelnen Atlas-Texture-Handle (rig.atlas.diffuse_texture_handle) plus per-Bone UV-Rect (ADR-0044 Source-Rect-Args), statt eines per-Bone-Handles. load_texturesM.2-Rewrite: Per-Atlas-Texture-Handle; Bones referenzieren Atlas-Index + UV-Rect statt der alten per-Bone Asset-Alias-Indirection.bone_world_transform(handle, bone_id): gibt die gecachte Welt-Transformx, y, rotdes Bones zurück.- Anchor-Hardcoding: Render liest
b.anchor[1]/[2](Default 0); fehlender Bone-Anchor wird inload_texturesauf die Tile-UV-Mitte gesetzt.
v0.4.4
puppet.set_play_speed(handle, anim_id, speed): mutates the play-speed of a currently-running animation WITHOUT resetting its cycle position. Silent no-op if anim is not playing. Use case: sprint-toggle mid-walk doubles cadence smoothly (puppet.playwith new speed would resett = 0and visibly restart the cycle).
v0.4.3
inherit_scale: falsebone 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 declarelook_at: "target_id"(string). Legacylook_at: truemaps to "default" target. - Slerp smoothing:
look_at_slerpfield (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 asworld.rot + sprite_rot. Matches substrate puppet.c:2101 convention.
v0.3.2
- Persistent
look_at_stateper 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.5.0)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)