Face Pack Authoring Guide
Create custom visual identities for Open Face with .face.json files.
Minimal Example
A face pack is a single JSON file. Here's the smallest valid pack:
{
"$type": "face",
"$version": "1.0.0",
"meta": {
"name": "My Pack",
"author": "Your Name",
"description": "A custom face pack"
},
"geometry": {
"eyes": { "style": "oval" },
"mouth": {}
},
"palette": {
"stateColors": {
"idle": "#4FC3F7",
"thinking": "#CE93D8",
"speaking": "#4FC3F7"
}
}
}
Everything not specified falls back to sensible defaults from the renderer.
File Structure
A .face.json has these top-level sections:
| Section | Required | Purpose |
|---|---|---|
meta | Yes | Name, author, license, description |
geometry | Yes | Eye/mouth/brow shapes, head, body, spacing |
palette | Yes | Colors for states, emotions, features |
animation | No | Blink intervals, lerp speeds, breathing |
personality | No | 5 traits that modulate animation behavior |
states | No | Per-state visual parameter overrides |
emotionDeltas | No | Additive overlays for each emotion |
accessories | No | Layered props (antenna, glasses, custom) |
Eyes
Four eye styles are supported:
| Style | Shape | Best For |
|---|---|---|
oval | Smooth oval bezier | Default, Classic, Warm, Corporate |
round | Perfect circle | Kawaii, Clay Buddy |
rectangle | Rounded rectangle | Robot, Cyberpunk |
dot | Small filled circle | Zen, Halloween |
"eyes": { "style": "oval", "baseWidth": 0.058, // fraction of canvas width "baseHeight": 0.086, // fraction of canvas height "spacing": 0.15, // distance between eye centers "verticalPosition": -0.04, // negative = above center "pupil": { "color": "#0A0A0A", "size": 0.35, "gazeStrength": 0.6 }, "eyelid": { "cover": 0.15, "color": "#1a1a2a" } }
narrow, square, pixel) are rejected. Use only oval, round, rectangle, or dot.Mouth
"mouth": { "style": "default", "width": 0.08, "verticalPosition": 0.12, "renderer": "fill", // "fill" or "line" "rendererByState": { "speaking": "fill" // override per state }, "rendererByEmotion": { "excited": "fill" // override per emotion } }
Resolution order: base renderer → rendererByState → rendererByEmotion. Fill wins over line at each tier.
Head Layer
"head": { "shape": "circle", // "fullscreen" | "circle" | "rounded" "width": 0.82, "height": 0.82, "verticalPosition": 0.005, "strokeWidth": 0.003 }
Use "fullscreen" (default) for no visible head — the face fills the entire canvas background. "circle" and "rounded" create a visible head silhouette.
Body
"body": { "shape": "blob", // "capsule" | "trapezoid" | "roundedRect" | "blob" "width": 0.52, "height": 0.38, "anchor": { "y": 0.34 }, "neck": { "width": 0.14, "height": 0.06 }, "shoulders": { "width": 0.48, "slope": 0.12 }, "arms": { "style": "stub", "length": 0.12 }, "motion": { "breathScale": 0.3, "tiltScale": 0.5, "speakingBob": 0.2 } }
Body rendering is optional — omit geometry.body entirely for a face-only pack. Body motion is derived from existing face signals (breathing, tilt, amplitude) with no rigging.
Accessories
"accessories": [ { "id": "antenna-left", "type": "antenna", "layer": "back", // "back" | "mid" | "front" | "overlay" "anchor": { "x": -0.12, "y": -0.18 }, "mirrorX": true, // auto-creates mirrored copy "segments": 8, "segmentLength": 0.14, "restAngle": 62, // degrees from vertical "restCurve": 0.62, "tipCurl": 1.0, // ramps in late (t > 0.55) "physics": { "stiffness": 0.86, "damping": 0.9, "gravity": 0.01, "headInfluence": 1.8 } } ]
Antenna physics runs at a fixed 120Hz timestep. tipCurl concentrates curl at the far end of the antenna (shaft stays straight, tip curls). Use stateOverrides to change physics per state.
Personality
Five traits modulate animation behavior:
| Trait | Range | Effect |
|---|---|---|
energy | 0-1 | Animation speed multiplier |
expressiveness | 0-1 | Animation range/amplitude |
warmth | 0-1 | Bias toward positive expressions |
stability | 0-1 | Micro-expression frequency (lower = more twitchy) |
playfulness | 0-1 | Sway, bounce, idle variation |
"personality": { "energy": 0.6, "expressiveness": 0.7, "warmth": 0.5, "stability": 0.8, "playfulness": 0.4 }
Palette
"palette": { "stateColors": { "idle": "#4FC3F7", "thinking": "#CE93D8", "speaking": "#4FC3F7", "listening": "#81C784", "working": "#90CAF9" // ... all 11 states }, "emotionColors": { "happy": "#FFD54F", "sad": "#7986CB" // ... override per emotion }, "head": { "fill": "#1a1a28", "stroke": "#2a2a3f" }, "body": { "fill": "#1a1a28", "stroke": "#2a2a3f" } }
Use emotionColorBlend (0-1) at the top level to control how much emotion colors override state colors. 0 = state only, 1 = full override.
Strict Contract
The face loader enforces strict rules:
- Deprecated keys are rejected:
geometry.eyes.highlight,palette.highlight,geometry.mouth.speakingFill - Eye styles are strict: only
oval,round,rectangle,dot - Validate against the schema:
protocol/v1/face.schema.json
Testing Your Pack
- Place your
.face.jsonin thefaces/directory - Add an entry to
faces/index.json - Run
bun run qa:facesto validate manifest parity - Run
bun run buildto bundle - Open
site/test.htmlto see your pack across all states/emotions - Open
dashboard.htmland select your pack from the dropdown
debug-overlay attribute on <open-face> to see live parameter values while tuning.