hash11: The Same Dice, Rolled Between −1 and 1

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

Series: Stdlib Primers

  1. hash01 — a random number that never changes its mind
  2. hash11 (this post) — 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

hash11(n, seed?) is hash01's twin for nudges. Same deterministic lookup, same seeds-as-decks behavior, same "truncates to whole numbers" rule — but the answer lands between −1 and 1 (−1 inclusive, 1 exclusive) instead of 0 and 1.

The relationship is exact and worth seeing once: hash11(n) is precisely hash01(n) * 2 - 1. Everything you learned in part 1 — determinism, bit-exactness across machines, seeds, integer truncation — carries over unchanged, so this post won't repeat it.

Why you'd use it

Because most design randomness isn't "pick a value" — it's "start from the right value and drift a little, either direction." Signed means the drift can be negative: a push left as easily as right, down as easily as up. Baseline wobble, tilt, breathing room, hand-drawn looseness — they're all symmetric drifts around a deliberate center, and [−1, 1) is their natural shape.

The idiom to memorize (it's all over this blog):

let wobble = 1 + hash11(i, layerIndex) * 0.2;

That's "a ±20% factor, per index, per layer" — multiply it onto a width, a radius, a spacing, anything. The 0.2 is the amplitude dial; the seed keeps each layer's wobble independent.

Example 1 — Above and below the line

The range, visually: 48 dots whose height is hash11(i) * 43 measured from a center axis. About half land above, half below, at unrelated heights.

// viewBox="0 0 400 190" //-- 48 dots straddling an axis: hash11(i) answers between -1 and 1, so //-- roughly half land above the line and half below, at hashed heights. define ViewBox(0, 0, 400, 190); let axis = PathLayer('axis') ${ stroke: oklch(0.5 0.02 260); stroke-width: 1; fill: none; }; axis.apply { M 40 95 L 380 95 } let ticks = TextLayer('ticks') ${ font-family: system-ui, sans-serif; font-size: 10; fill: #888; text-anchor: end; }; ticks.apply { text(34, 52)`+1` text(34, 99)`0` text(34, 146)`−1` } let dots = PathLayer('dots') ${ fill: oklch(0.62 0.16 260); stroke: none; }; dots.apply { for (i in 0..47) { circle(calc(44 + i * 7), calc(95 - hash11(i) * 43), 2); } } 48 dots at hash11(i) · 43 from the center axis — ticks mark −1, 0, and +1.

Note the y expression: 95 - hash11(i) * 43. Screen y grows downward, so subtracting a positive answer moves the dot up — the sign of hash11 maps directly onto "which side of the line."

Example 2 — Hand-set type

The simplest real use: baseline jitter. Both rows are the same 26 bars; the bottom row drops each baseline by hash11(i) * 5 and leans each bar by hash11(i, 1) * 4.

// viewBox="0 0 400 170" //-- Two rows of "type": equal-width bars standing on a baseline. The top //-- row is machine-set. The bottom row nudges each bar's baseline up or //-- down and leans it left or right -- instantly warmer, and repeatable. define ViewBox(0, 0, 400, 170); let machine = PathLayer('machine-set') ${ stroke: oklch(0.7 0.03 260); stroke-width: 7; fill: none; }; machine.apply { for (i in 0..25) { M calc(28 + i * 13.5) 62 l 0 -32 } } let hand = PathLayer('hand-set') ${ stroke: oklch(0.75 0.12 60); stroke-width: 7; fill: none; }; hand.apply { for (i in 0..25) { let drop = calc(hash11(i) * 5); let lean = calc(hash11(i, 1) * 4); M calc(28 + i * 13.5) calc(138 + drop) l lean -32 } } The same 26 bars twice: machine-set above, baseline-and-lean jittered below.

Five pixels of drop and four of lean — tiny numbers, big warmth. Because the amounts are signed, bars drift both up and down, left and right; with hash01 you'd get a row that only ever sagged one way.

Example 3 — The jitter knob

Jitter amplitude as a single tunable dial. Three copies of one 10×6 grid, with every dot offset by (hash11(idx) * j, hash11(idx, 1) * j) — and j set to 0, 2, and 5.

// viewBox="0 0 400 132" //-- The same 10 x 6 dot grid three times. The only difference is j -- the //-- jitter amplitude multiplying hash11: 0 (rigid), 2 (relaxed), 5 //-- (scattered). One number is the entire design decision. define ViewBox(0, 0, 400, 132); fn jitteredGrid(name, x0, j) { let g = PathLayer(name) ${ fill: oklch(0.68 0.13 200); stroke: none; }; g.apply { for (row in 0..5) { for (col in 0..9) { let idx = row * 10 + col; circle(calc(x0 + col * 11 + hash11(idx) * j), calc(48 + row * 11 + hash11(idx, 1) * j), 1.7); } } } } jitteredGrid('rigid', 28, 0); jitteredGrid('relaxed', 153, 2); jitteredGrid('scattered', 278, 5); let labels = TextLayer('labels') ${ font-family: system-ui, sans-serif; font-size: 10; fill: #888; text-anchor: start; }; labels.apply { text(28, 26)`j = 0` text(153, 26)`j = 2` text(278, 26)`j = 5` } One 10×6 grid, three amplitudes: j = 0, 2, 5.

Read it left to right: rigid, relaxed, scattered. Same underlying pattern, same hashed offsets — the entire difference is one number. This is what "randomness as a design decision" means in practice: the amount of chaos is a parameter you tune, not a property you hope for.

Example 4 — ±20% on a stroke

The house idiom applied to a variable-width stroke. Both ribbons share one smooth width profile (a bump — covered in part 5); the bottom one multiplies each stop's width by 1 + hash11(i) * 0.25 — the memorized idiom with its amplitude dialed up to 0.25.

A quick gloss on the stroke machinery, since this is its first appearance in the series: compoundVariableOffset turns a path into a ribbon by placing width stops along it — each vo.stop(t, w, ..., -w, ...) call says "at position t, extend w units each side." The builder function is applied with the << operator, CurveContinuity.G1 means "no kinks between stops," and Cap.tapered closes the ends to points. Full story in the variable-offset docs; here, all that matters is the width at each stop is a number you compute.

// viewBox="0 0 400 170" //-- The house idiom: 1 + hash11(i) * 0.25 is a +/-25% wobble factor you //-- multiply onto anything. Top ribbon: a smooth width profile. Bottom: //-- the same profile times the wobble -- the edge gets tooth. define ViewBox(0, 0, 400, 170); fn ribbon(name, y0, wobble) { let mk = {|vo, pb| vo.startCap(Cap.tapered(2, CurveContinuity.G0)); for (i in 0..47) { let t = i / 47; let w = (3 + 9 * bump(t, 0.5, 0.55)) * (1 + hash11(i) * wobble); vo.stop(t, w, CurveContinuity.G1, -w, CurveContinuity.G1); } vo.endCap(Cap.tapered(2, CurveContinuity.G0)); }; let spine = @{ l 330 0 }; let rib = spine.compoundVariableOffset() << mk; let band = PathLayer(name) ${ fill: oklch(0.62 0.16 260); stroke: none; opacity: 0.9; }; band.apply { M calc(35 + rib.anchor.x) calc(y0 + rib.anchor.y) rib.draw(); } } ribbon('smooth', 48, 0); ribbon('jittered', 122, 0.25); One smooth width profile, twice: clean above, times 1 + hash11(i) · 0.25 below. The profile is the design; the wobble factor is the texture — and each is tunable without touching the other.

The smooth profile is the design; the wobble factor is the texture. Keeping them separate — a clean profile times a signed jitter — means you can retune either without touching the other. This is exactly how the sixteen-layer glow in "The Reliable Line" gets its shimmer.

Example 5 — Sketchy circles

A finished effect: five concentric "pencil" rings, each drawn twice like overlapping pencil passes. Every ring is a 64-sided polygon whose vertex radius wobbles by ±7%, with the ring-and-pass number as the seed — so every pass wobbles its own way.

// viewBox="0 0 400 260" //-- Five concentric "pencil" rings, each drawn twice like overlapping //-- pencil passes. Every vertex radius wobbles by a few percent, with the //-- ring number and pass number as seeds -- so every pass wobbles its own //-- way, and the whole sketch is repeatable. define ViewBox(0, 0, 400, 260); fn ring(name, r, seed) { let g = PathLayer(name) ${ stroke: oklch(0.6 0.11 280); stroke-width: 1; fill: none; opacity: 0.65; }; g.apply { let r0 = calc(r * (1 + hash11(0, seed) * 0.07)); M calc(200 + r0) 130 for (k in 1..64) { let a = k / 64 * 2 * PI(); let rk = r * (1 + hash11(k, seed) * 0.07); L calc(200 + rk * cos(a)) calc(130 + rk * sin(a)) } } } for (i in 0..4) { let radius = 28 + i * 20; ring(`ring-${i}-a`, radius, calc(i * 2)); ring(`ring-${i}-b`, radius, calc(i * 2 + 1)); } Two passes per ring at 65% opacity: where they agree the line darkens, where they disagree it feathers — a pencil, from one signed wobble and disciplined seeds.

Two passes at 65% opacity is what sells the pencil: where the passes agree the line darkens, where they disagree it feathers. The whole effect is one signed wobble (r * (1 + hash11(k, seed) * 0.07)) plus disciplined seeds — pass A and pass B of ring 2 read decks 4 and 5, so no two strokes ever wobble in sync.

Where to go next

  • hashRange — when the drift should live in a min/max band instead of around a center.
  • bump — the smooth width profiles this post's example 4 jitters.
  • noise — when neighboring indices should agree instead of drifting independently.
  • Reference: Hash & Noise docs.