the pencil
A stroke is not a line. It is a filled ribbon: the spine wanders, the width breathes, the ends run past where they should stop. Built up one habit at a time —
stroke() never calls ctx.stroke(). It resamples the spine to even steps of max(2.2, w·.9) px, pushes every sample sideways along its own normal by three summed sines, builds a left and a right rail from a half width that breathes on two more, and fills the polygon between them at 62% ink. Then it litters: one to four crumbs per sample, thrown up to ±1.05 of the half width — past the edge, not inside it — and a paper-coloured square 45% of the time, biting back in. Everything random comes from this.j, a mulberry32 reseeded per boil frame, so the same numbers and the same seed give back the same stroke crumb for crumb.
src/sketch.js — at every sample along the spineoff = amp * (.55·sin(t·f1·2 + p1) + .3·sin(t·f2 + p2) + .15·sin(t·f3 + p3))
f1 1.5–3.5 slow drift f2 5–9 waver f3 11–17 tremor
px = spine[i] + normal·off + jr(-.35, .35) ← and grit on every sample
half = w/2 · taper(t) · (1 + .38·sin(t·7.3 + p4) + .14·sin(t·19 + p2)) · jr(.88, 1.14)
Up close it is graphite, not paint. It sheds.
src/sketch.js — a thin stroke stays clean, a thick one crumblesif (w >= 1.2) { ink crumbs at jr(.2,.55), scattered to ±1.05 of the half width,
and 45% of the time a PAPER-coloured square, bitten out of the edge }
Three of them make everything. Every contour you will see below is the third.
src/sketch.jss.sline(pts, w, alpha) s.stroke(pts, w, o) s.broken(pts, w, o)
the shape
There are no curves. Every shape is a ring of points. Wobble says how carefully it was drawn — eyes are drawn slowly, hair is scribbled.
blobPts(cx, cy, rx, ry, rot, wob) walks 16 points around an ellipse, multiplies each radius by 1 + (.17·sin 2θ + .1·sin 5θ)·wob, rotates the whole ring and runs one Chaikin pass so the corners round off. Two traps live here. It picks a random rotation when you do not pass one, so anything that is not roughly circular will eventually stand on end and slash across the character. And wob is a statement about the hand, not a detail: an eye is drawn slowly at ~.4, a scribbled mass at 1.
src/sketch.jsconst pts = s.blobPts(x, y, rx, ry, rot, wobble)
the material
One ring of points, six materials. A part never picks graphite or paint. It asks the medium for a mass and an edge, and the medium answers in its own vocabulary.
A medium answers three questions and nothing else: tone(s, pts, o) fills a mass, skin(s, pts, col, o) lays colour on a face, edge(s, pts, w, o) closes a shape. Underneath they reach for different techniques in sketch.js — graphite hatches and scribbles, ink dry-brushes, watercolour stacks translucent layers with a dark rim where the puddle dried, oil lays opaque daubs, charcoal smears, marker leaves streaks. A part that called pencilFill itself would stay graphite while the rest of the character turned to paint, which is the whole reason the indirection exists. The fourth answer is underdraw: whether pencil construction still shows through — true for graphite, ink, charcoal and marker, false for oil, which covers.
src/media.js — the whole contracttone(s, pts, o) a mass: hair, a horn, the dark inside a socket skin(s, pts, col, o) colour laid over the face edge(s, pts, w, o) the contour that closes a shape underdraw does graphite hatching still read underneath?
A part does get one opinion: how dark the mass should be. Not how to make it dark — that stays the medium's business.
src/media.js — five densities, one number eachconst DENSITY = { black: 1, hatch: .72, scribble: .62, stipple: .5, light: .34 }
F.media.tone(s, pts, { style: 'stipple' }) // the part asks for .5 …
// …the medium decides what .5 looks like
the head
One head polygon, slid most of the way onto a target shape. The shape family is the creature's identity — and it is nine words in one list.
headLayout() lays down five keypoints per side plus a chin and a crown, smooths them twice with Chaikin, then slides every point round (.82–.97 of the way) onto a target radius function — square is a superellipse of exponent 5.5, drop pinches the crown and keeps the jowls, pear and lump swell one end. A ¾ turn swells the near side by 10% and collapses the far side by 28%. A snout is not a part: it is skull.muzzle, a lobe pushed out of this same silhouette, and the layout publishes where it landed so the nose and mouth sit on it without ever learning what a muzzle is.
src/parts/skull.jsshape: C.pick(rng, 'shape', [['round', 20], ['square', 16], ['tall', 13],
['drop', 13], ['pear', 12], ['lump', 8], …])
the map
Before anything is drawn, one file measures the body once and publishes every anchor. The mouth never has to find the chin. The hand never has to find the fist.
buildLayout(recipe, Ps) runs once per character and returns F: F.s the head scale in px, F.w the half width, F.L.* the face anchors, F.B.* the body block, F.lwMain/F.lwThin the two line weights, plus the medium and the colour casting. Its rngs are seeded apart from the boil, so the construction holds still while the ink is redrawn every frame. Two coordinate systems meet here: draw() works in character pixels with y down and the origin at the centre of the head, while bones() works in world units (px / U) with y up — hence the minus sign on every bone.
src/layout.jsF.L.eyeX(-1) F.L.noseY F.L.my F.B.hipX F.B.floorY
the parts
A part is one small file: roll your numbers, ask for a bone, draw. They are stacked in one ordered list — behind first, in front last. Watch it land.
A part exports gen/bones/size/draw, and optionally states, skip, species, base, pivot, order, depth and region. gen(rng, C) rolls plain JSON numbers — C is the casting helper, so a species can lean on any of them without the part knowing. bones(P, F) returns one entry per canvas, and two entries make a mirrored pair. size(P, F) is the canvas that has to cover the drawing, in world units. Every canvas becomes a texture on a plane hanging off a THREE.Group, which is the thing the animator moves. Adding a feature is one file plus one line in the registry — the editor, the recipe, reroll, lock and the crowd all read that list.
src/parts/index.jsgen(rng, C) → the numbers that make this one different bones(P, F) → where it hangs draw(s, P, st, F, bone) → the drawing itself
the species
A dog is not a new set of drawings. Same seed, same parts, same code — a species only loads the dice toward floppy ears, a snout and no hair.
One table per part id, and the value's TYPE is the instruction: an object is a weighted pick (options you leave out cannot happen), an array is a range, a bare number is a probability. It biases gen() and nothing else — once the params exist they are numbers, so a saved recipe rebuilds identically even if the profile changes or disappears. Three levers, cheapest first: weights (data only), a skull param like muzzle (one branch), or a part of its own via species: ['nightmare'] (a new file). Wings qualified. A snout did not — so now anything can have one.
src/species.jsdog: { cast: { crest: { style: { floppy: 84, bear: 10, none: 6 } },
skull: { muzzle: [.26, .44] }, extras: { spots: .55 } } }
the boil
Every part is drawn three times, same numbers, different wobble. Flipping between them is the whole illusion of life. Nothing moves; it is redrawn.
makePart() draws BOIL_FRAMES canvases per state, each seeded s.boil(hash(seed, name, state, frame)), and uploads them as THREE.CanvasTextures on a single plane — so animating is a texture swap that costs nothing at runtime. Each part flips on its own clock (fps .85–1.6, offset on purpose) or the whole character would boil in unison and read as a video. The corollary is the rule that catches everyone: randomness inside draw() is re-rolled on every redraw, so a choice that must hold still — which style, how long, which side — belongs in gen().
src/part.jsfor (let f = 0; f < BOIL_FRAMES; f++) { s.boil(hash(seed, name, f)); draw(s); }
the face
Spare faces are drawn in advance and kept. An expression is a swap, landed while a blink has the eyes shut, so it reads as intent.
States are drawn lazily: only the resting one costs a canvas at build time, so a character can carry a full emotional repertoire and never pay for the faces nobody pulls. An expression is two things — a state per face part, and continuous body language written through the same offset API a pose uses. Because a texture swap is binary and would otherwise pop, the animator holds it until a blink has the eyes shut: the brows and the shiver ramp over ~0.5s, the eyes close, and the new face is already there when they open.
src/expressions.jsangry: { states: { eyes: 'angry', brows: 'angry', mouth: 'angry' } }
the pose
The drawings never change here — the bones under them move. A pose writes offsets, scaled by its blend weight, so two poses simply mix and nothing snaps.
A pose writes offsets through a small ctx API — root, head, body, bone, each, state — and every write is multiplied by that pose's blend weight, so a transition is literally two poses summing (0.38s, or 0.13s for a one-shot like attack, which plays out and hands back). Walk and run share one gait phase, so a change of tempo never teleports a foot. Poses scale the autonomic layer rather than switching it off (auto: { gaze: 0, breath: 2.2 } for sleep), and every pose handles all three bases: biped, sit and quad.
src/poses/gait.jsctx.bone(e, { dy: -S * .04, rot: .3 }) // offsets, never absolutes
the seed
All of it comes back to one integer. The recipe is the only thing that is saved — same JSON in, same creature out, on any machine.
The recipe is the only state: { seed, species, base, media, color, parts: { id: { params, lock, rr } } }. Each part's rng is seeded from hashStr(seed:partId:rr), so bumping rr rerolls exactly one part and lock carries a part through a global regenerate untouched. Nothing touches a canvas until a character is actually built, so thousands of recipes can be rolled and scored in a loop — which is how the class-photo game's numbers were set instead of guessed.
the whole character