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#
| Name | Type | Description |
|---|---|---|
specVersionrequired | 1 | Always 1 for now. |
namerequired | string | (non-empty) |
descriptionrequired | string | |
archetyperequired | "biped" | "quadruped" | "blob" | "winged" | "object" | "custom" | Body plan, used to fit presets. |
canvasrequired | object | |
paletterequired | object[] | Named colours the drawings reference as $name. |
partsrequired | object[] | The rig, at least one part. (at least 1) |
anchorsrequired | object[] | 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#
{
"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#
| Name | Type | Description |
|---|---|---|
idrequired | id | Unique part id, e.g. head, arm_l, tail. |
parentrequired | id | null | Parent part id, or null for the root |
zrequired | integer | Global draw order; higher draws on top |
pivotrequired | [number, number] | Joint location in canvas coordinates; rotation and scale happen around it |
rotateLimitsrequired | [number, number] | null | Allowed rotation range in degrees [min, max], or null if this part should not rotate |
svgrequired | string | SVG fragment (restricted subset) in canvas coordinates at rest pose |
statesrequired | object[] | Alternate drawings; empty if none |
defaultStaterequired | id | null | Which 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.
| Name | Type | Description |
|---|---|---|
namerequired | id | State name, e.g. open, closed, happy, smile, o |
svgrequired | string | SVG 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#
| Name | Type | Description |
|---|---|---|
clipVersionrequired | 1 | Always 1 for now. |
namerequired | string | (non-empty) |
durationrequired | number | Seconds (0.2–20) |
looprequired | boolean | |
tracksrequired | object | object[] | Keyframed properties, at most one track per part and property. |
modifiersrequired | object | 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.
{
"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#
| Name | Type | Description |
|---|---|---|
partrequired | string | Part id the track animates |
proprequired | "rotate" | "translateX" | "translateY" | "scaleX" | "scaleY" | "opacity" | |
keysrequired | object[] | Keyframes, sorted by t (at least 1) |
| Name | Type | Description |
|---|---|---|
trequired | number | Normalized time 0..1 within the clip (0–1) |
vrequired | number | |
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#
| Name | Type | Description |
|---|---|---|
partrequired | string | Part id the track animates |
proprequired | "state" | |
keysrequired | object[] | 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: 0and ends att: 1with the same value, so the loop is seamless. rotatevalues stay within the part'srotateLimits. Parts withrotateLimits: nullcan'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#
| Name | Type | Description |
|---|---|---|
typerequired | "breathing" | Always "breathing". |
partrequired | string | Usually the torso/root |
amountrequired | number | Scale amplitude, e.g. 0.02 (0–0.1) |
cyclesrequired | integer | Breaths per clip loop (integer so it loops) (≥ 1) |
blink#
| Name | Type | Description |
|---|---|---|
typerequired | "blink" | Always "blink". |
partrequired | string | |
closedStaterequired | string | |
countrequired | integer | Blinks per clip loop (1–4) |
durationrequired | number | Seconds the eyes stay closed (0.05–0.4) |
follow#
| Name | Type | Description |
|---|---|---|
typerequired | "follow" | Always "follow". |
partrequired | string | A dangling part (tail, ear, antenna) that lags behind its parent's motion |
strengthrequired | number | How much it swings, 1 = natural (0–2) |
stiffnessrequired | number | (10–400) |
dampingrequired | number | (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.
{
"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_requestand every problem indetails: schema violations (aspath: message), SVG that doesn't parse, duplicate or unknown ids, more than one root, parent cycles, pivots outside the canvas, unknown palette tokens, undefinedurl(#…)references, and the clip rules above. - Warnings are sanitizer removals (a disallowed element or attribute). The spec is still accepted, and
PUT /mascots/{id}/specreturns them aswarnings.