packages/skin-spec/README.md

Clankie animation packs

@clankie/skin-spec is the public, node-free animation contract for lead and worker appearances. It exports TypeScript types, the exact SKIN_STATES list, event/fallback maps and a runtime validator. No art, renderer or executable plugin is included. See ADR 0267.

Author a pack

Choose a stable lowercase ID, a pack version, display name and supported roles. Keep the same envelope for either body kind. Supply idle; other states can use the documented public fallback chain. Prefer authored spawn/handoff/poof for worker lifecycle, and the lead/pet acts your look supports. SKIN_STATES is the complete accepted list; misspellings fail validation.

A minimal 2D JSON manifest:

{
  "specVersion": 1,
  "id": "example.sprout",
  "version": "1.0.0",
  "displayName": "Sprout",
  "roles": ["lead", "worker"],
  "body": {
    "kind": "2d",
    "atlas": {
      "image": "sprout.png",
      "width": 32,
      "height": 40,
      "frames": { "rest": { "x": 0, "y": 0, "width": 32, "height": 40 } }
    },
    "animations": {
      "idle": { "frames": ["rest"], "playback": "loop", "frameDurationsMs": [1000] }
    }
  }
}

Use named atlas rectangles within its bounds; preserve transparent margins and stable feet/origin. Each animation has exactly one timing source: fps or one positive duration per frame. A once animation emits completion after its final duration. A hold selects a pose and emits no completion. mirrorX can reuse right-facing pixels on the left. The renderer determines its scale and mask.

For 3D, replace only body:

{
  "kind": "3d",
  "model": "sprout.glb",
  "cell": { "width": 40, "height": 64 },
  "fps": 12,
  "animations": {
    "idle": { "playback": "loop" },
    "think": { "playback": "loop" },
    "work_type": { "clip": "think", "playback": "loop" },
    "sleep": { "clip": "think", "playback": "hold", "range": { "from": 0.5, "to": 0.5 } }
  },
  "postProcess": { "kind": "pixel", "levels": 4, "outline": true }
}

Bake/retarget animations before export. Name each clip after its public state: this example needs unique idle and think clips, not a second copy for work_type. Embed buffers and textures in the GLB; external image URIs are rejected. The cell is the render target and hit-mask grid. Range endpoints are fractions of clip duration in [0,1]; only hold may have equal endpoints. A once completes when its selected range finishes. Rendering fps is independent of baked track sample rate. An optional envelope restStill is a 2D rest-pose image.

Validate and load

import { loadSkinAnimationPack, validateSkinModelAsset, resolveSkinState } from "@clankie/skin-spec";
const pack = loadSkinAnimationPack(untrustedManifest);
const state = resolveSkinState(pack.body, "work_type");
// For 3D, after the client's glTF loader parses the actual asset:
if (pack.body.kind === "3d") validateSkinModelAsset(pack, parsedGltfJson);

Validation performs no IO. SkinSpecError.issues provides paths and reasons. Use paths in authored JSON; the importing client resolves them under its own asset policy. Numeric asset IDs are only the client's in-memory bundled form. Use assets you may distribute; licensed source clips do not belong in this public repository. A complete pack should be reviewed in motion in the target renderer; passing structural checks is not visual approval.