Step 1 · Equations
Two chemicals and a Laplacian
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:
- propagate — leak a little of each chemical into its neighbors (that's the Laplacian);
- react — consume feedstock where catalyst already is, turning it into more catalyst;
- 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 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.
∂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 v² 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]; }
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
Presets
Parameters
Save
Saved presets live in your browser and appear below, ready to export to the T-QT.
Palette
Saved presets
No saved presets yet — find something alive and hit Save preset.
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:
| Signal | GPIO |
|---|---|
| SPI MOSI / SCLK | 2 / 3 |
| LCD CS / DC / RST | 5 / 6 / 1 |
| Backlight | 10 (active-low!) |
| Buttons — next preset / reseed | 0 (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:
| Stop | RGB | Fully established at | What it marks |
|---|---|---|---|
| null (black) | 0, 0, 0 | ≤ 0.013 | bare feedstock — the extinct state |
| periwinkle | 92, 92, 175 | 0.100 | a front's leading edge |
| raspberry | 190, 58, 138 | 0.187 | a rising front |
| vermilion | 232, 64, 32 | 0.273 | the colony plateau |
| lime green | 135, 222, 20 | ≥ 0.300 | the 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/frame | Frame rate | Chip time on physics | Feel |
|---|---|---|---|
| 1 | ~56 fps | 33% | silk, half pace |
| 3 Pulse | ~33 fps | 60% | smooth |
| 12 Lily | ~12 fps | 86% | lively |
| 30 Bloom | ~5 fps | 94% | 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):
| Change | What it does | Effect |
|---|---|---|
| Build for speed | -O3 instead of the default -Os (optimize for size) | ~3× on the float loop |
| Precompute wraps → open edges | dropped per-pixel modulo, then per-pixel wrap tables entirely | ~26% per step on-device |
| Two cores | split each step across both CPUs | 8.6 → 6.0 ms/step (~1.4×) |
| One SPI transaction | set the draw window once, stream all rows (was 128 setups) | render 14 → ~7 ms |
| Interlace | draw every other row per frame | halves render (small total win) |
| Frame-cap idle | sleep the rest of a target period — the one that costs speed, on purpose | lowers 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.