hash01: A Random Number That Never Changes Its Mind

Part 1 of 7 in our series of stdlib primers — the deterministic hash, noise, and shaping functions.

Series: Stdlib Primers

  1. hash01 (this post) — a random number that never changes its mind
  2. hash11 — the same dice, rolled between −1 and 1
  3. hashRange — randomRange with a memory
  4. smoothstep — the S-curve that turns a cliff into a ramp
  5. bump — a hill you can put anywhere
  6. noise — randomness with a smooth ride
  7. noise2 — a weather map of smooth randomness

What it does

hash01(n) takes a whole number — an index, a loop counter, any integer label — and hands back a number between 0 (inclusive) and 1 (exclusive) that looks random. It isn't random at all: it's a lookup. The same label always produces the same answer, on every machine, in every browser, on every recompile, forever.

Under the hood it's a hash — which is just a scrambler: a fixed recipe of bit-mixing that turns the label 7 into 0.9646… so thoroughly that neighboring labels produce completely unrelated outputs. hash01(7) and hash01(8) have nothing to do with each other. And because every step of the recipe is pinned down exactly by the JavaScript standard — no trigonometry, no rounding wiggle room — there is no drift between engines. Your artwork compiles to the same bytes in the CLI, the playground, and the VS Code preview.

The second argument is a seed: hash01(i, 3) reads the same card number i from a different shuffled deck. hash01(i, 0) and hash01(i, 1) are two unrelated sequences over the same indices — which is how one loop index can drive many independent random-looking properties at once. (Leaving the seed off means deck 0.)

Why you'd use it

Because random() and randomRange() re-roll on every compile. That's fine for exploring, but the moment you like what you see, you want it to stay. hash01 is the version with a memory: randomness as a design decision rather than a dice throw. Scatter, jitter, texture, variation — all reproducible, all tunable, all shippable.

Two things to know before the examples:

  • It reads only the label, not the fraction. Inputs are truncated to whole numbers, so hash01(0.9) is the same as hash01(0) — and anything that isn't a real number at all (a divide-by-zero infinity, a NaN) is quietly treated as label 0. If you want a smooth function of a continuously varying input, that's what noise() is for.
  • The range is 0 up to (but never exactly) 1 — written [0, 1). In practice you'll multiply it into whatever range you need, and the missing endpoint never matters visually.

Example 1 — Ask twice, same answer

The whole function in one picture. The first two rows ask hash01(i) the same 48 questions from two separate loops. The third row asks randomRange(0, 1) instead.

// viewBox="0 0 400 210" //-- Three rows of 48 dots. Rows one and two ask hash01 the same questions //-- from two separate loops -- identical answers, identical rows. Row three //-- asks randomRange, which re-rolls on every compile. define ViewBox(0, 0, 400, 210); let labels = TextLayer('labels') ${ font-family: system-ui, sans-serif; font-size: 10; fill: #888; text-anchor: start; }; labels.apply { text(22, 26)`hash01(i) — first loop` text(22, 87)`hash01(i) — second loop, same answers` text(22, 152)`randomRange(0, 1) — different every compile` } let rowA = PathLayer('hash-first-ask') ${ fill: oklch(0.62 0.16 260); stroke: none; }; let rowB = PathLayer('hash-second-ask') ${ fill: oklch(0.62 0.16 260); stroke: none; }; let rowC = PathLayer('random-every-time') ${ fill: oklch(0.6 0.15 20); stroke: none; }; rowA.apply { for (i in 0..47) { circle(calc(22 + i * 7.6), calc(30 + hash01(i) * 30), 1.8); } } rowB.apply { for (i in 0..47) { circle(calc(22 + i * 7.6), calc(95 + hash01(i) * 30), 1.8); } } rowC.apply { for (i in 0..47) { circle(calc(22 + i * 7.6), calc(160 + randomRange(0, 1) * 30), 1.8); } } let scene = GroupLayer('scene') ${}; scene.append(labels, rowA, rowB, rowC); Three rows of 48 dots: two separate hash01 loops (blue), one randomRange loop (red).

The two blue rows are identical — not similar, identical — because hash01 is a lookup, not a roll. And here's the part worth trying yourself: drop this code in the playground and recompile — only the red row changes. The blue rows are fixtures; the red row is weather. (This is the one example in this post that uses randomRange — the contrast is the whole lesson.)

Example 2 — One label, three decks: a starfield

One index i, three seeds. The x-position reads deck 0, the y-position deck 1, the size deck 2 — three independent random-looking properties from one loop counter.

// viewBox="0 0 400 220" //-- One index, three decks: x from the default deck, y from deck 1, size //-- from deck 2. 140 stars land convincingly at random -- and land in //-- exactly the same places on every compile. define ViewBox(0, 0, 400, 220); let backdrop = PathLayer('night') ${ fill: oklch(0.22 0.04 275); stroke: none; }; backdrop.apply { rect(15, 15, 370, 190); } let stars = PathLayer('stars') ${ fill: oklch(0.92 0.03 260); stroke: none; }; stars.apply { for (i in 0..139) { circle(calc(22 + hash01(i) * 356), calc(22 + hash01(i, 1) * 176), calc(0.5 + hash01(i, 2) * 1.4)); } } 140 stars from one loop: x from seed 0, y from seed 1, radius from seed 2. Three independent streams, one index — and the same sky on every compile.

