There is no fire simulation in ShoggothOx. No combustion model, no fuel map, no particles rising through a velocity field. The flame effect that gives the visualizer its name comes from a rule you could carry out with a pencil: replace each pixel with the average of four of its neighbours, then subtract one.
That is the whole thing. Everything else — the rising, the guttering, the way trails thin as they climb — falls out of two decisions: which four neighbours you average, and what you subtract afterwards.
Start with one bright pixel in a dark field. Replace every pixel with the average of its neighbours. The bright pixel's value bleeds into the cells around it while the original dims, because its own neighbours were dark. Repeat, and the dot becomes a smudge, then a haze.
This is Gaussian blur arrived at the slow way. Repeated local averaging converges on convolution with a Gaussian, and in continuous form it is the heat equation — the same mathematics that describes warmth spreading through metal. Each frame is one time step; the neighbour average stands in for the Laplacian.
Which is a satisfying thing to know and does not yet explain fire. Heat diffusion is symmetric: it spreads in every direction equally. Fire goes up.
The trick is to average an asymmetric set of neighbours, and it runs backwards from intuition: to make brightness rise, you sample from below.
Consider a pixel at row r. If its output is computed from the
cells at row r+1, then whatever is bright one row down gets
copied up into row r on the next frame. The frame after that,
row r-1 copies from row r. Brightness climbs the
sampling chain one row per frame. Nothing moves — each pixel is
recomputed in place — but the pattern of values migrates upward, and
that is all motion is on a screen.
Pick a mode below to see the stencil. The bright cells are the ones summed to produce the outlined centre pixel:
Notice that up reaches two rows down for its fourth sample
rather than taking a fourth cell from the row immediately below. That
extra reach is what gives the flame vertical momentum: brightness
propagates upward faster than it spreads sideways, so trails stretch
instead of blooming.
Averaging alone conserves brightness. Spread a bright pixel across its neighbours forever and the total never drops — the image would converge to a uniform grey and stay there. Every frame needs to lose something.
Hence the -1. In ShoggothOx the whole decay lives in a lookup
table indexed by the neighbour sum:
#[allow(clippy::cast_possible_truncation)]
pub const DIVSUB: [u8; 1024] = {
let mut table = [0u8; 1024];
let mut ii = 0usize;
while ii < 1024 {
let quarter = ii / 4;
table[ii] = if quarter > 0 { (quarter - 1) as u8 } else { 0 };
ii += 1;
}
table
};
Four bytes sum to at most 4 × 255 = 1020, so a
1024-entry table covers every input the kernel can produce. The inner loop
then does no division at all — it adds four bytes and reads one
array slot. That was essential on a 1990s CPU where a divide cost dozens
of cycles, and it remains pleasant now for a different reason: the table
is 1 KB and sits in L1 cache permanently.
The two terms decay at different rates. Averaging divides by four each frame, so an isolated bright region falls geometrically. The subtraction removes a fixed amount regardless of brightness, so it dominates at the dark end — and it is what guarantees the screen actually reaches black rather than approaching it forever. Roughly:
vn ≈ v0 / 4n − n
ShoggothOx ships three decay tables. The interesting part is how they pair with the stencils:
| Table | Formula | Cells summed | Effect |
|---|---|---|---|
DIVSUB |
sum / 4 - 1 |
4 | A true mean. The standard look. |
DIVSUB_SLOW |
sum / 3 - 1 |
3 | Also a true mean — longer trails. |
DIVSUB_FAST |
sum / 5 - 1 |
4 | Deliberately under-averages. Quick fade. |
The subtle modes divide by three and sum only three
cells, so they remain an honest average; they last longer because they
drop the far sample that was carrying brightness away fastest. The
fast modes keep all four cells but divide by five, which
multiplies the mean by 4/5 every single frame. That extra
geometric factor, compounding on top of the subtraction, is what makes
them gutter out.
It is a one-character difference in a table constructor, and it is the difference between embers and sparks.
Below is the kernel from this post applied to a real grid. The wave at the bottom stands in for the audio stage, which in the visualizer is what keeps injecting fresh brightness — turn it off and you can watch the field run down to black, which is the point about the subtraction made visible. Drag on the canvas to paint.
The demo runs the same arithmetic the visualizer does — the
TypeScript is a direct port of
crates/core/src/flame.rs, with the same offsets, the same
tables and the same clamping, checked by unit tests that assert each mode
drifts the way its name claims.
Every cell updates from its neighbours, simultaneously, under one fixed rule. That is the definition of a cellular automaton — but not the kind most people picture. Conway's Game of Life, which we covered previously, has two states per cell and a rule built from counting. Flame has 256 states per cell and a rule built from arithmetic.
That difference in state space is what makes it look organic rather than crystalline. Life produces hard edges because a cell is either on or off. Flame produces gradients because a cell can be 137, and 137 decays to 33, and 33 decays to 7. Discrete automata generate structure; continuous ones generate texture. Formally it sits closer to a dissipative medium than to symbolic logic:
ct+1 = max(0, ⌊¼ Σ ct(xk, yk)⌋ − 1)
One loose end. A pixel in the bottom row of an up flame has
no row below it to sample. ShoggothOx treats out-of-bounds reads as zero,
which means edge pixels are averaging against darkness and fade faster
than interior ones.
This is not a flaw to be corrected — it is the reason flames appear to have a source. Brightness injected near the bottom edge is immediately being pulled toward black from one side, so it thins as it rises, exactly as a real flame narrows away from its fuel. The original Cthugha achieved the same thing differently, padding the buffer with three hidden guard rows above and below so the inner loop never had to check bounds at all. We trade a few branch instructions for a simpler memory layout; the visual result is the same.
Flame is one stage of a pipeline: sound comes in, the flame decays and blurs the previous frame, a translation table warps it, the wave stage draws new content from the audio, and the palette maps the result to colour. Each stage is swappable, so twelve flame modes multiply against the warps, waves and palettes rather than adding to them.
Which is the real reason the effect has survived thirty years. It is not that averaging four neighbours produces particularly convincing fire. It is that it produces fire cheaply enough to compose with everything else.