Skip to content
DeepForgeby svx

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.

9 min read
  • 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.

Deterministic trait selection pipelineA seed string is hashed with FNV-1a and streamed through mulberry32, which makes eight rarity-weighted picks — one per category. The chosen traits stack in a fixed paint order into one 96 by 96 SVG. The same seed always produces the same avatar.src/lib/nftAvatar/select.ts1 function · 8 picks · 0 stateseed"nova_7f3a" · user · rerollFNV-1a → mulberry32streams: :traits · :palette8 weighted picksweight 1–13, one per categoryselectAvatarTraits(seed, style)catalogbackground10clothing8head12mouth14eyes10headwear13accessories13extras13rare: macaque · weight 2final SVG · 96×968 layers · 1 <svg>fixed paint ordersame seed = same avatar — every pick is a pure function of the seed string
Fig 1.The selection pipeline. One hash, one PRNG stream, eight weighted picks, one fixed-order layer stack. Every pick is a pure function of the seed string, which is why the same seed always draws the same avatar.

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.

Why pixel got its own trait family instead of a filter
OptionChosenWhy
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 gridchosenEight 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.

The uid contract — types, a trait, and the rendererTypeScript
// 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.

SVG definition id collision and the uid fixLeft: two avatars on one page both reference the id hd-macaque-base, and the browser resolves it to the first definition in the document, so the second avatar borrows the first avatar's gradient and clip. Right: every definition id carries a per-seed uid suffix, so each avatar resolves to its own defs and a seed renders identically everywhere.one document · two avatars · both rolled the macaque headsrc/components/avatars/NftAvatarArt.tsxbug — shared def idsurl(#hd-macaque-base)seed nova_7f3aseed sol_9c21hd-macaque-basegradient · clip — first match winsfix — uid-suffixed defsurl(#hd-macaque-base-{uid})seed nova_7f3aseed sol_9c21hd-macaque-base-nova_7f3ahd-macaque-base-sol_9c21one id per seed — the same seed always resolves to the same defs
Fig 2.Why one page, two avatars, breaks: both SVGs declare hd-macaque-base and every url(#…) reference resolves to the first one in the document. Suffixing each id with a per-seed uid gives every avatar its own defs — and because the uid is stable, a seed still renders identically.

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 — the localhost guardJavaScript
// 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;
The stale-cache loop and the inline escape hatchThe service worker serves the old bundle cache-first on localhost. The self-heal code that would clear the cache lives inside that old bundle, so it can never run — only a hard refresh shows the new build. The escape is an inline script in the server HTML, which is always fresh: it unregisters the worker, deletes deepforge caches, and reloads once.localhost onlypublic/sw.js · VERSION v21 · load the page/_next/static/... chunk2 · cache-first hitold CSS + JS bundle3 · self-heal runs?it lives inside the bundlethe fix is cached inside the bug — no path outescape hatch — inline script in the server HTMLsrc/app/layout.tsx1 · unregister the worker2 · delete deepforge-* caches3 · reload once (session guard)Always fresh: navigations are network-first, so the hatch is never served from the cache.sw.js v2 refuses to cache on localhost at all — the hatch only helps browsers already poisoned.breaks the loopsymptom: hard refresh “fixed” it — until the next reload
Fig 3.The loop: the cached bundle serves the old app, and the self-heal that would clear the cache is itself part of that old bundle, so it can never run. The inline script in the server HTML is the only code guaranteed to be fresh — it breaks the loop once, and the worker's localhost guard keeps it broken.

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

  1. src/data/problems/problem-meta.ts and scripts/measure-bundle.ts — the light problem index and the per-route gzip measurements.
  2. src/lib/nftAvatar/ — select.ts, types.ts, palette.ts, the traits/ and pixel/ families, pinned by tests/nftAvatar.test.ts.
  3. src/components/avatars/NftAvatarArt.tsx and src/components/AvatarPicker.tsx — the layer-stack renderer that derives the per-seed uid.
  4. public/sw.js, src/app/layout.tsx, and src/components/PwaManager.tsx — the versioned worker, the inline self-heal, and production-only registration.
  5. src/lib/avatarStorage.ts and supabase/migrations/20260914000000_avatars.sql — the bucket client and the folder-scoped RLS policies.
  6. src/components/Celebration.tsx and src/components/ProblemView.tsx — the first-solve-only burst and its reduced-motion gate. Read about DeepForge.