ESP32 · 02-face
Architecture: Mochi Face Animation
Note: This document was generated by an AI agent.
Overview
The character's eyes are soft filled blobs rather than rounded rectangles, and its idle behaviour is a set of scenes: the blobs merge and part, the eyes rove around with a sense of depth, the whole shape bounces, it peeks behind itself, and it turns into a pair of analog watch dials. The look is named after mochi — soft rice cake — because the personality comes from the squish, not from any particular pose.
Three pieces produce it, all driven from the same seven FaceState eye fields: a drawing routine that shapes one eye (and a variant that turns it into a clock face) — every expression uses it; a composition step that adds the depth turn per eye; and a keyframe engine that says where a scene is at a given time, feeding both the idle mochi loop and the clock-eyes mode.
The blob shape applies to all twenty expressions. The scenes are idle-only. Neither depends on the other.
Why a rounded rectangle was already most of the way there
The eye-drawing routine has always drawn a filled shape — one rounded rectangle. What made it read as a rectangle was a modest corner radius: 8px on a 30x30 box.
Adafruit_GFX builds fillRoundRect from a centre rectangle plus quarter-circle corner arcs, so at r = min(w,h)/2 it degenerates into exactly a stadium, and into a circle when w == h. The whole softness change is therefore one line — stop taking the radius from a field and always use the full one.
This matters for a 1-bit 128x64 panel: there is no anti-aliasing, no bezier primitive and no spare CPU for a curve rasteriser. The soft look had to come from a primitive the library already draws well.
The freed field
With the corner radius no longer per-expression, FaceState's eyeR had nothing left to do, so it was renamed to eyePinch and given a new job: waisting the blob into an hourglass. Renaming rather than deleting keeps the struct layout stable, so the float-array tween and its host-test guards needed no changes — and the pinch interpolates for free.
When eyePinch > 0, the eye routine switches from one fillRoundRect call to two round end caps with a per-column profile between them, the same scanline idiom the mouth routine and the lidArc squint carve already use. The profile divides by span - 1, so it reaches +1 on its final column and meets both caps flush; dividing by span leaves a one-pixel notch against the right cap.
The three motion axes
Travel — eyeX / eyeY
Saccades used to be horizontal only: one scalar applied to eyeX, ±11px at most. Gaze scheduling now picks a target on both axes, and the ranges widened (±16 horizontal, ±7 vertical under the look expression), so the eyes wander rather than sweep. Brow drawing follows eyeY at half weight — full weight reads as the whole face sliding rather than the eyes moving within it.
Depth — eyeTurn
One float, -1 (turned left) to +1 (turned right). A head turning is read
almost entirely from two cues, and both are cheap:
- Foreshortening — the far eye narrows toward the turn
- Parallax — both eyes shift toward the near side
eyeW/eyeH are shared by both eyes (only lidL/lidR are per-eye), so composing the frame builds a per-eye copy of the pose rather than widening the eye routine's signature. That keeps the drawing unit a pure function of what it is handed, per the architecture rules.
Squash — squashStretch()
The four places that animated eye size before all scaled uniformly — both eyeW and eyeH growing by the same amount — which reads as the eye growing, not being compressed. squashStretch() counter-scales instead: +1 squashes flat and wide, -1 stretches tall and thin. It lives beside the other pure pose helpers, in a module that is free of the Arduino API and therefore host-testable.
The hiccup expression now drives it from the existing damped-oscillator impulse, turning a uniform swell into a real jolt.
The one number the merge turns on
The animation engine draws the eyes mirrored around their centres, so with the
left and right centre constants set 48 pixels apart, they sit
48 + 2*eyeSpread pixels apart. Against the default eyeW of 30:
eyeSpread | Centre distance | Reads as |
|---|---|---|
0 | 48 | two separate blobs |
-4 | 40 | two close blobs, a 10px gap |
-9 | 30 | circles exactly touching — a figure-8 |
-13 | 22 | overlapped into one lozenge |
This is the load-bearing insight and it is not obvious from the code: two touching circles already are an hourglass. No compositing, no union operator, no second drawing path — just two overlapping filled shapes on a monochrome buffer.
Scenes are data
Composing the frame already carries around eight special-case branches, and adding an expression costs four files. That does not scale to "many distinct scenes", so scenes are tables rather than code:
Each scene is a named list of keyframes; every keyframe is a full eye pose (the same seven fields as before) plus a hold time — how long to ease from this key to the next.
The pose sampler walks the table given a scene, elapsed time and speed. Adding a scene means adding a table and one line to the registry; nothing in the drawing or controller layers learns about it. The four shipped are merge, lookAround, bounce and peek, and ambient mode advances to the next one each time it selects the mochi scene.
Two authoring rules, both enforced by the host test suite:
- The last key must repeat the first, or the loop pops on wrap.
- Vary the dwells. Equal beats are what made the first version read as mechanical; character animation holds a pose then moves quickly between them. Per-key holdMs is the whole reason the table replaced a global beat length.
A speed setting scales every dwell together, so a scene's internal timing keeps its shape at any speed.
Why the waist releases before the blobs separate
In the merge scene, keys 3 and 4 look redundant — same spread, same size, only the pinch differs. They are not. Releasing the waist while separating draws each blob independently pinched as they pull apart, which reads as two small hourglasses drifting away rather than one shape relaxing. The extra key holds them merged until the waist is gone. A test caught this, not the eye: it checks that whenever the eyes are pinched, their centres stay closer together than the eye's own width.
Phase wraps, it does not clamp
The one-shot timelines elsewhere in the face code settle on a resting value past their end, because a held pose must stay valid indefinitely. A scene is a loop, so past the end means back to the start. It still cannot run away — the property that convention protects.
Timing: one walker, four tables
The pose sampler is the whole engine, and it does four things: it scales the scene's total cycle length by the speed setting, wraps elapsed time into that cycle rather than clamping it, walks forward through keys until it finds the current segment, then eases the position within that segment.
The cycle length excludes the last key's dwell. The final key is a landing point, not a departure — and by convention it repeats the first key, so the loop closes. Including its holdMs would add a pause at the seam.
Phase wraps rather than clamping, and the header explains the distinction against the one-shot timeline convention: a one-shot timeline settles on a resting value past its end, because a held pose must stay valid indefinitely. A scene is a loop, so past the end means back to the start. Either way it cannot run away, which is the property the convention protects.
Speed scales every dwell identically, both the total and each segment:
total = Σ holdMs × speed / 100 and dwell = holdMs × speed / 100
Because the same factor divides out of phase / dwell, the shape of the
motion is untouched — only the clock changes. A speed setting of 200 is half
speed for a sleepier character, 60 is livelier, and neither redistributes time
between the keys.
The four scenes
| Scene | Keys | Cycle at 100% | at 200% | at 60% |
|---|---|---|---|---|
| Mochi merge | 6 | 3,500 ms | 7,000 ms | 2,100 ms |
| Mochi look around | 7 | 3,800 ms | 7,600 ms | 2,280 ms |
| Mochi bounce | 6 | 1,660 ms | 3,320 ms | 996 ms |
| Mochi peek | 7 | 4,260 ms | 8,520 ms | 2,556 ms |
Twenty-six keyframes in total describe every idle animation the character has.
The ambient cycle
The ambient scheduler holds a scene for 30,000 ms before choosing another, and the controller only enters ambient after 60,000 ms without touch. So one mochi slot plays its scene:
30,000 ms / 3,500 ms = 8.6 loops of Mochi merge
30,000 ms / 1,660 ms = 18.1 loops of Mochi bounce
The partial loop at the end is not a problem, because the scene is abandoned mid-phase rather than being run to completion — and since every scene closes its loop, any phase is a valid pose to leave from.
Shuffling reseeds from the device clock at each idle session, so the order differs between sessions but never repeats a first scene back to back. Scenes needing live data (clock, weather, clock-eyes) are excluded from the shuffle until that data is valid; the mochi scene needs nothing and is always available.
Clock eyes
Rendering one eye as a watch dial draws a circular bezel at r=12, twelve
ticks via the same i * (PI/6) idiom used elsewhere for icon rays, and hour
and minute hands from the local time.
It is drawn at the composed centre — eyeX + faceX, eyeY + faceY — so the dials inherit gaze and the breathing bob for free, the same way the spiral-eye effect does.
The dial never invents a time. The clock-eyes scene is gated on having valid time exactly as the ordinary clock scene is, so with no NTP sync the scene is skipped rather than shown wrong; a validity flag on the dial parks the hands at twelve as a second line of defence. This is the project's honesty rule — a plausible-looking wrong clock is worse than no clock.
Deliberate exception
The dizzy expression keeps its stroked spiral. The spiral-eye effect walks an outward spiral with individual line segments and bypasses the normal eye routine entirely. Filling a self-intersecting curve needs a rasteriser with no precedent here, and the spiral is the point of the expression. The exception is commented at the branch in the animation engine.
The surprised expression changed most visibly when the blob shape landed. At eyeW == eyeH == 34 it used to carry a 14px radius and rendered noticeably boxier than a circle; it is now a full circle.
An earlier version of this document claimed a "comma/spiral twist" beat from the reference video was deliberately out of scope. That was wrong: the frame in question was the analog clock face, now implemented above. The claim came from reading eight sparse stills and is recorded here so the error is not repeated.
Extending it
To add a scene, add a table and an entry to the scene registry. Do not add another special-case branch to frame composition — that is the growth point the table system exists to avoid. The host test suite checks every registered scene automatically for wrap, bounds and pinch confinement, so a new table is covered the moment it is listed.
To give an ordinary expression a waist or a turn, set eyePinch or eyeTurn in its preset definition; both tween like every other field. Keep eyePinch below eyeH / 2 or the waist erases the middle of the blob.
To add another data-dependent ambient scene, follow the pattern the clock-eyes scene set: add the new scene before the count marker, extend the ordering and availability logic, route it in the controller, and cover the gating with a host test.
Failure modes
| Symptom | Cause |
|---|---|
| The loop pauses at the seam | The last key's holdMs is being included in the cycle length |
| The scene jumps at the wrap | The last key no longer repeats the first |
| Changing the speed changes the motion's character | Something scales the total but not each dwell, or vice versa |
| The eyes never merge | eyeSpread not reaching −9 against the current eyeW; the threshold moves with eye size |
| Merged eyes show a seam | Two shapes are being drawn with different fills, or one is being erased |
| Ambient never starts | Ambient is disabled in the configuration, or something keeps resetting the idle timer |
| Ambient always shows the same scene first | The shuffle was reseeded from a constant instead of the clock |
| Clock or weather scenes never appear | Their data is not valid; they are excluded from the shuffle until it is |
| A scene runs away past its end | Phase clamped instead of wrapped |
Configuration reference
| Setting | Default | Meaning |
|---|---|---|
| Ambient enabled | on | Whether idle scenes run at all |
| Idle time before ambient | 60,000 ms | Untouched time before ambient begins |
| Scene hold time | 30,000 ms | How long one scene is held |
| Mochi speed | 100% | Scales every dwell; 200% is half speed, 60% is livelier |
The eye geometry the merge depends on lives with the model rather than the settings, because it's geometry rather than a preference: the left eye centre sits at x = 40, the right at x = 88, and both sit at y = 26.