Deterministic dithered dot avatars. A name and a seed go in; a two-tone tile comes out — circles arranged by ordered dithering, washing from empty through a half-tone band into solid.
Same input, same tile. Forever, on every platform. No dependencies.
npm install picotileimport { avatar, avatarDataUri, generate } from 'picotile';
const svg = avatar('Gafelson', { seed: 42 }); // SVG string
const uri = avatarDataUri('Gafelson', { seed: 42 }); // <img src={uri} />
const pattern = generate('Gafelson', { seed: 42 }); // raw dot coordinatesTwo independent halves, which is the idea worth keeping hold of: the field decides how dense each part of the tile should be, and the dither decides how to draw that density in circles. Change one and the other still behaves.
- Seed.
nameandseedare hashed (xmur3) into the state of ansfc32PRNG. Every later decision draws from that one stream, which is what makes the result reproducible. - Field. A smooth scalar landscape over the tile — a ramp, a radial falloff, a conic sweep or concentric rings, plus two or three gentle harmonics that bend it. Low frequencies only, so it reads as a warped gradient rather than as texture fighting the dither.
- Shaping. Field values are mapped onto a density profile by rank: the lowest cells become empty, the highest become solid, and the middle gets a linear ramp between them. See below for why this is by rank.
- Dither. A cell is lit when its density beats a Bayer threshold. Bayer spreads thresholds so evenly that a half-lit region resolves into a clean checkerboard, and the crossings either side of it into regular half-tone bands. Random thresholds would just give noise.
- Render. One circle per lit cell, all the same radius, two flat colours.
The four field types, same name and seed (avatar('Gafelson', { seed: 42, field })):
linear radial sweep ripple
The obvious approach is to fit a curve to the raw field — gamma, gain, an offset. It doesn't work, and the reason is worth stating: the result is then at the mercy of whatever histogram the field happens to have. A radial falloff piles most of its cells near one end, so any fixed curve collapses the tile into a solid blob with a hard edge and no half-tone at all.
Ranking sidesteps that. The field decides which cells are densest; the profile
decides how dense that ends up being. So every pattern gets a real gradient
whatever shape produced it, and coverage and contrast mean the same thing in
all four field modes. A test asserts the half-tone band survives on hundreds of
generated tiles, which is exactly the regression to guard against.
generate and renderSvg are split so you can draw the dots yourself — canvas,
WebGL, a plotter, CSS boxes. A Pattern is plain data:
{
name: string;
seed: string;
gridSize: number;
field: 'linear' | 'radial' | 'sweep' | 'ripple';
coverage: number;
contrast: number;
ditherSize: number;
dots: { x: number; y: number; value: number }[];
palette: { background: string; dot: string };
}| option | default | meaning |
|---|---|---|
seed |
'' |
string or number; changes the tile completely |
gridSize |
32 |
cells per side |
field |
'auto' |
linear, radial, sweep, ripple |
coverage |
from seed | fraction of cells lit, 0..1 |
contrast |
from seed | 0 a slow wash across the whole tile, 1 a hard edge |
ditherSize |
4 |
Bayer matrix size: 2, 4 or 8. Larger is finer |
palette |
from seed | override background and/or dot |
coverage is honest: ask for 0.35 and about 35% of the cells light up, in
every field mode, to within a couple of cells.
| option | default |
|---|---|
size |
160 |
dotScale |
0.86 (diameter per cell; 1 touches, >1 merges into solid) |
background |
true |
radius |
0 (fraction of size, for rounded tiles) |
sized |
true (emit width/height as well as viewBox) |
title |
the name (null to omit) |
dotScale is the main aesthetic dial. Below 1 the tile stays visibly dotted
everywhere; at 1.2 the dense regions fuse into continuous shapes while the
half-tone band stays granular.
avatar('Gafelson', { dotScale: 1.2, palette: { background: '#007139', dot: '#00e19f' } });npx picotile Gafelson --seed 42 --size 512 --out gafelson.svg
npx picotile "Ada Lovelace" --field radial --contrast 0.2 --dot-scale 1.1- Pure ESM, zero runtime dependencies, Node 18+ and any modern browser.
- Output is a plain string, so it works server-side, at build time, or in a service worker just as well as in the DOM.
- A 32×32 tile is a few hundred
<circle>elements. If you are rendering thousands on one page, generate once and cache the string. npm run demowritespreview/index.html— a gallery plus a panel showing what each option does.
npm run build && npm test
npm publish --access publicpicotile is the reserved name on the registry.
GPL-3.0-or-later