Skip to content

Reference

MascotSpec & AnimationClip

The two JSON formats behind every mascot: the rigged drawing and the motion that plays on it.

Every mascot is two kinds of JSON. A MascotSpec is the rigged drawing: vector parts, their hierarchy, pivots, joint limits and alternate drawings. An AnimationClip is a motion that plays on that rig: keyframed tracks plus procedural modifiers. Both are defined in Zod in packages/schema; the tables below are generated from those schemas, and the full JSON Schemas are in the REST reference. Read a spec with GET /mascots/{id}/spec, write one with PUT /mascots/{id}/spec or POST /mascots { spec }.

MascotSpec#

MascotSpec
NameTypeDescription
specVersionrequired1Always 1 for now.
namerequiredstring(non-empty)
descriptionrequiredstring
archetyperequired"biped" | "quadruped" | "blob" | "winged" | "object" | "custom"Body plan, used to fit presets.
canvasrequiredobject
paletterequiredobject[]Named colours the drawings reference as $name.
partsrequiredobject[]The rig, at least one part. (at least 1)
anchorsrequiredobject[]Named points that move with a part (a hand, a hat mount).

Coordinates and structure#

  • Every part is drawn in absolute canvas coordinates at its rest pose, inside canvas.width × canvas.height. Pivots must lie within the canvas.
  • There is exactly one root (parent: null). Every other parent must exist, and the hierarchy can't loop. A part's transform rotates and scales around its pivot and is inherited by its children.
  • Draw order is by z, globally, independent of the hierarchy, so a child such as the far arm can sit behind its parent.
  • Ids (parts, states, palette tokens, anchors) are snake_case: lowercase letters, digits and underscores, starting with a letter. Part ids must be unique.

A complete example#

dot.spec.json
{
  "specVersion": 1,
  "name": "Dot",
  "description": "Dot — a tiny round helper who waves at new users.",
  "archetype": "blob",
  "canvas": { "width": 400, "height": 400 },
  "palette": [
    { "name": "body", "color": "#3A5BFF" },
    { "name": "ink", "color": "#0E1120" }
  ],
  "parts": [
    {
      "id": "body", "parent": null, "z": 0, "pivot": [200, 330], "rotateLimits": [-8, 8],
      "svg": "<circle cx=\"200\" cy=\"230\" r=\"100\" fill=\"$body\"/>",
      "states": [], "defaultState": null
    },
    {
      "id": "eyes", "parent": "body", "z": 2, "pivot": [200, 210], "rotateLimits": null,
      "svg": "",
      "states": [
        { "name": "open", "svg": "<circle cx=\"170\" cy=\"210\" r=\"10\" fill=\"$ink\"/><circle cx=\"230\" cy=\"210\" r=\"10\" fill=\"$ink\"/>" },
        { "name": "closed", "svg": "<path d=\"M160 212h20M220 212h20\" stroke=\"$ink\" stroke-width=\"5\" stroke-linecap=\"round\"/>" }
      ],
      "defaultState": "open"
    },
    {
      "id": "arm_r", "parent": "body", "z": 1, "pivot": [290, 240], "rotateLimits": [-120, 30],
      "svg": "<rect x=\"285\" y=\"230\" width=\"60\" height=\"22\" rx=\"11\" fill=\"$body\"/>",
      "states": [], "defaultState": null
    }
  ],
  "anchors": [{ "name": "hand_r", "part": "arm_r", "at": [340, 241] }]
}

Parts#

Part
NameTypeDescription
idrequiredidUnique part id, e.g. head, arm_l, tail.
parentrequiredid | nullParent part id, or null for the root
zrequiredintegerGlobal draw order; higher draws on top
pivotrequired[number, number]Joint location in canvas coordinates; rotation and scale happen around it
rotateLimitsrequired[number, number] | nullAllowed rotation range in degrees [min, max], or null if this part should not rotate
svgrequiredstringSVG fragment (restricted subset) in canvas coordinates at rest pose
statesrequiredobject[]Alternate drawings; empty if none
defaultStaterequiredid | nullWhich state shows at rest, or null to use `svg`

States#

States are alternate drawings for one part: eyes open and closed, mouths smile and o. When a state is active its svg is drawn instead of the part's base svg. defaultState must be one of the part's states, or null to show the base drawing at rest. A part needs a base drawing or at least one state.

PartState
NameTypeDescription
namerequiredidState name, e.g. open, closed, happy, smile, o
svgrequiredstringSVG fragment drawn instead of the part's base svg when this state is active

Joints#

rotateLimits is the allowed rotation in degrees, [min, max] with min ≤ max, or null for parts that shouldn't rotate. Presets and the motion agent stay inside these limits, and clips that leave them are rejected.

Palette tokens#

Drawings reference colours by token, not by value: fill="$fur". Every $token must be in the palette. This is what makes recolouring instant: POST /mascots/{id}/recolor { "palette": { "fur": "#3b82f6" } } or the player's palette attribute swap the value without touching a single path. Colours are #rrggbb.

The SVG subset#

