Drop in an SVG. It gets eyes, a mouth, 25 expressions and 40 moods — then leaves as a React component. Everything runs in the browser; nothing is uploaded anywhere.
The output is one self-contained .tsx file with React as its only dependency:
import RoboAvatar from './RoboAvatar'
<RoboAvatar state="thinking" size={160} />Set state and it animates itself — picks that state's resting face, drifts through its
expression pool on its own cadence, and blinks on its own rhythm.
npm install
npm run dev # http://localhost:5173
npm run build # static files in dist/ — deploy anywhere
npm test # geometry, solver, importer, persistence
npm run check # engine freshness + lint + tests + build, same as CIsrc/engine/faceEngine.tsx is generated but committed, so build never regenerates it —
a clean clone builds with nothing else present. Regeneration is a separate, manual step
(see below) that needs the original lab.
- Sanitize. This renders stranger-supplied markup inline, so
<script>,on*handlers,<foreignObject>,<image>and externalhrefs are stripped, and every id is namespaced (a user'sid="grad"would otherwise collide with the engine's defs). - Measure.
getBBox()on a briefly-mounted copy gives the artwork's real drawn bounds, which are mapped into the engine's 228.541-unit face box. The viewBox is only a fallback: artwork rarely fills its own canvas, and using it would leave the mascot small and off-centre. - Fit. Rasterise to a 256² alpha mask, build an exact signed distance field, seed the anchor at the largest inscribed circle, then search anchor × scale until every one of the 25 expressions — eye outlines and mouth curve — sits inside the silhouette.
- Report. Because the solver knows exactly which expressions fail, the UI can say "3 of 25 expressions clip" and offer a fix, instead of letting you find out later.
src/engine/faceEngine.tsx the animation engine — GENERATED, and the export payload
src/svg/import.ts parse, sanitize, namespace, measure
src/fit/sdf.ts rasterise → signed distance field
src/fit/solve.ts anchor/scale search + clipping report
src/state/project.ts what persists between visits, and the .blobstudio.json file
src/export/generate.ts assemble the downloadable .tsx
src/export/snapshot.ts Photo Mode — the live frame, serialized
src/shapes/builtin.ts nine parametric starting shapes
src/ui/ dropzone, shape, head, fit, colour, expressions, your states,
photo, export, project
scripts/gen-engine.cjs regenerates the engine from the GrokBot lab
scripts/engine-effects.part.tsx confetti, comets, glyphs
scripts/engine-life.part.tsx micro-saccades
scripts/engine-sequence.part.tsx custom-state playback
npm run gen:engine:check regenerates into memory and compares against the committed
engine without writing. It runs first in npm run check, because a stale engine is
otherwise silent: the preview and the export both come from it, so they agree with each
other while disagreeing with the lab they claim to be generated from.
It is a local check only, and CI deliberately skips it. The working copy of the lab
carries edits that are not in the published gist, so a fresh clone does not reproduce the
committed engine — meaning the true source of the engine currently exists on one machine.
Worth keeping a backup of ../gist/index.html, or pushing those edits to the gist, before
that matters.
Your shape, fit, gaze, colours and toggles autosave to this browser as you work, so a reload lands you back where you were. The distance field is not stored — it is a megabyte of derived data, and the solver rebuilds it inside a frame. An upload keeps its original file text rather than the sanitized result, so restoring replays the whole import path and markup that has been sitting in localStorage gets re-sanitized rather than trusted because it was clean once. Reads are defensive per field: a corrupted gaze costs you a gaze, not the artwork you spent ten minutes fitting.
One consequence worth naming: the Effects, Glyphs and Life toggles persist too. They used to reset on every reload, so a stray click healed itself; now it follows you. Since 17 of the 40 states draw something extra — and body motion at zero suppresses effects as well, because a burst thrown from a still body reads as debris — the stage says when a switch is hiding something, and offers to undo it.
Download project writes the same object as <name>.blobstudio.json. That is the backup —
clearing site data takes the stored copy with it — and the way a mascot moves between
machines. An SVG too large for the storage quota is dropped from the autosave rather than
failing it, and the panel says so.
The 40 built-in states pick from a pool at random on a cadence, which is what makes a mood
look unscripted. It is exactly wrong for a scripted beat — "look up, pause, nod" — where the
order is the content, and it is the only thing on offer if your app needs a syncing state
the lab never authored.
Your states is the other tool: ordered steps, each with its own hold, morph time and
transition feel (smooth, spring, snappy), played loop, once or pingPong, with its
own blink cadence and optionally another state's body motion borrowed by name.
They travel with the export. Each becomes an entry in SEQUENCES, keyed by a slug of its
name, and the recipient plays one by name:
<RoboAvatar sequenceName="greeting" size={160} />Steps reference an expression by index, because that is the only identity the expressions have — the 25 outlines are generated into the engine as an ordered table, not authored in the studio. A step pointing past the end of that table is dropped on load rather than wrapped to a face nobody chose.
Ping-pong turns around at the ends without repeating them, so a three-step state reads 0,1,2,1,0 — repeating an endpoint holds it for double time and reads as a stumble.
Between expression changes the eyes used to be perfectly motionless, which is uncanny in a way that is hard to name and easy to feel. Life adds micro-saccades: small ballistic jumps a few times a second — fast in, slow out — with a little drift while held.
Three properties it has to have, and does:
- Eyes only.
gazemoves the whole face, because a deliberate look turns the head with it. A saccade is the eyes alone moving inside a face that stays put. - Desynchronised. Seeded per component instance, not per expression, because the state grid puts forty mascots on screen and forty eyes twitching on the same frame reads as the page stuttering.
- Paid for in the fit. The solver widens every eye point's required clearance by the saccade radius, so the clipping report stays true. This costs face size on tight silhouettes — that is the honest price of live eyes, and turning Life off refits larger.
Scaled by motion, so motion={0} and a reduced-motion preference still hold perfectly
still.
The library export renders stills from geometry, which is right for a catalogue: with no turn, gaze or blink an expression collapses to two eye rings and a mouth. It is the wrong tool for "grab this exact instant", because everything worth catching mid-animation — a comet halfway round its ring, a landing squash, confetti in flight — lives in attributes the frame loop writes imperatively.
So Photo serializes the live <svg> out of the DOM instead, at 256/512/1024 as SVG or
PNG, over a transparent, solid, linear or radial background. Hold the frame freezes the
stage so you can catch a specific instant rather than gambling on the click.
The engine is imported normally for the live preview and via Vite's ?raw for the export
template, so the preview and the downloaded file cannot drift apart.
Expression rings, the mouth table, state pools and cadences all come from the original
GrokBot lab at ../gist/index.html. After changing it:
npm run gen:engineThe generator also does two things worth knowing about.
Gaze is split from shape. Every expression originally baked its look-direction into
absolute eye positions — expression 00, the resting face for idle and 13 other states, sat
+41 right and −61 up from centre, so a mascot at rest permanently glanced up-and-right. Each
expression is now centred, with its offset kept in GAZE and re-applied at render time as
GAZE[i] × lookAround (default 0.35). Side effect: the face's footprint drops from 194×171
to 136×111, so it fits roughly 1.4× larger on any silhouette.
Resting faces point forward. A state shows its pool's first expression at rest, and that
ordering wasn't chosen with resting in mind. Each expression is scored on how forward-facing
it is — level pair, matched sizes, matched tilt, upright — and each state now rests on its
most forward-facing member. Cycle order is otherwise untouched, so nothing loses character:
angry still rests on a scowl, because every face in its pool is a scowl. The seven states
sharing the generic [0,8] pool also gain expression 6, which is symmetric, upright, and
was used by no state at all.
Two different things aim the eyes, and they compose:
lookAroundis per-expression drift — how much of each expression's own baked-in look-direction to apply. It varies as expressions cycle.gazeis a constant aim on top of that, each axis −1…1. The studio's pad sets it; it defaults slightly right, because dead-centre eyes read as a stare rather than as attention.
The fit solver accounts for gaze, not just lookAround. Aiming the eyes at an edge moves
every point of every face toward it, so the clipping report has to include it or it would
claim a fit that anyone can see is wrong.
The face engine on its own leaves the silhouette perfectly still, which reads as dead for
states literally named bouncing or spawning. MOTION gives every state its own body
behaviour — bob, sway, pulse, orbital drift, jitter, tilt, squash, plus one-shot entrances
and exits.
It is all shape-agnostic, so an uploaded logo animates exactly like the built-in circle:
bouncingtravels vertically and squashes on landing, pivoting on the ground rather than the middle, so it lands instead of floatingspawningpops in from a dot with an overshoot;powering-downshrinks away and holdsalertingandscaredjitter on two incommensurate waves, so they read as nervous rather than metronomicidlejust breathes, ±1.4%
The motion prop scales all of it (0 holds the body still). It defaults to off when the
viewer's OS asks for reduced motion.
Three more layers, all shape-agnostic, all driven imperatively from the frame loop rather than React state — a celebrate burst is 16 elements changing every frame, and a page showing every state at once would otherwise re-render continuously.
Confetti (celebrate, excited, laughing) launches from a ring at the body's rim
rather than from its centre, so a burst frames the face instead of covering it. Travel is
deliberately bounded: the viewBox carries only 15 units of margin, so a piece thrown much
past ~130 from centre would be clipped mid-flight.
Comets (orbit, radar, progress, loading, uploading) circle the body on rings
that are tipped and rolled in 3D. Each is a filled outline rather than a stroke, because SVG
cannot taper a stroke and taper is most of what separates a comet from a hoop: a round head
thinning to a tail, with a gradient running its length. Every ring is cut at the horizon, so
a comet passes behind the mascot and comes back out in front — the near half draws over the
body, the far half under it. These states also sit the body back to about three quarters
size, which is what gives the rings room to clear the silhouette instead of scribbling
across its face.
Comet dash (sending, receiving) balls the mascot up into a dot and throws it along a
figure-eight with three tails strung out behind, mirrored between the two.
Thinking dots (thinking-dots) breaks the body apart into the three-dot loading
indicator every chat app has. The dots are not a separate picture that replaces the mascot —
the body shrinks toward the middle one while the outer two slide out from behind it, so what
you read is one thing dividing. The body runs on a shorter clock than the row, so it has
gone by the time the outer dots are still travelling. Run the state's clock back down to
zero and it plays in reverse: the row gathers and the body reforms.
Pops (spawning, powering-down) shed a few small copies of the body as it collapses to
a point or springs out of one.
Badge (notifying) lands an unread dot on the mascot's shoulder — it keeps its face,
because it is telling you about something rather than becoming it.
Glyphs (alerting, notifying, confused) replace the mascot with a symbol — ! or
? — painted in the body's own gradient, scaling up as the body fades out so the swap reads
as a transformation rather than a cut. The glyph rides the body's motion, so an alerting
mascot's ! jitters too.
effects and glyphs props turn each off. Both are baked into exports.
turn rotates the head in degrees. The eyes and mouth travel around the implied sphere,
foreshortening as they approach the limb and disappearing once they pass it, which is what
makes a spin read as a face on a ball rather than a face sliding sideways. spin() on the
imperative handle runs a full revolution.
eyeScale takes a number for both eyes, or { left: [w, h], right: [w, h] } to size each on
its own. Which path is the left eye is decided by where it sits, not by which was authored
first.
- Shape — nine generated starting shapes, each with its own parameters, because the meaningful knobs differ: a cone has a tip and a base to round, a polygon has sides and corners, a circle has neither. Changing one re-imports and re-solves, debounced; the solver finishes inside a frame.
- Scrubbable numbers — drag a label to change its value, shift for fine, arrows to nudge, or type. A slider spends its width encoding a range, which a panel of a dozen values cannot afford.
- Expressions — all 25 as rendered faces rather than numbered chips, drawn from the live shape and colours so they stay honest, with any that clip ringed in red.
- Dark stage — a mascot reads differently on light and dark, and both ship.
Download video records the mascot playing every state in order — lifecycle, reactions, agent morphs, product cycle — a second each, on a black or white background.
It is recorded rather than rendered. A canvas capture stream feeding MediaRecorder is the
only way to get a video out of a browser without uploading anything or taking on an encoder
dependency, and both of those are things this tool does not do. The consequence is that
recording happens in real time: a forty-state reel takes forty seconds to make because it
takes forty seconds to watch.
The mascot being filmed is a second copy mounted off-stage at 1080², not the preview, so the
video is a fixed square whatever size the page happens to be. It is positioned off-screen
rather than hidden, because display: none and visibility: hidden both stop an element
rendering, and something that is not rendering cannot be rasterised.
Container depends on the browser: Chrome and Edge record MP4, Firefox WebM. Each frame is
serialised and decoded, so a slow machine drops frames rather than stretching time —
MediaRecorder timestamps what it actually receives.
The component on its own only helps a React project. Download library (.zip) packages the mascot so it drops into anything:
<Name>Mascot/
<Name>Avatar.tsx the animated component — React its only dependency
svg/states/*.svg 40 still frames, one per state
svg/expressions/*.svg 25 still frames, one per expression
svg/sprite.svg every state as a <symbol>, for plain HTML
png/*.png optional, 256/512/1024, for anywhere vectors won't go
manifest.json states, groups, and which expression each rests on
README.md usage for React and non-React alike
The still frames are generated from the same geometry the live component uses, not
screenshotted: with no turn, gaze or blink the eye transform collapses to identity, so a
frame is just its two eye rings plus the mouth derived from them. Every file is
self-contained, so <img src="svg/states/thinking.svg"> works with no other assets.
The archive is written by a small store-only ZIP writer (src/export/zip.ts) rather than a
zip dependency — it is about a hundred lines and keeps the project at zero runtime deps.
- Clip regions come from filled shapes. Outline-only artwork (everything
fill="none") has no area to clip against, and the importer says so. <use>references and CSS<style>blocks are dropped rather than resolved.- Recolouring with a gradient flattens multi-colour artwork to one ramp. Turn it off to keep the original fills.
The face engine — the 25 expression outlines, the state pools and their cadences, the spring morph and the spherical eye projection — is derived from Sébastien Montlouis-Calixte's GrokBot reverse-engineering prototype:
https://gist.github.com/smontlouis/49a4c9303de70118a90dc43badc1aba5
That gist carries no explicit licence, so treat the expression data as the author's work.
Added on top of it here: the mouth system, the gaze/shape split that makes a resting mascot look forward, the distance-field fitting solver, the SVG importer and sanitizer, the component exporter, three-axis head orientation, the comet and thinking-dots effects, and the parametric starting shapes.