nyxcore-systems
7 min read

Crafting Dynamic AI Personas: A Journey into Zero-Dependency Avatar Generation

We just shipped a new avatar generator for our AI personas, moving from static images to dynamic, deterministic 24x24 block-matrix designs driven by skills and theme, all without a single external dependency for PNG encoding.

TypeScriptNode.jsAvatar GenerationZero-DependencyDeveloper ExperienceFullstack

Just wrapped up an intense dev session, and it feels good. The goal was ambitious: replace our bland, static pool of AI persona portraits with something truly dynamic, something that reflects the AI's core identity. We're talking about a deterministic 24x24 block-matrix avatar generator, spitting out crisp 300x300 transparent PNGs, all driven by the AI's skills and chosen theme. And I'm thrilled to report: it's feature complete and ready for commit.

This wasn't just about aesthetics; it was about bringing our AI personas to life, giving them a visual identity that evolves with their function and personality.

The Core: A Deterministic Avatar Engine

The heart of this new system lives in src/server/services/avatar-generator.ts. What started as a blank file is now a lean, zero-dependency avatar powerhouse.

At its core, it's a 24x24 grid. We've built a symmetric humanoid silhouette – a head, neck, shoulders, torso, and a base – by strategically marking cells in this matrix. But the magic truly begins with how these blocks are colored.

Skill-Driven & Depth-Based Coloring

Instead of random colors, we've implemented a two-pronged coloring system:

  1. Depth-based zoning: The avatar is rendered in layers. Edge cells get one color, the main fill another, and a central "core" layer yet another. This gives it a sense of three-dimensionality.
  2. Skill-driven palette: This is where the AI's personality shines. We've mapped over 20 different skills (e.g., coding, analytics, creativity, strategy) to specific RGB color values. When an avatar is generated, its primary skills dictate the core color palette, ensuring a visual connection to its function.

Themes & Personality

We've also introduced two distinct themes:

  • nyxcore: This theme adds a subtle glow ring, a gold sigil, and more intricate accents, giving a slightly futuristic, ethereal feel. Crucially, if a persona's skills or theme explicitly include nyxcore, a distinct diamond+cross sigil overlay appears, a nod to its advanced capabilities.
  • minimal: For those who prefer understated elegance, this theme offers cleaner lines and fewer accents.

To inject a touch of personality, all avatars feature white, symmetric "eyes" at row 4, giving them a subtle, watchful presence.

Determinism & Variety

Crucially, the generator is deterministic. We use a seeded PRNG (specifically, mulberry32) where the seed is derived from the persona's name or a hash of its core attributes. This means the same inputs will always produce the exact same avatar.

However, we also wanted variety. So, we added a variant counter. Incrementing this counter slightly alters the PRNG seed, allowing users to regenerate a new, yet still thematically consistent, avatar with a single click.

The Zero-Dependency PNG Encoder

This is perhaps my favorite part of the implementation. I initially considered sharp for PNG generation – a common, powerful library. However, adding a native dependency for a relatively simple task felt like overkill and introduced unnecessary build complexity.

Instead, I opted for a more... Spartan approach. Leveraging Node.js's built-in crypto and zlib modules, I manually implemented the PNG chunk encoding. The result? A perfectly functional PNG encoder that outputs transparent, highly optimized images (around 1.5-2KB each!) with zero external dependencies. It's a testament to the power of Node.js's standard library.

The generated avatars are saved to public/images/personas/avatar-{hash}.png, ready for serving.

Wiring It All Up: Integration Points

Getting the generator to work was one thing; integrating it seamlessly into our application was another. We tackled three main integration points:

  1. generateAvatar tRPC Mutation: In src/server/trpc/routers/personas.ts, we added a new mutation. It accepts skills, name, systemPrompt, theme, and variant. If explicit skills aren't provided, it intelligently auto-extracts specializations directly from the persona's systemPrompt. The persona's name serves as the fallback seed if nothing else is available.
  2. New Persona Creation UI: The src/app/(dashboard)/dashboard/personas/new/page.tsx got a significant overhaul. The old static portrait picker is gone, replaced by the dynamic generator. Users can now:
    • Toggle between nyxcore and minimal themes during the customization phase.
    • Hit a "Regenerate" button, which increments the variant counter and fetches a new avatar.
    • See a skeleton loader during generation, with graceful fallbacks if anything goes wrong.
    • Even when selecting a pre-suggested persona, handleSelect now triggers avatar generation based on that suggestion's systemPrompt.
  3. Existing Persona Backward Compatibility: We've kept the PORTRAIT_IMAGES constant for now. Any existing personas referencing these static images will continue to work, providing a smooth transition.

