npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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-buddy

Or 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 turn

React

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' | null

Props

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.

Props reference

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.

Cast reference

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.

Egg reference

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 audio

The 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 y

Tracing

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:done

Special 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 GIF

svg, 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 matches

assets/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
                                │        │        │      │     └── pitch

manifest.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.html

The 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