spelling-buddy
v1.1.0
Published
A procedural 2.5D character rig for Canvas2D and SVG. Zero runtime dependencies, zero image assets — the character is math.
Maintainers
Readme
spelling-buddy
A procedural 2.5D character rig for the web. The character is mathematics, not artwork — there are no PNGs, no sprite sheets, and no runtime dependencies. Every shape is an arc, an ellipse, or a Bézier curve, evaluated per frame.
That one decision is what makes the rest possible:
- Infinite resolution. Renders identically at 24px in a toolbar and at 2000px on a projector.
- It deforms. Squash-and-stretch, head turns, and expression blends are computed, so poses interpolate instead of snapping between fixed frames.
- It turns. The face is not a picture pasted on a ball: the patch, its fringe and every feature are drawn face-on and pushed through one projection, so the wrap, the lean and the crowding of the far side fall out of the geometry. Past sixty degrees a brow, a nose and a chin break the leading edge, and the face stays legible all the way to profile.
- One rig, many outputs. The identical drawing code renders live to Canvas2D and emits real SVG geometry, PNG stills, sprite sheets, and GIFs. Assets cannot drift from runtime, because there is only one source.
81 kB minified, 30 kB gzipped. Zero dependencies.
Documentation
Building an app with the character? Read
AGENTS.md — the whole contract in one screen — then copy a
whole component out of RECIPES.md. Everything below is for
building new things rather than using the existing one.
llms.txt is the machine-readable index of both.
| | | |---|---| | AGENTS.md | The contract: one import, one prop, six phases, four things not to do | | RECIPES.md | whole working components — spelling test, letter grid, scored trace pad | | docs/index.html | Live docs — every example runs in the page, including an interactive explainer for the sphere projection | | Getting started | install, first buddy, sizing, cleanup, performance | | API reference | every option, method, event, and adapter | | Expressions & animations | the full catalog, plus adding your own | | Theming | brand tokens, custom palettes | | Integration recipes | wiring it into a real lesson flow | | Asset export | the committed SVG collection and its manifest, the derived raster pack, one-off exports, CI diffing | | Architecture | how the turn, springs and backends work | | Troubleshooting | real failure modes and fixes | | Speech & visemes | mouth shapes, letter names, lip-sync | | Tracing & cues | letter formation, audio hooks | | Props | the 75-item catalogue, conflicts, recolouring, writing your own | | The cast | twelve characters, and the axes that keep them apart | | The egg | the shell, the crack, the hatch | | CHANGELOG.md | what changed, and which bug each change came from |
Install
npm install spelling-buddyOr drop the bundle in and skip the build step entirely:
<script src="spelling-buddy/dist/spelling-buddy.global.js"></script>Quick start
Vanilla
import { mount } from 'spelling-buddy'
const { buddy } = mount('#buddy', { theme: 'ink', size: 240 })
buddy.express('thinking') // while the learner is typing
buddy.react('correct') // on a right answer
buddy.spell('cat') // hold up each letter, then celebrate
buddy.sayLetters('cat') // articulate the letter names
buddy.trace('a') // show how the letter is formed
buddy.traceWord('cat') // …every letter in turnReact
import { SpellingBuddy } from 'spelling-buddy/react'
<SpellingBuddy
size={240}
theme="ink"
expression={isTyping ? 'thinking' : 'happy'}
action={result === 'right' ? 'correct' : result === 'wrong' ? 'wrong' : undefined}
/>Or drive it imperatively:
import { useBuddy } from 'spelling-buddy/react'
function Lesson() {
const { canvasRef, react, spell } = useBuddy({ theme: 'ink', size: 240 })
return (
<>
<canvas ref={canvasRef} style={{ width: 240, height: 240 }} />
<button onClick={() => spell('CAT')}>Show me</button>
</>
)
}Web Component
<script type="module">
import { defineSpellingBuddy } from 'spelling-buddy/element'
defineSpellingBuddy()
</script>
<spelling-buddy theme="ink" size="240" expression="happy" idle></spelling-buddy>document.querySelector('spelling-buddy').react('correct')API
new Buddy(options) / mount(canvas, options)
| Option | Default | |
|---|---|---|
| theme | 'ink' | name, or a partial override object |
| character | — | one of the twelve; sets shape, fringe, ears and theme at once |
| shape | 'v1' | proportions — a preset name, or a preset with overrides. Per instance |
| fringe ears | — | the creature's anatomy: geometry, not palette |
| accessories | — | a prop id, or an array of them |
| seed | 1 | PRNG seed — same seed gives identical output |
| expression | 'happy' | starting expression |
| autoLook | true | eyes and head track the cursor when idle |
| idleActions | false | spontaneously look around / think |
| showHands | false | hands normally appear only when an animation needs them |
| showShadow showSparks showBlush showTrail | true | part toggles |
| scale tempo bobAmt breathAmt blinkEvery | 1, 1, 1, 1, 3.2 | motion tuning |
mount() adds size, interactive, dragToTurn, clickToPop, autoStart, maxDPR.
Phases — the recommended surface
Everything else is rig-level. A phase is lesson-level: it says what the learner is doing, and the choreography lives in one place, so page twenty behaves like page one.
buddy.phase('typing')
buddy.phase('correct') // celebrates, then returns to idle
buddy.phase('stuck', { word: 'cat' }) // spells it — without celebrating
buddy.phase('teaching', { letter: 'g' }) // traces it<SpellingBuddy phase={status} word={word} nonce={attempts} />idle · typing · correct · wrong · stuck · teaching
Idempotent, so it is safe to call from a render; momentary phases fall back to
a steady one by themselves; entering a phase cancels the last one's work.
integrations/nextjs has a drop-in App Router wrapper.
Methods
buddy.express('proud') // set expression, cross-faded
buddy.react('turnaround') // play a special animation
buddy.spell('CAT') // letter-by-letter, then celebrate
buddy.hold('B') // hold one letter card
buddy.face(45, -10) // point the head (degrees)
buddy.turnBy(0.2) // relative turn, radians (drag gestures)
buddy.setTheme('blue')
buddy.setShape('sprout') // this character's build, fringe and ears
buddy.setCharacter('momo') // all four cast axes at once
buddy.wear(['cap', 'pencil']) // props — worn and held
buddy.egg(true) // …and the shell it came out of
buddy.pointer(x, y, inside) // feed normalised cursor position
buddy.reset()
buddy.on('action:end', name => …)
buddy.on('spell:letter', ch => …)
buddy.on('spell:done', () => …)
buddy.on('hatched', () => …)
buddy.busy // an action or spell is running
buddy.expression // current expression name
buddy.yawDeg // where the head is pointing
buddy.wearing // ['cap', 'pencil']
buddy.eggState // 'closed' | 'wobbling' | 'cracked' | 'opening' | nullProps
Seventy-five items across seven slots — headwear, head-side, face, ears, neck and front, held, back. All procedural, the same arcs and Béziers the character is drawn from.
mount('#buddy', { accessories: 'glasses' })
buddy.wear(['bow', { name: 'crown', color: '#FFC94A' }])
buddy.wear([{ name: 'alphabet-card', letter: 'K' }]) // held, and the letter is geometry
buddy.wear(null)
buddy.wearing // ['bow', 'crown']A worn thing is not a picture stuck to the front of the head. It lives in the head's own frame and is rotated with it, so turning away puts part of it behind the skull and the rest out past the silhouette. Three rules make that work, and each one is a bug that shipped before it was a rule:
- Worn things use a true rotation, not the face's wrap cheat. The cheat pulls features inward so eyes never overhang the body edge; applied to hardware it drags an earcup into the middle of the face at profile.
- Depth sorts, it does not fade. Every part draws in one of seven named passes by its own depth, and closed shapes are split at the horizon, so nothing dissolves mid-turn and nothing pops.
- Foreshorten only the axis that foreshortens, and never past the width at which the shape stops reading.
A prop is a declaration, not a drawing: an id, a slot, a physical
footprint, the passes it draws in, a frame and a shape tree. The compiler owns
projection, depth, foreshortening, clipping to the head's real outline, the
form light and the contour pass. Six hand-written draw() functions do not
become seventy-five, and a general-purpose scene language is the other failure.
Conflicts fall out of the footprints rather than a hand-written table — a cap
and a crown clash because both need skull.top; a bow does not:
import { conflictsWith, checkLoadout } from 'spelling-buddy'
conflictsWith('cap') // ['crown', 'headphones', 'beanie', …]
checkLoadout(['cap', 'crown']) // ['crown and cap both need skull.top', …]
checkLoadout(['cap', 'bow', 'pencil']) // []Props name material roles, never hex, and the feedback colours are reserved: a prop that asks for the correct-answer green gets something else.
Run npm run sweep before calling one finished. Every accessory defect this
project has had was invisible at the two angles that get checked by hand and
obvious on one contact sheet.
The cast
Twelve characters, and one creature. The silhouette stays; what varies is build, fringe, ears and palette.
new Buddy({ character: 'momo' })
new Buddy({ character: 'nox', theme: 'coral' }) // Nox in someone else's colours
buddy.setCharacter('lumi')pip · momo · lumi · vivi · tavi · nox · coco · nori · bram ·
sunny · mika · zuzu
Fringe (7) and ears (5) are geometry, not palette — they used to live in the theme, which is exactly what made a cast inexpressible: two characters could not share colours and differ in hair. Three rules stop the twelve being one drawing in twelve colours, and all three are measured rather than intended: every pair differs on at least two non-colour axes, no two draw the same in one palette, and every one reads bare. Turning the first of those into a measurement found that the plan's own cast table broke it.
The egg
The shell is the character's own silhouette, scaled up — so what comes out matches the hole it came from, and the hatch reads as "it was in there all along" rather than as two drawings swapped at a cut.
buddy.egg(true)
buddy.crack(0.6) // 0..1 — the caller's number, never a spring
buddy.hatch() // finishes the crack, then opens
buddy.on('hatched', () => …)crack belongs to the caller because a crack does not settle; open belongs
to the rig because a shell coming apart has weight. The fissure is seeded on
its own randomness substream, x-monotone, and revealed by arc length.
Form
The character is a sphere and the face is a hole in it, and both of those are
gradients rather than geometry. One light, fixed in world space — never
attached to the turn, because a highlight that swings with the yaw reads as a
moving lamp and the point is to give the face a form to travel across. Anything
worn takes the same light at two-thirds strength; a flat hat on a shaded head
is the same sticker problem one layer up. The face patch takes it too, at lower
strength and backing off at profile, because a shaded head with an unshaded
face is that same problem at the centre of the drawing. theme.form = false
turns it off.
Shading
The character is shaded, and the shading is derived from the body colour rather
than authored per theme — the brand colour is the gradient's middle stop, so
it is actually present rather than approximated. Gradients are plain data
({type, coords, stops}) in the path's own space, which is what lets canvas and
exported SVG produce the same pixels. Measured cost: 0.080 → 0.095 ms/frame.
Green stays feedback-only. Shading gives depth within the body colour; it is not a licence to make the character green.
Expressions
happy · excited · thinking · surprised · proud · sleepy · confused · dizzy · content
Speech
Ten blendable viseme shapes, so the mouth articulates rather than flaps.
buddy.sayLetters('CAT') // letter NAMES — exact, 26-entry table
buddy.say('through') // words — approximate from spelling
buddy.sayVisemes([['MBP', 0.08], ['AI', 0.22]]) // exact control
buddy.attachSpeech(utterance) // follow Web Speech audioThe alphabet

