Designing Parametric Algorithms
From seed determinism to layered systems — the rules that make evolving art beautiful and the four patterns that recur.
The seed-determinism invariant
Before any aesthetic decisions, one rule. It is non-negotiable.
Same seed + same parameters = byte-identical output, on any machine, any OS, any Python version, any time.
This is not a nice-to-have. It is the difference between “generative image” and “evolving artwork that can be sold.” A buyer who re-renders your minted token with the same seed must get the same bytes back — otherwise they cannot prove the work is yours.
Practically, this means:
- Always seed via
numpy.random.default_rng(seed)— never the legacynp.random.seed(), never Python’srandom.seed()without re-seeding per call. The legacy global RNG is not thread-safe and is process-global, so two simulations on the same process will interfere. - Never read system time, environment variables, or hardware IDs inside the step loop. The seed must be the only source of randomness.
- Be careful with hash-based seeding. Python’s built-in
hash()is salted per-process by default (since Python 3.3), sohash("foo")returns different values in different processes. Usehashlib.sha256ormmh3if you need cross-process stable hashing. - Don’t iterate dicts or sets in your render path. Their iteration order is insertion-order in modern Python but explicitly not guaranteed for keys mutated during iteration. If you must, sort first.
A simple self-test that catches most of these:
def test_determinism(sketch_cls, params, steps=100):
a = sketch_cls(**params); a.step(steps); ra = a.render()
b = sketch_cls(**params); b.step(steps); rb = b.render()
assert np.array_equal(ra, rb), "determinism violated"
Every sketch in this corpus passes that test. Make sure yours does too.
The parameter space
Every knob you expose to the user is a knob they’ll need to understand. Too few and the work feels generic; too many and the work feels like configuration, not art.
A good rule of thumb from ten years of writing and shipping these systems:
Expose 3–7 parameters. Three aesthetic, two numerical, one seed-related.
By “aesthetic” I mean parameters whose effect is visible at a glance — the angle of branching in an L-system, the alpha of the ink, the colour palette. By “numerical” I mean resolution-style parameters — dim, iterations, num_agents. The seed-related parameter is just seed, and it must be the only source of variation if you want reproducible editions.
For PeterDeJong, the four “Bourke constants” a, b, c, d are the heart of the parameter space. They are real numbers in [-3, 3], and varying them gives you qualitatively different attractors — leaves, swirls, fractals, lattices — without changing the algorithm at all. That’s the magic: the algorithm is fixed, the coordinates of where you are in parameter space determine what you see.
# These four values produce a strikingly different attractor from the defaults:
PeterDeJong(a=2.20, b=-2.40, c=1.10, d=-0.50)
For Lindenmayer, the parameter space is structural — you can swap the production rules entirely (PRESETS = {"bush", "stochastic_tree", "fractal_weed", "koch_snowflake"}), vary the angle in [5°, 90°], and pick an iteration depth in [1, 8].
A useful exercise: for each of your parameters, ask “what does this knob do if I move it by 10%?” If the answer is “I can’t tell,” the parameter is too high-order or too coupled to others to be useful.
The “deterministic aesthetic”
Here’s the observation that justifies the whole genre: constraints create beauty.
PeterDeJong is four numbers and a four-line trigonometric map. There is no learning, no neural net, no curated dataset. And yet some (a, b, c, d) tuples produce images you would frame.
The reason is that the algorithm has a strong prior — it can only draw certain kinds of things, and the parameters select from that vocabulary. The vocabulary is constrained, so every output reads as a member of the same family. The parameters are continuous, so the family is large. The combination — constrained family × continuous selection — is what gives evolving art its “of-course-it-looks-like-that” quality.
When you design a new system, the most important question isn’t “what does it draw?” — it’s “what can it not draw?” A sand-painter cannot draw a single continuous line; a particle system cannot draw a filled circle; an iterated map cannot draw discrete dots. Those limitations are the aesthetic.
Four patterns that recur
If you read across Tarbell, Reas, Quilez, Akten, Shiffman, Hobbs, Cherniak, Hansmeyer — all 153 works in this corpus — the same four patterns account for almost everything.
Pattern A — Iterated maps
A small state vector (often 2D, sometimes 4D) is updated by a closed-form rule every step. Many agents iterate the same map in parallel; their trajectories accumulate on a fading canvas.
# Peter de Jong attractor — 2 coupled trig maps
xn = np.sin(a * y) - np.cos(b * x)
yn = np.sin(c * x) - np.cos(d * y)
Strengths: Closed-form means no history; you can stop, restart, fork at any step. Parameter space is continuous and well-mapped. Easy to find “good” tuples by sweep.
Weaknesses: Boring regions of parameter space look like noise. You need a way to know whether a given (a, b, c, d) has converged to an interesting attractor or to a fixed point.
Works that use it: henonPhase, henonPhaseDeep, peterdejong, cubicAttractor, LorenzAttractor3D.
Pattern B — Particle systems
Hundreds to thousands of agents, each with position, velocity, age, and possibly internal state. Per step, each agent moves (driven by a vector field, a noise function, a steering rule, or physics), then paints a tiny stroke on the canvas. Agents age out and respawn.
# Sand-stroke style — k horizontal sweeps, each lays wd grains per step
for agent in agents:
agent.x += vx # walk rightward
paint_fan(agent.x, agent.y, gauge=…) # tapered grain cloud
if agent.x > DIM:
agent.respawn(y=random_y, palette=next_palette)
Strengths: Easy to compose. Each agent’s behaviour is a small black box; you can swap steering rules without rewriting the renderer.
Weaknesses: Parameter space explodes combinatorially (number of agents × number of respawn rules × number of palette choices × …).
Works that use it: binaryRing, sandstroke, sandDollar, sandTraveler, stitches, limbStroke, limbsandstroke, limbstrat, BoidsFlock, MagnetoParticles, SaturnRings, boidsClassic, ParticleSpring2D.
Pattern C — Cellular / lattice growth
Local rules update cells in a grid each generation. Growth at a boundary, decay, diffusion, infection — the structure emerges from rule application rather than from agent motion.
# Gray-Scott reaction-diffusion
U, V = init_field()
for _ in range(steps):
Lu = laplacian(U)
Lv = laplacian(V)
U += Du * Lu - U * V * V + f * (1 - U)
V += Dv * Lv + U * V * V - (f + k) * V
Strengths: Highly structured, often organic-looking. Sensitive to f, k parameters — small changes produce qualitatively different patterns (stripes, spots, mitosis, lattices).
Weaknesses: Computationally expensive (every step updates every cell). Boundary conditions matter — wrap-around vs. zero vs. mirror each give different looks.
Works that use it: grayScottRD, ReactionDiffusionGS, substrate, interAggregate, interMomentary, boxFitting, boxFittingImg, wolframCA1D, wolframCA2D, cyclicCellularAutomaton, cellularAutomataWorm, recursiveTree, lindenmayer, HansmeyerSubdivision, DillenburgerVoxelField.
Pattern D — ML / kernel projection
You have a model (often a small neural network, often pre-trained, frozen). You sample latent codes, project them through the model, and render the output as the artwork. The “evolution” is typically a walk through latent space or a drift guided by some external signal.
# Latent-walk skeleton — interpolate between two latent codes
z = z_start
for t in range(steps):
z = z_start + (z_end - z_start) * t / steps
img = decoder(z) # pre-trained, frozen
paint_frame(img)
Strengths: The model does most of the aesthetic work. You get rich outputs cheaply. Latent space is smooth, so you can interpolate.
Weaknesses: The work is “model + walk,” not “your algorithm.” Provenance is harder to argue — you have to publish the model weights. Rendering cost is often higher than the other patterns.
Works that use it: latentWalk, KlingemannMemorex, KlingemannGlitch, KoganStyleTransferField, SarinWatercolor, CrespoNeuralBiodiversity, TykaDeepDreamField.
Most works in this corpus are a hybrid of two patterns — e.g., Guts is particle + sand-painter (B + sand painter strokes); BoidsFlock is particle + steering field (B + noise field); peterdejong_with_oscillator is iterated map + field-driven perturbation (A + B). Pick a primary, layer in a secondary.
Layered systems: the IrritableArtwork wrapper
When you want to take an existing sketch and add an external driver — say, an oscillating field that perturbs the trajectories of a Peter de Jong attractor — you don’t rewrite the original. You wrap it.
The IrritableArtwork wrapper is a Tesla-style stimulus loop:
field.step() # advance external irritant
sample F at agents # read field at each agent position
receptor.update(state, |F|) # integrate into 'living principle'
response.delta(...) -> velocity # translate state into action
apply that action back to agents
In code, the contract the wrapper expects from a base object is minimal:
class IrritableArtwork:
def __init__(self, base, field, receptor, response,
get_agents, apply_velocity, ...):
self.base = base
self.field = field
self.receptor = receptor
self.response = response
# ...
The base just needs .step() and .render(). If it also exposes agents (e.g., PeterDeJong._x, PeterDeJong._y), the wrapper can perturb them via the receptor/response pair. If not, the wrapper falls back to painting onto the canvas directly.
The five demos in irritants/demos/ — attractor_dance, pulse_cascade, stigmergy_pioneers, resonance_filaments, peterdejong_with_oscillator — are all the same wrapper around different (field, receptor, response) triples, run against different bases. The combinatorial surface area is the entire design tool.
Worked example: Guts (Tarbell, 2004)
Guts is the most parameter-rich of Tarbell’s sketches. Each step, num “cpaths” (curved paths) grow outward from the canvas centre, rotating, thickening, and laying down colored sand strokes with three independent sand painters. When a cpath exceeds the canvas boundary it fades out and respawns.
The core evolution step is 12 lines:
def _grow_cpath(self, cp):
cp.time += rng.uniform(0, 4.0)
cp.x += cp.v * np.cos(cp.a) # walk
cp.y += cp.v * np.sin(cp.a)
av = 0.1 * np.sin(cp.time * cp.tdv) + 0.1 * np.sin(cp.time * cp.tdv / cp.tdvm)
limit = (np.pi / 2) / cp.grth # clip turn rate by thickness
while abs(av) > limit: av *= 0.73 # soft clamp
cp.a += av # turn
cp.grth += cp.gv # thicken
cp.gv += rng.uniform(-0.15, 0.12) # growth rate noisy-walks
self._draw_cpath(cp) # sand-painter fan
The trick that makes Guts beautiful rather than chaotic is the coupling between thickness and turn rate: thicker paths turn less, thinner paths turn more. This naturally produces the curling, tendril-like growth Tarbell is known for.
The parameter space:
Guts(dim=500, num=7, numsands=3, seed=42)
dim is canvas resolution. num is the number of simultaneous growing paths. numsands is the number of sand painters per side (3 each on center/left/right). seed is the only randomness source — same seed reproduces the exact paths.
Run it:
python3 -m evolving_art.library.guts.reference
That writes library/guts/sample.png after 900 steps. With seed=42 you get a specific curling tendril growth; seed=43 is a different one in the same family.
A small worked parameter walk
The single most useful thing you can do with a new sketch is take 8 seeds and lay them out in a 4×2 grid. If they all look like members of the same family, the parameter space is well-tuned. If three of them are obviously broken (all-black, all-white, sparse noise), the space is too open and you need to either narrow the seed range or add a pre-flight check.
For Guts, the canonical seeds are 42, 43, 44, 45, 46, 47, 48, 49 — eight distinct curl patterns from the same algorithm. They make a coherent family portrait.
Where to go next
In Chapter 3 we’ll scale this up — running hundreds or thousands of seeds in parallel, auto-picking the “hero shots,” and building the contact-sheet that becomes the minting inventory.
The full corpus of iterated-map works is in strange_attractors. The full particle + path works are in particles_paths. The five Tesla-style wrapper demos are linked from irritants.