# Automata

thirteen classic cellular automata under one rule picker: Game of Life, Brian's Brain, Wireworld, Griffeath's cyclic automaton, an excitable medium, the hodgepodge machine, the forest fire, Schelling's segregation, the Ising model, the Abelian sandpile, Lenia, SmoothLife, and falling sand. Each one has its own ramp, seeding recipe, and parameters. Drag to paint, and hold a key to erase (makeSimField(...))

Run it with `swift run --package-path Examples Example-Simulation-Automata`.

```swift
import Ollin

/// Thirteen classic **cellular automata** on one `SimField`, behind a rule picker.
/// Each rule is one entry in a table: its sim and parameters, its ramp, its
/// seeding recipe, and its parameters. Switching rules starts a fresh field with that
/// rule's classic opening. One shared brush works everywhere: drag to paint the
/// field, hold any key while dragging to erase. The rules, a line each (the `Sim`
/// catalog in `Docs/Drawing/Effects.md` carries every rule's full story):
///
///   • **life**: Conway's Game of Life, B3/S23 over a chunky grid; a random
///     soup settles into gliders and oscillators, and the brush draws cells.
///   • **brain**: Silverman's Brian's Brain; fire on exactly two, rest one
///     step, and loose soup explodes into permanent glider traffic (a solid
///     blob dies at once, so the brush sprinkles single cells).
///   • **wire**: Silverman's Wireworld; a wire lights when one or two neighbors
///     are electron heads and stays dark on three, which is enough to build a
///     clock, a diode, and every gate; the opening circuit is typed as text, two
///     clocks feeding a bus through diodes, and the pen lays wire or electrons.
///   • **cyclic**: Griffeath's cyclic automaton; each color eats the one
///     before it, and seeded noise self-organizes into turning spiral cores; a
///     closed hue wheel hands the top state back to state zero without a seam.
///   • **excitable**: the Greenberg-Hastings excitable medium; fire, recover,
///     rest; sparks grow rings, and a scripted half-plane wipe at frame forty
///     breaks the biggest front so its cut ends curl into a spiral pair.
///   • **hodgepodge**: the Gerhardt-Schuster machine; infection chasing
///     recovery into Belousov-Zhabotinsky spirals, `speed` (the original
///     authors' constant g) the behavior dial.
///   • **sandpile**: the Abelian sandpile; a dropped mountain collapsing four
///     grains at a time into the classic lobed figure, one color per stable
///     grain count; hold the mouse to pour a torrent somewhere else.
///   • **lenia**: Lenia, the continuous Game of Life; a mass field convolved
///     with a soft ring kernel, blobs that pulse, split, and swim, the growth
///     rule tunable live.
///   • **smooth**: SmoothLife, Life's own birth and survival rule carried to a
///     continuous field, a disc for the cell and a ring for its neighbors; the
///     opening is a scatter of notched discs, the seed the paper's glider grows
///     from, and the brush drops another wherever it lands.
///   • **forest**: the Drossel-Schwabl forest fire; trees grow at random, a
///     rare strike lights one, and the fire runs the stand it can reach, so the
///     field settles at its own density and throws fires of every size.
///   • **schelling**: Schelling's segregation; two kinds on a board with empty
///     cells, each content with a share of like neighbors, the unhappy moving
///     nearby, and a mild preference sorts the whole board into patches.
///   • **ising**: the Ising model; every cell a spin that wants to agree with
///     its neighbors, flipped by Metropolis coins against the heat, so cold it
///     magnetizes into growing domains, hot it is noise, and at the critical
///     temperature (the opening setting) clusters come in every size.
///   • **sand**: the falling-sand automaton; grains fall, roll off each other
///     into heaps, sink through water that spreads flat, and stop at walls; the
///     brush pours whichever material the parameter names, `friction` sets how steep
///     a heap can stand.
///
/// See `Simulation/GrayScott` for the reaction-diffusion sibling.
@main
final class Automata: Sketch {

    enum Rule: String, CaseIterable, ParamOption {
        case life, brain, wire, cyclic, excitable, hodgepodge, forest, schelling, ising, sandpile, lenia,
             smooth, sand
    }

    /// What the Wireworld pen lays down: wire, or an electron on it.
    enum Pen: String, CaseIterable, ParamOption {
        case wire, electron
    }

    /// What the brush pours in the falling-sand rule.
    enum Grain: String, CaseIterable, ParamOption {
        case sand, water, wall

        var material: SandMaterial {
            switch self {
            case .sand: return .sand
            case .water: return .water
            case .wall: return .wall
            }
        }
    }

    @Param(icon: "square.grid.3x3", group: "Rule") var rule: Rule = .life

    // Per-rule parameters, grouped under the rule they tune.
    @Param("States", 2 ... 24, icon: "circle.grid.3x3", group: "Cyclic") var hueStates = 14
    @Param("Threshold", 1 ... 4, icon: "chart.bar.fill", group: "Cyclic") var threshold = 1
    /// Count the corner neighbors too (the eight-cell block instead of the four).
    @Param("Corners", icon: "square.grid.3x3", group: "Cyclic") var corners = false
    /// The excitable cycle length: rest, firing, then the refractory tail. Longer
    /// tails make broader waves and slower, wider spirals.
    @Param("States", 3 ... 24, icon: "timer", group: "Excitable") var tail = 6
    /// The speed of infection (the hodgepodge rule's constant g).
    @Param("Speed", 1 ... 60, icon: "speedometer", group: "Hodgepodge") var speed = 25
    /// Radius of the sandpile's dropped mountain, in canvas points.
    @Param("Mountain", 4 ... 40, icon: "triangle", group: "Sandpile") var mountain = 16.0
    /// Toppling passes per frame: an avalanche front moves one cell per pass,
    /// so this is the pacing dial.
    @Param("Pace", 1 ... 128, icon: "speedometer", group: "Sandpile") var pace = 64.0
    @Param("Pen", icon: "pencil.line", group: "Wireworld") var pen: Pen = .wire
    /// The chance an empty cell grows a tree in a step.
    @Param("Growth", 0.002 ... 0.06, icon: "leaf", group: "Forest") var growth = 0.015
    /// Strikes per million tree-steps. Far below the growth rate is what makes fires
    /// of every size; near it, every tree burns as soon as it grows.
    @Param("Lightning", 0.5 ... 200, icon: "bolt", group: "Forest") var strikes = 6.0
    /// The share of its neighbors an agent wants to be its own kind. Raise it and the
    /// settled board comes unsettled and sorts further.
    @Param("Preference", 0 ... 1, icon: "person.2", group: "Schelling") var preference = 0.3
    /// The share of empty cells the board starts with; changing it starts a fresh board.
    @Param("Vacancy", 0.05 ... 0.6, icon: "square.dashed", group: "Schelling") var vacancy = 0.25
    /// The chance an unhappy agent moves in a pass: the pace of the sort.
    @Param("Mobility", 0.002 ... 1, icon: "figure.walk", group: "Schelling") var mobility = 0.02
    /// The heat, in units of the coupling between neighbors. The critical value is
    /// about 2.27: below it domains grow, above it the heat wins.
    @Param("Temperature", 0.5 ... 4, icon: "thermometer.medium", group: "Ising") var temperature = 2.27
    /// An outside field pulling every spin one way: positive up, negative down.
    @Param("Field", -1 ... 1, icon: "magnet", group: "Ising") var magneticField = 0.0
    @Param("Growth center", 0.05 ... 0.3, icon: "target", group: "Lenia") var growthCenter = 0.15
    @Param("Growth width", 0.005 ... 0.05, icon: "slider.horizontal.below.rectangle", group: "Lenia") var growthWidth = 0.015
    /// The neighborhood's outer radius in field texels; the glider is about two of
    /// them across. Changing it starts a fresh field.
    @Param("Radius", 6 ... 24, icon: "circle.dashed", group: "SmoothLife") var reach = 14
    /// How soft the birth and survival intervals' edges are. Near zero the rule
    /// snaps like Life and the field goes to grain; wider melts the creatures.
    @Param("Softness", 0.01 ... 0.08, icon: "slider.horizontal.below.rectangle", group: "SmoothLife") var softness = 0.028
    @Param("Pour", icon: "paintbrush.pointed", group: "Sand") var grain: Grain = .sand
    /// How often a grain that could roll off a slope stays put: 0 slumps flat,
    /// 1 stacks straight up.
    @Param("Friction", 0 ... 1, icon: "triangle", group: "Sand") var friction = 0.2

    private var field: SimField!
    private var active: Rule?
    private var fieldAge = 0
    private var seededVacancy = 0.0

    /// Life's grid, in cells across: one texel is one chunky, visible cell.
    private let cells = 130

    /// Wireworld's grid, coarser still: a circuit wants cells you can read.
    private let wireCells = 96

    /// The opening circuit, typed the way these circuits are shared: `#` wire,
    /// `H` an electron head, `t` its tail. Two ring clocks of different periods,
    /// each tapped through a diode (the two-wide bar with a gap under it, which an
    /// electron crosses one way and dies at the other), meet on one bus that
    /// forks into two lamps.
    private let circuit = [
        "................................................................",
        "..#tH######.....................................................",
        "..#.......#.......##............................................",
        "..#.......#########.######......................................",
        "..#.......#.......##.....#......................................",
        "..#########..............#..................................##..",
        ".........................#..................................##..",
        ".........................#.................................#....",
        ".........................##################################.....",
        ".........................#.................................#....",
        ".........................#..................................##..",
        "..#tH########............#..................................##..",
        "..#.........#.....##.....#......................................",
        "..#.........#######.######......................................",
        "..#.........#.....##............................................",
        "..#.........#...................................................",
        "..#.........#...................................................",
        "..###########...................................................",
        "................................................................",
    ]
    private let wireLevels: [Character: WireworldCell] = [".": .empty, "#": .conductor,
                                                          "t": .tail, "H": .head]

    /// Brian's Brain: ready is the dark ground, resting the cool afterglow,
    /// firing the white spark.
    private let glow = Ramp(stops: [(0.0, Color(hex: 0x05070C)),
                                    (0.5, Color(hex: 0x3A6BD8)),
                                    (1.0, .white)])

    /// The cyclic state ramp, closed into a wheel: the last stop repeats the first
    /// hue so the top state advancing to zero reads as one more step, not a cliff.
    private let wheel = Ramp(stops: (0 ... 6).map {
        (position: Double($0) / 6,
         color: Color(hue: Double($0) / 6, saturation: 0.72, brightness: 0.95))
    })

    /// Sandpile: one color per stable grain count, the classic way these piles are
    /// pictured. The field stores its count in quarters, so 0, 1, 2, 3 grains sit
    /// at exactly 0, ¼, ½, ¾ and the top catches cells flashing mid-topple.
    private let counts = Ramp(stops: [(0.00, Color(hex: 0x10141F)),
                                      (0.25, Color(hex: 0x2C6E91)),
                                      (0.50, Color(hex: 0xE3A857)),
                                      (0.75, Color(hex: 0xF2E9DC)),
                                      (1.00, .white)])

    /// Forest fire: bare ground, a stand of trees, and the fire in it, at the
    /// three levels the field stores (empty, tree, burning).
    private let woods = Ramp(stops: [(0.0, Color(hex: 0x1A1410)),
                                     (0.5, Color(hex: 0x2F7D45)),
                                     (1.0, Color(hex: 0xFFC24A))])

    /// Wireworld's classic colors: yellow wire, red tails, blue heads, on black,
    /// at the four levels the field stores.
    private let wire = Ramp(stops: [(0.0, Color(hex: 0x0B0B0F)),
                                    (1.0 / 3.0, Color(hex: 0xE8B923)),
                                    (2.0 / 3.0, Color(hex: 0xE0432E)),
                                    (1.0, Color(hex: 0x2E7BFF))])

    /// Schelling's board: empty ground and a color per kind, at the three levels
    /// the field stores.
    private let kinds = Ramp(stops: [(0.0, Color(hex: 0x14161C)),
                                     (0.5, Color(hex: 0xE4572E)),
                                     (1.0, Color(hex: 0x17BEBB))])

    /// Ising: a tone per spin, down and up, at the two levels the field stores.
    private let spins = Ramp(stops: [(0.0, Color(hex: 0x1B2A4A)),
                                     (1.0, Color(hex: 0xF4E9D3))])

    /// Falling sand: one tone per material, at the thirds the field stores them
    /// on (empty, water, sand, wall).
    private let materials = Ramp(stops: [(0.000, Color(hex: 0x14161C)),
                                         (1 / 3, Color(hex: 0x2E7BC4)),
                                         (2 / 3, Color(hex: 0xD9B36C)),
                                         (1.000, Color(hex: 0x6B6B70))])

    override func setup() {
        restart(with: rule)
    }

    override func draw() {
        background(.black)
        if rule != active { restart(with: rule) }   // a rule switch starts fresh
        if rule == .schelling && vacancy != seededVacancy { restart(with: rule) }   // a new board
        field.sim = sim(for: rule)                  // the parameters retune the rule live
        fieldAge += 1

        withField(field) {
            noStroke()
            seedIfDue()
            if mouseIsPressed { paint() }
        }
        drawImage(recolored(), 0, 0)
        drawCaption(caption())
    }

    /// A fresh field for `rule`: new ping-pong state, age zero, so the rule's
    /// seeding recipe replays from its opening frame.
    private func restart(with rule: Rule) {
        field = makeSimField(sim(for: rule), scale: fieldScale(for: rule))
        active = rule
        fieldAge = 0
        seededVacancy = vacancy
    }

    /// The table's sim column: each rule's `Sim` case under its parameters.
    private func sim(for rule: Rule) -> Sim {
        switch rule {
        case .life: return .gameOfLife()
        case .brain: return .briansBrain()
        case .wire: return .wireworld()
        case .cyclic: return .cyclic(states: hueStates, threshold: threshold,
                                     neighborhood: corners ? .moore : .vonNeumann,
                                     seed: Double(variation))
        case .excitable: return .excitable(states: tail)
        case .hodgepodge: return .hodgepodge(infectionRate: speed, seed: Double(variation))
        case .forest: return .forestFire(growth: growth, lightning: strikes / 1_000_000,
                                        seed: Double(variation))
        case .schelling: return .schelling(preference: preference, vacancy: vacancy,
                                           mobility: mobility, seed: Double(variation))
        case .ising: return .ising(temperature: temperature, field: magneticField, sweeps: 2,
                                   seed: Double(variation))
        case .sandpile: return .sandpile(pour: 1024, topplings: Int(pace))
        case .lenia: return .lenia(growthCenter: growthCenter, growthWidth: growthWidth)
        case .smooth: return .smoothLife(radius: reach, intervalSoftness: softness)
        case .sand: return .fallingSand(passes: 16, friction: friction)
        }
    }

    /// Field resolution per rule: coarse where one texel should read as a cell,
    /// finer for the smooth fields.
    private func fieldScale(for rule: Rule) -> Double {
        switch rule {
        case .life: return Double(cells) / width
        case .wire: return Double(wireCells) / width
        case .brain: return 0.15
        case .cyclic, .excitable, .hodgepodge, .forest, .schelling, .ising: return 0.25
        case .sandpile, .lenia, .sand: return 0.5
        case .smooth: return 0.35   // every step reads a disc of radius texels
        }
    }

    /// The table's seeding column, run inside the paint block: each rule's classic
    /// opening at the age it calls for. Cyclic, hodgepodge, Schelling, and Ising
    /// need nothing (they start from seeded random states picked by `variation`).
    private func seedIfDue() {
        switch rule {
        case .wire where fieldAge == 1:
            // The circuit, one cell per character, centered on the grid.
            let cw = width / Double(wireCells)
            let left = (wireCells - circuit[0].count) / 2, top = (wireCells - circuit.count) / 2
            for (y, row) in circuit.enumerated() {
                for (x, ch) in row.enumerated() where ch != "." {
                    fill(wireLevels[ch]!.color)
                    drawRect(Double(left + x) * cw, Double(top + y) * cw, cw, cw)
                }
            }
        case .life where fieldAge == 1:
            // A random soup to get life going, cell-aligned so one mark is one cell.
            fill(.white)
            let cw = width / Double(cells)
            for _ in 0 ..< 7000 {
                let gx = Double(Int(random(Double(cells)))), gy = Double(Int(random(Double(cells))))
                drawRect(gx * cw, gy * cw, cw, cw)
            }
        case .brain where fieldAge == 1:   // the classic start: soup everywhere
            fill(.white)
            for _ in 0 ..< 6500 {
                drawCircle(random(width), random(height), 3.4)
            }
        case .excitable where fieldAge == 1:
            // A line whose front the wipe will break, and a few loose sparks.
            fill(.white)
            drawRect(width * 0.25, height * 0.55, width * 0.5, 6)
            for _ in 0 ..< 10 {
                drawCircle(random(width), random(height), 4)
            }
        case .excitable where fieldAge == 40:
            // The classic spiral recipe: wipe half the plane, and the broken
            // front's two free ends curl into counter-rotating spirals.
            fill(.black)
            drawRect(0, 0, width, height * 0.52)
        case .sandpile where fieldAge == 1:   // the opening mountain
            fill(.white)
            drawCircle(width / 2, height / 2, mountain)
        case .lenia where fieldAge == 1:
            // A dense primordial soup, reproducible per variation.
            for _ in 0 ..< 350 {
                fill(Color(white: 1, alpha: random(0.2, 0.8)))
                drawCircle(random(width), random(height), random(15, 70))
            }
        case .smooth where fieldAge == 1:
            // A scatter of the paper's glider seeds, each a disc a little under
            // the radius with a notch bitten out of one side, facing every way.
            for _ in 0 ..< 14 {
                let c = Vector2(random(width), random(height))
                glider(at: c, facing: random(.pi * 2))
            }
        case .sand where fieldAge == 1:
            // Two shelves and a pool: the stream piles on the upper shelf,
            // slumps off it, and what reaches the pool sinks to its floor.
            fill(SandMaterial.wall.color)
            drawRect(width * 0.12, height * 0.45, width * 0.36, 8)
            drawRect(width * 0.36, height * 0.68, width * 0.28, 8)
            fill(SandMaterial.water.color)
            drawRect(width * 0.55, height * 0.8, width * 0.45, height * 0.2)
        case .sand where fieldAge <= 300:
            // A stream of loose grains from one tap for the first seconds.
            fill(SandMaterial.sand.color)
            drawCircle(width * 0.27 + random(-12, 12), 10, 2)
        default:
            break
        }
    }

    /// The shared brush: the mouse paints each rule's ink, a held key erases.
    private func paint() {
        switch rule {
        case .life:
            // Cell-aligned, so the brush lands on whole cells.
            fill(keyIsPressed ? .black : .white)
            let cw = width / Double(cells)
            let gx = Double(Int(mouseX / cw)), gy = Double(Int(mouseY / cw))
            drawRect((gx - 1) * cw, (gy - 1) * cw, cw * 3, cw * 3)
        case .wire:
            // Cell-aligned: the pen lays one cell of wire or one electron head
            // under the mouse, and a held key clears a three-by-three patch.
            let cw = width / Double(wireCells)
            let gx = Double(Int(mouseX / cw)), gy = Double(Int(mouseY / cw))
            if keyIsPressed {
                fill(WireworldCell.empty.color)
                drawRect((gx - 1) * cw, (gy - 1) * cw, cw * 3, cw * 3)
            } else {
                fill(pen == .wire ? WireworldCell.conductor.color : WireworldCell.head.color)
                drawRect(gx * cw, gy * cw, cw, cw)
            }
        case .brain:
            // A solid blob dies at once, so the brush sprinkles loose cells.
            if keyIsPressed {
                fill(.black)
                drawCircle(mouseX, mouseY, 80)
            } else {
                sprinkle(mouseX, mouseY, radius: 60)
            }
        case .cyclic:
            // White stamps the top state (the wheel spins it back in), black
            // stamps state zero; either way the spirals eat the wound.
            fill(keyIsPressed ? .black : .white)
            drawCircle(mouseX, mouseY, 60)
        case .excitable:
            // A small dab sparks a ring; a broad black brush calms the medium.
            fill(keyIsPressed ? .black : .white)
            drawCircle(mouseX, mouseY, keyIsPressed ? 60 : 8)
        case .hodgepodge:
            // Mid-gray stamps a degree of infection; black heals.
            fill(keyIsPressed ? .black : Color(white: 0.6))
            drawCircle(mouseX, mouseY, 60)
        case .forest:
            // White sets a fire where you drag it; a held key cuts a firebreak the
            // flames cannot cross (and the trees grow back into it).
            fill(keyIsPressed ? .black : .white)
            drawCircle(mouseX, mouseY, keyIsPressed ? 60 : 10)
        case .schelling:
            // A block settled by one kind, which the sort then answers; a held
            // key clears a block, and the movers find the room.
            fill(keyIsPressed ? SchellingCell.empty.color : SchellingCell.first.color)
            drawCircle(mouseX, mouseY, 50)
        case .ising:
            // A patch magnetized up, which the heat then works on; a held key
            // flips a patch down.
            fill(keyIsPressed ? IsingSpin.down.color : IsingSpin.up.color)
            drawCircle(mouseX, mouseY, 50)
        case .sandpile:
            // Sand cannot be unpoured (a dark mark adds no grains), so the held
            // key simply holds the torrent.
            if !keyIsPressed {
                fill(.white)
                drawCircle(mouseX, mouseY, mountain)
            }
        case .lenia:
            fill(keyIsPressed ? .black : Color(white: 1, alpha: 0.85))
            drawCircle(mouseX, mouseY, 42)
        case .smooth:
            // A glider seed under the mouse, or a held key erases a patch.
            if keyIsPressed {
                fill(.black)
                drawCircle(mouseX, mouseY, 80)
            } else {
                glider(at: Vector2(mouseX, mouseY), facing: time)
            }
        case .sand:
            // The brush pours the chosen material; a held key clears a hole
            // (an empty mark is a material too, so it also cuts through walls).
            fill(keyIsPressed ? SandMaterial.empty.color : grain.material.color)
            drawCircle(mouseX, mouseY, grain == .wall ? 12 : 24)
        }
    }

    /// The paper's glider seed: a disc a little under the radius with a smaller
    /// disc bitten out of its side, the notch on the side the glider will lead
    /// with. In canvas points, so it follows the field's scale.
    private func glider(at center: Vector2, facing angle: Double) {
        let r = Double(reach) / fieldScale(for: .smooth)
        fill(.white)
        drawCircle(center.x, center.y, r * 0.86)
        fill(.black)
        drawCircle(center.x + cos(angle) * r * 0.43, center.y + sin(angle) * r * 0.43, r * 0.33)
    }

    /// A pinch of random soup: loose single cells at the density that boils. One
    /// dot is one field cell, so the dot radius follows the field's scale.
    private func sprinkle(_ x: Double, _ y: Double, radius: Double = 150) {
        fill(.white)
        for _ in 0 ..< Int(radius * radius * 0.018) {   // constant soup density
            let angle = random(.pi * 2)
            let r = radius * random().squareRoot()
            drawCircle(x + cos(angle) * r, y + sin(angle) * r, 3.4)
        }
    }

    /// The table's ramp column: Life's raw state is already the classic crisp
    /// black-and-white; every other rule recolors through a gradient map.
    private func recolored() -> Image {
        switch rule {
        case .life: return field.image
        case .brain: return field.filtered(.gradientMap(glow)).image
        case .wire: return field.filtered(.gradientMap(wire)).image
        case .cyclic: return field.filtered(.gradientMap(wheel)).image
        case .excitable: return field.filtered(.gradientMap(.inferno)).image
        case .hodgepodge: return field.filtered(.gradientMap(.turbo)).image
        case .forest: return field.filtered(.gradientMap(woods)).image
        case .schelling: return field.filtered(.gradientMap(kinds)).image
        case .ising: return field.filtered(.gradientMap(spins)).image
        case .sandpile: return field.filtered(.gradientMap(counts)).image
        case .lenia: return field.filtered(.gradientMap(.magma)).image
        case .smooth: return field.filtered(.gradientMap(.viridis)).image
        case .sand: return field.filtered(.gradientMap(materials)).image
        }
    }

    private func caption() -> String {
        switch rule {
        case .life:
            return "Game of Life · drag to draw cells, hold a key to erase"
        case .brain:
            return "Brian's Brain · fire on exactly two · drag to sprinkle, hold a key to clear"
        case .wire:
            return "Wireworld · one or two heads light a wire · drag to lay wire or electrons, hold a key to erase"
        case .cyclic:
            return "cyclic automaton · each color eats the one before it · drag to stamp"
        case .excitable:
            return "excitable medium · fire, recover, rest · dab to spark, hold a key to calm"
        case .hodgepodge:
            return "hodgepodge machine · infection chasing recovery · drag to infect, hold a key to heal"
        case .forest:
            return "forest fire · trees grow, lightning strikes · drag to set fires, hold a key to cut a break"
        case .schelling:
            return "Schelling's neighborhood · a mild preference sorts the board · drag to settle a block, hold a key to clear one"
        case .ising:
            return "Ising model · spins against the heat · drag to magnetize a patch, hold a key to flip it back"
        case .sandpile:
            return "Abelian sandpile · four grains at a time · hold to pour another mountain"
        case .lenia:
            return "Lenia · a continuous automaton · drag to add mass, hold a key to erase"
        case .smooth:
            return "SmoothLife · Life's rule on a continuous field · drag to drop gliders, hold a key to erase"
        case .sand:
            return "falling sand · grains fall, roll, and sink through water · drag to pour, hold a key to erase"
        }
    }
}
```