Part drawings are SVG fragments from a restricted subset. Anything outside it is removed when the spec is validated (and reported as a warning), so a spec can never carry scripts, external references or features the renderers and the Lottie converter can't reproduce.

  • Elements: g, path, circle, ellipse, rect, line, polyline, polygon, defs, linearGradient, radialGradient, stop, clipPath.
  • Attributes: geometry (d, cx, cy, r, rx, ry, x, y, width, height, x1–y2, points), paint (fill, fill-opacity, fill-rule, stroke, stroke-width, stroke-opacity, stroke-linecap, stroke-linejoin, stroke-miterlimit, opacity), transform, id, clip-path, clipPathUnits, and gradient attributes (offset, stop-color, stop-opacity, gradientUnits, gradientTransform, fx, fy, fr).
  • References are local only: url(#id) must point at an id defined somewhere in the mascot, and element ids must be unique across the whole mascot.

AnimationClip#

AnimationClip
NameTypeDescription
clipVersionrequired1Always 1 for now.
namerequiredstring(non-empty)
durationrequirednumberSeconds (0.2–20)
looprequiredboolean
tracksrequiredobject | object[]Keyframed properties, at most one track per part and property.
modifiersrequiredobject | object | object[]Procedural motion layered on top.

Key times are normalized: t runs from 0 to 1 across the clip, so changing duration retimes the whole motion.

hello.clip.json
{
  "clipVersion": 1,
  "name": "hello",
  "duration": 1.6,
  "loop": true,
  "tracks": [
    {
      "part": "arm_r", "prop": "rotate",
      "keys": [
        { "t": 0, "v": 0, "ease": "linear" },
        { "t": 0.25, "v": -100, "ease": "easeOutBack" },
        { "t": 0.5, "v": -70, "ease": "easeInOut" },
        { "t": 0.75, "v": -100, "ease": "easeInOut" },
        { "t": 1, "v": 0, "ease": "easeInOut" }
      ]
    },
    {
      "part": "eyes", "prop": "state",
      "keys": [{ "t": 0, "v": "open" }, { "t": 0.9, "v": "closed" }, { "t": 0.96, "v": "open" }]
    }
  ],
  "modifiers": [
    { "type": "breathing", "part": "body", "amount": 0.02, "cycles": 1 },
    { "type": "blink", "part": "eyes", "closedState": "closed", "count": 1, "duration": 0.12 }
  ]
}

Save a hand-written clip with POST /mascots/{id}/animations { "clip": …, "name": "hello" }; it is validated against the mascot's rig.

Tracks#

Numeric tracks#

NumericTrack
NameTypeDescription
partrequiredstringPart id the track animates
proprequired"rotate" | "translateX" | "translateY" | "scaleX" | "scaleY" | "opacity"
keysrequiredobject[]Keyframes, sorted by t (at least 1)
NumericKey
NameTypeDescription
trequirednumberNormalized time 0..1 within the clip (0–1)
vrequirednumber
easerequired"linear" | "easeIn" | "easeOut" | "easeInOut" | "easeInBack" | "easeOutBack" | "easeOutElastic" | "easeOutBounce" | "step"Easing used to arrive at this key from the previous one

Properties: rotate (degrees), translateX and translateY (canvas units), scaleX and scaleY (1 = rest), opacity (0–1). Each key's ease shapes the move into that key: linear, easeIn, easeOut, easeInOut, easeInBack, easeOutBack, easeOutElastic, easeOutBounce, step.

State tracks#

StateTrack
NameTypeDescription
partrequiredstringPart id the track animates
proprequired"state"
keysrequiredobject[]Keyframes, sorted by t (at least 1)

A state track switches a part between its states at the given times, e.g. a mouth opening and closing while a character talks.

Rules#

  • Keys are sorted by t, and a part/property pair appears in one track only.
  • In a looping clip, every numeric track with more than one key starts at t: 0 and ends at t: 1 with the same value, so the loop is seamless.
  • rotate values stay within the part's rotateLimits. Parts with rotateLimits: null can't rotate.
  • State keys name states the part actually has.

Modifiers#

Modifiers add the motion that makes a character feel alive without keyframing it:

breathing#

breathing modifier
NameTypeDescription
typerequired"breathing"Always "breathing".
partrequiredstringUsually the torso/root
amountrequirednumberScale amplitude, e.g. 0.02 (0–0.1)
cyclesrequiredintegerBreaths per clip loop (integer so it loops) (≥ 1)
blink modifier
NameTypeDescription
typerequired"blink"Always "blink".
partrequiredstring
closedStaterequiredstring
countrequiredintegerBlinks per clip loop (1–4)
durationrequirednumberSeconds the eyes stay closed (0.05–0.4)

follow#

follow modifier
NameTypeDescription
typerequired"follow"Always "follow".
partrequiredstringA dangling part (tail, ear, antenna) that lags behind its parent's motion
strengthrequirednumberHow much it swings, 1 = natural (0–2)
stiffnessrequirednumber(10–400)
dampingrequirednumber(1–40)

follow is a spring: the part lags behind its parent's motion and overshoots, which is what gives ears, tails and antennae their follow-through. A blink's closedState must be one of the part's states.

Bundles#

A bundle is what <lumorig-player> loads from GET /mascots/{id}/bundle: the current spec, a clip for every preset (fitted to this rig), every saved animation keyed by name, and every canvas keyed by name.

GET /api/v1/mascots/msc_…/bundle
{
  "bundleVersion": 1,
  "id": "msc_…",
  "name": "Dot",
  "spec": { "specVersion": 1, "name": "Dot", … },
  "clips": { "idle": { "clipVersion": 1, … }, "wave": { … }, "hello": { … } },
  "canvases": { "assistant": { "canvasVersion": 1, … } }
}

Bundles are public for public mascots, need a signed ?token= for private ones, and are served with open CORS so they can be embedded anywhere. See Embed and Interactive canvases.

Validation#

validateSpec(raw) and validateClip(raw, spec) in packages/schema are what the API runs on every spec and clip it accepts. They return { ok: true, value, warnings } or { ok: false, errors, warnings }.

  • Errors reject the request with 400 invalid_request and every problem in details: schema violations (as path: message), SVG that doesn't parse, duplicate or unknown ids, more than one root, parent cycles, pivots outside the canvas, unknown palette tokens, undefined url(#…) references, and the clip rules above.
  • Warnings are sanitizer removals (a disallowed element or attribute). The spec is still accepted, and PUT /mascots/{id}/spec returns them as warnings.