October 4, 2026•16 min read

Building the Blobatar SwiftUI SDK, Same Face Across Runtimes

Author
Khai
Software Engineer | ML | AI Agents | Flutter

The Brief

After building Blobatar's Dart and Flutter SDK, I wanted the same avatar engine to work in a native SwiftUI app. The public surface looked small: pass a name, choose a size, draw a face.

The constraint underneath it was much bigger. Blobatar generates a geometric avatar from a string. That string determines the silhouette, proportions, colors, and eye placement. If an application uses the same name on the web, in Flutter, and on iOS, changing frameworks should preserve that identity.

A plausible-looking Swift implementation would not be enough. I needed the generation-2 seed-to-look mapping to survive another language, another string implementation, another numeric runtime, and another renderer.

I also wanted native rendering. The Swift SDK would resolve geometry in Swift and paint SwiftUI Canvas paths. There would be no JavaScript runtime, SVG parser, or web view between the name and the image.

This post follows the implementation in the Swift port branch. It focuses on the compatibility and lifecycle decisions; it does not assume that the prepared release version has been published.

Two Products, One Identity Contract

The root Package.swift exposes two library products:

  • BlobatarCore owns normalization, hashing, traits, palettes, geometry, expressions, and elapsed-time motion.
  • BlobatarSwiftUI depends on that core and supplies static and animated views.

The manifest requires Swift 6 and declares iOS 15 and macOS 12 as the deployment floors. The UI layer can use native Canvas while the core returns renderer-neutral data.

The static flow is straightforward. resolveBlobatar reads a name and immutable BlobatarOptions, resolves the keyed traits, constructs the palette and layout, applies the expression, and returns a BlobatarDrawing. SwiftUI converts its structured paths into a render plan, then fills those paths in order.

The animated flow shares the same foundation. A BlobatarAnimationModel holds the resolved base drawing and motion seeds. The SwiftUI driver supplies activity and transition state. At a requested time, the model produces transforms and colors for the renderer.

That split gives me a useful place to ask two different questions: did Swift calculate the correct figure, and did SwiftUI display the correct state?

The application-facing call stays small:

Using the public products
➜~// SwiftUI integration
import BlobatarCore
import BlobatarSwiftUI
AnimatedBlobatar(
name: user.email,
size: 64,
options: BlobatarOptions(
background: .squircle,
expression: .happy
),
animation: .always,
active: isOnScreen,
accessibilityLabel: "Avatar of the current user"
)

The name drives the picture. The accessibility label is supplied separately. I did not want a private email or internal identifier to become spoken UI text just because it happened to be the generation input.

Problem 1: A String Is Not the Same Thing in Every Language

The first compatibility boundary appears before any geometry exists.

The hash initializes its state using the input's UTF-16 length, then feeds UTF-8 bytes into its mixing function. Swift's convenient String.count would measure a different thing. A composed character or emoji can have different counts depending on whether I count grapheme clusters, Unicode scalars, UTF-16 code units, or encoded bytes.

The Swift implementation therefore preserves both parts of the contract explicitly:

BlobatarCore/Hash.swift
➜~// seedState(_:normalize:)
let value = normalize ? normalizeSeed(seed) : seed
let initial = UInt32(1_779_033_703) ^ UInt32(truncatingIfNeeded: value.utf16.count)
return feed(initial, bytes: value.utf8)

The arithmetic matters too. The mixer uses UInt32 and wrapping multiplication, written as &* in Swift. Replacing that with ordinary multiplication would introduce overflow traps where the original algorithm intentionally wraps.

Normalization has its own edges. The implementation applies NFC, trims an explicit ECMAScript whitespace set, and handles lowercase mappings including dotted capital I and contextual Greek sigma before completing the lowercase operation.

Using a generic whitespace helper would make the hash depend on Foundation's definition of whitespace. Using a casual lowercase conversion would leave cross-runtime differences hidden until an international name reached the SDK.

I still do not claim universal Unicode equivalence. The package documentation explicitly bounds the supported result by the checked-in international vectors and notes that uncommon scalars can lowercase differently across Swift and JavaScript.

After hashing, each trait reads an independently keyed stream. The body radius, eye gap, hue, and other decisions do not consume successive values from one mutable random generator. They derive their values from the seed state and a key.

That distinction is valuable when extending an engine. Reading one additional key does not advance a cursor and shift every later trait. It does not, however, give me permission to change the authored ranges or silhouette thresholds: those still determine the frozen identity mapping.

Lesson: preserve the original runtime's string and arithmetic semantics explicitly. A familiar method name is not a compatibility guarantee.

Problem 2: Parity Needs More Than a Screenshot

I reused the frozen TypeScript 2.4.0, generation-2 fixture rather than treating the Swift port as its own reference implementation. The Swift tests consume fixtures/blobatar-v2.4.0.json and compare the layers independently.

Phase2ParityTests checks normalized names, hash states, keyed streams, pinned and narrowed overrides, palette outputs, layout values, and path data. This makes a failure local enough to investigate. If the hash state differs, I can stop before debugging eye geometry.

