Building the Official Blobatar Flutter SDK, From Bit-Exact Seeds to Deterministic Motion

The Brief
Blobatar already had the hard part: a pure TypeScript engine that turns any string into a deterministic geometric avatar. Normalize the name, hash it once, derive every trait from that state, resolve an OKLCh palette with authored lightness/chroma swatches, and lay out one of ten gen-2 silhouettes inside a 100-by-100 frame. What it did not have was a Flutter story, and I needed one for a manga reader app I am building.
The constraint that made this interesting was not "make it look the same." It was the library's own contract: same name + same options → same avatar, forever, within the frozen generation-2 mapping. The bands, the numeric ranges, and the tone set are part of that contract; changing any of them is a new major. A port that approximates the mapping is not a port, it is a lookalike.
There was also a coordination wrinkle worth recording. The upstream issue thread about a Dart port had grown three interested parties, including another contributor who had already shipped a port with its own parity suite. The maintainer's ruling was the right one: adapters consume the core, but a Dart port reimplements it, so it needs an explicit boundary — and what the ecosystem really needs is one reference artifact, so every port shares one definition of correct.
I built the SDK in my fork at packages/flutter and kept the work phase-gated: coordination, deterministic core, static Canvas widget, expressions, elapsed-time animation, then package quality and upstream review. The maintainer later approved the official SDK direction and the blobatar package name. The finished branch is now upstream PR #33; publication remains deliberately separate.
What I Built
The package has two libraries and one contract.
The core (lib/blobatar.dart) is a pure Dart port of the generation-2 engine: normalization, UTF-8 hashing with exact JavaScript uint32 semantics, the keyed trait stream, clamped trait overrides, the OKLCh pipeline (gamut reduction, contrast enforcement, tinting), the path primitives, all ten silhouettes, fourteen expression poses, and deterministic elapsed-time motion. It has zero Flutter imports — source that imports the core never pulls Flutter types into its own API.
The widget layer (lib/flutter.dart) is opt-in: a BlobatarRenderer that resolves a seed once and paints through dart:ui, a CustomPainter with value-based shouldRepaint, and a small Blobatar widget that owns sizing, a RepaintBoundary, and Semantics. Phase 4 added AnimatedBlobatarRenderer and AnimatedBlobatar; they cache the same geometry, evaluate motion from elapsed time, and repaint transforms and colors without regenerating paths.
The fixture pipeline (tools/export-reference-vectors.ts) runs the TypeScript implementation at a pinned v2.4.0 checkout and writes test/fixtures/reference-vectors.json. The Dart side reads it read-only. It holds 1,570 layout cases (at least 25 per silhouette band, the rarest band dictating the scan length), 31 hash vectors, 9 override vectors, 112 palette vectors across the tone edges, and 42 expression cases.
The studio (example/) became the integration surface for the finished SDK: a live seed preview, silhouette and expression bottom sheets, hue and backdrop pickers, hover/always animation controls, held-expression demos, a named 3-by-4 gallery, and locked Claude/Codex seed easter eggs ported from the web example.
Two rules make the fixture trustworthy, and both are written into its meta:
- The vectors are generated from the TypeScript implementation, never from the port's own output. A vector recorded from the thing under test proves nothing.
- Every comparison rule is explicit: hash states, trait streams, palette hex, and path data compare exactly; trig-derived layout floats compare under a documented 1e-9 relative tolerance, because
dart:math's trig calls the host C library and IEEE 754 does not mandate one bit-exact implementation.
The test suites are split the same way the libraries are: test/dart/ runs under plain dart test, test/flutter/ under flutter test, and the studio has its own widget tests. At the final gate that was 72 core tests, 28 Flutter renderer/widget tests, and 4 example tests.
Problem 1: JavaScript uint32, Dart int64
The hash is a murmur3-style feed and finalizer over UTF-8 bytes, and its every operation is 32-bit: Math.imul, (h << 13) | (h >>> 19), a finalizer that returns >>> 0. Dart's int is 64-bit two's complement, so every one of those needs deliberate masking, and Math.imul in particular cannot be (a * b) & 0xFFFFFFFF — that wraps fine on the Dart VM but silently loses precision when compiled to JavaScript, where ints are doubles. So I decomposed the multiply into 16-bit halves:
final int xl = x & 0xFFFF, xh = x >> 16;
final int low =
(xl * yl + (((xl * yh + xh * yl) & 0xFFFF) << 16)) & 0xFFFFFFFF;The version I wrote first looked almost identical and was completely wrong:
final int low = (xl * yl + ((xl * yh + xh * yl) & 0xFFFF) << 16) & 0xFFFFFFFF;In Dart, + binds tighter than <<. That line shifted the entire sum left by 16 bits, not just the high-half contribution. The hash still produced numbers in range, seeds still produced stable avatars — everything looked healthy while being wrong from the first byte. The reference vectors caught it immediately: the very first empty-seed state mismatched. Side-by-side with Node made it obvious:
The fix is two parentheses. A follow-up review pass ran randomized differential tests against Node — hundreds of thousands of Math.imul pairs and generated seed-to-stream triples — and found no further deviation.
Lesson: a port's failure mode is not a crash, it is a plausible-looking wrong number. The fixture is not a nice-to-have; it is the only thing standing between "runs" and "matches."
Problem 2: The seed contract includes lowercase
I assumed the normalization port would be one line: nfc(seed).trim().toLowerCase(). Two of those three calls hide contract decisions.
NFC. Dart has no built-in Unicode normalization, so the unorm_dart package supplies it. This is the paste-a-name case the maintainer had flagged: precomposed é and decomposed e + combining accent must hash equally or the same person gets two avatars depending on how their name was typed.
Lowercase. Dart's String.toLowerCase implements the Unicode simple mapping; JavaScript implements the full mapping. They differ in two places, and the fixture caught the first one immediately: İ (U+0130) lowercases in JS to i plus a combining dot, not to bare i. The second was subtler and my first fix was wrong: capital sigma becomes the word-final form ς only when it is preceded by a cased letter and no cased letter follows. My first implementation only checked the "nothing cased follows" half, so an isolated Σ became ς where JS keeps σ. After the fix, the behavior matches: ΣΣ → σς, ΟΣ → ος, bare Σ → σ. I then added seven sigma seeds to the exporter so the fixture pins exactly the cases that bit me.
One deviation remains and it is documented rather than hidden: Dart's Unicode data predates a few modern case pairs, so Georgian Mtavruli U+1C90 stays uppercase here where JS maps it to U+10D0. There is a test asserting the current behavior with a comment saying it should flip if Dart's data ever catches up. An unavoidable deviation needs a test and a paragraph, not a shrug.
Problem 3: One definition of correct, written down
The exporter deserves its own section because the fixture is the load-bearing artifact. It iterates a fixed corpus — empty and whitespace seeds, mixed case, CJK, emoji with shared surrogates, Arabic, flags, İ, ß, and the sigma family — and for layout it scans seeds until every band meets its quota. The schema is self-describing: version, generation, the git SHA of the export, per-shape case counts, and the comparison rules. Version skew or a truncated export is visible by reading meta, not by debugging a red test.
The tolerance rule earned its place the honest way. The other contributor's port had already reported that dart:math's cos/sin differ by one ULP between Linux and macOS on some inputs, flipping 10 of 349 geometry cases. Bit-exact float equality across engines is simply not achievable in Dart, so the fixture says so up front: layout floats get 1e-9 relative tolerance, everything quantized (hex, path data after the core's two-decimal rounding, integers, shape names) is exact. When a test fails under those rules, it is a port bug, not a fixture negotiation.
Problem 4: String parity is not canvas parity
Phase 2 paints the core layout through dart:ui. The conversion, toUiPath, walks the core's structured segments — MoveTo, CubicTo, QuadTo, horizontal/vertical lines, Close — into a ui.Path, tracking the current point so H/V commands resolve. My first version updated the current point by calling the same helper that issues moveTo.
The result was a painter that drew plausible blobatars built from disconnected pieces. A superellipse is one closed contour; mine was four. The organic spline with six radii became six. Path.computeMetrics() — which returns one metric per contour — caught it immediately: a test expected one closed subpath and got six. Nothing in the string-level parity could ever have seen this, because the path data was perfect; only the conversion to canvas commands was wrong.
The raster tests caught a second, smaller issue: ImageDescriptor.raw expects raw RGBA bytes, not PNG, so my first attempt to decode a rendered PNG back into pixels failed with "Codec failed to produce an image." Comparing toByteData(format: rawRgba) directly is simpler and removes the codec from the equation entirely.
What the canvas layer now proves, deterministically:
- The same seed paints identical pixels; a fresh renderer after restart paints identical pixels; different seeds differ.
backdrop: noneleaves corners fully transparent;square/circle/squirclepaint their plates.- An interior body pixel — chosen by scanning for a point inside the drawn body but inside no eye — equals the fixture palette's head hex exactly.
- Every fixture case's converted paths stay inside the 100-by-100 frame.
Lesson: byte-exact serialization proves the data. It says nothing about the drawing. Canvas-level tests are a different gate, and the contour bug is exactly the class of failure they exist for.
Problem 5: An expression is not an enum
Phase 3 looked smaller than the core port: fourteen names, fourteen poses. The TypeScript implementation made the real scope clear. An expression can change horizontal and vertical eye scale, lean, offset, second-eye deltas, body offset, tint strength, tremor, and the held thinking loop. Treating happy or mad as a switch inside the painter would have duplicated the contract across static and animated rendering.
I modeled the contract as data instead. Pose has fourteen numeric channels. Expression pairs a name and pose with an optional Tint. The public values — idle, happy, sad, mad, surprised, wink, sleepy, smug, unsure, scared, love, shy, sick, and thinking — are ordinary constants, so callers pass BlobatarOptions(expression: happy) rather than relying on a string registry.
Two functions then define what those values mean:
bakePose(layout, pose)applies a pose to static geometry. The second eye receives only the additiveesx2,esy2,tilt2, andedy2channels, preserving expressions such as wink and thinking without a separate eye type.expressionPalette(palette, expression)resolves the optional tint against the palette the avatar is actually wearing, then blends byheat. A custom hue still gets a warmmadtint without bypassing the contrast pipeline.
That same Pose later flows through lerpPose for animation. Static and animated expressions therefore cannot quietly acquire different channel semantics.
The exporter added 42 expression cases to the pinned artifact. The Dart tests compare every pose channel exactly, compare baked geometry and tint against TypeScript, prove idle is identical to omitting an expression, sweep posed eyes for overlap, and check eye-to-head contrast after tinting.
Lesson: when a feature must work both frozen and in motion, model the state once. The renderer should consume the model; it should not become the model.
Problem 6: CSS keyframes do not port to Flutter
The browser implementation gets a lot for free from CSS: independent keyframes, negative delays, alternate directions, cubic Bézier easing, and a shared page clock. Replacing that with one AnimationController per effect would have been easy to write and wrong in a crowd. Every avatar mounted in the same build would breathe, blink, and glance together unless I recreated the seeded phase offsets myself.
I made motion a pure core calculation:
final frame = motionAt(
renderer.motionSeeds,
elapsedMilliseconds,
amplitude,
shake: pose.shake,
);motionSeeds derives the browser's periods, phases, and signed look direction from the existing keyed trait stream. motionAt evaluates all channels from those values and one elapsed timestamp: 2,800 ms breathing, 3,400 ms bobbing, seeded 3,500–6,500 ms blinks, seeded 4,200–7,600 ms saccades, the 900 ms thinking seesaw, and the 112 ms mad tremor.
The saccade was the fussy part. It is not a smooth orbit; it holds, flicks, holds again, and slightly foreshortens each eye as the look crosses the face. I ported the authored stop tables into _saccadeStops and _wrapStops, including the side-dependent scale and rotation of the secondary eye. I also ported the CSS timing curves with a small cubic Bézier solver rather than substituting Flutter curves that merely looked close.
AnimatedBlobatarRenderer resolves the layout, paths, palette, backdrop, and motion seeds once. Each paint applies shake, hover lift, breathing scale, bob, eye pose, saccade translation, blink scale, and eye wrap as nested Canvas transforms. The backdrop stays outside those transforms, matching the browser's layer order.
Because the arithmetic is pure, tests can ask for frame 0, frame 1,200, or any selected timestamp without pumping a real animation. The core tests compare seeded values and selected frames to TypeScript calculations. Raster tests prove that elapsed frames move, remain deterministic, keep the backdrop fixed, preserve the thinking loop, and visibly apply secondary-eye wrap.
Lesson: port the clock and the phase model, not the screenshot of the animation. Once motion is a function of (seed, time, amplitude, pose), both rendering and testing become much simpler.
Problem 7: Animation correctness includes stopping
Getting a blobatar to move was only half of Phase 4. It also had to stop when Flutter expected it to stop, survive expression changes, and avoid rebuilding immutable geometry on unrelated widget updates.
AnimatedBlobatar separates four concerns into four controllers: a repaint pulse, ambient amplitude, hover response, and expression morph. The pulse does not define animation progress; it only asks for another frame. _frame() reads SchedulerBinding.instance.currentSystemFrameTimeStamp, so avatars mounted later join the same deterministic clock with their own seeded offsets instead of starting another synchronized group.
Expression interruption needed one deliberate rule. When the target changes mid-morph, the widget captures the current interpolated pose and colors as the next transition's source. It does not jump back to the previous expression's endpoint. Entry takes 300 ms with the authored curve; returning to idle takes 400 ms with its own easing.
The widget also has three ways to become quiet:
MediaQuery.disableAnimationsswitches to the staticBlobatarpath by default.active: falselets an application stop known off-screen list items.- Flutter's ticker lifecycle follows
TickerMode, and every controller is disposed with the state.
The clock pulse only repeats while something needs it: always-on ambient motion, an active hover, an amplitude ramp, an expression morph, a held thinking loop, or tremor. A static, non-hovered avatar does not pay for continuous repaints.
Widget tests cover hover ramps, always mode, interrupted morph continuity, renderer reuse after unrelated rebuilds, reduced motion, TickerMode, and disposal. That is the part of animation work I trust most: not that it moves in a demo, but that the lifecycle assertions explain when it must not move.
Problem 8: The example became an integration test
The original example was a deterministic grid and restart button. As the SDK gained options, that stopped being enough. I turned it into Blobatar Studio so one screen could exercise the public API the way an application would.
The large preview updates from a seed search field. Shape and expression live in a visual bottom sheet rather than long dropdowns. Hue has a circular spectrum picker, backdrops have their own selector, and motion switches between hover and always. Below the preview, held thinking and mad cards expose the loops that a static screenshot cannot, while a hardcoded 3-by-4 named gallery makes synchronized motion obvious if the phase logic ever regresses.
The Claude and Codex cards are intentionally different. The web example recognizes a small set of normalized seed hashes and replaces the generated blobatar with fixed marks. I ported that example-only lookup and both Canvas marks into web_seed_marks.dart; selecting either seed locks shape and expression controls because changing them would imply those marks came from the generation engine. A test pins the aliases and the locked behavior.

Blobatar Studio showing the live seed preview, appearance controls, expression demos, seeded gallery, and Claude/Codex easter eggs.
The studio has four widget tests: web seed aliases, live seed/shape/expression changes, motion controls, and Claude/Codex replacement and locking. It is still an example, not public package architecture, but it now catches wiring failures that core parity vectors cannot see.
Phase 5: Making the port reviewable
The last phase was deliberately boring work, which is another way of saying release work. I completed the 0.1.0 pubspec, API documentation, changelog, MIT attribution, platform notes, package README, example commands, and parity table. The package advertises Dart 3.6 / Flutter 3.27 as its floor and declares Android, iOS, web, macOS, Windows, and Linux support; the rendering layer uses Flutter Canvas only and has no platform plugin.
I added a separate Flutter CI job rather than making Dart package setup depend on Bun. The matrix runs Flutter 3.27.4 and current stable, then formatting, analysis, pure Dart tests, Flutter tests, dartdoc, example analysis/tests, and a publish dry run. Locally, dart pub publish --dry-run completed with zero warnings, and dart doc completed with zero warnings.
The upstream PR states the compatibility boundary plainly: generation 2 remains pinned to Blobatar 2.4.0, the TypeScript generation source and golden files are untouched, Canvas antialiasing can differ from browser SVG, and the later pointer-gaze API is not part of this first release. It also includes the studio screenshot and the parity counts. No pub.dev publication happens in the PR; uploader ownership is a separate maintainer step.
One repository check remained red for an unrelated reason: all 209 TypeScript tests passed, but the existing React bundle measured 5,400 bytes against a 5,370-byte budget. The Flutter work changes neither that bundle nor the generation source, so I documented the 30-byte drift in the PR instead of relaxing another package's gate to make my branch look green.
Architectural Tradeoffs I Made (and Why)
One package, two libraries. The alternative was splitting the pure core and the Flutter widget into separate pub packages. I kept one package with lib/blobatar.dart (Flutter-free) and lib/flutter.dart (the widget layer), because the SDK is meant to be one thing on pub.dev and the core's Flutter-freedom is enforced by imports, not by packaging. The cost is real: the package now requires the Flutter SDK to resolve, so a strictly Dart-only consumer cannot depend on it. If that consumer ever matters, the split is a mechanical extraction — the core never imports Flutter today.
Structured segments plus a serializer, not path strings. The core emits typed segments (CubicTo, QuadTo, ...) and toPathData() reproduces the TypeScript markup byte-for-byte. The painter consumes the segments at full precision. That gives markup parity and canvas precision from one source of truth, at the cost of a second representation to keep honest — which the fixture does.
Unrounded doubles on canvas. The markup rounds coordinates to two decimals (r2); the painter draws the unrounded values. Slightly different pixels than a browser would produce, in exchange for no precision loss at any widget size. The parity tests compare geometry, not antialiasing, so this is documented rather than fought.
Pure elapsed-time motion, imperative lifecycle shell. I could have encoded the whole animation in controllers and tweens. Instead, the core owns motionAt and Flutter only supplies a clock, activation ramps, and repaint scheduling. This exposes more low-level API than a widget-only implementation, but it makes deterministic frame tests and custom renderers possible.
The studio is not the SDK. The Claude/Codex marks, visual pickers, gallery names, and seed cards stay inside example/. They prove integration and mirror the website, but none of that example-specific policy leaks into the package's deterministic core.
A web runner, a platform-neutral package. The checked-in studio runner targets web because hover behavior and the web easter eggs are easiest to review there. The package itself depends only on Flutter Canvas and widgets, with no platform plugin. I documented the difference rather than generating six example runners solely to make the tree look comprehensive.
One reviewable gate per phase. The fork carried focused commits and PRs for core parity, static rendering, expressions, the studio, animation, and release documentation. Only the finished package went upstream. That made maintainer review about the SDK boundary rather than the history of every debugging turn.
The Result
The finished branch has the same seed-to-look mapping it started with: 1,570 layout vectors, 42 expression vectors, exact hash/trait/palette/path comparisons, and the documented 1e-9 tolerance only for host-trig layout floats. All ten silhouettes and fourteen expressions render statically; the animated path adds seeded breathing, bobbing, blinking, saccades, eye wrap, expression morphs, thinking, and tremor without regenerating geometry.
The example builds for web, dartdoc reports zero warnings, and the publish dry run reports zero warnings. The package README now states the supported platform and parity boundary instead of leaving it in phase notes. The upstream PR contains the implementation, screenshot, test evidence, known rendering differences, and the explicit promise that nothing has been published to pub.dev yet.
What I'd Do Differently
- Ship the hostile seeds on day one. The
İand sigma cases caught real bugs, but only because the fixture happened to grow them. The exporter's seed list should start adversarial: case pairs with full-vs-simple mapping differences, sigma in every position, decomposed input, lone-surrogate neighbors. - Write the contour test before the painter. A
computeMetricscheck counting one metric per closed shape would have caught themoveTobug the moment it was written, instead of after a full test run. - Put the differential fuzzing in CI. The randomized
Math.imulcomparison against Node ran once, in review. It belongs in the gate, especially since the fix is a precedence change that a refactor could silently undo. - Export motion cases with the fixture.
motion_test.dartpins selected TypeScript-derived seeds and frames, but the motion values are not yet a first-class section ofreference-vectors.json. One artifact should eventually own layout, expressions, and elapsed-time motion together. - Split the studio before it grows again. The example's
main.dartnow carries the whole preview, control panel, bottom sheet, gallery, demos, and seed cards. That is acceptable for an example; another feature would justify extracting those sections into focused files. - Document deviations at discovery, not at phase exit. The Mtavruli note and Canvas antialiasing boundary exist because I wrote them down immediately. A compatibility caveat should become a test and a paragraph as soon as it is found.
Takeaways
- A port's hardest bugs are plausible wrong numbers, not crashes. Every early parity bug produced working-looking output. Only pinned vectors and Canvas invariants could tell the difference.
- Normalization and arithmetic are public API. NFC, lowercase mapping, uint32 overflow, and operator precedence decide identity just as surely as a widget constructor does.
- Static and animated rendering need one state model.
Pose, palette tinting, andMotionFrameare core values; painters consume them instead of inventing parallel behavior. - Port time, not keyframe screenshots. Seeded phases plus a pure elapsed-time evaluator preserve independent motion and make exact frame tests possible.
- Animation correctness includes inactivity. Reduced motion,
TickerMode, off-screen state, controller disposal, and interruption continuity deserve the same attention as visible movement. - A release phase is engineering work. Package metadata, API docs, CI boundaries, dry-run publishing, screenshots, and an honest deviations table are what turn a correct port into a reviewable SDK.