Learn Creative Coding (#24) - Seed-Based Art: Reproducible Randomness
Last episode we talked about what makes art generative -- the spectrum between order and chaos, thinking in layers, constraints as creative vocabulary, and the role of curation. We touched on seeds briefly at the end: how randomSeed() and noiseSeed() let you freeze randomness into something reproducible. This episode, we go deep. Seeds are the foundation of everything that comes next in Phase 4 -- generative art that can be collected, traded, and perfectly reproduced. Same seed, same art, every time, on every machine.
In episode 12 we built Perlin noise with a seed parameter. Change the seed, get different noise. Same algorithm, different output, perfectly deterministic. Now we're scaling that idea up to entire compositions. A single number -- the seed -- determines everything: the colors, the shapes, the layout, the density, the vibe. You're not making one piece anymore. You're making a system that can produce thousands of pieces, each unique, each recognizably "yours."
This is how generative NFT platforms like fxhash and Art Blocks work. Each mint gets a unique seed (usually derived from the blockchain transaction hash), and the algorithm produces a unique artwork. The artist designed the algorithm, the palette, the composition rules, the parameter ranges. The seed just picks one specific point in that possibility space. The creative challenge shifts from "make this one thing look good" to "design a space where everything looks good." That's harder, and more rewarding :-)
Why Math.random() isn't enough
Math.random() isn't actually random. It's pseudorandom -- generated by a mathematical formula that produces numbers that LOOK random but follow a deterministic sequence. The starting point of that sequence is the seed.
Problem is, JavaScript's Math.random() doesn't let you set the seed. The browser picks one internally (usually from system entropy) and you have no control over it. That's why p5.js has randomSeed() -- it replaces the built-in generator with one you can control.
But for understanding AND for use in standalone generative pieces (especially anything that might live on-chain), we need our own. Let's build one from scratch.
Building a seedable PRNG
A PRNG (pseudo-random number generator) is just a function that takes a state, munges it with some math, and returns a number that looks random. Here's a simple one called "mulberry32" -- small, fast, and good enough for art:
function createRandom(seed) {
let state = seed;
return function() {
state |= 0;
state = state + 0x6D2B79F5 | 0;
let t = Math.imul(state ^ state >>> 15, 1 | state);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
// usage
let rng = createRandom(42);
console.log(rng()); // always 0.8279...
console.log(rng()); // always 0.2257...
console.log(rng()); // always 0.7814...
Call it three times with seed 42 and you always get the same three numbers. Seed 43 gives a completely different sequence. The bitwise operations (>>>, ^, Math.imul) scramble the state so thoroughly that even seeds that differ by 1 produce totaly unrelated outputs. That's the whole point -- nearby seeds should look nothing alike.
Why not something simpler? You could use a basic linear congruential generator:
function createSimpleRNG(seed) {
let s = seed;
return function() {
s = (s * 16807 + 0) % 2147483647;
return s / 2147483647;
};
}
This works, but it has correlation issues -- sequential seeds can produce similar early outputs, and the distribution isn't great. Mulberry32 is only a few lines longer and the quality difference is real. When you're generating art from thousands of different seeds, those subtle biases in a cheap PRNG show up as visible patterns. Use the good one.
In p5.js, you get seeded randomness for free with randomSeed() and noiseSeed():
function setup() {
let seed = 42; // or from URL, user input, whatever
randomSeed(seed);
noiseSeed(seed);
// everything from here is deterministic
}
For our custom Perlin noise from episode 12, the seed was already built into the constructor. The rule for seed-based art: no unseeded randomness anywhere. Every random decision must flow from the seed. One stray Math.random() call and your art is no longer reproducible.
A complete seeded toolkit
For generative art, you need more than bare 0-to-1 numbers. You need random floats in a range, random integers, picking from arrays, shuffling, weighted choices, and gaussian distributions. Build utility functions on top of your PRNG:
function createArt(seed) {
let rng = createRandom(seed);
function random(min, max) {
if (max === undefined) { max = min; min = 0; }
return min + rng() * (max - min);
}
function randomInt(min, max) {
return Math.floor(random(min, max));
}
function pick(arr) {
return arr[Math.floor(rng() * arr.length)];
}
// Fisher-Yates shuffle
function shuffle(arr) {
let a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
let j = Math.floor(rng() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// weighted random choice
function weighted(options) {
// options: [{value: 'x', weight: 3}, {value: 'y', weight: 1}]
let total = options.reduce((sum, o) => sum + o.weight, 0);
let r = rng() * total;
for (let opt of options) {
r -= opt.weight;
if (r <= 0) return opt.value;
}
return options[options.length - 1].value;
}
// gaussian-ish distribution (Box-Muller transform)
function gaussian(mean, std) {
let u1 = rng();
let u2 = rng();
let z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
return mean + z * std;
}
return { random, randomInt, pick, shuffle, weighted, gaussian };
}
Now your entire piece runs off one seed:
let seed = 42;
let R = createArt(seed);
let palette = R.pick(palettes);
let numShapes = R.randomInt(20, 60);
let compositionType = R.weighted([
{ value: 'grid', weight: 3 },
{ value: 'spiral', weight: 2 },
{ value: 'scatter', weight: 1 }
]);
Change the seed, get a different palette, a different shape count, maybe a different composition type entirely. But seed 42 always picks the same choices. That weighted function is especially useful -- it gives you controllable rarity. "Scatter" only shows up roughly 1 in 6 times. Collectors love rare trait combinations. We'll come back to that.
The gaussian function deserves a quick explanation. The Box-Muller transform takes two uniform random numbers and converts them into a normally distributed number -- values cluster around the mean and tail off in both directions. Use it when you want most elements concentrated in a region with a few outliers. Particle positions clustered around a center point, stroke widths mostly thin with occasional thick ones. Uniform randomness spreads things evenly. Gaussian randomness creates density and emphasis.
Seeded noise
Remember in episode 12 when we built Perlin noise? That implementation already had a seed parameter because the permutation table was shuffled at construction time. For seed-based art, we need to make sure every noise field is also reproducible. Here's a seeded 2D value noise using our PRNG:
function createNoise(seed) {
let rng = createRandom(seed);
// pre-generate a permutation table
let perm = [];
for (let i = 0; i < 256; i++) perm[i] = i;
for (let i = 255; i > 0; i--) {
let j = Math.floor(rng() * (i + 1));
[perm[i], perm[j]] = [perm[j], perm[i]];
}
// double it to avoid wrapping issues
for (let i = 0; i < 256; i++) perm[256 + i] = perm[i];
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + (b - a) * t; }
function grad(hash, x, y) {
let h = hash & 3;
let u = h < 2 ? x : y;
let v = h < 2 ? y : x;
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
}
return function noise(x, y) {
let xi = Math.floor(x) & 255;
let yi = Math.floor(y) & 255;
let xf = x - Math.floor(x);
let yf = y - Math.floor(y);
let u = fade(xf);
let v = fade(yf);
let aa = perm[perm[xi] + yi];
let ab = perm[perm[xi] + yi + 1];
let ba = perm[perm[xi + 1] + yi];
let bb = perm[perm[xi + 1] + yi + 1];
return lerp(
lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u),
lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u),
v
);
};
}
let noise = createNoise(42);
console.log(noise(1.5, 2.3)); // always the same value for seed 42
Same noise landscape, every time, for the same seed. Different seed, different landscape. This is the same fade/lerp/grad structure from episode 12 -- the only difference is we're using our seeded PRNG to build the permutation table instead of relying on a fixed one. Now your flow fields, your textures, your terrain -- all deterministic.
How fxhash and Art Blocks do it
On generative art platforms, each minted token gets a unique hash -- usually derived from the blockchain transaction. The artist's algorithm converts that hash to a seed:
// fxhash provides a unique hash string per mint
// let fxhash = "oo..." (unique per token)
function hashToSeed(hash) {
let seed = 0;
for (let i = 0; i < hash.length; i++) {
seed = ((seed << 5) - seed) + hash.charCodeAt(i);
seed |= 0;
}
return Math.abs(seed);
}
let seed = hashToSeed(fxhash);
let rng = createRandom(seed);
Art Blocks on Ethereum works the same way. A transaction hash becomes the seed. The moment someone mints, the seed is fixed, the art is determined, and nobody -- not even the artist -- chose what it looks like. The algorithm decided. That's the magic of it. The artist defines the possibility space, the blockchain rolls the dice.
Designing trait distributions
Think of your seed-based algorithm as generating "traits" -- discrete choices that affect the output. This is the NFT mindset: each output has properties, and those properties have rarity:
function generateTraits(R) {
return {
background: R.pick(['dark', 'light', 'colored']),
density: R.weighted([
{ value: 'sparse', weight: 2 },
{ value: 'medium', weight: 5 },
{ value: 'dense', weight: 3 }
]),
palette: R.pick([
['#264653', '#2a9d8f', '#e9c46a', '#e76f51'],
['#f72585', '#7209b7', '#3a0ca3', '#4361ee'],
['#606c38', '#283618', '#fefae0', '#dda15e'],
['#0d1b2a', '#1b263b', '#415a77', '#778da9'],
]),
hasAccent: R.random(0, 1) > 0.3, // 70% chance
symmetry: R.pick(['none', 'horizontal', 'radial']),
};
}
"Dense" appears 30% of the time. "Sparse" appears 20%. The accent element shows up in 70% of outputs. Radial symmetry is roughly 33%. This gives you controllable rarity -- and on NFT platforms, rarity drives collector interest. People love feeling like they got something special. A "sparse, radial, dark background with no accent" might only appear in 1-2% of all possible mints. That combination becomes desirable purely because it's uncommon.
But be careful: rare variants still need to look good. A rare ugly output is worse than no rarity at all. Every combination of traits needs to produce something visually coherent. That's the hard part.
Testing across seeds
Always test your algorithm with many seeds. Not 5, not 10 -- hundreds. Here's a quick testing workflow:
function testSeeds() {
for (let s = 0; s < 100; s++) {
let R = createArt(s);
let traits = generateTraits(R);
console.log(`Seed ${s}: ${JSON.stringify(traits)}`);
}
}
Look for:
- Are all palette options actually appearing? If one palette never shows up in 100 seeds, your pick function might have a bias.
- Is the density distribution matching your weights? Count the occurrences.
- Are there seeds that produce broken or boring outputs? Those need fixing.
- Is there enough visible variety between consecutive seeds? Seeds 0, 1, 2 should look clearly different.
The more interesting test is visual. Render 50-100 outputs side by side and review them with your eyes. Which ones look great? Which ones look off? What parameter ranges produce the best results? Narrow the ranges, regenerate, review again. Iterate until the "worst case" seed still looks acceptable.
Professional generative artists spend most of their time on this curation-through-parameter-tuning process. The algorithm might take a day. Tuning the parameter space takes weeks. But it's the difference between "sometimes produces nice output" and "every single output is gallery quality."
A complete seeded sketch
Allez, let's put everything together. A full seed-based flow field piece with traits, noise, and the complete toolkit:
function render(seed) {
let R = createArt(seed);
let noise = createNoise(seed + 1000); // offset to avoid correlation
let canvas = document.getElementById('canvas');
let ctx = canvas.getContext('2d');
let W = canvas.width = 800;
let H = canvas.height = 800;
// generate traits
let palette = R.pick([
['#0d1b2a', '#1b263b', '#415a77', '#778da9', '#e0e1dd'],
['#003049', '#d62828', '#f77f00', '#fcbf49', '#eae2b7'],
['#10002b', '#240046', '#3c096c', '#7b2cbf', '#c77dff'],
['#2d6a4f', '#40916c', '#52b788', '#74c69d', '#b7e4c7'],
]);
let density = R.randomInt(60, 200);
let noiseScale = R.random(0.003, 0.01);
let lineLength = R.randomInt(40, 120);
let strokeAlpha = R.random(0.1, 0.4);
// background
ctx.fillStyle = palette[0];
ctx.fillRect(0, 0, W, H);
// flow field
for (let i = 0; i < density; i++) {
let x = R.random(0, W);
let y = R.random(0, H);
let color = R.pick(palette.slice(1));
ctx.beginPath();
ctx.moveTo(x, y);
for (let s = 0; s < lineLength; s++) {
let angle = noise(x * noiseScale, y * noiseScale) * Math.PI * 4;
x += Math.cos(angle) * 2;
y += Math.sin(angle) * 2;
ctx.lineTo(x, y);
if (x < 0 || x > W || y < 0 || y > H) break;
}
ctx.strokeStyle = color;
ctx.globalAlpha = strokeAlpha;
ctx.lineWidth = R.random(0.5, 3);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
// render with URL parameter as seed
let params = new URLSearchParams(window.location.search);
let seed = parseInt(params.get('seed')) || 0;
render(seed);
document.title = `Seed: ${seed}`;
Visit ?seed=0, ?seed=1, ?seed=2 -- each one is a unique piece, but they're clearly from the same family. Same DNA, different expressions. That's exactly what we talked about last episode: the algorithm IS the artwork. The seed is just the knob.
Notice the createNoise(seed + 1000) call. I'm offsetting the noise seed from the art seed to avoid correlation. If both the PRNG and the noise use the same seed value, the first few random numbers from the PRNG might correlate with the noise pattern in subtle ways. Adding an offset breaks that correlation. Small detail, but it matters when you're generating thousands of outputs.
The flow field structure here is similar to what we used back in episode 12 when we first played with Perlin noise. But now it's wrapped in the seed system so it's fully reproducible. Same concept, new superpower.
Designing for diversity
The hard part of seed-based art isn't the technical implementation -- it's making sure your algorithm produces interesting variety across seeds. Here are the techniques I've found work best:
Constrained color theory. Don't just pick random colors. Pick palettes that are pre-designed to work. Each palette in our example is a curated set of 4-5 colors that harmonize. The seed picks WHICH palette, but every option is guaranteed to look good. This is the constraint-as-quality-control idea from last episode.
Parameter interaction. Make parameters interact so different seeds feel genuinely different, not just "the same thing but slightly shifted." If density is high, maybe line length should be shorter. If the palette is dark, maybe stroke alpha should be higher. Cross-parameter dependencies create more visual variety than independent parameters:
let density = R.randomInt(60, 200);
let lineLength = R.randomInt(40, 120);
// interaction: dense sketches get shorter lines to avoid muddiness
if (density > 150) {
lineLength = Math.min(lineLength, 70);
}
Rare variants. Add a small chance of something unexpected. Maybe 5% of seeds get an inverted palette (background becomes the brightest color). Maybe 3% get a radically different composition. These rare outputs become collector favorites -- but they still need to look good. Test every rare branch separately.
Hash quality matters. Simple seeds (1, 2, 3, 4...) sometimes produce correlated early outputs if your PRNG is weak. Mulberry32 handles sequential integers well, but if you're converting from strings (like blockchain hashes), use a proper hash function that distributes even similar inputs across the full output space. The hashToSeed function above does this with the djb2 algorithm.
Presenting seed-based work
When you show seed-based art, show multiple outputs from the same algorithm. A single output could be anything. Three outputs side by side show the system -- the viewer sees what varies and what stays constant, what the algorithm controls and what the seed determines. That's the artistic statement: not "look at this one picture" but "look at this space of possibilities."
For a portfolio, consider creating a page with a live version where visitors can enter their own seed and see what they'd get. This lets people experience the system, not just static images. Interactive demos are what make generative art different from traditional digital art -- the viewer becomes a participant.
On platforms like fxhash, the convention is a preview gallery of 10-20 curated outputs that represent the range of the algorithm. Collectors browse these to understand the aesthetic space before minting their own unique output. Your preview gallery IS your portfolio piece. Pick outputs that show the full range: different palettes, different densities, different compositions -- all from the same algorithm, all from different seeds.
The order-of-consumption problem
One thing that trips people up with seed-based art: the order in which you consume random numbers matters. Your PRNG is a sequence. The first call gives number A, the second gives number B, the third gives number C. If you add a new random decision early in your code (say, a new trait), every subsequent random call shifts by one position in the sequence. Suddenly all your existing outputs look different even though the seed hasn't changed.
This is why you should lock down your trait generation order early in development. Once you're happy with how traits are generated, don't insert new random calls before the existing ones. Add new randomness at the end. Or better yet, use separate PRNG instances for separate concerns:
let traitRng = createRandom(seed);
let layoutRng = createRandom(seed + 1);
let colorRng = createRandom(seed + 2);
// trait decisions use traitRng
let density = Math.floor(traitRng() * 140 + 60);
// layout decisions use layoutRng
for (let i = 0; i < density; i++) {
let x = layoutRng() * W;
let y = layoutRng() * H;
// ...
}
// color decisions use colorRng
let baseHue = colorRng() * 360;
Now adding a new trait to traitRng doesn't affect the layout or colors. Each concern has its own isolated random stream. This is critical once you start showing or selling outputs -- changing an existing seed's output after people have seen it is bad form.
A curation tool
Last episode we built a simple curation workflow in p5.js. Here's the vanilla Canvas version that works with our seed system:
let currentSeed = 0;
let savedSeeds = [];
function explore(direction) {
currentSeed += direction;
if (currentSeed < 0) currentSeed = 0;
render(currentSeed);
document.getElementById('seedDisplay').textContent = `Seed: ${currentSeed}`;
}
window.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'ArrowRight') {
explore(1);
}
if (e.key === 'ArrowLeft') {
explore(-1);
}
if (e.key === 's') {
savedSeeds.push(currentSeed);
console.log('Saved:', savedSeeds);
}
});
render(0);
Space bar or right arrow for next seed, left arrow to go back, S to save. Flip through hundreds of seeds. Save the good ones. Study what makes them good. That analysis feeds back into your algorithm as tighter constraints and better parameter ranges. The curation workflow from last episode, but now integrated with the seed system.
Remember in episode 20 when I said to always save your random seed alongside every export? This is why. The seed IS the identity of a specific output. Lose the seed, lose the ability to reproduce it. Every PNG you save from this system should have the seed in the filename: piece_seed_42.png, piece_seed_1337.png. That metadata is as important as the image itself.
't Komt erop neer...
- PRNGs generate deterministic sequences from a seed -- "random" but reproducible
- Build your own PRNG (mulberry32) for full control over the random stream
- Layer utility functions on top:
random,pick,shuffle,weighted,gaussian - NFT platforms use transaction hashes as seeds -- the mint moment determines the art
- Test across 100+ seeds to ensure your algorithm produces consistently good results
- Seed your noise function too -- everything must be reproducible
- Traits with weighted probabilities create collectible rarity distributions
- Watch the order-of-consumption -- adding random calls early in the code shifts everything downstream
- Use separate PRNG instances for separate concerns (traits, layout, color) to isolate changes
- Present seed-based work as a system, not a single image -- show the range
We've got the technical foundation for reproducible generative art locked down. But a seed-based system is only as interesting as the compositions it produces. How do you make sure every output feels intentionally designed, not randomly scattered? That's a question about structure -- grids, subdivisions, recursive layouts, spatial relationships. The composition layer is where "generative" starts looking like "art." We'll get into that next time :-)
Sallukes! Thanks for reading.
X