The comparison rules are intentionally uneven:

ValueComparison
Hash states and keyed streamsExact
Rounded hexadecimal colors and serialized path dataExact
Trigonometric layout valuesRelative tolerance of 1e-9, with a unit floor

The layout helper uses abs(actual - expected) <= 1e-9 * max(1, abs(actual), abs(expected)). That tolerance belongs at the floating-point geometry boundary. It should not spread into values that the contract can compare exactly.

I also added checks that do not simply replay exported examples. ContainmentTests resolves 6,000 names, verifies that all ten silhouettes appear, and checks constraints such as eye containment, non-overlap, attached petals, and coordinates staying inside the drawing frame. A separate sweep exercises extreme and randomized trait overrides.

Those tests answer a different question from the fixture. The fixture asks whether Swift matches known TypeScript results. The containment sweep asks whether the resulting geometry still obeys its spatial invariants over a larger population.

Expressions and motion have their own parity coverage. MotionParityTests compares seeded periods and phases, fixed elapsed frames, and pose and tint interpolation against TypeScript vectors. A still image cannot prove that a blink wraps correctly or that an interrupted expression starts from the visible state.

There is also a boundary I kept explicit: matching geometry does not promise matching every edge pixel. Browser SVG and Core Graphics can antialias the same shape differently. The compatibility target is the geometry, colors, draw order, and transforms. Native raster tests check the SwiftUI renderer's behavior without pretending that two raster engines are interchangeable.

Native Paths Without an SVG Round Trip

The core represents paths as structured segments: moves, lines, cubic and quadratic curves, horizontal and vertical segments, and close operations.

swiftUIPath(from:) walks those segments directly. Horizontal and vertical segments need the current point to supply the coordinate they omit. Closing a contour resets that point to the subpath's start. The conversion preserves unrounded geometry instead of serializing it to text and parsing it back.

The static render plan preserves the authored layering: backdrop, petals, extra marks, body, then eyes. An expression's vertical body offset applies to the figure while the backdrop stays in place.

A small viewport calculation fits the 100-by-100 drawing into the largest centered square offered by the parent. That keeps a wide or tall layout proposal from stretching the avatar. Supplying size pins the square; omitting it lets the parent provide the available space.

The static and animated plans are cached separately. Both use NSCache with a count limit of 512, guarded by a lock. That is an opportunistic reuse mechanism, not a promise that every request stays resident forever.

The animated cache excludes the expression from its geometry key. Expressions can change pose and tint while retaining the base paths. Identity or geometry changes select a different rendering and cut to that figure rather than interpolating unrelated silhouettes.

This is where the module boundary pays for itself: the renderer works with a resolved plan, and timeline ticks do not need to hash the name or regenerate the silhouette.

Problem 3: Deterministic Motion Still Needs a Lifecycle

The motion model takes a time value. It does not advance by adding frame deltas to mutable counters.

The SwiftUI adapter supplies milliseconds from ProcessInfo.processInfo.systemUptime. All avatars using that shared monotonic clock can evaluate their own seeded phases at the current time. If a render tick is delayed, the next evaluation uses the current time instead of trying to catch up through missed increments.

That does not mean every platform displays the same frame at the same wall-clock instant. The cross-runtime contract lives at a specified elapsed time and input state. The scheduler decides when a platform actually presents a frame.

The renderer applies the frame's transforms in an explicit hierarchy: root, hover, breathe, body, eye pair, then each eye's pose and glance. The backdrop is painted before those figure transforms. Order matters because translating and scaling around different origins produces different pictures even when every individual number is correct.

The SwiftUI driver manages amplitude, hover, and expression transitions. When a transition is interrupted, it samples the current visible value and retargets from there. An expression change therefore does not restart from a stale endpoint halfway through an earlier morph.

Activity is a separate decision. The effective state requires the caller's active flag, an active scene, and a compatible Reduce Motion setting. When it is inactive, the view uses the static renderer. When it is active, TimelineView requests animation ticks with a minimum interval of 1.0 / 60.0 and can pause when continuous frames are unnecessary.

That interval is a scheduling request, not a measured frame-rate guarantee. Likewise, the SDK cannot infer every application's off-screen row state. The caller can set active: false when it knows the avatar is not visible.

Problem 4: The Correct Request Arrived One Update Late

The most instructive bug was outside the deterministic math.

The commit 44da1c4, titled fix(swift): apply animated view updates immediately, records a case where shape, expression, and motion controls appeared one operation behind. The earlier change handlers synchronized through the prior SwiftUI value. Request N reached the driver only when request N+1 caused another update.

This is the kind of failure a pure-core parity suite cannot detect. Every request resolves correctly in isolation. The view is simply delivering the wrong request at the wrong moment.

I replaced the separate delivery paths with a composite synchronization request containing the rendering, expression, animation mode, and effective activity. The change handler receives the new request and passes that value into synchronization.