If x and y came from the same deck, every star would sit on the diagonal (x always equal to y, scaled). The seeds are what make the scatter two-dimensional. This is the core hash01 idiom: one index in, as many independent properties out as you have seeds.

Example 3 — A grid that isn't boring

Regular structure plus hashed variation. Every tile in this 14×8 grid sits exactly on its grid cell, but its lightness comes from one stream and its hue from another — a woven-textile effect from two lines of code.

// viewBox="0 0 400 240" //-- A 14 x 8 grid of tiles. Every tile's lightness comes from one hashed //-- stream and its hue from another -- the grid reads as a woven textile //-- swatch, and it's the same swatch on every compile. define ViewBox(0, 0, 400, 240); for (row in 0..7) { for (col in 0..13) { let idx = row * 14 + col; let L = 0.5 + hash01(idx) * 0.3; let H = 200 + hash01(idx, 1) * 130; let c = Color(L, 0.11, H); let cell = PathLayer(`cell-${idx}`) ${ fill: c; stroke: none; }; cell.apply { roundRect(calc(20 + col * 26), calc(20 + row * 26), 22, 22, 5); } } } Structure from the grid, variation from two hashed streams: lightness on one deck, hue on another. row * 14 + col gives every cell its own integer label.

Note how the index is built: row * 14 + col gives every cell its own integer label. That little arithmetic pattern — flattening a 2D position into one index — is how you hash grids, and it shows up again in noise2, where the grid itself becomes the random thing.

Example 4 — The hand-drawn ruler

Rigid layouts read as mechanical; a few hashed nudges make them read as human. The bottom ruler jitters each tick's x-position, lean, and length by small hashed amounts.

// viewBox="0 0 400 150" //-- Two rulers. The top one is machine-perfect. The bottom one nudges every //-- tick's position, lean, and length by a hashed amount -- it reads as //-- drawn by a person, and redraws identically forever. define ViewBox(0, 0, 400, 150); let rigid = PathLayer('machine') ${ stroke: oklch(0.65 0.02 260); stroke-width: 1.2; fill: none; }; rigid.apply { M 20 48 L 380 48 for (i in 0..35) { M calc(25 + i * 10) 48 l 0 -12 } } let loose = PathLayer('hand-drawn') ${ stroke: oklch(0.75 0.12 60); stroke-width: 1.2; fill: none; }; loose.apply { M 20 115 L 380 115 for (i in 0..35) { let x = calc(25 + i * 10 + hash01(i) * 3); let lean = calc(hash01(i, 1) * 2 - 1); let len = calc(10 + hash01(i, 2) * 6); M x 115 l lean calc(0 - len) } } Same ruler twice: machine-perfect above, three small hashed nudges below. The lean line's * 2 - 1 remap — 'either direction' instead of 'one way' — is the next post's whole reason to exist.

Look at the lean line: hash01(i, 1) * 2 - 1. That * 2 - 1 remaps [0, 1) into [-1, 1) — "nudge either direction" instead of "nudge one way." It's such a common move that it has its own function: hash11, the next post in this series.

Example 5 — A meadow that ships

Everything at once: ninety grass blades and a dozen seed heads, with position, height, lean, and color each drawn from its own seeded stream, composed into a finished little landscape.

// viewBox="0 128 400 132" //-- Ninety grass blades and a dozen seed heads, every property -- position, //-- height, lean, color -- drawn from its own hashed stream. A finished //-- little landscape that regenerates identically on every compile: change //-- any one seed constant and the whole meadow re-lands in a new (but //-- equally final) arrangement. define ViewBox(0, 128, 400, 132); let ground = PathLayer('ground') ${ fill: oklch(0.32 0.06 145); stroke: none; }; ground.apply { rect(15, 228, 370, 17); } fn blade(i) { let x = 22 + hash01(i) * 356; let h = 25 + hash01(i, 1) * 55; let lean = hash01(i, 2) * 30 - 15; let c = Color(0.52 + hash01(i, 3) * 0.18, 0.13, 135 + hash01(i, 3) * 30); let g = PathLayer(`blade-${i}`) ${ stroke: c; stroke-width: 1.6; fill: none; }; g.apply { M x 230 q calc(lean * 0.3) calc(0 - h * 0.6) lean calc(0 - h) } } for (i in 0..89) { blade(i); } let heads = PathLayer('seed-heads') ${ fill: oklch(0.8 0.1 85); stroke: none; }; heads.apply { for (k in 0..11) { let hx = 30 + hash01(k, 10) * 340; let hy = 150 + hash01(k, 11) * 60; circle(hx, hy, calc(1.5 + hash01(k, 12) * 1.5)); } } Ninety grass blades and a dozen seed heads — position, height, lean, and color each on its own seeded stream.

The payoff of determinism is right here: this meadow is done. It will render exactly like this in a blog post, a client deliverable, or a print export, next week and next year. And it's still one knob away from being a different meadow — bump any seed constant (hash01(i, 3)hash01(i, 7)) and every blade re-lands in a new, equally settled arrangement. Randomness you can direct.

Where to go next

  • hash11 — the same dice, rolled between −1 and 1 (the * 2 - 1 remap, built in).
  • hashRangerandomRange with a memory.
  • noise — the smooth version, for continuously varying input.
  • Reference: Hash & Noise docs.