A–Z, a–z and 0–9, drawn as monoline strokes rather than set in a font —
so they render identically everywhere, at any size, with nothing installed.
Case is preserved everywhere. hold('a'), spell('cat') and trace('g')
show lowercase, on the real baseline, with a real x-height and real descenders.
Most early-years curricula teach lowercase first, and a rig that quietly
upper-cases its input is unusable for those lessons.
cap -0.5 ── b d f h k l t
x-line -0.12 ── a c e o
baseline 0.5 ──
descender 0.78 ── g j p q yTracing
buddy.trace('a') // the letter draws itself, stroke by stroke
buddy.on('trace:done', …)Nearly free: the glyphs are monoline strokes, so their path data is already the pen's centreline. The coordinates that draw a letter also describe how to write one — something a filled-outline font cannot tell you.
And the other direction — the child traces, you grade it:
import { scoreTrace, identifyTrace } from 'spelling-buddy'
scoreTrace('A', paths)
// { score, accuracy, coverage, direction, verdict, hint, strokesHit }
scoreTrace('b', paths, { diagnose: true })
// … plus reversed: true, looksLike: 'd'Three metrics, not one: distance alone lets a scribble in one corner pass.
And a grade is not a lesson. A child who draws a d when asked for a b has
not failed to control the pencil — they know the letter and wrote its mirror,
which is the most common early-years handwriting error there is. diagnose
mirrors their own marks and re-scores against the same target, so the app can
say "that's a b written backwards" instead of "try again". No table of
mirror-pairs; it catches a backwards 3 too.
Reference lesson
examples/lesson.html is the whole loop in ~180 lines — typing feedback,
answer checking, spelling aloud, tracing, finger-tracing scored live, and sound
synthesised from cues with no audio files.
Accessibility
mount() gives the canvas role="img" and a label, and announces the moments
that carry information — spell(), hold(), trace() — through an off-screen
aria-live region it creates and cleans up itself. The word is announced once,
not letter by letter. Feedback is left to the host, because the host almost
always shows its own. All of it is overridable, and announce: false opts out.
prefers-reduced-motion is respected: the idle bob and motion trail stop, the
expressions stay.
Audio cues
The rig makes no sound; it reports when something worth hearing happened.
buddy.on('cue', ({ name, detail }) => sfx.play(name))
// correct · wrong · pop · land · letter · trace:start · trace:stroke · trace:doneSpecial animations
| | |
|---|---|
| Feedback | correct wrong nod |
| Turn | turnaround peek lookAround |
| Physical | jump dizzy |
| Social | wave dance |
| Idle | sleep think |
| Micro | pop |
Themes
Colours live in one object; nothing in the drawing code hard-codes a value.
| theme | body | notes |
|---|---|---|
| ink | #16161A | default — the action colour on white canvas |
| blue | #1478C9 | selection blue |
| cream | #16161A | ink on a warm editorial field |
| indigo | #4A56D8 | original exploration colour |
| slate plum berry coral amber teal rose snow | | skins — the same character, different colour |
| oat strawberry sky lavender apricot inkling | | the kawaii set — the only ones with a drawn contour |
Green (#2CB02B) appears only on correct-answer feedback; it is never decoration.
The kawaii skins are the same rig with a line around it, and in them the contour is the darkest value in the drawing rather than a field of near-black. Three weights and never four: the body's, the face patch's at ninety per cent of it, worn things at sixty. The face's own edge at body weight turns the patch into a ring, and a light disc inside a heavy ring reads as a finger hole no matter how good the face inside it is.
Builds
The rig is about fifteen numbers, so a different build of the same character is a table of numbers rather than a fork of the drawing code.
mount('#a', { shape: 'kawaii' }) // squat, bottom-heavy, tall eyes, a small high mouth
mount('#b', { shape: 'v1' }) // what shipped: taller egg, round eyes, wide smile
mount('#c', { shape: 'sprout' }) // one of the three cast builds
buddy.setShape('cuddle', { fringe: 'curtain', ears: 'flop' })A build belongs to a character, not to the page: each buddy owns a frozen geometry with its own sampled half-width table, so two of them with different proportions render correctly in the same frame. That is the prerequisite for a cast existing at all.
applyShape — which used to mutate a shared global — now throws. Once geometry
became per-instance the call reached nothing, and a dead control that looks
alive is worse than a missing one. Use setShape or setCharacter.
Override any slot:
mount('#buddy', {
theme: { extends: 'ink', body: '#0B2A4A', spark: '#FFC94A' }
})Asset export
Everything below comes out of the same rig, so exported art always matches what ships at runtime.
npx spelling-buddy sheet # one SVG character sheet
npx spelling-buddy alphabet # A–Z a–z 0–9 on ruled paper
npx spelling-buddy svg --out assets/svg # per-pose SVGs, zero deps
npx spelling-buddy png --size 512 # rasterised stills
npx spelling-buddy sprite --action correct # sprite-sheet PNG
npx spelling-buddy gif --action wave # animated GIFsvg, sheet and alphabet need nothing installed. png / sprite / gif use sharp
(optional dependency) and, for GIF, ffmpeg.
Programmatically:
import { poseSVG, sheetSVG, toSVG } from 'spelling-buddy'
poseSVG({ expression: 'proud', yaw: 45 }) // → '<svg …>'
toSVG(buddy, { width: 512 }) // snapshot the live rig
sheetSVG([{ expression: 'happy' }, { yaw: 90 }], { cols: 4 })Frames are produced from a seeded PRNG at a fixed timestep, so exporting twice gives byte-identical output — safe to commit and to diff in CI.
The collection
npm run collection # write assets/v1/
npm run collection:check # re-render and diff; silent when it matchesassets/v1/ is the committed release pack: 700 SVG plates and a manifest,
10.6 MiB. Eight yaw angles (0–315 in 45° steps) of every one of the 75 props on
one canonical character, eight of each of the twelve cast members bare, and the
four egg states — every plate a whole character wearing the thing, because a
prop exported on its own is missing its rear half and the skull's occlusion of
it.
assets/v1/svg/props/wizard-hat/pip__wizard-hat__happy__y045__p000.svg
└───────────────────────────────── character
│ └──────────────────────── prop
│ │ └─────────────── expression
│ │ │ └──────── yaw
│ │ │ │ └── pitchmanifest.json carries the coordinate system, a digest of the source that drew
the pictures, per-prop registry metadata (slot, footprint, conflicts, material
roles, visibility policy), and per file its path, sha256, bytes, dimensions,
content bounds, pivot and — on held props — the grip points where the hands
are. Every plate is the same fixed 320-unit square with 8% padding and is never
trimmed, so a page can lay plates side by side without the creature changing
size. The categories are driven by the registries, so the collection grows when
the rig grows.
--check is the last step of npm test: it re-renders all 700 plates in about
three seconds and diffs them against the manifest, the bytes on disk and the
registry. Change the rig and the suite fails until you run npm run
collection and commit what it writes.
PNG plates and sprite sheets are derived from those vectors and are not committed — 21 MiB against 10.6, and one command to remake. Full detail, and what a consumer does with a pivot and a grip point, in Asset export.
How it works
The projection
Each facial feature is given a position on the surface of a sphere. yaw and
pitch rotate that sphere; an orthographic projection returns the 2D position
and the local foreshortening factors, so eyes compress correctly approaching
profile and fade off the terminator instead of popping.
A true projection would slide features all the way out to the silhouette, where they overhang the body edge. The rig applies a wrap cheat — features travel about half as far at full profile — but only to the face group's anchor point. Feature spacing within the face still uses the honest projection, so the eyes don't crowd together. Travel is stylised; foreshortening is physical.
The face is a surface, not a shape
The pale patch the features sit in used to be an upright oval squashed across screen-x. That is an affine map, and an affine map preserves relative spacing: the fringe scallops stayed evenly spread while the outline beside them foreshortened progressively. The eye reads that disagreement long before it can name it — the head looks round and the face looks like a sticker on it.
The patch is now built face-on — a circle with the fringe across the top, exactly as it is drawn at rest — and pushed through the same projection as the eyes, placed as a cap rather than as a longitude/latitude patch. (A patch this large pinches to a point near the pole of the face sphere, and the face came out with a tail on it.) The lean of the oval, the bank of the fringe, the crowding of the far scallops and the wrap past the limb are all consequences of that one projection. None of them is drawn.
The profile
A 43° cap is still half visible at ninety degrees, so a face that fades to nothing at the limb leaves a plain egg with a hair whorl on it — a back view arriving early. In the last thirty degrees of turn:
- brow, bridge, nose, notch and chin break the leading edge. Late, deliberately: a nose that starts growing at three-quarter view is a lump on a cheek that is still facing you.
- the rim rule inverts. Head-on, a face flush with the outline is the sticker failure; at the limb, a face not cut by the outline floats as a lens on the side of the head. The anchor walks out onto the outline and the clip does the cutting.
- the nose is filled in the face's colour, because at profile the nose is face. In the body colour it is a lump growing out of a scalp.
- the whorl waits. A whorl and a nose on screen together read as a back view with a face stuck to the edge of it.
The face also lags the head slightly — physically the visible face at ninety degrees is a sliver, which is correct and unreadable. Every hand-drawn turnaround cheats this; the cheat here is on the foreshortening only, so the head still reads as fully turned.
Springs, not tweens
Every impulse is injected as velocity into a damped spring rather than played as an eased keyframe. Eased tweens arrive and stop; springs overshoot and settle. That is what reads as weight. Animations set spring targets and inject impulses — they never assign positions directly, which is why hand-authored beats and physical settling don't fight each other.
One Surface, two backends
The rig draws against a small interface — ellipse, arc, fill, stroke,
clip, text. CanvasSurface forwards those to Canvas2D. SVGSurface turns
them into path data, converting arcs to cubic Béziers the way any vector tool
does. Neither backend knows anything about the character.
Add an expression once, and it appears at runtime, in exported SVG, in the sprite sheet, and in the GIF.
Development
npm test # behaviour, rendering invariants and snapshots, types, docs
npm run build # dist bundles (IIFE + ESM, minified and not)
npm run snapshot # re-record visual snapshots after an intentional art change
npm run assets # regenerate the SVG asset set
npm run collection # regenerate assets/v1/ — required after any rig change
open demo/index.htmlThe demo renders the live canvas beside an SVG exported from the same rig each frame — if the two backends ever diverge, you see it immediately.
License
MIT
