Building a 150-trait avatar engine (and the hardest bugs we fixed making it)
DeepForge avatars are deterministic: a seed string goes in, one SVG comes out, and the same seed renders the same avatar everywhere. The engine ships 153 traits across an illustrated family and a pixel/voxel family. The two bugs that cost us the most time were not in the art: SVG definition ids are document-global, so avatars on one page collided, and a service worker cached the very bundle that contained its own self-heal. This is the selection pipeline, the fixes, and the numbers behind them.
- avatars
- svg
- service-worker
- performance
DeepForge avatars are deterministic generative art. A seed string goes in — usually a username, sometimes a reroll UUID — and one assembled SVG comes out. There is no upload, no server render, no stored image. The engine ships 153 traits — the 150 in the title — across two complete style families. The two bugs that cost us the most time were not in the art: one was SVG's document-global id namespace, and the other was a service worker that had cached its own fix.
1.Deterministic by construction
src/lib/nftAvatar/select.ts is about seventy lines. FNV-1a hashes the string {seed}:{style}:traits, mulberry32 turns that hash into a stream of numbers, and one call to selectAvatarTraits() makes eight picks: backgrounds, clothing, heads, mouths, eyes, headwear, accessories, extras. pickWeighted() subtracts each trait's weight (1–13) from a roll until the roll lands, so a weight-2 trait is roughly a sixth as likely as a weight-13 one. The palette comes from pickPalette(), keyed on {seed}:{style}.
That is the whole contract: same seed and style, same trait ids, same palette. tests/nftAvatar.test.ts pins determinism and independence between the two styles. Rarity is not a flag either — rarityScore() counts how many of the eight picks have weight ≤ 4, and the picker shows that number as a badge.
The catalog is 93 illustrated traits. The wild rares are the fun part: rainbow tongue, gold and diamond grillz, third eye, angel wings, hologram glitch. The flagship is the Macaque head at weight 2 — 241 SVG nodes and 115 stroked fur clumps pulled from eight clump arrays. None of it would line up if the layers did not agree on coordinates, so the anchor contract lives in src/lib/nftAvatar/types.ts, not in each drawing: head centered at (48, 50) with radius ~26, eyes at (40, 46) and (56, 46), mouth at (48, 62), headwear above y=30, clothing from y=78, accessories at the neck and ears. Paint order is fixed there too, and traits can be drawn independently because the geometry is frozen in a type file.
2.Pixel is a second system, not a filter
The pixel family is not a CSS effect over the illustrated set. It is 60 traits across the same eight categories, drawn on a hidden 48×48 grid where 2 SVG units equal one pixel. Every rect is even-sized, silhouettes are 2-unit ink borders, and each head paints a five-step tone ramp built by mix() in src/lib/nftAvatar/pixel/heads.tsx. At render time NftAvatarArt sets shapeRendering="crispEdges" for pixel avatars and leaves the illustrated family antialiased. Selection never notices: traitCategoriesFor(style) swaps the catalog and selectAvatarTraits() is unchanged.
| Option | Chosen | Why |
|---|---|---|
| CSS filter over the illustrated SVG | — | A filter runs at raster time. It cannot move a curve onto an even grid, and it turns hairline strokes into noise rather than pixels. |
| Downscale illustrated traits to 48×48 | — | Nearest-neighbor keeps the silhouette but loses the discipline: shapes land between pixels, tones stay continuous, and the result reads as low-res illustration. |
| Draw a second family on the 48×48 grid | chosen | Eight mirrored categories, even-sized rects, 2-unit ink silhouettes, five flat tones. The grid is a drawing rule, not a post-process — and selection stays one function. |
3.The bug: SVG ids are document-global
The symptom showed up only on pages with more than one avatar. Occasionally one avatar wore another avatar's gradient, or a clip path from somebody else's head cut it in half. A single avatar always looked right; the second one changed the first. The rule we had forgotten is blunt: ids in SVG are not scoped to the <svg> element. They are global to the document, and url(#hd-macaque-base) resolves to the first element with that id anywhere on the page.
Every trait defines its own gradients and clips; the Macaque head alone declares a dozen gradients plus two clip paths. Two avatars on one page therefore declare the same ids twice, and the first definition wins. The second avatar silently borrows the first avatar's geometry — same id, wrong art.
// src/lib/nftAvatar/types.ts — the contract on every trait
render: (palette: AvatarPalette, uid: string) => ReactNode;
// src/lib/nftAvatar/traits/heads.tsx — every def id carries the uid
<radialGradient id={`hd-${id}-base-${uid}`}> ... </radialGradient>
<clipPath id={`hd-${id}-clip-${uid}`}> ... </clipPath>
// src/components/avatars/NftAvatarArt.tsx — uid is a pure function of the seed
const uid = selection.seed.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 24) || "df";The fix is one parameter. Trait.render receives a uid, and every definition id carries it as a suffix. NftAvatarArt derives that uid from the seed, sanitized and sliced to 24 characters. Because the uid is a pure function of the seed, the same seed still renders identically everywhere; different seeds simply stop sharing definitions. The requirement lives on the Trait type in types.ts, so an artist cannot forget it twice.
4.The service worker that cached its own fix
public/sw.js caches the Pyodide runtime and /_next/static/** cache-first so solving keeps working offline. In production that is correct: chunk URLs carry content hashes, so a cached URL can never be stale. On localhost it is wrong — dev asset URLs are stable, so a worker left behind by a production build served locally keeps serving the old CSS and JS after you edit the source.
The symptoms were absurd. Both theme icons visible at once. The hero and footer lost their padding. The aurora background vanished. A hard refresh fixed it, and the next navigation brought it back. The cause was a worker answering from a cache built hours earlier.
Then the trap: our self-heal code lived in src/components/PwaManager.tsx, inside the client bundle. The bundle was the stale thing. The code that would unregister the worker and clear caches could not run, because it shipped in the chunk the worker refused to update — chicken-and-egg, with the browser holding the chicken.
The escape hatch had to live where caching cannot reach it, so it is an inline script in the server HTML: DEV_CACHE_RESET_SCRIPT in src/app/layout.tsx. Navigations are network-first, so this script is always current. On localhost it unregisters every worker, deletes every cache whose name starts with deepforge-, and reloads exactly once, guarded by sessionStorage. The worker itself got VERSION = "v2" and an IS_LOCAL check: on localhost it unregisters itself at install, and its fetch handler returns before touching a cache. PwaManager now registers the worker only in production.
// public/sw.js — localhost never reaches the caches
const IS_LOCAL = ["localhost", "127.0.0.1", "0.0.0.0"]
.includes(self.location.hostname);
// install: unregister anything already there
if (IS_LOCAL) { await self.registration.unregister(); return; }
// fetch: no caching on localhost, so no stale chunk can win
if (IS_LOCAL) return;One smaller landmine: browser back/forward restores the previous scroll position, which dropped the home landing mid-hero under the sticky header. src/app/page.tsx now resets that scroll on mount — scrollTo(0, 0) plus a frame and a 60 ms retry — while leaving deep links alone.
5.Uploads stay local-first
Uploaded photos go to the public avatars bucket in Supabase Storage at {user_id}/avatar.jpg — one object per user, upserted, with a cache-busted public URL. src/lib/avatarStorage.ts never throws: without a configured client or a cached session every helper resolves to { url: null, error: null } and the UI keeps using the local data URL preview. The security boundary is RLS, not the client (supabase/migrations/20260914000000_avatars.sql): reads are public because an avatar is public identity, while insert, update, and delete require (storage.foldername(name))[1] = auth.uid()::text, pinning a user to their own folder.
The same local-first rule governs the celebration. Celebration.tsx draws a 110-particle canvas burst for 1.5 seconds using the live theme tokens. ProblemView.tsx arms it only when a solve is new, and bails before navigator.vibrate?.(10) when prefers-reduced-motion is set, so the motion and the haptic tick stand down together.
6.The bill: bundle size
The work around the engine exposed an old cost: every route used to ship roughly 1,290 kB gzip of problem data because the shell pulled the whole bank through PageShell → Header → SyncPanel → remote.ts → stores → @/data/problems. A generated light index — src/data/problems/problem-meta.ts [1] — plus a lazy heavy bank and lazy auth/remote fixed the chain. Measured by scripts/measure-bundle.ts: home 1,553 → 283 kB gzip, /about 1,497 → 183 kB, /problems 1,499 → 251 kB. It is also why the header avatar can afford to render on every page.
Deterministic art is cheap: no storage, no server, no network. The expensive bugs were in the ambient systems — SVG's global id namespace, and a browser cache that outranked our own fix. Put the contract in the types, and the escape hatch outside the cache.
Code and references
src/data/problems/problem-meta.tsandscripts/measure-bundle.ts— the light problem index and the per-route gzip measurements.src/lib/nftAvatar/—select.ts,types.ts,palette.ts, thetraits/andpixel/families, pinned bytests/nftAvatar.test.ts.src/components/avatars/NftAvatarArt.tsxandsrc/components/AvatarPicker.tsx— the layer-stack renderer that derives the per-seed uid.public/sw.js,src/app/layout.tsx, andsrc/components/PwaManager.tsx— the versioned worker, the inline self-heal, and production-only registration.src/lib/avatarStorage.tsandsupabase/migrations/20260914000000_avatars.sql— the bucket client and the folder-scoped RLS policies.src/components/Celebration.tsxandsrc/components/ProblemView.tsx— the first-solve-only burst and its reduced-motion gate. Read about DeepForge.