Fluids
Verified against Minecraft 26.3 · Part IV · A bucket of water is emptied on flat stone and spreads one step.
A player holding a water bucket clicks the top of a stone block. Their own client places the source at once, on a prediction, and will never spread it a single block for the rest of the session. The server places the same block and then also does nothing — for five ticks. When the appointment falls due, the source looks down, finds stone, and hands the decision to four independent searches, one per horizontal direction, each walking out through the surrounding blocks looking for somewhere the water could fall. On flat stone all four fail in exactly the same way, so all four tie and the water goes out evenly. Break the symmetry and it does not, because water turns toward a hole four steps past the block it is about to fill, every side running its own depth-first search — and a side the water is not allowed to replace still votes on where the rest of it goes.
This page is the fluid model: the two registry objects behind each fluid, the block that carries them, what one fluid tick decides and where it sends the result. The queue that appointment sits in — booked, deduped, drained, saved and reloaded — is scheduled ticks, which this page borrows whole and never explains.
The cast
| class | what it decides | thread |
|---|---|---|
Fluid | the registry object: its Fluid.stateDefinition, and the numbers every fluid has, the tick delay first, as overridable methods | built at bootstrap, read anywhere |
FluidState | one interned combination of its fluid’s properties — what a block reports; a tick names the Fluid | immutable |
FlowingFluid | the whole algorithm: what a position should hold, whether to go down or sideways, and which sides win, with the slope numbers | Server; the flow’s direction read on both sides |
WaterFluid | water’s numbers, and whether two sources may make a third | Server; its particles on the client |
LavaFluid | lava’s numbers, and the one override that turns liquid into rock | Server; its particles and sounds on the client |
LiquidBlock | the block form of a fluid, and where a fluid tick is booked from when the fluid is the block | Server |
SimpleWaterloggedBlock | that a stair can be full of water without being a water block | Server |
BucketItem | where a source comes from, and the one attribute that stops it arriving | Server, with a client prediction |
Two registry objects, one substance
Fluid is to FluidState what Block is to BlockState, and it is literally
the same machinery (the state, a
leaf): StateHolder, a
StateDefinition built in the constructor,
and one interned instance per combination of properties, so two FluidStates
can be compared by identity. FlowingFluid puts FlowingFluid.FALLING on every
state it defines, and the flowing subclasses — WaterFluid.Flowing,
LavaFluid.Flowing — add FlowingFluid.LEVEL
(BlockStateProperties.LEVEL_FLOWING, 1 to 8) on top of it. The static
initialiser in Fluids then walks BuiltInRegistries.FLUID and pours every
state of every fluid into Fluid.FLUID_STATE_REGISTRY, the one global id table.
Thirty-seven fluid states make up the game: one for Fluids.EMPTY, and
eighteen each for water and lava (two from the source object, sixteen from its
flowing twin).
Each fluid being two registry objects is not bookkeeping. Fluids.WATER is a
WaterFluid.Source whose WaterFluid.Source.getAmount returns 8 and whose
WaterFluid.Source.isSource returns true without consulting any property;
Fluids.FLOWING_WATER is a WaterFluid.Flowing whose
WaterFluid.Flowing.getAmount reads FlowingFluid.LEVEL and whose
WaterFluid.Flowing.isSource returns false without consulting anything
either. WaterFluid.isSame answers true for either, which is how
the algorithm treats water as one substance no matter which object it is
holding. The scheduler does not: a scheduled tick is keyed on the fluid object,
so Fluids.WATER and Fluids.FLOWING_WATER are two different appointments and
one of each can be pending at a single position (scheduled
ticks).
The amount is also the height. FluidState.getOwnHeight delegates to
FlowingFluid.getOwnHeight, which divides the amount by nine — so a full
source stands 8/9 of a block tall, and FlowingFluid.getHeight has to
special-case a same-fluid block overhead and return a flat 1 so that a submerged
column is not full of seams.
The block underneath the water
Nothing in the world stores a FluidState. A chunk section stores BlockStates
(sections and their four counters), and
BlockBehaviour.BlockStateBase.getFluidState returns a field —
BlockBehaviour.BlockStateBase.fluidState, filled once by
BlockBehaviour.BlockStateBase.initCache at bootstrap by asking the block.
Reading the fluid at a position is a block lookup and a field read, which is why
the flow algorithm can afford to ask hundreds of times a tick.
For water and lava the block is LiquidBlock, whose single property is
LiquidBlock.LEVEL (BlockStateProperties.LEVEL, 0 to 15). Its constructor
builds LiquidBlock.stateCache, nine FluidStates: index 0 is the source, 1
through 7 are flowing at amount 8 minus the index, and index 8 is flowing at
amount 8 with FlowingFluid.FALLING set. LiquidBlock.getFluidState clamps the
level to 8, so block levels 8 through 15 all read back as falling and full,
and FlowingFluid.getLegacyLevel, the encoder going the other way, is
correspondingly lossy for falling flows — which costs nothing, because
FlowingFluid.getNewLiquid only ever produces a falling state at amount 8.
LiquidBlock has a block tick of its own, and it spreads nothing.
LiquidBlock.tick calls BubbleColumnBlock.updateColumn, and only when the
fluid there is a full source in FluidTags.BUBBLE_COLUMN_CAN_OCCUPY; the tick
was booked twenty ticks out by LiquidBlock.tryScheduleBubbleBlockColumn
because soul sand or magma is underneath. Flow is entirely a fluid tick, in
the other queue with its own budget — two queues, two appointments, one block.
Waterlogging runs the other way round: a block that implements
SimpleWaterloggedBlock reports water of its own accord.
SimpleWaterloggedBlock.canPlaceLiquid accepts Fluids.WATER and nothing else,
and SimpleWaterloggedBlock.placeLiquid sets BlockStateProperties.WATERLOGGED
and books the water tick itself rather than leaving it to LiquidBlock.onPlace.
The state it reports is a source, which is why a waterlogged stair is never
drained by a fluid tick: the first half of FlowingFluid.tick is skipped
entirely for a source. Exactly one block in the game reports a source that is
also falling — WaterloggedTransparentBlock, the copper grate — and
FlowingFluid.FALLING is the flag that lets FlowingFluid.getFlow add a
downward component to the current it pushes entities with.
A bucket, five ticks, four neighbours
sequenceDiagram
box transparent the server
participant BI as BucketItem
participant SL as ServerLevel
participant LB as LiquidBlock
participant LTs as LevelTicks
participant FF as FlowingFluid
end
box transparent the client
participant CPL as ClientPacketListener
end
BI->>SL: setBlock of the source — neighbours, clients, immediate
SL->>LB: the section write lands, then onPlace, which these flags do not skip
LB->>SL: scheduleTick for Fluids.WATER, five ticks out
SL->>LTs: schedule into this chunk's fluid container
SL->>CPL: one ClientboundBlockUpdatePacket at broadcast time
Note over BI,CPL: five ticks later, inside ServerLevel.tick
LTs->>SL: the fluid queue hands the position back to tickFluid
SL->>FF: FlowingFluid.tick, through FluidState.tick — a source skips the scan
FF->>FF: spread tries down, stone refuses
FF->>FF: spreadToSides scores the four sides
FF->>SL: setBlock of flowing water at amount 7, four times
SL->>LB: onPlace on each new block
LB->>SL: each new block books its own tick
SL->>LB: neighborChanged on the source, which books it again
SL->>CPL: four more block changes, in one section packet when they share a section
A bucket emptied, and the five ticks before anything moves: the box on the right is the only lane on the other machine, and of the broadcast block changes it hears two, the source and then the four blocks the water reached — nothing about the appointment, the scan or the scoring crosses. The note bar is the whole delay, and the two self-messages under it are the search this page is really about.
BucketItem.use picks the position, then BucketItem.emptyContents asks two
questions before it places anything. Is the block there a LiquidBlockContainer —
the interface SimpleWaterloggedBlock narrows to water, and the waterlogging
path — and does
EnvironmentAttributes.WATER_EVAPORATES hold at this position, read through
EnvironmentAttributeReader.getValue? The Nether’s dimension type sets that
attribute, so there the bucket plays a hiss, throws eight smoke particles and
returns success having placed nothing: this page’s whole trace never happens in
the Nether (environment attributes).
The first question only decides whether the position is a legal target; the
evaporation branch returns before the waterlogging is done, so a
bucket on a Nether stair hisses too.
On overworld stone it destroys and drops whatever was there and calls
Level.setBlock with the source’s FluidState.createLegacyBlock and the flag
word 11 — Block.UPDATE_NEIGHBORS, Block.UPDATE_CLIENTS,
Block.UPDATE_IMMEDIATE (the flag word,
enumerated in block update flags).
The appointment is the block’s doing, not the fluid’s.
LevelChunk.setBlockState writes the section and then, server-side and unless
Block.UPDATE_SKIP_ON_PLACE is set, calls LiquidBlock.onPlace, which asks
LiquidBlock.shouldSpreadLiquid (always true for water) and schedules a tick
for Fluids.WATER at WaterFluid.getTickDelay, five ticks out. LiquidBlock.neighborChanged and
LiquidBlock.updateShape book the same appointment when something next door
moves, and so does SimpleWaterloggedBlock.placeLiquid — but the habit is not
LiquidBlock’s alone. Sixty-three call sites across fifty-six classes schedule a
fluid tick, because every waterloggable block books water’s tick from its own
override of BlockBehaviour.updateShape, WaterloggedTransparentBlock
included. Nothing in FlowingFluid books its own future except the single
line in FlowingFluid.tick that follows a state change — and that booking is
a duplicate the queue drops, because the write one line earlier has
already booked the same appointment through LiquidBlock.onPlace.
The client is told none of this, because there is nothing to tell. The placing
player’s client ran BucketItem.use itself inside
MultiPlayerGameMode.startPrediction and wrote the source block at once —
what the window holds is not the write but the old state, filed under a
sequence number against the day the server disagrees (the six
windows) — so the source
appears locally with no round trip; the blocks the water later touches arrive the way any
run of block changes does (one flush a tick, two
audiences),
which for a flow spreading inside one section means one
ClientboundSectionBlocksUpdatePacket a tick. LevelChunk.setBlockState
skips
LiquidBlock.onPlace off the server, and the client’s fluid queue is a black
hole (where an appointment waits),
so no client ever runs the spread. What it does run is FluidState.animateTick from
ClientLevel.doAnimateTick — ambient sound and particles, with
Fluid.getDripParticle fetched separately just after — and
FluidState.getFlow, which both the fluid mesher and shared entity physics
need to know which way the surface leans. Flowing water on a client is a stream
of block updates and a direction.
Five ticks on, LevelTicks drains the fluid queue and hands the position back
through ServerLevel.tickFluid, which re-reads the block and fires
FluidState.tick only if the fluid still there is the one the tick named. It
is, and because it is a source, FlowingFluid.tick goes straight to
FlowingFluid.spread.
What one fluid tick decides
For anything that is not a source, the first half of FlowingFluid.tick
answers a question with nothing to do with spreading: what should this position
hold, given its surroundings? FlowingFluid.getNewLiquid answers it, and the
order of its three branches is most of a fluid’s character.
flowchart TD
T["FlowingFluid.tick, on a state that is not a source"]
T --> SCAN["FlowingFluid.getNewLiquid: one pass over the four horizontal neighbours"]
SCAN --> B1{"two or more sources, conversion allowed, and a floor under this block"}
B1 -- yes --> SRC["a source, not falling"]
B1 -- no --> B2{"the same fluid directly above"}
B2 -- yes --> FALL["flowing at amount 8, falling"]
B2 -- no --> B3["the highest neighbour amount, minus FlowingFluid.getDropOff"]
B3 --> Z{"zero or less"}
Z -- yes --> EMPTY["Fluids.EMPTY"]
Z -- no --> FLOW["flowing at that amount, not falling"]
Three ways for one fluid block to decide what it should now be, in the order they are asked. The order is the character: source conversion is asked first, so even a block with the same fluid overhead becomes a source when two sources and a floor allow it, and only a block that fails the first question can be made to fall.
That is only the answer. What is done with it is a second, shorter fork, and its first question is the one a reader guesses wrong: an empty answer never reaches the comparison the other two must pass.
flowchart TD
NEW["the state the scan arrived at"]
NEW --> E{"is it Fluids.EMPTY"}
E -- yes --> AIR["plain air, neighbours and clients, nothing scheduled"]
E -- no --> CMP{"the same state that is already here"}
CMP -- yes --> KEEP["write nothing, schedule nothing"]
CMP -- no --> SET["the new state, the same two flags, and LiquidBlock.onPlace books the next tick"]
AIR --> SPREAD["FlowingFluid.spread, which returns at once on an empty state"]
KEEP --> SPREAD
SET --> SPREAD
What a tick does about the answer: only one of the three arms books another
tick, which is how a flow stops on its own. All three arrive at
FlowingFluid.spread, which returns at once for the block that just became
air.
The scan that feeds the branches counts a neighbour only if
FlowingFluid.canPassThroughWall says the face between the two positions is
open.
Why the wall test is affordable
That test runs constantly — once per neighbour per scan, and again for every
candidate of every slope search below — so it is built to be cheap twice over.
FlowingFluid.canPassThroughWall short-circuits the easy cases first: either
side a full cube is a no, both sides empty is a yes. Only past those does it
merge the two collision shapes with Shapes.mergedFaceOccludes (shapes and
collision), and it
memoises that answer in FlowingFluid.OCCLUSION_CACHE, a thread-local
200-entry map keyed by FlowingFluid.BlockStatePairKey, which hashes both
states by identity and the direction. A block with a dynamic shape
(Block.hasDynamicShape)
skips the cache entirely, because its answer cannot be keyed on the state.
The first branch is source conversion, and it is where infinite water lives: two
source neighbours, a yes from WaterFluid.canConvertToSource — which reads
GameRules.WATER_SOURCE_CONVERSION, true by default (game
rules) — and something solid
(BlockBehaviour.BlockStateBase.isSolid) or another source of the same fluid
directly below the position being filled. LavaFluid.canConvertToSource reads
GameRules.LAVA_SOURCE_CONVERSION, false by default, so infinite lava is one
rule away rather than a property of lava. The second branch is the same fluid
overhead, which always produces a falling state at full amount, however little
is falling past. The fallback is the highest same-fluid neighbour minus
FlowingFluid.getDropOff — one for water, two for lava outside the Nether — and
zero or less is empty.
Two outcomes at the bottom of the figure matter more than the branches above them. Empty means the block becomes plain air and nothing is rescheduled: a fluid tick that decides a position should be dry books no follow-up, and the appointment book simply forgets it. And when the answer equals the state already there — the ordinary case for a settled flow — neither the write nor the booking happens, so a stable pool that is ticked one last time falls silently off the books.
Down first, then sideways
FlowingFluid.spread tries below before anything else. If the block there can
hold the fluid, its existing FluidState.canBeReplacedWith allows the swap and
the floor between them is open, the fluid falls: FlowingFluid.spreadTo fills
the position below and FlowingFluid.spread returns — unless
FlowingFluid.sourceNeighborCount finds three or more source neighbours around
the position that is pouring, in which case it spreads sideways as well. That is
the lip of a large pool draining into a hole: it pours down and keeps flooding
outward in the same tick.
Under our bucket the block below is stone, so nothing passes. Because the state
is a source, FlowingFluid.spreadToSides runs anyway (the other way in is a
position whose floor is not a FlowingFluid.isWaterHole). Its first act is a
gate, not a value: the amount minus the drop-off, or a flat 7 for a falling
state, and if that is zero or less nothing spreads at all. What each side
actually receives is whatever FlowingFluid.getNewLiquid computes for that
side from scratch.
Four searches, and the losers still vote
FlowingFluid.getSpread returns a map from direction to the state that
direction would get, and building it is the most expensive thing a fluid does. A
direction is a candidate if FlowingFluid.canMaybePassThrough — not already a
source of this fluid, a block that can hold fluid at all, and not walled off —
and if the state it would receive can be held there. Each candidate is then
scored by its distance to the nearest hole, where a hole is
FlowingFluid.isWaterHole: a position whose floor will let the fluid through
and whose lower neighbour will take it.
A candidate that is itself a hole scores 0 with no search at all. Otherwise
FlowingFluid.getSlopeDistance searches outward from it: a depth-first walk
that tries the three horizontal directions other than the one it arrived from,
returns the pass number the moment it finds a hole, and recurses only while the
pass is still below FlowingFluid.getSlopeFindDistance — 4 for water, 2 for lava,
4 for lava in the Nether. A search that finds nothing returns 1000. So a side’s
score is 0 to 4, or 1000 for nowhere to fall from here, and a score of 4 means
the hole is four steps past the block the water is about to fill. Three
branches, four deep, is up to a hundred and twenty positions per side;
FlowingFluid.SpreadContext, built once per FlowingFluid.getSpread call, caches block
states and hole answers in two maps keyed by the candidate’s x and z offset from
the origin packed into a short, so the overlap between the four searches is paid
for once.
The winners are the sides holding the minimum score. A better score clears
everything collected so far and a tie is kept, which is why on flat stone all
four directions score 1000, all four survive, and the water goes out evenly. And
here is the part nobody expects: the running minimum is updated by every
scored candidate, but only a candidate whose existing fluid passes
FluidState.canBeReplacedWith is put in the map.
WaterFluid.canBeReplacedWith allows replacement only from directly above and
only by something that is not water, so a neighbour that is already water
refuses every sideways spread — and still clears the map, and still lowers the
minimum. One unreplaceable near neighbour can therefore suppress every other
direction. It is also why a pool that has finished spreading is quiet: the
source’s four neighbours all refuse, the map comes back empty, and
FlowingFluid.spreadToSides places nothing.
FlowingFluid.spreadTo does the placing and is deliberately dumb. A
LiquidBlockContainer gets LiquidBlockContainer.placeLiquid; anything else
that is not air (air, the usual target, is skipped) has
FlowingFluid.beforeDestroyingBlock run over it —
WaterFluid.beforeDestroyingBlock drops the block’s items through
Block.dropResources, LavaFluid plays a fizz — and then
LevelWriter.setBlockAndUpdate, flags 3. It schedules nothing itself (a
LiquidBlockContainer.placeLiquid books its own). Every new flowing block
books its own tick from its own LiquidBlock.onPlace, and the neighbour
update at the tail of Level.setBlock reaches back to the source, whose
LiquidBlock.neighborChanged books the source again before the shape pass’s
booking is dropped as a duplicate.
How a flow stops
Two ways, and only one of them is dramatic. The quiet one is the gate. Water
leaves a source at amount 7 and loses one per block, so the seventh block out
holds amount 1, its FlowingFluid.spreadToSides gate computes zero, and the front simply
stops. Behind it every block’s tick keeps getting the same answer from
FlowingFluid.getNewLiquid, writes nothing and books nothing, and the pool
falls off the books until a LiquidBlock.neighborChanged or a
LiquidBlock.updateShape wakes it.
The loud one is what happens when the source is taken back.
LiquidBlock.pickupBlock swaps it for air — level 0 only, which is why you
cannot fill a bucket from flowing water — the flag word runs
Level.updateNeighborsAt, each neighbouring LiquidBlock.neighborChanged books
a fluid tick, and five ticks later those blocks compute
FlowingFluid.getNewLiquid with no source in reach. Each block that ticks
comes back with its highest same-fluid neighbour’s amount less the drop-off —
lower than before, and empty once nothing beside it holds more than the
drop-off — writes that, and books itself again unless it came back empty. So
the pool re-levels repeatedly on its way out, and each block’s last act is to
schedule nothing.
Lava is water with worse numbers, and three exceptions
| water | lava | lava under EnvironmentAttributes.FAST_LAVA | |
|---|---|---|---|
Fluid.getTickDelay | 5 | 30 | 10 |
FlowingFluid.getDropOff | 1 | 2 | 1 |
FlowingFluid.getSlopeFindDistance | 4 | 2 | 4 |
| how far a flow reaches | 7 blocks | 3 blocks | 7 blocks |
| source conversion | GameRules.WATER_SOURCE_CONVERSION, true | GameRules.LAVA_SOURCE_CONVERSION, false | unchanged |
Fluid.isRandomlyTicking | no | yes | yes |
LavaFluid.isFastLava reads EnvironmentAttributes.FAST_LAVA through
EnvironmentAttributeReader.getDimensionValue. The attribute is declared
EnvironmentAttribute.Builder.notPositional, so it is a property of the whole
dimension rather than of a place in it, and the Nether’s dimension type sets it
— along with EnvironmentAttributes.WATER_EVAPORATES. Nether lava is not
special-cased anywhere in LavaFluid; it is the same three methods reading one
boolean.
On top of the numbers, LavaFluid.getSpreadDelay multiplies the delay by
four, three times in four, whenever a non-falling flow is about to get deeper
— but the booking it feeds is the one FlowingFluid.tick makes after its own
write, which the queue drops, because the write has already booked
Fluid.getTickDelay through LiquidBlock.onPlace. So lava keeps its steady
delay, and the uneven creep the method is written to make never reaches the
world.
The three places lava stops behaving like a fluid
All three end in a fizz and leave a block behind. LavaFluid.spreadTo
intercepts a downward spread onto water:
the fizz plays, nothing spreads down, and the target becomes Blocks.STONE —
the method checks it is a LiquidBlock, which it always is, because lava
cannot pour into anything else that holds water, and runs over a waterlogged
stair as over any solid top. So a lavafall into a pool builds a plug rather
than replacing the water. The other two share one method,
LiquidBlock.shouldSpreadLiquid, called from
LiquidBlock.onPlace and LiquidBlock.neighborChanged: for lava it walks
LiquidBlock.POSSIBLE_FLOW_DIRECTIONS and tests each direction’s opposite, so
the faces it inspects are the top and the four sides and never the bottom. Water
at any of them turns this block into Blocks.OBSIDIAN if its own fluid is a
source and Blocks.COBBLESTONE if it is not, and returns false so no tick is
booked at all. The third exception shares that walk and is the only place the block below is
read: Blocks.SOUL_SOIL underneath and Blocks.BLUE_ICE at one of those five
opposites turns the lava into Blocks.BASALT, again with a fizz and again
without booking a tick.
Lava’s random tick is fire rather than flow, and a selected position gets it twice — once as a block and once as a fluid — for reasons that belong to scheduled ticks.
Where to look
Where a source comes from and who books its tick: BucketItem.emptyContents ·
LiquidBlock.onPlace · LiquidBlock.shouldSpreadLiquid. Then one tick, in the
order it decides: FlowingFluid.tick · FlowingFluid.getNewLiquid ·
FlowingFluid.spread · FlowingFluid.spreadToSides ·
FlowingFluid.getSpread · FlowingFluid.getSlopeDistance ·
FlowingFluid.spreadTo. The numbers files, and what makes lava lava:
WaterFluid · LavaFluid · LavaFluid.spreadTo ·
LiquidBlock.shouldSpreadLiquid.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.