A soft-body companion character, and the engine that draws her.
She is an ovoid whose widest point sits at 0.295 of her height, formed from superellipse exponents 1.86 above the waist and 2.58 below. She squashes without changing area, leans by a shear pinned at the point where she meets the surface, breathes on a one-sided curve and settles on an underdamped spring. Her face rides on her at a grip of 0.82 — following the body without looking printed on.
Every image in this file was rendered by the package itself, from MOCHI. There
is no second drawing of her anywhere in this repository for the code to drift
away from.
Zero runtime dependencies.
npm install mochi-avatar<script type="module">
import 'mochi-avatar/element'
</script>
<dough-avatar emotion="happy" style="width: 200px; height: 200px"></dough-avatar>That is the whole integration. The element makes its own canvas, sizes it at device resolution, runs the frame loop, and stops when it leaves the document.
Attributes: emotion (one of the eight below), size (a percentage or
fit-canvas), face (a FaceSpec as JSON). Removing an attribute resets it.
import { DoughAvatar } from 'mochi-avatar'
const avatar = new DoughAvatar(canvas.getContext('2d'), { size: 'fit-canvas' }) // Mochi by default
avatar.resize(300, 300, devicePixelRatio)
avatar.setEmotion({ emotion: 'happy', intensity: 1 })
const tick = (now) => {
avatar.render(now)
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)That image is not a loop somebody animated. It is the engine, running, with no
input at all — because an idle character who holds perfectly still reads as a
crashed one. She is breathing, and blinking about once every seven seconds —
which is her own schedule, not a decision made in the picture. What is turned
off there is the sway: setDrift(false), because a still on a page should hold
its frame.
| Breath | 3400ms, one-sided. Two half raised-cosines meeting at zero slope, 1:1.5 in to out — inspiration is muscular, expiration is elastic recoil. It only ever spreads her; her resting silhouette is a floor she returns to, never a midpoint. |
| Blink | 130ms, 35% closing and 65% opening, because a real lid shuts faster than it opens. Gaps come from a clamped exponential — blinking is a Poisson process, and a uniform gap reads as a metronome within about thirty seconds. |
| Drift | Three mutually incommensurate sines per channel, so the pattern never visibly repeats. About 2.6px of sway on a 94px body: somebody shifting their weight, not somebody pacing. |
All of it is a pure function of the clock — no timers, no random walk, no state that a throttled tab can desynchronise. Ask it where she is at time t and it answers.
avatar.setEmotion({ emotion: 'happy', intensity: 1 })
avatar.playMotion('hop') // nod · sway · hop · swing · turn · wander
avatar.lookAt(0.4, -0.2) // she follows a point; the far side wraps out of sight
avatar.poke() // squash, then settle on the spring
avatar.setAsleep(true) // she keeps breathing; stopping entirely reads as a crashavatar.setSpeaking(true)
avatar.setMouthOpen(0.8) // drive this from your audio, per frame
avatar.setSpeaking(false)EnvelopeMouth in core/mouth will do the driving for you from an audio
envelope — advanceEnvelope turns a Float32Array of samples into a mouth
opening with attack and release, so speech does not chatter on every zero
crossing.
Two honest limits: setVisemes exists on the backend interface but this rig
does not implement it — the mouth is one lens with an opening, not a phoneme
shape, and caps.visemes says so rather than pretending. And the custom element
wires emotion, size and face only; anything above needs the JS object.
avatar.setReducedMotion(true) // stops the idle motion, keeps one-shot replies
avatar.setDrift(false) // stops only the sway; she goes on breathingsetReducedMotion is the accessibility preference, and it stops the breath
along with everything else. setDrift is the weaker, orthogonal one: use it
when she has to hold a fixed frame but should still look alive — which is
exactly what the image at the top of this page is doing. A companion who
answers nothing is a picture, not a quieter companion — the preference asks for
less movement, not for no feedback.
neutral · happy · shy · sad · angry · surprised · thinking · sleepy
Each is a set of multipliers on the neutral geometry rather than separate artwork, so one silhouette carries all eight. Sleepy has no mouth on purpose — a mouth left on a sleeping face reads as awake-but-quiet.
matcha · sakura · kinako · yuzu · ramune · budo
import { mochiIn, MOCHI } from 'mochi-avatar'
const sakura = mochiIn('sakura', MOCHI)One geometry throughout — five colour fields swapped, nothing else. Which is the claim the picture is making: she is the shape, not the colour.
core is pure arithmetic. It returns points and numbers, and imports no canvas,
no document and no platform — useful for hit-testing, your own renderer, or
working out where to put something.
import { domeOutline, squashed, widthAt } from 'mochi-avatar'
const shape = {
halfWidth: 50,
height: 78,
waist: 0.295,
upperShoulder: 1.86,
lowerShoulder: 2.58,
lean: 0,
}
domeOutline(shape) // the closed outline, as points
widthAt(shape, 0.5) // half-width at half height, 0..1
squashed(shape, 0.2) // area-preserving| Target | How |
|---|---|
| Browser | canvas.getContext('2d') |
| Worker | OffscreenCanvas |
| Node | @napi-rs/canvas or node-canvas |
| No canvas at all | core only, or the SVG emitter |
CanvasRenderingContext2D is used structurally, so no adapter is needed. The
conformance test has been rendering her under Node this whole time.
A face is data, not code — see FaceSpec. A design is a JSON file, and the
format bounds every field so a bad one is refused with a reason instead of
rendering something wrong in a way nothing mentions.
import { DoughAvatar, PLAIN } from 'mochi-avatar'
new DoughAvatar(ctx, { face: { ...PLAIN, waist: 0.5, colBody: '#c88e9d' }, size: 'fit-canvas' })PLAIN is a deliberately generic body — a near-symmetric egg, one flat tone, no
blush — built to be visibly a different creature from her along the axes that
identify her. It exists to be started from.
Give no face at all and you get Mochi, since that is whose package this is.
examples/codex-pet/ exports her as a sprite atlas — 74
frames, no canvas and no frame loop, because the core is pure functions and does
not need either. Six colourways are prebuilt, so installing one is a copy
into ~/.codex/pets/.
It is also the closest thing to a fidelity test this package has. Re-pointed at the published build, 73 of its 74 frames came out pixel-identical to an atlas produced against the original application's source tree — and the six flavours have pixel-identical alpha channels, which is the "same shape, five fields swapped" claim measured rather than asserted.
Two licences, split by directory. The engine is MIT. Mochi
herself — src/characters/ — is not: use her as she is, in anything including
what you sell, but don't rename her, sell her as the goods, or make her your
brand. The full terms are short and in LICENSE.md; the reasoning
is in BRAND.md.
If you want a character of your own, the engine is all you need.
Mochi began as a realtime-voice AI companion that lived on your macOS desktop. That app is no longer developed.
- Downloads still work. Every release remains available, including v0.1.20 with signed and notarized builds for Apple silicon and Intel.
- The source is preserved on the
archive/appbranch, exactly as it was at v0.1.20. - No further releases will be published here, so installed copies will go on reporting themselves up to date rather than trying to update into a package.
The character outlived the application, which is the usual way round.


