Skip to content

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.

Avatar for seed alpha
Layers, bottom to top
avatarId (buildAvatarId) 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)
Overrides (0 set)
Head
Brows
Eyes
Nose
Mouth
Hair
Hats
Extras
Accessories
Body
Style
Skin
Hair colour
Hat colour
Accessory colour
Body colour
Neighbouring seeds (seed + 1 … 6)
The 17 seed draws behind this avatar
Slotun⌊u·n⌋
Head0.21869951
Brows0.81585297
Eyes0.036937240
Nose0.47085462
Mouth0.7872851914
Hair0.6108313621
Hats0.336052248
Extras0.48449441
Accessories0.137774283
Body0.3711002910
Style0.06616240
Skin0.182234285
Hair colour0.5505652011
Hat colour0.375702207
Accessory colour0.72371475
Body colour0.365264207
Contain hair0.4009882false
Raw SDK output. The web app's buildAvatarUrl also forces texture: "none" and clamps mouths and colours to flagsGamesConfig, so a seed can look slightly different in the game.

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.

packages/flavitars-sdk/src/upstream/avatar/engine/avatar-generator.ts
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;
}

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.

packages/flavitars-sdk/src/upstream/avatar/engine/avatar-generator.ts
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;
}
DrawCategory (stateKey)Pool size
1head5
2eyebrows9
3eyes24
4nose6
5mouth19
6hair36
7hat24
8extras4
9accessories28
10body29
11texture4
12–16skinTone, hairColor, hatColor, accessoryColor, bodyColor28, 20, 20, 7, 20
17containHairnext() > 0.5

Hat, hair and body colours all draw from HAIR_COLORS. AVATAR_METADATA.colors.hat and .body are the same list.

AvatarLayers stacks six groups. Four carry a className; accessories and the hat do not, which is why the simulator targets them with sibling selectors.

packages/flavitars-sdk/src/upstream/avatar/core/layers.tsx
<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.

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.

packages/flavitars-sdk/src/index.ts
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}`;
}
apps/web/src/lib/avatar/stored-avatar-id.ts
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.

apps/web/src/lib/avatar/avatar-utils.ts
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.

For seed code units c0…cn−1c_0 \ldots c_{n-1} (UTF-16, via charCodeAt), with offset basis h0=2166136261h_0 = 2166136261 and prime p=16777619=224+28+0x93p = 16777619 = 2^{24} + 2^{8} + \texttt{0x93}:

hi+1=((hi⊕ci)⋅p) mod 232h_{i+1} = \big( (h_i \oplus c_i) \cdot p \big) \bmod 2^{32}

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 h0h_0. "alpha" hashes to 15694186671569418667 (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.

With state xx held as a signed 32-bit integer, one call to next() computes

x←x⊕(x≪13),x←x⊕(x≫a17),x←x⊕(x≪5),u=x mod 232232∈[0,1)x \leftarrow x \oplus (x \ll 13), \qquad x \leftarrow x \oplus (x \gg_{a} 17), \qquad x \leftarrow x \oplus (x \ll 5), \qquad u = \frac{x \bmod 2^{32}}{2^{32}} \in [0, 1)

and a pick from a pool of nn items is ⌊u⋅n⌋\lfloor u \cdot n \rfloor.

The middle shift is JavaScript >>, an arithmetic shift (≫a\gg_a). Marsaglia’s xorshift32 uses a logical shift. With >>, bit 31 of x≫a17x \gg_a 17 equals bit 31 of xx, 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 232−12^{32}-1 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 2322^{32}.

The pool sizes in §3 multiply to

5⋅9⋅24⋅6⋅19⋅36⋅24⋅4⋅28⋅29⋅4⋅28⋅20⋅20⋅7⋅20⋅2≈4.33×1018≈261.95 \cdot 9 \cdot 24 \cdot 6 \cdot 19 \cdot 36 \cdot 24 \cdot 4 \cdot 28 \cdot 29 \cdot 4 \cdot 28 \cdot 20 \cdot 20 \cdot 7 \cdot 20 \cdot 2 \approx 4.33 \times 10^{18} \approx 2^{61.9}

combinations. A seed passes through a 32-bit hash first, so seeds can reach at most 232≈4.3×1092^{32} \approx 4.3 \times 10^{9} of them. Two random 12-character seeds from generateRandomSeed collide on hash with probability 2−322^{-32}; across kk players the birthday bound is about k2/233k^2 / 2^{33}, 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.

packState writes each field as an index into its pool, using ⌈log⁡2max⁡(2,n)⌉\lceil \log_2 \max(2, n) \rceil bits, and prints the integer in base 36 after p_. Summing the widths:

FieldsBits
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
containHair1
Total71

texture is packed twice because it appears in CATEGORIES and is packed explicitly after the colours. 71 bits need at most ⌈71/log⁡236⌉=14\lceil 71 / \log_2 36 \rceil = 14 base-36 digits.

Only % # < > " are escaped, so most of the SVG passes through unchanged. Base64 costs 4⌈b/3⌉4 \lceil b/3 \rceil characters for bb 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":

EncodingMean lengthMinMax
Percent-encoded (shipping)6,5381,67412,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 every sortedKeys list, and the colour arrays. An upstream sync that adds a hair style changes nn 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.ts exists for that backfill.
  • Unknown part ids. resolveAvatarParts falls back to the first registered component for required categories and to () => null for optional ones. A stale id renders as something, never as an error.
  • Seeds with p_. getAvatarStateFromId treats p_… as a packed id. base36ToBigInt maps unknown characters to indexOf = −1 and keeps going, so p_Hello produces a valid but unrelated state. generateRandomSeed never emits _.
  • Invalid seed characters. parseAvatarId accepts a seed only if it matches ^[a-zA-Z0-9_-]+$ and has no :. Anything else becomes the seed avatar. 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.
ChoiceAlternativeWhy this one
Render locally to a data URIHit flavitars.com/<seed>.svgNo third-party request per avatar, works offline and in SSR, no CDN cache to purge.
Vendored upstream + sync scriptHand-maintained forkKeeps src/upstream/ diffable against upstream; the transforms are mechanical and listed in the SDK README.
Preact + preact-render-to-stringCustom JSX-to-string factoryUpstream components use React APIs; Preact runs them with the import rewrites only.
Seed plus sorted overrides in one stringPacked p_ idsHuman-readable, diffable in Convex dashboards, and stable across pool changes once normalized. Costs up to 200 characters instead of 16.
Percent-encoded data URIBase64Slightly shorter and readable in DevTools. The size win is small (§4).
<img> for every avatarInline <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.