Lessons Learned & Overcome Challenges

No dev session is complete without a few head-scratching moments.

1. The Curious Case of Set Iteration (TS2802)

I hit a snag when trying to iterate directly over a Set<string> using for (const key of sigil). TypeScript threw TS2802: 'for-of' statements with custom iterators are only supported when the '--downlevelIteration' flag is enabled..

This is a known constraint in our project's tsconfig.json (documented in CLAUDE.md Prisma Gotchas), where downlevelIteration isn't enabled to maintain broader compatibility and simpler compilation targets.

The Fix: The workaround was simple but effective: convert the Set to an array first.

typescript
// Original (failed)
// for (const cell of getSigilCells()) { ... }

// Workaround
for (const cell of Array.from(getSigilCells())) {
  // ... do stuff with cell
}

This allowed me to proceed without altering the project's core TypeScript configuration.

2. Native Dependencies vs. Built-ins: The PNG Encoder Choice

As mentioned, the decision to forego sharp for PNG generation was a conscious one. While sharp is incredibly powerful, introducing a native dependency for image processing brings:

  • Increased node_modules size.
  • Potential build failures on different environments (especially CI/CD).
  • Slower install times.

By leveraging zlib.deflateSync for compression and manually constructing the PNG chunks (IHDR, IDAT, IEND, etc.) using Node.js's Buffer and crypto for CRC32 checksums, I achieved a pure JavaScript solution. It's a little more work upfront, but the long-term benefits in terms of stability, deployability, and lean architecture are well worth it. This reinforced the principle: always explore Node.js built-ins before reaching for external libraries, especially for core functionalities.

What's Next?

With the core generator and initial integrations locked down, the immediate next steps are:

  1. Commit and Push: Get these changes into the main branch.
  2. Seed Built-in Personas: Regenerate avatars for our default personas in prisma/seed.ts using the new generator.
  3. Expand UI Integration: Add avatar regeneration functionality to the persona detail/edit page (/dashboard/personas/[id]).
  4. Analytics Panel: Consider integrating the generator into the persona overview panel in our analytics dashboard, especially for any personas that might be missing an imageUrl.

This has been a deeply satisfying session. Moving from static images to dynamic, deterministic, and skill-driven avatars fundamentally changes how our users will interact with and perceive our AI personas. It's a small detail, but it adds a significant layer of depth and personality.

json
{"thingsDone":[
  "Implemented zero-dependency 24x24 block-matrix avatar generator (head, neck, shoulders, torso, base)",
  "Developed depth-based zone coloring and 20+ skill-driven color mappings",
  "Added 'nyxcore' (glow, gold sigil) and 'minimal' themes with conditional sigil overlay",
  "Integrated white symmetric eyes for personality",
  "Utilized seeded mulberry32 PRNG for deterministic avatar generation with variant counter",
  "Created built-in PNG encoder using Node.js `crypto` + `zlib` for ~1.5-2KB transparent PNGs",
  "Wired up `generateAvatar` tRPC mutation (accepts skills, name, systemPrompt, theme, variant)",
  "Enabled auto-extraction of specializations from systemPrompt for skill inference",
  "Updated new persona creation UI: replaced static picker, added theme toggle, regenerate button, skeleton loader",
  "Ensured `handleSelect` triggers avatar generation for suggested personas",
  "Maintained backward compatibility for existing static portrait images"
],
"pains":[
  "TypeScript TS2802 error when iterating `Set` without `--downlevelIteration`",
  "Decision to avoid `sharp` (native dependency) for PNG generation"
],
"successes":[
  "Successfully implemented `Array.from()` workaround for `Set` iteration",
  "Developed a robust, zero-dependency PNG encoder using Node.js built-ins (`crypto`, `zlib`)",
  "Achieved deterministic avatar generation with user-controlled variety",
  "Seamlessly integrated generator into server and client-side UI",
  "Delivered highly optimized, small file-size PNGs"
],
"techStack":[
  "TypeScript",
  "Node.js",
  "tRPC",
  "React",
  "Next.js",
  "zlib (Node.js built-in)",
  "crypto (Node.js built-in)"
]}