Step 1 · Equations

Two chemicals and a Laplacian

Running live — the same dish you'll get in Step 2.

This is what the rest of this page explains: two invisible chemicals, one rule per cell, and patterns nobody drew.

Or read on — the theory takes about five minutes.

First: the recipe IS the equation

You'll see a differential equation further down (the \(\partial v/\partial t = \dots\) bit) and it can look like it demands calculus machinery. It doesn't — but not because it's something other than a differential equation. Operationally this is exactly the recipe you'd guess — every tick, for every cell, do three cheap things and add them up:

  1. propagate — leak a little of each chemical into its neighbors (that's the Laplacian);
  2. react — consume feedstock where catalyst already is, turning it into more catalyst;
  3. feed & kill — top up fresh feedstock everywhere, and remove a slice of catalyst.

Add those three, add the result to the cell's current value, move to the next tick. That's the entire loop — feed, then consume, then spread, just like you pictured. And here's the honest relationship: that loop isn't a stand-in for the differential equation — it is the differential equation, run at finite resolution. “Each cell updates from its neighbors” is what every PDE becomes the moment you put it on a computer; \(\partial v/\partial t = \dots\) is just the compact way to say “how much this cell changes in one tick = spread + react + feed − kill,” and as the cells shrink and the ticks quicken, the recipe converges to the smooth law. (Course 3, Step 6 earns this properly: the grand heat equation turns out to be fifteen beads, each chasing its neighbors' average.) So read every equation below as a recipe, not a derivation — each term is one of those three jobs. On the chip a “tick” is one steps update, with the time-step fixed at 1.

One classifying word is worth knowing here: linear. An equation is linear when every term treats the unknown proportionally — double the concentration, double the term — which is why solutions of linear equations can be added together and still solve it. The propagate and feed & kill jobs are linear; a world with only those is the heat equation, and it can only ever smooth things flat. The react job is the exception: \(uv^2\) multiplies the chemicals together, so doubling both blows the term up eightfold. That single nonlinear term is what flips diffusion from a pattern-destroyer into a pattern-grower — and it's why this course can't be solved by the add-up-the-waves trick that finishes Course 3.

Course 3 built the Laplacian and used it to smooth things out — heat spreading until a rod sits flat. Here the same operator does the opposite: coupled to a reaction, diffusion stops erasing detail and starts growing patterns. (Course 5 will put it to a third use, holding a fluid incompressible.)

The screen shows one of two invisible chemicals. Everywhere starts full of feedstock (drawn dark — you never see it directly). A second chemical, the catalyst (drawn bright), does one thing: it converts feedstock into more of itself. So catalyst spreads into the dark regions, eating as it grows.

What about the grays in between? The catalyst isn't just there-or-not — it's an amount. Bright means dense catalyst, black means none (just feedstock, still invisible), and every shade between is a middling concentration — the moving fronts where catalyst is thickening or thinning. So gray isn't the feedstock showing through or “mixing in”; the feedstock is never drawn at all. Gray is simply less catalyst — which is why the edges, not the flat areas, are where you see the detail.

If that were all, it would just fill the screen. Two things hold it back: catalyst is slowly removed (kill rate \(k\)), and fresh feedstock constantly drips back in (feed rate \(F\)). The pattern is the standoff between growth and removal — and because the catalyst spreads slower than the feedstock refills, that standoff never settles into flat gray. It keeps carving edges. That's the whole trick.

Why the patterns move — and split

Here's the part that trips everyone up: the catalyst kills itself as it advances. Watch a bright front spread. At its leading edge it's pushing into fresh feedstock, so it grows. But right behind it the feedstock is already used up — nothing left to eat — and there the kill term wins, so the trailing edge dies off. The shape isn't sliding across the grid; it's being rebuilt at the front and starved at the back. That's why patterns appear to travel.

Same reason a blob doesn't just swell forever: once it's wide enough, its own middle runs out of feedstock and hollows out, so a solid dot becomes a ring — and the ring pinches into two. That's the “mitosis” look, cells dividing. Growth only survives at the boundary between fed and starved, which is exactly why you see edges and fronts instead of filled shapes.

Two fates: why some colors barely appear

Here's a detail you can catch by staring at the dish: some colors are everywhere, and some seem to have nowhere to live. Load a preset and you'll see broad regions of near-black and broad regions of established colony — but the shades in between only ever show up as a thin rim, a pixel or two wide, sliding across the screen. Whole slices of the color map get almost no screen time.

That's not a rendering quirk. It's the single most important fact about this system: Gray–Scott is bistable. Take one cell and ignore its neighbors for a moment. Two catalyst levels are self-consistent — states the cell can sit at forever:

  • extinct (\(v = 0\)) — no catalyst, so nothing is made; feed keeps the cell full of feedstock and it stays empty. Perfectly stable: with \(v\) exactly zero, \(uv^2\) is zero too.
  • the colony plateau (\(v \approx 0.2\text{–}0.3\)) — catalyst production and the kill term balance exactly. Consumption holds the feedstock down, which holds production in check. Also stable: nudge it and it comes back.

Between them sits a third balance point — and it's unstable. It's a threshold: a hair below and the kill term wins, so the cell empties out; a hair above and \(uv^2\) wins, so it runs up to the plateau. Nothing can rest there. That's why a cell holding a middling density is always a cell mid-decision, and why those colors only paint the moving edge of a front. The intermediate shades aren't states — they're transits.

This is why the patterns look the way they do. Bistability is what buys you sharp edges: since the middle ground is unstable, the dish is forced to sort itself into "on" and "off" with a thin boundary between, instead of settling into a smooth gradient the way the heat equation would. Every crisp rim you see is a cliff between two attractors. And the reason those rims move — rather than freezing into a final picture — is the diffusion coupling from the previous section: a colony's edge keeps pushing its neighbor over the threshold, while its own interior starves behind it.

And yes — this is Course 3's phase portrait, one dish-cell at a time. There we drew every possible state of a pendulum as a point on a map and let the equation's arrows carry it: stable centers that states spiral into, unstable saddles that the faintest nudge sends careening away. Same picture here, with the axes relabeled to \((u, v)\) — the concentrations of feedstock and catalyst. Extinct and the colony plateau are the stable points; the threshold between them is the saddle. A pendulum "never settles at the top" for exactly the reason a cell never settles at a middling density.

One difference, and it's what makes this course harder than that one: the pendulum was a single point moving on one map. Here every one of the 4,096 cells is its own point on its own copy of the map — and diffusion ties each cell's arrows to its neighbors' positions. The patterns are what 4,096 coupled phase portraits look like from above.

Practical upshot, visible in Step 4's color map: its density anchors are deliberately unevenly spaced. An evenly-spaced ramp would hand a third of its colors to densities that only ever exist on a one-pixel rim.

Every term is one of three jobs

Both chemicals follow the same skeleton — each term does one of three jobs. The middle one is what ties them together:

Job \(u\) — feedstock \(v\) — catalyst
Spread to neighbors the \(\nabla^2\) Laplacian spreads out spreads out, slower
Reaction gets eaten + gets made
Supply / removal + fresh drips in feed \(F\) washed out kill \(F + k\)

Read the reaction row across: it's the same quantity, minus for feedstock and plus for catalyst. Every scrap of feedstock the reaction destroys reappears as catalyst — that shared term is why it's two coupled equations, not two unrelated ones. They're joined right there.

Two equations at once, and a supply line

Worth naming what kind of object that table describes, because the name accounts for the behavior. It's a system of two partial differential equations: two unknowns, \(u\) and \(v\), that have to be advanced together, since neither one's rate of change can be computed without the other's current value. Course 3's rod was one equation in one unknown. Three things separate them.

  • Two fields instead of one. A single field has nothing to interact with, so the only thing it can do is redistribute itself.
  • One nonlinear term — the \(uv^2\) above. Solutions of a linear equation can be added together, which is what makes Course 3 solvable by summing waves and also what makes it incapable of surprise: its modes ignore each other. Here they don't.
  • A supply line. The rod is closed. Heat gets moved around, nothing is added, and the only available ending is flat. The supply / removal row is what breaks that: \(F\) drips fresh feedstock in everywhere, indefinitely, and \(F + k\) carries catalyst away. The tank is held permanently away from equilibrium, and every pattern on the screen is paid for by that throughput. Set \(F\) to zero and the patterns don't settle into something static — the catalyst gets consumed with nothing replacing it, and the screen goes dark. Structure here is not a state the system reaches; it's something it has to keep spending on.

There's also a clean way to see where each half comes from. Delete both Laplacians — every cell sealed off from its neighbors, no spreading anywhere — and what remains is a pair of ordinary differential equations in \(u\) and \(v\): two numbers chasing each other in time, with space gone from the problem entirely. That's a phase portrait, the object Course 3, Step 3 built. Put the Laplacians back and every cell's phase portrait is wired to its neighbors'. That's the whole construction — Course 3's phase portrait at each point, plus Course 3's Laplacian connecting the points.

The diffusion ratio (Dv/Du)

Both chemicals spread — but not at the same speed, and that gap is the Dv/Du slider in the playground. We fix the feedstock's speed at Du = 1 and set the catalyst's to a fraction of it (default 0.45 — catalyst spreads at 45% of the feedstock's rate). It's the one control that isn't about how much, but how fast.

Why it's the make-or-break knob: if both spread equally (ratio near 1), everything blurs together into flat gray — no pattern at all. Because the catalyst is slower, it stays clumped where it already is while its food arrives from farther out — so it can grow, but only hemmed into tight shapes. That mismatch — slow catalyst, fast-arriving feedstock — is Turing's insight: it's the reason a smooth soup spontaneously breaks into spots and stripes. Nudge the ratio down for finer, sharper structure; nudge it up toward 1 and the patterns coarsen and eventually wash out.

How big the blobs get — the diffusion length

There's a single number hiding behind “how big is a spot”: the diffusion length — how far the catalyst can spread before the kill term catches up with it. Roughly \(\ell \sim \sqrt{D / \text{rate}}\): a chemical that diffuses faster, or dies slower, reaches farther before it's spent — and that reach is exactly what sets a blob's radius. Everything you see is about one \(\ell\) wide, because that's the distance over which “grow at the front, starve at the back” plays out.

The catch is the square root. Blob size scales with \(\sqrt{D}\), not \(D\) — so to make spots twice as big you'd need to quadruple the diffusion. And you can't: an explicit step is only stable up to a diffusion ceiling (past it the grid oscillates and blows up), and we already sit right under it (\(D_u = 1\)). Turning the knobs moves you around the \((F, k)\) plane — spots vs. stripes vs. worms — but it can't buy you a bigger \(\ell\). The size is baked into the grid.

The trick: change the ruler, not the physics. Diffusion length is fixed in cells, not pixels. So we run the sim on a coarser 64×64 grid and upscale it 2× to the T-QT's 128×128 screen. Same \(\ell\) in cells — but each cell is now two screen pixels wide, so every blob lands twice as big. Bonus: a quarter the cells means the chip does a quarter the work per step, so it runs cooler and faster too. Bigger blobs for free — we just zoomed in.

The precise version

Same three jobs, written as math — the Gray–Scott system, the exact update the playground and the T-QT firmware run each step. Hover any term for what it does:

Hover or focus a highlighted term. The readout explains what that piece does to the field.

\(\dfrac{\partial u}{\partial t}\) \(D_u \nabla^2 u\) \(u v^2\) \(F\,(1 - u)\)
\(\dfrac{\partial v}{\partial t}\) \(D_v \nabla^2 v\) \(u v^2\) \((F + k)\,v\)

∂u/∂t — how fast feedstock changes

  • Du∇²u feedstock spreads out to its neighbors
  • − uv² the reaction eats it — only happens where catalyst is present
  • + F(1−u) fresh feedstock drips in everywhere, at feed rate F

∂v/∂t — how fast catalyst changes

  • Dv∇²v catalyst spreads out too, but slower
  • + uv² the reaction makes it — the is “you need catalyst to make catalyst” (v × v), so it grows only next to itself
  • − (F+k)v catalyst is removed at kill rate F+k

Hover any term above for a longer note.

Discrete Laplacian on the grid

On a grid, \(\nabla^2 u\) at a cell is “how far below the neighborhood average am I” — a weighted sum of the 8 neighbors minus the cell itself. The word for that arrangement is a stencil: the fixed pattern of neighboring cells an update reads in order to compute one cell's new value. It's the shape of the neighborhood, and the number in its name is how many cells the shape covers.

  • 3-point, in one dimension — a cell plus its left and right neighbor. You have already met this one: the bracket in Course 2's derivation, \(\rho(x-h) - 2\rho(x) + \rho(x+h)\), is the 3-point stencil.
  • 5-point, in two dimensions — a cell plus north, south, east, west. Drawn out, it's a plus sign. This is the version Course 5 uses.
  • 9-point — the same, plus the four diagonals at lower weight, because they sit \(\sqrt{2}\) times farther away.

So “stencil” and “discrete Laplacian” are nearly interchangeable in practice: the stencil is the shape, the Laplacian is what that shape computes. The shape also explains why the same kernel can run in a browser and on a microcontroller unchanged — every cell reads the same relative offsets, so the addresses are predictable and each cell can be computed independently of the others.

This lab (and the firmware it exports to) uses the 9-point stencil: adjacent cells weigh 0.2, diagonals 0.05, center −1. At the edges the neighbors are clamped (zero-flux), so a pattern that reaches the rim exits “off camera” instead of wrapping to the far side.

// identical kernel in the browser and in the LilyGO T-QT firmware
float Laplacian(float[] f, int x, int y)
{
    return 0.2f * (L + R + T + B)
         + 0.05f * (TL + TR + BL + BR)
         - f[x, y];
}
Why patterns and not mush. \(v\) diffuses slower than \(u\) (\(D_v = D_u/2\) by default). The catalyst stays concentrated where it is while its food arrives from far away — local activation, long-range inhibition. That asymmetry is Turing’s 1952 instability, and the whole zoo of spots, stripes, and worms lives on a knife’s edge in the \((F, k)\) plane.

Step 2 · Playground

The (F, k) plane, live

Step 1 fixed the two update rules; this canvas just runs them on a 64×64 grid, upscaled 2× to the LilyGO T-QT’s 128×128 screen (see the diffusion-length note above — that's what makes the blobs this big). Presets jump to known regimes of the \((F, k)\) plane; then nudge k a tick at a time and watch a species flip into another. Draw on the canvas to inject catalyst.

The tank bright = catalyst · dark = feedstock (invisible) · border = flow through each wall · draw on the dish to inject catalyst

1.6 s 96 slices, bottom oldest — once it fills, the block scrolls · one full turn every 11 s

Presets

Parameters

T-QT · actual size (0.85″)

Save

Saved presets live in your browser and appear below, ready to export to the T-QT.

Palette

Saved presets

All dark: the catalyst went extinct (\(k\) too high) — Reseed. All bright: it saturated the dish (\(F\) too high). The interesting universes sit on the boundary between those two fates.

The heatmap — the substrate needn't be uniform

So far the feed rate \(F\) is the same everywhere — a flat, uniform dish. But nothing says it has to be. You can paint a heatmap: a map that says “feed here more than there,” so each cell gets its own \(F\). The update doesn't change at all — it's still the same per-tick recipe — you're just handing each cell a different feed value.

That's a surprisingly strong lever. Recall the boundary above: a little more \(F\) tips a region from “extinct” into the pattern regime, a little less starves it. So if you raise \(F\) along a shape and leave it low elsewhere, the reaction preferentially lives on that shape — the pattern fills the letters and thins out around them. You're growing an image out of the chemistry, by feeding its outline.

The C5 preset does exactly this: its feed map is boosted along the glyph “C5.” Note it's fed into the shape, not seeded as the shape — the dish starts as uniform noise, and the heatmap alone decides where structure survives. Load the C5 chip, then use the Magnet controls: strength sets how hard you feed the outline, and the strength LFO oscillates it so the letters dissolve and reform — a breathing sign.

Step 3 · The wide tank

Twenty screens in one

Everything so far ran on a 64×64 grid, because that's what the T-QT runs. That constraint is honest but it hides something: at that size Gray–Scott makes a handful of blobs. Give the same chemistry room — a quarter of a million cells, sixteen chip screens in one — and it stops making blobs and starts making landscapes.

Nothing about the rule changed. Same three jobs per cell, same nonlinear \(uv^2\). The only difference is how much space the pattern has to organise itself across, and it turns out that scale is a parameter: fronts get room to curve, defects get room to travel, and whole regions settle into different species that then argue along their borders. Your browser does in a second what the chip would take an hour to cover.

This one needs a bigger machine. The wide tank updates a quarter of a million cells several times a second, which is a lot to ask of a phone — so it's switched off on small screens rather than left to stutter. Everything else in this course works fine here; come back to this step on a laptop.

The border works like the chip lab's tank upstairs, but it feeds instead of flowing: each wall has its own feed rate, fading inward over about 40% of the tank. Give the top wall more feed than the bottom and you get two different species growing toward each other, with a border war along the seam — which is the real reward for a tank this size, and something a 64×64 grid simply has no room for. Negative starves that side instead. Still walls zeroes them all. The species chips jump to six known neighbourhoods of the \((F, k)\) plane, and this tank exposes \(D_u\) and \(D_v\) separately where the chip lab gives their ratio: keeping \(D_v < D_u\) is the entire Turing condition, so try \(D_v > D_u\) and watch patterning die.

The strip under the tank is the colour map — the same one the tank is painting with, so the two can't drift apart. Start from a named ramp, then tune it: hue rotates where it begins, spread is how far around the wheel it travels as feedstock fills (negative runs backwards, and past ±360° it wraps more than once, which gives banded contour lines), and saturation takes it to greyscale. Nothing about the chemistry changes — you're only relabelling numbers as colours, which is worth remembering: every false-colour image you've ever seen involved somebody making this choice.

The grid is fixed at 640×400 — 256,000 cells, sixteen chip screens. That's not a technical ceiling (it'll run at 800×500) but a chosen one: this is the largest size that stays smooth on ordinary hardware, and a playground that stutters teaches nothing. The readout under the tank shows what it's actually costing — cells, chip-screens, frame rate, and millions of cell-updates a second.

Step 4 · The Lily build

From simulation to a thing in your pocket

Everything so far runs the same kernel the LilyGO T-QT Pro runs — a thumbnail-sized ESP32-S3 with a 0.85″ 128×128 color screen. Tune a preset in Step 2, and it behaves the same in your hand. This step is the build log: how a preset travels there, how to flash it, and the story of making it fast and stable — which turned out to be the most interesting hardware lesson of the whole project.

How a preset travels to the device

Same kernel, same grid, same parameters — so a preset you save in the lab is a complete description of what the hardware will render. presets.json drops into the firmware project (~/Lily) and is baked in at build time. Copy C header produces the same data as a lily_presets.h you can paste straight into the firmware source, or into a chat with your build assistant. On the device, the two physical buttons cycle presets and reseed the dish.

typedef struct {
    const char *name;
    float F;       // feed rate
    float k;       // kill rate
    float dvdu;    // Dv / Du diffusion ratio
    uint8_t palette;
    uint8_t steps;  // sim steps per rendered frame
} GsPreset;

Five numbers and a name — which means the export is lossy, and deliberately so. The browser lab has grown knobs the chip doesn't have: the wall flows on the tank border and the two oscillators are saved with your preset in the browser, but there's nowhere for them to go in this struct, so the firmware export drops them. A preset that leans on a current or a breathing LFO — Breathe, C5 and Bloom — will look calmer on the device than in the browser. Only Lily and Pulse use nothing but what the struct carries, so those two travel exactly.

The board

The display is driven with TFT_eSPI's GC9A01 driver (it speaks to the panel's GC9107 correctly), all configured in platformio.ini build flags — no library files edited. Pins verified against Bodmer's Setup211_LilyGo_T_QT_Pro_S3.h:

SignalGPIO
SPI MOSI / SCLK2 / 3
LCD CS / DC / RST5 / 6 / 1
Backlight10 (active-low!)
Buttons — next preset / reseed0 (BOOT) / 47

Flash it

Everything's preinstalled (PlatformIO). Plug in with a USB-C data cable, then from the ~/Lily project:

cd ~/Lily
pio run -t upload      # build + flash
pio device monitor     # watch the log

Or test it on your Mac first with no board at all — make -C sim run opens a desktop window running the identical kernel (shared gs_kernel.h).

What Lily does

Powered on, the device is fully autonomous. It boots into Bloom and rotates presets every 20 seconds: Bloom → Lily → Pulse → repeat. Each rotation ends with a 3-second brightness pulse — the backlight breathes faster and faster (1 → 10 Hz, long bright peaks, short dips) until a blackout, and the next preset emerges from the dark as the light returns. Every start is a fresh random injection: one dominant enzyme blob plus two satellites at random positions, so no two blooms are alike (a perfectly centered injection just grows a symmetric mandala — asymmetry is what makes it interesting).

  • Bloom — 30 steps/frame: maximum evolution rate, big energetic jumps (~5 fps).
  • Lily — 12 steps/frame, and it breathes: a 0.1 Hz LFO sweeps the diffusion ratio between 0.35 and 0.70, so the pattern slowly tightens and relaxes on a 10-second cycle.
  • Pulse — 3 steps/frame: slow rolling boil, smooth (~33 fps).
  • Buttons: BOOT (nearest USB-C) skips to the next preset; the other button (IO47) reseeds the current one. Both reset the 20s timer.

The color map

Every pixel is colored by its enzyme density. This is the device's five-stop map, and you can now switch the browser dish onto it with the T-QT chip in the Palette row — worth doing once, because it reveals something the default Flux ramp hides. Flux is a four-stop approximation with no raspberry in it; the device has raspberry sitting at 0.187, right below the colony plateau. Since a bistable dish lives at roughly 0.21–0.27 (see “Two fates” in Step 1), the hardware spends most of its ramp inside raspberry→vermilion — so the very same pattern reads distinctly pinker in your hand than in the browser:

0 0.10 0.19 0.27 0.30 1.0 · density
StopRGBFully established atWhat it marks
null (black)0, 0, 0≤ 0.013bare feedstock — the extinct state
periwinkle92, 92, 1750.100a front's leading edge
raspberry190, 58, 1380.187a rising front
vermilion232, 64, 320.273the colony plateau
lime green135, 222, 20≥ 0.300the densest crests

Note the anchors aren't evenly spaced — and that they can't be, usefully. Because the dish spends almost no time at the in-between densities, an evenly-spaced ramp donates a big slice of its range to values that only ever paint a one-pixel rim. Where you place a stop decides whether it gets any screen time at all.

The energy dial: steps per frame

A "physics step" advances the chemistry one tick; steps/frame is how many ticks pass between the pictures you see — the flipbook page count. It doesn't change what the system does, only how you see it: few steps = smooth glide, many steps = big energetic jumps. There's also an efficiency angle: every drawn frame costs ~12 ms the chip could have spent simulating, so bundling steps amortizes that tax. Measured on the device (~6 ms/step, dual-core):

Steps/frameFrame rateChip time on physicsFeel
1~56 fps33%silk, half pace
3 Pulse~33 fps60%smooth
12 Lily~12 fps86%lively
30 Bloom~5 fps94%maximum energy

Two other levers set the ceiling: building for speed (-O3) rather than size — worth ~3× on this float-heavy loop — and measuring before optimizing (on-device timing showed the sim, not the display, owns ~90% of each frame).

Two cores, and how they talk

The ESP32-S3 has two full CPU cores. The sim splits each step between them: the main loop (core 1) computes the top half of the grid while a worker task pinned to core 0 computes the bottom. This is safe because each step reads only the old grid and writes only its own rows of the new one — the halves can't collide. All the danger lives at the boundary in time, not space: neither core may start the next step until both finished the last.

They coordinate with task notifications — microsecond-scale wake-up signals. Each step is two round trips:

core 1 (loop)                    core 0 (worker)
notify "go"        ──────────▶   wakes
compute rows 0–63                compute rows 64–127
wait  ◀──────────────────────    notify "done"     ← barrier 1: both halves computed
notify "go"        ──────────▶   wakes
publish rows 0–63                publish rows 64–127
wait  ◀──────────────────────    notify "done"     ← barrier 2: new grid is live

Between frames the worker blocks (sleeps), which keeps the system watchdog fed and lets parameters change safely — they're only ever touched while the worker is asleep. The structure was proven before ever touching the board: a desktop twin runs the same handshake on two threads and checks the result is byte-identical to single-core, every thousand steps.

Result: 8.6 → 6.0 ms per step. Not the textbook 2× — both cores share one memory bus, and 256 KB of grid traffic per step saturates it. The honest ceiling of this hardware.

Why the browser used to look faster

Same preset, same steps/frame — but wall-clock speed is steps × frame rate, and a laptop computes a step in well under a millisecond while the chip needs ~6 ms. The browser cruised at 60 fps and showed a ~3.5× fast-forward of what the hardware could do. The playground now throttles to the device's measured pace by default (the "Device speed" checkbox in Step 2), so what you tune is what you get — untick it when you just want to explore quickly.

Every optimization, and what it bought

Getting from the first painful ~3 fps to a smooth, cool device was a chain of separate wins. Each one only mattered in proportion to how much of the frame it touched (Amdahl's law kept biting):

ChangeWhat it doesEffect
Build for speed-O3 instead of the default -Os (optimize for size)~3× on the float loop
Precompute wraps → open edgesdropped per-pixel modulo, then per-pixel wrap tables entirely~26% per step on-device
Two coressplit each step across both CPUs8.6 → 6.0 ms/step (~1.4×)
One SPI transactionset the draw window once, stream all rows (was 128 setups)render 14 → ~7 ms
Interlacedraw every other row per framehalves render (small total win)
Frame-cap idlesleep the rest of a target period — the one that costs speed, on purposelowers heat (trades pace)

The recurring lesson: speeding up a part only helps in proportion to that part's share of the whole. Optimizing the wrap math looked clever but did nothing until we measured and found the sim — not the display — owned ~90% of every frame.

The vintage look

These are cheap per-pixel passes applied while drawing, emulating a CRT filmed by a camera:

  • Chromatic aberration — red sampled 1px right, blue 1px left; the color fringing of a misconverged tube.
  • Sync wobble — each row drifts horizontally on a slow travelling wave, like a failing horizontal-hold.
  • Raster beat — soft, feathered dark bands rolling upward with a random-walking speed and the odd sync "hiccup": the moiré between a camera shutter and the tube's scan.
  • Warm dip — during the transition pulse, color shifts toward ember as brightness falls, mimicking a current-starved white LED (blue collapses first).

Hardware, briefly

A CPU (central processing unit) is the general-purpose brain that runs instructions one after another. A core is one such brain — this chip's CPU has two, so it can genuinely do two things at once (which is why the sim splits across them). A GPU would be even better for this — it's hundreds of tiny cores built for exactly this per-pixel math — but a $5 microcontroller like the ESP32-S3 simply doesn't have one. So we hand-parallelize across the two CPU cores instead of leaning on thousands of GPU ones.

Two kinds of storage, often confused:

  • RAM (working memory) — fast, but erased on power-off. The running program's data lives here: our four 64×64 float grids are 64 KB, and we're using ~86 KB of 320 KB (26%). The coarser grid (which is really about blob size — see Step 1) also bought back a lot of headroom; the old 128×128 grids alone were 256 KB.
  • Flash (storage) — slower, but keeps its contents with the power off. The compiled program itself lives here: ~303 KB of a 1.3 MB app partition (23%). Think RAM = your desk (cleared nightly), flash = the filing cabinet.

A watchdog is a hardware timer that reboots the chip if the software stops "checking in" within a timeout — a safety net against hangs. On this chip it expects each core's idle task to run occasionally; a core pinned at 100% would starve idle and trip it. That's exactly why the second core's worker sleeps between steps rather than spinning — it keeps the dog fed.

Back to the top ↑