Legacy
This WFD was imported from old blog posts scattered across my files. It may still be relevant or it may not. Kept for the record.
Gravatar generates unique avatars from email hashes. Same email, same avatar, every time. I wanted the same thing for my portfolio: abstract art generated from project IDs that could serve as cover images and social previews.
The key constraint was determinism. Same input → same output. Forever. No database, no storage, just pure computation.
the problem with math.random
JavaScript's Math.random() can't be seeded. It produces different results every time. I needed a pseudorandom number generator that accepts a seed value and produces repeatable sequences.
The mulberry32 algorithm is fast and deterministic:
export class SeededRandom {
private seed: number;
constructor(seed: number) {
this.seed = seed;
}
next(): number {
let t = (this.seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
nextInt(min: number, max: number): number {
return Math.floor(this.next() * (max - min)) + min;
}
pick<T>(array: T[]): T {
return array[this.nextInt(0, array.length)];
}
}
converting strings to seeds
Projects have string IDs like "my-cool-project". I needed a hash function to convert strings to numbers:
export function stringToSeed(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash);
}
Same string → same hash → same seed → same art.
the generator
The art is soft, blurred, colorful blobs layered on top of each other:
function generateAbstractArt(
width: number,
height: number,
stringSeed: string,
darkMode: boolean = false
): string {
const rng = createSeededRandom(stringSeed);
const colors = [
darkMode ? "#ff62b8" : "#ff3ea5",
darkMode ? "#818cf8" : "#6366f1",
darkMode ? "#fbbf24" : "#f59e0b",
];
const clouds: Cloud[] = [];
const cloudCount = rng.nextInt(4, 9);
for (let i = 0; i < cloudCount; i++) {
clouds.push({
type: "blob",
x: rng.nextFloat(-width * 0.4, width * 0.7),
y: rng.nextFloat(-height * 0.4, height * 0.7),
width: rng.nextFloat(width * 0.35, width * 0.9),
height: rng.nextFloat(height * 0.35, height * 0.9),
color: rng.pick(colors),
opacity: rng.nextFloat(0.12, 0.28),
blur: rng.nextFloat(50, 100),
rotation: rng.nextFloat(0, 360),
});
}
return generateSVG(clouds, backgroundColor, width, height);
}
Because the RNG is seeded, the composition is identical for identical input.
the api endpoint
Exposed via Next.js API route with aggressive caching:
export async function GET(request: NextRequest) {
const params = request.nextUrl.searchParams;
const width = parseInt(params.get("width") || "400");
const height = parseInt(params.get("height") || "400");
const stringSeed = params.get("string") || "default";
const darkMode = params.get("dark") === "true";
const svg = generateAbstractArt(width, height, stringSeed, darkMode);
return new Response(svg, {
headers: {
"Content-Type": "image/svg+xml",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
Generated once per unique input, cached for a year, served from edge forever.
usage
When a project doesn't have a custom image:
function getImageUrl(project: Project, isDark: boolean): string {
if (project.image) return project.image;
return `/api/abstract-art?width=${width}&height=${height}&string=${encodeURIComponent(project.id)}&dark=${isDark}`;
}
Dark mode changes the color palette but keeps blob positions unchanged. Same project, consistent visual identity across themes.
why this works
| Property | Benefit |
|---|
| No database | Zero storage costs |
| Deterministic | Infinitely cacheable |
| SVG output | Lightweight, scales perfectly |
| Pure JS | No image processing libraries |
I tried more complex generators with geometric shapes and gradients. The soft blobs looked best. Sometimes simpler is actually better.