ulam-prng: Seeded Randomness for Creative Coding
ulam-prng is a small TypeScript library of seeded randomness for generative art. At its core is a PCG generator, but most of it is the higher level randomness you actually reach for when drawing: weighted choices, shuffles, a shelf of distributions, random directions, points inside shapes, Poisson disk and quasi-random spreads, noise and random walks. It all comes from one seed, so the same seed always draws the same picture.
It was initially extracted from my Solandra creative coding library, where these helpers had grown up over several years of sketches, and has since gained a good deal that Solandra never had. It has no dependencies and works anywhere JavaScript does: canvas, SVG, WebGL, Three.js, Node or a plotter script.
Full documentation, including guides, an explorer for every distribution and the API reference, is at ulam-prng.pages.dev. The source is on GitHub. Every figure in this post is drawn live in your browser with the published package; most have a slider or two and a button to try a new seed.
Install
pnpm add ulam-prng
npm, yarn and bun work just as well. It is ESM only and includes its TypeScript types.
import { RNG } from 'ulam-prng'
const rng = new RNG(12345)
rng.number() // Uniform in [0, 1)
rng.randomAngle() // Radians, 0 to 2π
rng.sample(['red', 'green', 'blue'])
rng.gaussian({ mean: 10, sd: 2 })
rng.onUnitCircle() // A random direction, [x, y]
rng.poissonDiskPoints({ minDist: 0.05 }) // Evenly spread points
Why not Math.random()?
Math.random() is fine for a quick sketch, but it cannot be seeded. When you find an output you like, you cannot get it back, and you cannot share the recipe for it. A seeded generator turns every picture into a pair: some code and a seed. Keep the seed and you keep the picture, at any resolution, as often as you like.
The seed does not have to be a number. A string is hashed to a full 64 bits, so a sketch can be named rather than numbered:
new RNG() // Different every run, seeded from the platform's crypto
new RNG(42) // Reproducible
new RNG('sunflower') // Any string will do
Type into the box below. Nearby strings give unrelated seeds, so "tree" and "tres" look nothing alike, but going back to a word always brings back its picture.
That sketch is about a dozen lines: Poisson disk points for the layout, a truncatedGaussian for each radius (so they cluster around a typical size but never get absurd), a shuffled palette and a weightedSampler so one colour dominates and one is rare:
const rng = new RNG(seed)
const layout = rng.stream('layout')
const colour = rng.stream('colour')
const [a, b, c, d] = colour.shuffled(palette)
const pick = colour.weightedSampler([
[5, a],
[3, b],
[2, c],
[1, d],
])
for (const [x, y] of layout.poissonDiskPoints({ minDist: 0.075 })) {
const r = layout.truncatedGaussian({
mean: 0.026,
sd: 0.012,
min: 0.008,
max: 0.036,
})
circle(x, y, r, pick())
if (layout.bernoulli(0.35)) circle(x, y, r * 0.45, pick())
}
Streams: randomness that stays put
There is a problem with seeds that anyone who has done much generative art will have hit. You find a seed you love, then add one small thing (some texture, an extra layer) and the whole picture changes. Every number drawn after the new code comes from a different place in the sequence.
stream(name) fixes this. It gives a generator derived from the seed and the name only, not from how far along the parent is, so each part of a sketch gets its own independent supply:
const rng = new RNG('sunflower')
const texture = rng.stream('texture')
const layout = rng.stream('layout')
const colour = rng.stream('colour')
Both sides below draw some speckles first, then nine circles. On the left everything comes from one generator; on the right speckles, positions and colours each have a stream. Move the slider.
Streams nest, so a component handed layout can name streams of its own without colliding with anything above it. There is also fork(), for when there are simply a lot of things (one generator per petal, say) rather than a few named parts. And a generator can be saved, position and all, as a 32 character URL safe string with toJSON() and restored with RNG.fromJSON(). The guides to streams and saving to a URL cover all of that.
Distributions
rng.number() gives a uniform value between 0 and 1: every value equally likely. It is the raw material for everything else, but on its own it tends to look flat. Real things are not uniform. Sizes cluster around a typical value, gaps between events vary wildly and directions lean one way. Picking the right distribution is often the difference between a sketch that looks random and one that looks natural.
The library has twenty-one of them. Here are six of the more basic and useful ones, each shown as something you might draw with it and as a histogram of 20,000 draws underneath. The picture and the histogram use separate streams, so the histogram's 20,000 draws do not disturb the picture.
Gaussian
The bell curve, gaussian({ mean, sd }). Most values land near the mean; how far they spread is set by the standard deviation, sd. About two thirds of draws fall within one sd of the mean, and almost all within three.
For placing things, gaussianVec2 gives a point whose x and y are both gaussian: a round blur around a centre, like spray paint or a cluster of stars.
for (let i = 0; i < 1800; i++) {
const [x, y] = rng.gaussianVec2({ mean: [cx, cy], sd: 0.1 })
dot(x, y)
}
If you need a gaussian that stays within bounds, use truncatedGaussian({ mean, sd, min, max }) rather than clamping. Clamping piles values up on the edges; truncating draws only from the part of the bell inside the bounds.
Log-normal
logNormal({ mu, sigma }) is exp of a gaussian: always positive and skewed to the right. It is the classic distribution for sizes. Most things are smallish, a few are noticeably bigger and occasionally one is huge. Uniform sizes look mechanical by comparison. Turn sigma up for more drama, down for more uniformity.
const r = 0.012 * rng.logNormal({ sigma: 0.6 })
Exponential
exponential({ rate }) is the waiting time between events that happen independently at a steady average rate, like raindrops, arrivals or clicks of a Geiger counter. The mean gap is 1 / rate, and short gaps are always the most likely.
That last point surprises people. Truly random events bunch up: below, each row adds exponential gaps to place lines, and you get dense clusters and wide empty stretches rather than anything even. The rate only scales the picture; the shape of the histogram never changes.
let x = rng.exponential({ rate: 50 })
while (x < 1) {
line(x)
x += rng.exponential({ rate: 50 })
}
Beta
beta({ alpha, beta }) gives a proportion between 0 and 1. It is wonderfully flexible: the mean is alpha / (alpha + beta), both above 1 gives a hump, both below 1 gives a U shape (values pushed to the extremes) and both equal to 1 is uniform. Use it for how full something is, how far along an edge to put something, or, below, how tall to make a building.
const height = rng.beta({ alpha: 2, beta: 5 })
Try alpha and beta both at 0.5 for a skyline of towers and stumps, or both at 8 for something that looks planned.
Von Mises
vonMises({ mean, kappa }) is the gaussian for things that wrap round: angles, headings and hue offsets. A gaussian angle near ±π would need special care; von Mises handles the wrap for you. kappa is the concentration: 0 means any direction at all, and larger values bunch the angles ever tighter around mean.
Below, each blade of grass sits on a jittered grid and leans by a von Mises angle around straight up. Combine it with noise (making mean vary smoothly across the canvas) and you have the basis of a flow field with some life in it.
const angle = rng.vonMises({ mean: -Math.PI / 2, kappa: 6 })
Poisson
poisson(lambda) is a count: how many events happen in a fixed interval when they occur at a steady average rate lambda. It is the discrete partner of the exponential. Use it for how many of something to put in each region: seeds in a cell, windows on a floor, stars in a patch of sky. Each box below gets poisson(lambda) dots: red where it got more than one standard deviation (√lambda) above the average, blue where it got that far below.
const count = rng.poisson(3)
There are many more: the heavy tailed pareto and cauchy, triangular for a cheap 'around here', gamma, weibull, binomial, geometric, zipf for ranked sizes and dirichlet for splitting a whole into random shares (great for dividing a canvas or a colour budget). The distribution explorer on the docs site shows every one of them with live parameters.
Every distribution is also exported as a standalone function that takes any source of uniform randomness first, so you can use them with another generator or with Math.random:
import { gamma, RNG, weibull } from 'ulam-prng'
gamma(Math.random, { shape: 2, scale: 0.5 })
const rng = new RNG(1)
weibull(rng.random, { shape: 8 }) // rng.random is pre-bound
Spreading points
The most common thing to do with randomness in creative coding is probably placing things. And uniformly random placement, randomPoint(), is usually the wrong choice. Uniform points clump together and leave gaps; our eyes see the clumps as structure that is not really there.
The library has three better options, from cheapest to nicest:
rng.jitteredGridPoints({ columns: 20 }) // One random point per grid cell
rng.quasiRandomPoints({ n: 400 }) // A low discrepancy sequence
rng.poissonDiskPoints({ minDist: 0.04 }) // Never closer than minDist
randomPointjitteredGridPointsquasiRandomPointspoissonDiskPointsA jittered grid is as cheap as uniform points, but you can still see the grid if you squint. A quasi-random (low discrepancy) sequence fills space evenly in any order, which is lovely for progressive drawing, but up close it has a faint lattice feel. Poisson disk sampling, via Bridson's algorithm, gives points that are random but never closer than minDist. Nature tends to do the same (think of cells, trees in a forest or the receptors in your eye) and it is usually what looks best. The even spreads guide goes further.
Variable density: stippling
Poisson disk sampling gets really interesting when minDist is a function of position. Make it small where you want ink and large where you want paper and you have a stippling engine. You tell the sampler the largest spacing with maxDist, and contains confines the points to a shape:
const points = rng.poissonDiskPoints({
width: 1.5,
height: 1,
maxDist: widest,
contains: (p) => inSphere(p) || inShadow(p),
minDist: (p) => finest + (widest - finest) * brightness(p) ** 1.5,
})
Here a sphere is shaded with a simple Lambert light from the upper left, entirely in dots. Switch to noise for spacing driven by simplexNoise().fbm() instead.
These are just as useful for plotters, laser cutters and embroidery as for screens: every point is a mark and none of them overlap.
Points in shapes
A classic trap: to put a point in a circle, pick a random angle and a random radius. The result crowds the centre, because there is far less area near the middle than near the rim. inUnitDisc() is uniform by area:
rng.inUnitDisc() // Uniform by area
rng.inUnitDisc({ radius: 3 })
rng.inAnnulus({ inner: 0.8 }) // A ring
rng.inTriangle(a, b, c)
rng.inPolygon(vertices) // Convex or not
inUnitDisc()The same care goes into directions: onUnitCircle() and onUnitSphere() are even in every direction, rather than normalising a random point in a square (which favours the diagonals) or using latitude and longitude (which crowds the poles).
Walks and noise
Two last helpers deal with randomness that should flow rather than jump. walk() gives a path whose steps remember where the last one went. At momentum 0 it is the jagged scribble of Brownian motion; towards 1 it turns slowly and sweeps. There is also drift, for a current the walk is carried along by.
const path = rng.walk({ steps: 400, stepSize: 0.004, momentum: 0.9 })
And for values that vary smoothly from place to place (heights, hues, widths, angles), there are seeded perlinNoise(), simplexNoise() and valueNoise() fields, each with fbm() for stacking octaves of detail. They take their randomness from the generator once, when built, so a noise field is a fixed landscape and fits into the seed and streams model like everything else. See the noise and walks guides.
Find out more
The documentation at ulam-prng.pages.dev has a guide to each part of the library with interactive examples, the distribution explorer, the full API reference and the release notes. Install it with pnpm add ulam-prng and give your next sketch a name instead of a number.