The regression tests had to exercise the stateful view. An inactive avatar takes the static fallback, which would bypass the problematic driver entirely. The hosted raster tests mutate a shape or expression once and inspect the resulting render without depending on a second mutation to make it correct.

Lesson: test one user operation through the stateful path. Repeated updates can accidentally hide delayed state delivery.

Problem 5: Owning a Driver Did Not Make It Observable

Fixing request delivery exposed another boundary. BlobatarAnimationDriver lived in @StateObject, but its relevant mutations did not emit objectWillChange.

An inactive-to-active update could build an animation timeline while the driver still reported that it was paused. After the driver changed, SwiftUI needed an invalidation to reevaluate that scheduling decision. Without one, the avatar could remain frozen until another refresh.

Commit 00e95da adds notifications when rendering, expression, or activity changes. The activity path first checks whether the request actually differs, avoiding redundant invalidations for identical state.

My first activation regression relied on a hosted bitmap changing after a wall-clock delay. That failed on GitHub's headless macOS runners: an off-window NSHostingView did not advance TimelineView(.animation) as the test expected. Waiting longer was not a useful proof of the driver contract.

Commit c09bf94 moves that activation check to the driver seam. The current test subscribes to objectWillChange, activates an always-on driver at a fixed time, and verifies three things:

  1. The mutation emits an invalidation.
  2. The driver immediately requests continuous frames.
  3. A frame sampled later differs from the static frame.
AnimationDriverTests.swift
➜~// Activation regression assertions
XCTAssertGreaterThan(invalidations, 0)
XCTAssertTrue(driver.needsContinuousFrames(at: 10))
XCTAssertNotEqual(driver.frame(at: 210), staticFrame)

The test still catches the missing-notification bug. It simply stops depending on a display scheduler that the headless test environment does not provide. Hosted raster coverage remains useful for shape and expression delivery.

Lesson: separate an animation's deterministic state from the platform's opportunity to draw it. Both matter, but they need different evidence.

What I Chose to Leave Outside the SDK

Native Canvas rendering means the Swift package does not supply serialized SVG or data-URI output. Applications that need those artifacts can use the TypeScript surface. Adding serialization to Swift would introduce another compatibility boundary beyond the renderer I was building.

Interactive pointer gaze also remains outside this port. The seeded gaze.x and gaze.y traits place eyes at rest; they do not track a live pointer. Hover animation is supported where a pointer exists, while .always suits touch-first presentation.

I declared iOS and macOS support. tvOS, watchOS, and visionOS need their own interaction and rendering validation before they belong in the supported-platform contract.

These boundaries keep the public API honest. A missing capability is easier to reason about than an option accepted by every platform but silently ignored by some of them.

Verification Beyond the Main Package

The repository has a separate consumer package under packages/swift/Tests/Consumer. It imports only the two public products through the root manifest, resolves a drawing, constructs both views, and evaluates motion. That checks the package boundary a real consumer crosses, rather than relying entirely on test targets with internal access.

Blobatar Studio provides the integration surface for controls, expressions, traits, silhouettes, and animation. It has its own package tests and an Xcode project for iOS and macOS.

The CI definition checks Xcode 16 and the latest stable Xcode. Both legs build and test the package, run the consumer, and validate Studio. The current-toolchain leg also compiles both DocC catalogs with warnings treated as errors and builds the SwiftUI product and Studio for generic iOS destinations.

I deliberately separate those configured gates from claims about current hosted results. The workflow tells us what the repository checks; a fresh run is the evidence for whether a particular revision passes them.

What I'd Do Differently

I would establish the observation and request-delivery regressions earlier. The core seam was easy to isolate, so it attracted precise tests first. The lifecycle seam deserved the same treatment before the integration controls were complete.

I would also distinguish headless driver tests from live scheduling checks at the start. Fixed-time tests are excellent for interpolation and activation state. A real, displayed view is the right environment for confirming timeline presentation. Mixing those assumptions made the activation regression less reliable than the behavior it was supposed to protect.

For future normalization work, I would extend the upstream international vectors before changing the Swift mapping. Identity compatibility should grow through explicit examples and reviewed rules, not through a platform helper that happens to produce a different result.

Takeaways

  1. A deterministic port is a compatibility implementation. Encoding, normalization, overflow, keyed streams, and numeric comparison rules all contribute to the same face.
  2. Geometry and rendering need separate checks. A correct path representation does not prove correct conversion, layering, or state delivery.
  3. Elapsed-time motion creates a testable seam. Fixed inputs can prove animation behavior without sleeping or counting frames.
  4. SwiftUI ownership and observation are separate responsibilities. Keeping a driver in @StateObject does not notify the view when its internal scheduling state changes.
  5. Support boundaries belong in the API contract. Native rendering, pointer behavior, Unicode coverage, and declared platforms should be explicit.

The part I wanted to preserve was identity. The part I had to learn was delivery: getting the correct figure and motion state onto the screen when SwiftUI needed them. Building the SDK required both.