k-state physical-model showcase

Coffee extraction lab

A six- and sixteen-state model of water moving through coffee grounds. Pull the levers, preserve the physics, and try to spend the whole puck.

Built with the published npm package @hexlife/embed@1.7.1 <hexlife-ca> · jsDelivr import map · no build step

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

tick 0 poured 0 in bed 0 in cup 0 pouring
0.0%
extraction yield
best this session

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 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.


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.