The goal is to spend every ground. Water picks up solubles as it passes through wetted coffee, and once it is saturated it cannot pick up any more — it just runs through. So the way to a high yield is to make the water meet as much of the bed as possible before it saturates. Send it all down one path and you get a channel: that column extracts fully, everything either side of it stays dry, and the cup is both weak and bitter.
Every quantity here is conserved exactly, which is what makes the score mean something. Fluid is never created or destroyed by the rule, grounds are never created or destroyed, and the only things crossing the boundary are what you pour in at the top and what drips out of the bottom into the cup — both counted. That is the block backend's doing, and the backend comparison tab shows what happens without it.
The dual-porosity lab is the same idea taken to sixteen states after the six-state model was checked against the coffee-percolation literature. What is wrong with the six-state model is that its grains are impermeable: water can only ever sit in the gaps between them, so it pools on the surface, pockets of brew stay trapped forever, and at the packing a real puck runs at, most of the bed is never touched at all. Grains that hold liquid fix that, and that tab has the measurements. It also carries the write-up of a transport bug both rules used to share — gravity pointing 60° to the left — because the fix is the same three lines in all three rules on this page.
The brew
The puck
The rig
How the rule works
Six states, in two families. Fluid is air 0, water 1 and saturated water 2 — numbered in density order, so gravity only ever has to ask which of two cells is the heavier. Grounds are dry 3, wet 4 and spent 5 — numbered in the order a ground passes through them. Grounds never move.
Each tick, every 3-cell triangle of the partition is rewritten at once, in three steps: extract (a water cell sharing a block with a wet ground becomes saturated, and that ground becomes spent), then wet (a dry ground sharing a block with any fluid becomes wet), then fall (the heavier of two mobile cells takes the lower slot, along the triangle's vertical bond, or one column sideways-and-down when there is something in the way — see Gravity points down on the next tab for why it is a bond and not a sort). Extraction runs before wetting on purpose: grounds must be wet for at least one tick before they will give anything up, which is what makes a pre-infusion pause worth doing.
There is no transport beyond gravity — nothing wicks, nothing diffuses, and water can only enter a ground it happens to fall past or come to rest against. Water does not even climb: the sideways step is downhill-only, so a pool levels by spilling off its own top and never by pushing up anywhere. That is the model's real limitation, and the packing slider is where you meet it.
Saturated water is inert — it cannot extract again. That single clause is what turns channelling from a cosmetic problem into a scoring one, and it is why pouring faster is not the same as extracting more.
Note what the library reports about this rule: not conservative and not isotropic.
Both are correct. isConservative tests the strict multiset property, and extraction
changes which states exist (water→saturated, wet→spent); what it preserves is fluid count and
ground count separately, which the page checks exhaustively over all 216 block entries before
running a tick. isIsotropic is false because gravity has to single out a direction.
Both are reported, never enforced — reactions and gravity are legitimate physics.
Why six states means one backend
@hexlife/embed/ca has two backends, and they are two different ways of writing a rule
down rather than two speeds. The neighborhood backend is the direct generalisation of
HexLife's rule space: a dense table indexed by a cell and all six of its neighbours, so
k⁷ entries — 16 KB at k=4, but 279,936 entries at k=6 and 268 MB at k=16. It is
hard-capped at four states. The block backend rewrites a whole 3-cell triangle from a
k³ table — 216 entries here — and scales to sixteen.
So this model does not choose block mode; six states is more than the other table can hold.
The backend comparison is at four states because that is the largest k that fits both.
The six-state model treats a coffee grain as a solid wall. It is not one. Reported intragranular porosity of roasted coffee grains is 0.35–0.55, against an intergranular porosity that can be as low as ~0.1 in a packed puck — so most of the liquid in a real bed is inside the grains, not between them. A model where water can only ever occupy pore space is a bed of glass beads, and it is why water pools on the surface and why pockets of brew sit trapped.
So here grains hold liquid. Water is imbibed into a grain, solubles dissolve into it from the inside on their own clock, liquid wicks from grain to grain, and it drains back out downward. Sixteen states, a 4,096-entry table, and three quantities conserved exactly rather than two: grounds, liquid units, and dissolved solute.
The brew
The puck
The rig
The sixteen states
Free liquid is air 0 and three solute grades — fresh, lean and rich. Grains never move and carry two independent quantities: how much soluble charge is left (2, 1 or 0) and whether they are holding a unit of liquid. Three drained grains (, lightest = untouched) and nine wet ones, whose tint carries the concentration of the liquid inside — bluer when it is still fresh, browner as it loads up.
Each tick rewrites every triangle through five steps. Dissolve moves one unit of solute from a grain's solid into the liquid it already holds — no neighbour required, because this is intraparticle diffusion, and it is gated on the partition slot so that a grain gets one step per three ticks at a coarse grind and one per tick at a fine one. Moroney et al. measure that timescale at 42 s fine to 270 s coarse against a transit time of seconds, which is why it has to be decoupled from flow. Then imbibe, wick and drain move one liquid unit each; then disperse mixes two free parcels two grades apart; then fall.
Drainage is downward-only while imbibition accepts from any direction. That asymmetry is capillary retention, and it is the one free parameter with a real experimental target: a brewed bed holds about 2 mL of liquid per gram of coffee, which for a pour-over is roughly 12% of what you pour. At the defaults this model retains 14%, and 0.3–20% across the packing slider.
Gravity points down
Before any of the coffee physics, a transport bug — and it was in every rule on this page, not just
this one. Slots 0 and 2 of the partition triangle share a column while slot 1 sits one column to
the right, so 0→2 is a pure vertical bond and 0→1 and 1→2
are its two mirror-image half bonds. A plain density sort always sends the heaviest cell to
slot 2 — which means a parcel phase-locks onto the 1→2 bond and descends
to the left, forever, in an empty world with no obstacles at all. Dropping one cell of fluid into a
void and following it for 60 ticks:
| fall step | rows fallen | columns drifted | angle from vertical |
|---|---|---|---|
| sorted by density — what all three rules used to do | 30 | −60 | 59–60° |
| routed through the bonds — all three now | 30 | 0 | 0.0° |
The angle is measured in the plane, not in grid indices: hex columns are 1.5 apart where rows are √3 apart, so 60 columns per 30 rows really is 60° off vertical. It is also completely parameter-free — no bed, no obstacles, nothing but one parcel and a void.
The fix is to route gravity through those bonds explicitly. The vertical bond goes first, whenever both its ends are mobile. A half bond is offered only when the far end of the vertical bond is occupied: empty space down there means the cell is in free fall, and a free-falling cell takes no sideways step at all — it waits for the partition phase that hands it the vertical bond, which comes round once every three ticks. That is what makes free fall exact. Verified at zero columns of drift from all six starting parities, in the six-state rule, this one, and the four-state pair on the next tab.
Water in pillars: what “occupied” has to mean
That gate originally read occupied by a grain, and it stood water in columns. A parcel of liquid resting on more liquid had no legal move in the whole rule: the vertical bond was blocked by its own kind, and neither half bond was offered because the thing blocking it was not solid. So a poured stream built a tower and the tower stayed — 86 cells tall in a test that pours 600 units of water down a five-column stream onto a floor and lets it settle. Real water would have spread out about six deep.
Widening the gate from a grain to anything at all is the entire fix, and it is one word of rule. A parcel that cannot go straight down because something is under it — grain or liquid, it does not matter which — may take the half bond instead and step one column sideways and down. Free fall is untouched, because empty space below still forbids the half bond.
| a half bond opens when the cell below is… | columns the pool covers | tallest column | free fall |
|---|---|---|---|
| a grain, only | 7 of 104 | 86 | exactly vertical |
| a grain or liquid | 71 of 104 | 17 | exactly vertical |
There is no free lunch hiding in the second row: dropping the gate entirely, so that a half bond is always available, spreads the pool exactly as well and costs the exact fall. A lone parcel in a void then walks off at 60° again on a single handedness, because the one bond it can always take out of the middle slot points down-left; with the alternation below it only zigzags, landing within a column of where it started rather than in it. The gate has to be there. It just has to be about whether the cell is in free fall, not about what the obstruction is made of.
In a full brew the signature is the tallest standing column of free liquid left in the bed at the finish. At the dual lab's defaults it goes from 44 cells to 9, and the water that was standing in those towers is water that was not in a grain: retained liquid falls from 16.0% of the pour to 13.7% and extraction rises slightly, 64.0% to 65.8%. In the six-state lab, where gravity is the only transport there is, the tallest column goes from 62 to 11.
The other half of gravity: the partition was left-handed
Getting the fall step vertical was not enough, and you could see it: a centre stream fell straight down through the air and then, the moment it soaked into the bed, the wetting front took off to the left at about 13° and stayed there. Gravity was innocent. The partition was not.
The engine tiles the grid with up-triangles only — {cell, SE(cell), S(cell)} — so
slots 0 and 2 share a column and slot 1 is alone in the next column to the right. Every
transfer out of slot 1 therefore carries liquid sideways whichever of its two partners it picks,
and every transfer into it carries liquid the other way, with no mirror image of slot 1 inside the
triangle to cancel against. Imbibe, wick and drain have no escape from that, and no arbitration
between the three bonds fixes it — about twenty-five were tried, and every one that flattened the
drift did it by strangling lateral transport instead. Gravity used to escape by only ever taking a
half bond to get around a grain, which is exactly what it gave up to stop standing water in
pillars: a parcel spilling off a pool takes a handed step for the same reason everything else
does.
The fix is to stop using one handedness. Reflecting the grid left-to-right turns every up-triangle into a down-triangle, because the reflection swaps each cell's SE and SW neighbours — so mirror the grid, take one ordinary tick, and mirror back, and that tick ran on down-triangles with slot 1 one column to the left. Alternate the two every tick and the handedness cancels. This needs nothing from the engine; the reflection is a permutation of cells that the page applies itself, and it maps every row onto itself, so the pour, the drip and the guard row never notice.
| partition | blob drift, 600 ticks | wetting front |
|---|---|---|
| up-triangles only | −27.5 ± 4.8 cols | 13.3° off vertical |
| down-triangles only | +32.7 ± 6.4 cols | 15.8° the other way |
| alternating, every tick | −0.2 ± 0.8 cols | 1.5° |
The blob is 400 units of liquid dropped into a uniform bed and followed by centre of mass over six pucks; it descends about 50 rows in that time. The two single-handed rows being equal and opposite is the whole argument, and alternating them lands on zero to within the seed-to-seed scatter. The same experiment driven through the real wasm engine in the browser rather than the measurement harness gives +0.16 columns, with the liquid count and the grain count exactly preserved.
Both labs alternate. The six-state model has no transfer steps — gravity is its entire
transport — so it got away without this until gravity itself acquired a handed step. Now that a
blocked parcel can spill one column sideways, it needs the alternation for the same reason and it
has it. What that costs is the engine's own isSettled: the mirror rewrites the whole
grid and dirties every chunk, so the activity tracker it is built on never goes quiet. Six
consecutive no-change ticks is exactly as strong a claim — three partition phases times two
handednesses is every map the world can be hit with — so the lab counts them itself and the
settled badge means what it always did.
The four-state panel does not, and that is deliberate rather than an oversight: its whole
point is two backends stepping the same model under the element's own clock, and taking one of them
over with a host loop would spoil the comparison. It can afford to skip it because that model
destroys free water on contact with a dry ground instead of pooling it — measured, its free water's
centre of mass moves 0.97 columns out of 76 in the first thousand ticks and then never moves again,
because by then there is nothing left in transit. Both of its backends keep the engine's own
isSettled and its chunk-activity readout.
What it cost, and what it was hiding. Chunk skipping goes with it, in both labs, because a mirrored grid has no region the engine can prove is unchanged; at these sizes that is not worth measuring. The real cost is that the drift had been doing work: a front marching sideways as it descends sweeps far more of the bed than one going straight down, so extraction at this lab's defaults fell from 85% to 66% and never-wetted rose from 0.8% to 3.2%. Those higher numbers were the artefact, not the achievement. The centre stream is the clearest case — it now channels, leaving 84% of the bed dry, which is exactly what a stream poured into one spot should do and what the six-state lab next door has been saying all along.
A retraction. An earlier version of this section reported that alternating handedness had been measured and did not cancel — up −23, down +40, alternating −19. That was one puck per row. The seed-to-seed scatter is ±4 to ±7 columns and the two single-handed runs were never really 23 against 40; over six pucks, on the rule of the day, they were 29 against 31. The conclusion drawn from that single run, that this was an open problem needing an engine change, was wrong on both counts.
What changes
Measured in a standalone harness on the 150×174 grid this tab runs — shower pour, 30% water, coarse grind, each row the mean of three pucks, every run carried to its own finish — sweeping the same packing slider the six-state model is scored on in the other tab:
| packing | extracted | never wetted | retained | cup strength |
|---|---|---|---|---|
| 0.25 | 64.6% | 8.2% | 19.7% | 32.0% |
| 0.35 | 67.8% | 4.3% | 17.3% | 51.9% |
| 0.45 | 65.8% | 3.2% | 13.7% | 64.6% |
| 0.55 | 62.2% | 2.3% | 7.9% | 73.7% |
| 0.65 | 48.9% | 2.9% | 3.8% | 66.9% |
| 0.75 | 23.1% | 6.3% | 1.3% | 35.8% |
The puck no longer chokes. The six-state model stops delivering anything at all to the cup once its packing passes about 0.5, because it needs a pore path pointing downhill and runs out of them. This one gets the whole 30% in at every packing on its slider, all the way to 0.85 — which is what a real puck does, since a real intergranular porosity of ~0.1 is far below any 2D threshold and the bed conducts anyway. What falls away at the top of the slider is not intake but contact time: at 0.85 the water gets through and takes only 5.3% of the solubles with it. And the bed gets wetted. Never-wetted sits between 2 and 10% across the useful range; at the same packing of 0.45 the six-state model leaves 67% of its bed untouched. Turn wicking off with the checkbox, press New brew, and at the defaults never-wetted jumps to 30% while extraction falls from 66% to 38% — the single clearest demonstration on this page of what grain-to-grain transport is for, and of why a model whose grains are walls cannot reach most of its own bed.
Grind is the cleanest control here, and it is the one the six-state model could not express at all: at packing 0.45 it moves extraction 65.8% → 78.7% → 88.1% across coarse, medium and fine, purely by changing how fast solubles leave the solid, with the flow field untouched.
Completion is transport-aware. A moving parcel does not necessarily change any of the four monotone progress counters while it crosses empty headspace or an already-wet path. The old fixed 240-tick silence test could therefore finish the 300-row, shallow-bed preset before the wetting front reached the puck. The allowance now scales to one complete six-map partition/handedness period per row; ticks-per-frame still changes only playback speed, never the result.
Why block mode exists at all
The same four-state model — air, water, dry ground, wet ground — run under both backends from byte-identical cells. Watch the bottom row of each table.
A radius-1 synchronous cellular automaton cannot conserve mass, at any number of states.
Two water cells sitting diagonally above one empty cell each independently see “empty below me”
and vacate; the empty cell sees water above and fills. Two in, one out. Preventing that needs the
losing cell to know it lost — to see its competitor, two cells away — and radius 2 on a hex grid
is 18 neighbours, so an anisotropic rule table would be k¹⁹. It is not a bug in the
rule; it is a property of the neighbourhood.
Block partitioning fixes it by construction: arbitration happens inside the block, so a rule that permutes multisets is exactly conservative with no bookkeeping. This is the lattice-gas approach, and FHP is the hexagonal precedent.
The failure mode does not look like one. The right-hand grid stays perfectly plausible — water beads, soaks in, spreads down. Only the number moves, and it moves in both directions: two sources feeding one destination lose mass, one source feeding three duplicates it. This seed loses ~4% on the first tick and then runs away upward to roughly 4.5× the water it started with — an espresso puck that brews more coffee than you poured.
block — 3-cell triangle, k³ table
Transport and wetting are both rewrites of a whole triangle, so no two cells can ever claim the same destination. Total water is held to the count, forever — then it comes to rest.
neighborhood — radius 1, k⁷ table
The most natural radius-1 expression of the same model: water vacates when there is somewhere below to go, open space fills from above, ground wets on contact. Nothing arbitrates.
Why 66 rows and not 64
Block mode partitions the grid into up-triangles in three phases, cycling
φ = tick mod 3. The partition is seamless only if the sublattice residue survives the
row wrap, which needs rows ≡ 0 (mod 3). The embed's usual default of 64 does not
qualify, and <hexlife-ca> shows an error box rather than rounding it — the grid
you asked for should not silently become a different one. Every grid size offered above is a
multiple of three for the same reason.
Why this lattice
A hex grid has one neighbour class — six neighbours, all equidistant. A square grid has two, and
the anisotropy that follows is not cosmetic: six-fold symmetry is sufficient for a lattice gas to
recover isotropic hydrodynamics in the continuum limit and four-fold is not, which is why
square-lattice automata grow diamond-shaped fronts where physics wants circles. Hex cell centres
also form a triangular lattice, where site percolation has p_c = 1/2 exactly
— on the square lattice it is ≈0.5927, known only numerically.
That is the frame the packing slider on the six-state lab is working in. Sweeping it at an otherwise fixed brew (shower, flow 6, 62% bed, 18% water, mean of three pucks, each run to its fixed point) gives:
| packing | pore space | yield | never wetted | reached the cup |
|---|---|---|---|---|
| 0.20 | 0.80 | 59.3% | 35.6% | 4,584 |
| 0.25 | 0.75 | 53.6% | 39.4% | 4,445 |
| 0.30 | 0.70 | 46.9% | 43.4% | 4,189 |
| 0.35 | 0.65 | 39.1% | 48.0% | 3,700 |
| 0.40 | 0.60 | 30.4% | 53.9% | 2,485 |
| 0.45 | 0.55 | 20.1% | 67.3% | 976 |
| 0.48 | 0.52 | 10.3% | 82.2% | 342 |
| 0.50 | 0.50 | 7.4% | 86.4% | 0 |
| 0.55 | 0.45 | 4.0% | 92.4% | 0 |
The cup empties out between pore space 0.52 and 0.50 — which is to say at
p_c = 1/2, near enough that the measurement cannot tell them apart. That is close
enough to be worth being suspicious of, so it is worth being exact about what the two numbers are.
The threshold is about a connected cluster of open sites; what this rule needs is a path it can
actually walk, and gravity never moves a parcel upward or level. So the question is how close a
downhill path gets to a merely connected one, and that turns entirely on how
freely a blocked parcel may step sideways.
It used to be barely at all — a half bond was offered only to get around a grain, never to get off
a heap of water — and the cup then emptied out between pore space 0.65 and 0.60, well above
p_c, because strictly downhill paths run out long before undirected ones do. Widening
that gate to let a parcel spill off its own pool buys back almost the whole gap. It is still not a
proof that the two thresholds coincide; the numbers above are measured for this rule on this bed,
three pucks apiece, not a known constant.
The default sits at 0.30, comfortably inside the working range, with the edge up around 0.48. That edge has moved twice: it was 0.45 back when this rule's gravity drifted 60° to the left, because a parcel marching sideways as it fell swept far more of the bed than one falling straight and the bed looked far more permeable than the model says it is; fixing the gravity dropped the edge to 0.35; letting water spill off a pool put it back at 0.48. The first move was the model giving up a transport it should never have had, the second was it gaining one it always should have had, and free fall came out of both of them exactly vertical. None of it changes the point of this tab: a bed whose grains are walls has nothing but pore paths to offer, which is the sharpest possible statement of why the dual-porosity tab exists.
Percolation is a correct frame for this lattice and a poor one for coffee, and the
distinction is worth being precise about. The p_c = 1/2 is exact, and percolation
really is the right frame for a coffee bed — Wadsworth et al. (2026) fit lattice-Boltzmann
flow through X-ray micro-CT scans of real ground coffee and found a percolation-theory permeability
model beats Kozeny–Carman, especially at low porosity. But the threshold itself is a
two-dimensional number, and it governs the interparticle network only if the particles are
impermeable. Neither condition holds. In 3D the site threshold is far lower (≈0.31 on a simple
cubic lattice, lower still for continuum pore networks), and a packed espresso puck runs at an
intergranular porosity around 0.1 and conducts perfectly well. A real puck chokes through fines
migration and swelling-driven compaction, not by falling below a percolation threshold.
Which is exactly what the dual-porosity tab demonstrates: once grains can carry liquid themselves, the cliff turns into a smooth decline in throughput and the bed is still taking the whole pour at a packing of 0.85, with 12.5% of it never wetted. This model's slider stops at 0.75, and by then 98% of its bed has never seen water and the cup is empty. That is a strictly better story than this one, not a worse one — the lattice mathematics above is untouched, it simply stops being asked to carry a claim about coffee that it cannot support.
Running this page
loading @hexlife/embed…
It resolves @hexlife/embed from jsDelivr through the import map in the page source, so
it needs a network but no build. To point it at a local npm run build:embed instead,
swap that one block for the commented-out map beside it — nothing else changes, which is why it is
written with bare specifiers rather than URLs. The file ships from public/, so the
deploy serves exactly the bytes in the repository.
Want to change the transition table yourself? Open the k-state CA builder; these same six- and sixteen-state transition functions are its two physical-process starters.