Procedural flavitars
1. Foundational mental model
Section titled “1. Foundational mental model”A flavitar is a pure function of a short string. The string is hashed to 32 bits, the hash seeds a tiny PRNG, and 17 draws from that PRNG pick one item per part category, five colours, and a boolean. The resulting AvatarState is rendered to SVG with Preact and encoded as a data: URI. Nothing is fetched: there is no avatar CDN and no image server.
Players can then pin individual parts. Those choices are stored as overrides next to the seed in one dot-separated string, the avatarId. Convex stores that string; every client re-derives the same pixels from it.
The renderer under packages/flavitars-sdk/src/upstream/ is a vendored copy of the upstream Flavitars project. scripts/sync-from-flavitars.ts regenerates it and rewrites React imports to Preact. src/index.ts is the only hand-written file in the package.
Interactive
Flavitar studio
Runs the production @flags/flavitars renderer in your browser. Type a seed, override parts, hide layers, and watch the avatar id and data URI change.
alpha 5 of 200 characters allowed in storage.
- FNV-1a seed hash
- 0x5d8b6dab
- Data URI length
- 6,046 B
- Same SVG as base64
- 6,138 B (1.5% larger)
The 17 seed draws behind this avatar
| Slot | u | n | ⌊u·n⌋ |
|---|---|---|---|
| Head | 0.218699 | 5 | 1 |
| Brows | 0.815852 | 9 | 7 |
| Eyes | 0.036937 | 24 | 0 |
| Nose | 0.470854 | 6 | 2 |
| Mouth | 0.787285 | 19 | 14 |
| Hair | 0.610831 | 36 | 21 |
| Hats | 0.336052 | 24 | 8 |
| Extras | 0.484494 | 4 | 1 |
| Accessories | 0.137774 | 28 | 3 |
| Body | 0.371100 | 29 | 10 |
| Style | 0.066162 | 4 | 0 |
| Skin | 0.182234 | 28 | 5 |
| Hair colour | 0.550565 | 20 | 11 |
| Hat colour | 0.375702 | 20 | 7 |
| Accessory colour | 0.723714 | 7 | 5 |
| Body colour | 0.365264 | 20 | 7 |
| Contain hair | 0.400988 | 2 | false |
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”buildAvatarId lives in apps/web/src/lib/avatar/stored-avatar-id.ts. avatar-utils.ts imports it and exposes buildAvatarIdFromOverrides, which is buildAvatarId("avatar", overrides); it does not re-export buildAvatarId itself.
3. Concrete implementation
Section titled “3. Concrete implementation”Seed hash and PRNG
Section titled “Seed hash and PRNG”export function hashString(str: string): number { let hash = 2166136261; for (let i = 0; i < str.length; i++) { hash ^= str.charCodeAt(i); hash = Math.imul(hash, 16777619); } return hash >>> 0;}
export class SeededRandom { private seed: number;
constructor(seed: string | number) { this.seed = typeof seed === "string" ? hashString(seed) : seed; }
next(): number { this.seed ^= this.seed << 13; this.seed ^= this.seed >> 17; this.seed ^= this.seed << 5; return (this.seed >>> 0) / 4294967296; }Part selection
Section titled “Part selection”Draws are consumed in a fixed order: one per entry in CATEGORIES, then five colour pools, then containHair. Reordering CATEGORIES upstream would silently change every seed-only avatar in production.
export function generateAvatarFromSeed(seed: string | number): AvatarState { const rng = new SeededRandom(seed); const state: AvatarState = { ...DEFAULT_AVATAR_STATE };
CATEGORIES.forEach((category) => { const keys = category.sortedKeys; const index = rng.nextInt(keys.length); (state as unknown as Record<string, string | boolean>)[category.stateKey] = keys[index]; });
state.skinTone = SKIN_TONES[rng.nextInt(SKIN_TONES.length)].id; state.hairColor = HAIR_COLORS[rng.nextInt(HAIR_COLORS.length)].id; state.hatColor = HAIR_COLORS[rng.nextInt(HAIR_COLORS.length)].id; state.accessoryColor = ACCESSORY_ACCENT_COLORS[rng.nextInt(ACCESSORY_ACCENT_COLORS.length)].id; state.bodyColor = HAIR_COLORS[rng.nextInt(HAIR_COLORS.length)].id;
state.containHair = rng.next() > 0.5;
return state;}| Draw | Category (stateKey) | Pool size |
|---|---|---|
| 1 | head | 5 |
| 2 | eyebrows | 9 |
| 3 | eyes | 24 |
| 4 | nose | 6 |
| 5 | mouth | 19 |
| 6 | hair | 36 |
| 7 | hat | 24 |
| 8 | extras | 4 |
| 9 | accessories | 28 |
| 10 | body | 29 |
| 11 | texture | 4 |
| 12–16 | skinTone, hairColor, hatColor, accessoryColor, bodyColor | 28, 20, 20, 7, 20 |
| 17 | containHair | next() > 0.5 |
Hat, hair and body colours all draw from HAIR_COLORS. AVATAR_METADATA.colors.hat and .body are the same list.
Layer order
Section titled “Layer order”AvatarLayers stacks six groups. Four carry a className; accessories and the hat do not, which is why the simulator targets them with sibling selectors.
<g> <g className="hair-back-set"> <HairBackSet fill={hairColor} hatId={state.hat} headId={state.head} hairId={state.hair} /> </g> <g className="body-set" style={{ color: bodyColor }}> <BodySet headId={state.head} hatId={state.hat} skinTone={skinTone} /> </g> <g mask={state.hat === "astronautHelmet" ? `url(#${filterId}-astronaut-glass-mask)` : undefined}> <g className="head-group"> <HeadShape fill={skinTone} headId={state.head} hatId={state.hat} /> {!isSkiMask && ( <g style={{ color: facialFeaturesColor }} transform={getHeadFacialTransform(state.head)}> {/* ExtraSet, EyebrowSet, EyeSet, NoseSet, MouthSet */} </g> )} </g> </g> <g className="hair-front-set">{/* HairFrontSet */}</g> {(!isSkiMask || state.accessories === "headphones") && <g transform={/* … */}>{/* AccessorySet */}</g>} <HatSet fill={hatColor} headId={state.head} hatId={state.hat} /></g>types/index.ts also exports a LAYER_ORDER array that puts accessories (z 8) under front hair (z 9). The renderer does not read it; layers.tsx draws accessories after front hair.
SVG and data URI
Section titled “SVG and data URI”renderAvatarSvg in src/index.ts is a synchronous replacement for the upstream async renderer. It builds the tree with jsx from preact/jsx-runtime, renders with renderToStaticMarkup, and keeps two 256-entry LRU caches (SVG and data URI) keyed by a |-joined state string. <defs> with texture filters is emitted only when texture !== "none" or the hat is astronautHelmet.
const SVG_DATA_URI_ESCAPES: SvgDataUriEscapes = { '"': "%22", "#": "%23", "%": "%25", "<": "%3C", ">": "%3E",};
function svgToDataUri(svg: string): string { const minified = svg.replace(/>\s+</g, "><"); const escaped = minified.replace(/[%#<>"]/g, (ch) => SVG_DATA_URI_ESCAPES[ch] || ch); return `data:image/svg+xml;charset=utf-8,${escaped}`;}avatarId and the web wrapper
Section titled “avatarId and the web wrapper”export function buildAvatarId( seed: string, overrides: AvatarOverrides | Record<string, string>): string { if (Object.keys(overrides).length === 0) { return seed; }
const parts = [seed]; const sortedEntries = Object.entries(overrides).sort(([left], [right]) => left.localeCompare(right) ); for (const [key, value] of sortedEntries) { if (value) { parts.push(`${key}:${value}`); } }
return parts.join(".");}buildAvatarUrl resolves the id through the same editor normalization the avatar picker uses, then hands the SDK a snake_case input. The name is historical: it returns a data URI.
export function buildAvatarUrl( seedOrAvatarId: string, overrides?: Record<string, string>): string { const resolved = resolveAvatarRenderOverrides( seedOrAvatarId, AVATAR_METADATA, flagsGamesConfig ); const input: AvatarRenderInput = { seed: resolved.seed, texture: "none", ...resolved.overrides, ...overrides, }; return getAvatarDataUri(input);}Before writing, normalizeAvatarIdForStorage expands a bare seed into the canonical form: the seed plus the 7 editable parts (head, eyes, nose, mouth, hair, hat, body) and 2 colours (skin_tone, hair_color), sorted by key. abc123seed01 becomes abc123seed01.body:….eyes:….hair:…. Eyebrows, extras, accessories, the hat, body and accessory colours, and containHair are not pinned; they still come from the seed draws unless the id already carried them.
4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”FNV-1a 32
Section titled “FNV-1a 32”For seed code units (UTF-16, via charCodeAt), with offset basis and prime :
Math.imul gives the low 32 bits of the product as a signed int; >>> 0 reinterprets the final value as unsigned. The empty seed hashes to . "alpha" hashes to (0x5d8b6dab). Non-ASCII seeds hash per UTF-16 unit, so "é" precomposed and "e\u0301" give different avatars. The stored-id seed regex [a-zA-Z0-9_-] keeps persisted seeds ASCII anyway.
The xorshift step
Section titled “The xorshift step”With state held as a signed 32-bit integer, one call to next() computes
and a pick from a pool of items is .
The middle shift is JavaScript >>, an arithmetic shift (). Marsaglia’s xorshift32 uses a logical shift. With >>, bit 31 of equals bit 31 of , so the XOR always clears bit 31 after the second step. The update is therefore not a permutation of the 32-bit state, and the period argument for xorshift32 does not apply. For 17 draws per avatar that does not matter. It does mean you can’t swap in a textbook xorshift32 without changing every seed-only avatar.
A hash of exactly 0 would lock the generator at 0, and every draw would pick index 0. That needs one specific 32-bit value out of .
How much of the space seeds reach
Section titled “How much of the space seeds reach”The pool sizes in §3 multiply to
combinations. A seed passes through a 32-bit hash first, so seeds can reach at most of them. Two random 12-character seeds from generateRandomSeed collide on hash with probability ; across players the birthday bound is about , roughly 1.2% at 10,000 players. Overrides and the canonical stored form make a collision visible only for players who never touched the editor.
Packed ids
Section titled “Packed ids”packState writes each field as an index into its pool, using bits, and prints the integer in base 36 after p_. Summing the widths:
| Fields | Bits |
|---|---|
| 11 categories (3+4+5+3+5+6+5+2+5+5+2) | 45 |
| 5 colours (5+5+5+3+5) | 23 |
texture again, from Object.keys(Textures) | 2 |
containHair | 1 |
| Total | 71 |
texture is packed twice because it appears in CATEGORIES and is packed explicitly after the colours. 71 bits need at most base-36 digits.
Data URI encoding
Section titled “Data URI encoding”Only % # < > " are escaped, so most of the SVG passes through unchanged. Base64 costs characters for bytes. The saving depends on how many escaped characters the markup contains, and path-heavy SVG has a lot of ". Measured on seeds s0…s499 with texture: "none":
| Encoding | Mean length | Min | Max |
|---|---|---|---|
| Percent-encoded (shipping) | 6,538 | 1,674 | 12,612 |
| Base64 (README claim) | 6,671 |
The simulator shows both numbers for the current avatar.
5. Threat model, failure modes & edge cases
Section titled “5. Threat model, failure modes & edge cases”- Upstream drift. Seed-only avatars depend on the order of
CATEGORIES, the order and length of everysortedKeyslist, and the colour arrays. An upstream sync that adds a hair style changes for draw 6 and reshuffles every seed that lands past the insertion point. Canonical stored ids pin the 7 editable parts and 2 colours by name, so drift is limited to the unpinned fields (eyebrows, extras, accessories, three colours,containHair) and to ids that were never normalized.migrations/normalizeStoredAvatarIds.tsexists for that backfill. - Unknown part ids.
resolveAvatarPartsfalls back to the first registered component for required categories and to() => nullfor optional ones. A stale id renders as something, never as an error. - Seeds with
p_.getAvatarStateFromIdtreatsp_…as a packed id.base36ToBigIntmaps unknown characters toindexOf= −1 and keeps going, sop_Helloproduces a valid but unrelated state.generateRandomSeednever emits_. - Invalid seed characters.
parseAvatarIdaccepts a seed only if it matches^[a-zA-Z0-9_-]+$and has no:. Anything else becomes the seedavatar. The simulator warns when that would happen. - Injection. All SVG comes from the SDK’s own components, and the web app puts it in
<img src>. Scripts inside an SVG loaded as an image do not run, and filter or mask ids (avatar-filter-…) cannot clash with other avatars on the page. Inlining SVG with{@html}would lose both properties, which is why the simulator applies layer toggles by injecting a<style>into the markup and still rendering an<img>. - Cache size. Each LRU holds 256 entries. A leaderboard with more distinct avatars than that re-renders on scroll. Rendering is synchronous, so a cache miss costs main-thread CPU time, never a network wait.
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”| Choice | Alternative | Why this one |
|---|---|---|
| Render locally to a data URI | Hit flavitars.com/<seed>.svg | No third-party request per avatar, works offline and in SSR, no CDN cache to purge. |
| Vendored upstream + sync script | Hand-maintained fork | Keeps src/upstream/ diffable against upstream; the transforms are mechanical and listed in the SDK README. |
Preact + preact-render-to-string | Custom JSX-to-string factory | Upstream components use React APIs; Preact runs them with the import rewrites only. |
| Seed plus sorted overrides in one string | Packed p_ ids | Human-readable, diffable in Convex dashboards, and stable across pool changes once normalized. Costs up to 200 characters instead of 16. |
| Percent-encoded data URI | Base64 | Slightly shorter and readable in DevTools. The size win is small (§4). |
<img> for every avatar | Inline <svg> | Isolates ids and scripts; the cost is no CSS styling of individual layers from the page. |
Non-goals: unique avatars per player (seed collisions are allowed), server-side avatar images, animated avatars, and exposing the full upstream part catalogue in the editor. flagsGamesConfig deliberately narrows it.