<!-- https://minecraftdocs.dev/systems/rendering/particles — this page as markdown. The book's index: https://minecraftdocs.dev/llms.txt; the whole book in one file: https://minecraftdocs.dev/llms-full.txt -->

# Particles

> Verified against **Minecraft 26.3** · Part XI · A player breaks a block, and the puff of block texture appears on every screen within sixty-four blocks.

The block goes. On the breaker's own machine the puff is already there,
predicted, before the server has heard the dig finish; on every other
machine within sixty-four blocks it arrives a moment later as a level event
and lands as the same sixty-four textured quads, built by the same method
from the same shape. Two entirely different routes, one visual result — and
the interesting thing is what neither route does. Neither asks, on your machine, how far
away you are. Neither asks whether the packet was worth sending. And neither asks
your particle setting, which three pieces of the client read three different
ways, while the server is told what you chose and never once acts on it. **A
particle is not something the game decides to show you. It is something that
survives a series of gates that disagree about what they are gating.**

## The cast

| class | what it decides | thread |
|---|---|---|
| `ServerLevel` | which players are told about a particle at all — on dimension and distance, and for a level event who caused it | Server thread |
| `ParticleType` | the type's identity in the registry, and `ParticleType.getOverrideLimiter`, the "ignore the limits" flag baked into it | either |
| `ClientLevel` | the gated entry point, `ClientLevel.doAddParticle` — and the ungated ones beside it | Render thread |
| `ParticleResources` | which provider a type gets, registered once at construction, and which `SpriteSet` — rebound on every reload | load off-thread, bind on the Render thread |
| `ParticleProvider` | one per registered type: what class of `Particle` an options record turns into | Render thread |
| `ParticleEngine` | the groups, the one-tick admission queue, the emitters, the per-limit counts | Render thread |
| `ParticleGroup` | whether there is room: the per-render-type cap and the probabilistic reservoir | Render thread |
| `ClientExplosionTracker` | how many explosion particles happen this tick, and where — the client's own budgeted generator | Render thread |
| `SingleQuadParticle.Layer` | which of three atlases a quad reads, and which pipelines draw it | Render thread |

Everything below the second row runs on the Render thread, and the only
off-thread work in the system is the load half of `ParticleResources.reload`
— the bind that rebuilds each `SpriteSet` comes back to the Render thread.
Several things cross the network and none of them is a particle. Three carry
the bulk of it: a `ClientboundLevelParticlesPacket` is an explicit request
carrying count, spread, a speed on each axis, a randomization type and two
override flags, a `ClientboundLevelEventPacket` is an event the client
interprets, and a
`ClientboundExplodePacket` is a
description the client expands itself.

## Does the particle happen at all?

Both routes start in the same place. At the end of [a dig that
succeeded](../blocks/block-breaking), `Block.spawnDestroyByEntityParticles`
raises level event `LevelEvent.PARTICLES_AND_SOUND_DESTROY_BLOCK` with the
breaker as the source, and the two branches diverge only because of *who*
that source is relative to whoever is watching. That the wire carries an int rather than a
particle at all — and what else comes through the same door — is [what makes a
sound happen](../client/what-makes-a-sound#the-three-doors), which owns
`LevelEventHandler`; this page owns what the client does with the puff once
the switch has decided.

```mermaid
flowchart TD
    subgraph BC["the breaker's client"]
        MPGM["MultiPlayerGameMode.destroyBlock, predicting"] --> PWD1["Block.playerWillDestroy, which calls Block.spawnDestroyByEntityParticles"]
    end
    subgraph SV["the server"]
        SPGM["ServerPlayerGameMode.destroyBlock, once the dig succeeds"] --> PWD2["the same, on the server's level"]
        PWD2 --> SL["ServerLevel.levelEvent, the breaker as source"]
        SL --> PL["PlayerList.broadcast: within 64 blocks, not the breaker"]
    end
    subgraph OC["each other client in range"]
        CPL["ClientPacketListener.handleLevelEvent"]
    end
    MPGM -- "the dig reaches the server" --> SPGM
    PL -- "ClientboundLevelEventPacket" --> CPL
    PWD1 --> CL["ClientLevel.levelEvent: LevelEventHandler, the sound, then ClientLevel.addDestroyBlockEffect"]
    CPL --> CL
    CL -- "one TerrainParticle per cell" --> PE["ParticleEngine.add, never through ClientLevel.addParticle"]
```

*One break puff by two routes: the breaker's client raises the event itself,
and the server sends it to everyone else in range. Both routes end in the same
client code, which hands finished particles straight to the engine.*

**Neither route is gated.** Both end in `ClientLevel.addDestroyBlockEffect`,
which calls `ParticleEngine.add` directly and never passes through
`ClientLevel.addParticle` — so neither the distance check nor the particle
setting applies to either of them. The branches differ in who dispatches the
event, not in what the client then does with it.

And the breaker is not always a player.
`LevelEvent.PARTICLES_AND_SOUND_DESTROY_BLOCK` has fourteen call sites and
only three pass a source at all — a bed, a tall plant, and
`Block.spawnDestroyByEntityParticles`, which passes on the player
`Block.playerWillDestroy` hands it. A fox faceplanting into snow, a rabbit
eating a carrot down one age, a sheep eating grass, a zombie breaking a door,
a suspicious block that fell and shattered, and `Level.destroyBlock` itself,
through `Block.spawnDestroyParticles`, all raise it with a null source
— which means the server broadcasts it to *everybody*, including whoever
caused it.

**Sixty-four quads** make a full cube's puff, because
`ClientLevel.addDestroyBlockEffect` walks every box of
`BlockBehaviour.BlockStateBase.getShape` on a fixed quarter-block grid with
a minimum of two cells per axis.

That is the *outline* shape, not the collision shape, and the difference is
visible: a torch has no collision shape at all and still gives twelve
particles, where reading the collision shape would give none. Each particle
shows a different randomly-offset quarter-crop of the block's sprite, which
is why the puff does not look tiled. A block may opt out of the whole thing
— `BlockBehaviour.BlockStateBase.shouldSpawnTerrainParticles` gates both the
destroy and the crack particles — and the providers that build a `TerrainParticle`
for other callers additionally refuse `Blocks.MOVING_PISTON`.

### Three neighbours that look like the same thing

The *crack* particles that fly off while you are still mining come from
`ClientLevel.addBreakingBlockEffects`, and the server drives them:
`ServerPlayerGameMode.tick` raises `LevelEvent.PARTICLES_DESTROY_PROGRESS`
once per server **tick** of the dig —
`LevelEvent.PARTICLES_AND_SOUND_DESTROY_PROGRESS` every fourth, which adds
the hit sound — with a null source, so every screen within sixty-four
blocks, yours included, adds one crack particle per tick of your dig. Your
own client adds one more itself, with the hit sound, when the dig starts,
from `MultiPlayerGameMode.startDestroyBlock`. The `/particle` command arrives as
`ClientboundLevelParticlesPacket` and goes through `ClientLevel.addParticle`
once per requested count with Gaussian spread, unless the requested count is
zero, which is a second mode entirely: one particle whose velocity is the
offset vector scaled by the speed, which is how a *directed* particle is
spawned. And the ambient scatter is a fixed cost paid every tick regardless
of what is there — `ClientLevel.animateTick` samples 1,334 random block
positions, 667 within sixteen blocks and 667 within thirty-two, most of
which do nothing. Riding on that one loop are the drip particles
(`ClientLevel.trySpawnDripParticles`), the biome's `AmbientParticle` list
named by `EnvironmentAttributes.AMBIENT_PARTICLES`
([environment attributes and timelines](../world/environment-attributes-and-timelines)),
and the barrier and light **block markers** — in creative, holding one of
those two items makes every matching block in range emit a marker particle,
which is the only reason you can see them.

## Who is allowed to see it?

There are three distance rules on the two routes this section follows, the
particle packet and the level event, enforced by three different pieces of code,
and two of them happen to be the same number; an explosion's packet has a
64-block rule of its own, and an entity's own packets (a crit, an item pickup)
reach whoever is tracking it. The table's fourth row
is the absence that makes the page's hook true: once a finished particle is
handed to the engine, the client applies no distance rule at all.

| the gate | measured from | the distance | what an override does to it |
|---|---|---|---|
| the server choosing whom to send a `ClientboundLevelParticlesPacket` to | the receiving player | 32 blocks | *widens* it, to 512 — but from the caller's own boolean, in practice `/particle … force` and the creaking heart's, and never from the particle type |
| the client deciding whether to build the particle at all, in `ClientLevel.doAddParticle` | the **camera** | 32 blocks | skips the check entirely |
| the server broadcasting a level event — the break puff's second route | the receiving player | 64 blocks | not consulted: a level event carries no particle type |
| `ClientLevel.addDestroyBlockEffect`, and everything else that hands `ParticleEngine.add` a finished particle | — | none | nothing to override |

The two thirty-twos are independent, not one check written twice. A particle
that clears the server's test can still be dropped by the client's, because
the client measures from where you are *looking* rather than from where your
feet are, and a packet that took a tick to arrive is measured against a
camera that has since moved. And the two overrides are not one flag, though
one feeds the other: the client's comes off the particle type, through
`ParticleType.getOverrideLimiter`, whose three readers are all client-side,
while the server's is a boolean the caller passes in, which the packet then
carries down as an override of its own. On the client an override deletes the
distance check and the setting check outright; on the server it multiplies a
different check by sixteen.

The 64-block radius is the third rule, and nothing overrides it in either
direction. Once the puff's level event lands the client asks no further questions,
which is why a break puff at the edge of view is unconditional where the
same particle requested by `/particle` would never have been sent.

## Does the setting apply?

Three pieces of the client read the same particle setting, and none of them
agrees with the others about what its values mean.

| who reads it | what it does |
|---|---|
| `ClientLevel.doAddParticle` | an override creates the particle whatever the setting; otherwise *decreased* is rewritten to *minimal* about a third of the time, and *minimal* drops everything — except that the always-show flag rescues a *minimal* setting one time in ten, and the rescue lands on *decreased*, which is then re-rolled |
| `ClientExplosionTracker` | anything below *All* is off. The pending explosions are cleared unused, and there is no decreased tier for explosion block particles at all |
| `ClientLevel.tickWeatherEffects` | on *decreased*, halves its column count — rain thins rather than stopping; on *minimal* it breaks out before adding anything, and there it does stop |

Only the first of those is the gate everything is nominally supposed to go
through, and **four call sites outside the particle package bypass it
entirely** by handing a constructed particle straight to `ParticleEngine.add`:
the break puff, the crack effect, the firework starter and the item-pickup
streak. A fifth is inside the system, a firework spark spawning more sparks. The same puff arriving as a particle
*packet* would be distance-culled and might be diced away by the setting.
Arriving as a level event, it is unconditional.

The last piece reads like a bug and is not. **The server knows your particle
setting and never uses it.** It arrives in the client information and is
stored on the player, and the broadcasts filter on dimension, distance and,
for a level event, who caused it, never on the setting — so turning particles down saves your GPU and costs the
server exactly nothing.

## Is there room for it?

Explosions budget themselves before they ask anyone else, and they are not
a particle packet at all. What `ServerLevel.explode`
sends is a *description*: a `ClientboundExplodePacket` of a radius, a block
count and a `WeightedList` of `ExplosionParticleInfo`, which
`ClientPacketListener.handleExplosion` hands to `ClientExplosionTracker` to
expand locally.
Each tick it totals the block counts of every explosion it holds, caps the
total at `ClientExplosionTracker.MAX_PARTICLES_PER_TICK`, and draws that many
weighted samples, each a random direction at a cube-root-distributed radius
so the samples fill the volume evenly rather than crowding the centre, and
each rejected outright if the block there is not air. Then the whole list is
cleared, spent or not — which is the budget: an explosion gets one tick's
worth of particles and no second chance.

Everything that reaches `ParticleEngine.add`, an explosion's samples included,
then meets the engine's own two limits.

```mermaid
flowchart TD
    A["a particle reaches ParticleEngine.add"] --> B{"a ParticleLimit?"}
    B -- "none, nearly all" --> Q
    B -- "ParticleLimit.SPORE_BLOSSOM, full" --> X1["dropped, never queued"]
    B -- "under it, counted" --> Q["queued in ParticleEngine.particlesToAdd"]
    Q --> D{"next ParticleEngine.tick: its group's room"}
    D -- "at ParticleGroup.MAX_PARTICLES" --> X2["refused; a counted one's count given back"]
    D -- "past ParticleGroup.RESERVOIR_START" --> E{"kept by chance?"}
    D -- "below ParticleGroup.RESERVOIR_START" --> K["kept"]
    E -- "yes" --> K
    E -- "no" --> X2
    K --> T["ticked from the next tick onward"]
```

*The engine's two limits in the order a particle meets them: the per-limit
count on arrival, and its render type's group at the next tick. Only a
particle carrying `ParticleLimit.SPORE_BLOSSOM` has a count to meet, and one the
group refuses gives its count back.*

**There are four render types in the game**: `ParticleRenderType.SINGLE_QUADS`,
which is almost everything, `ParticleRenderType.ITEM_PICKUP`,
`ParticleRenderType.ELDER_GUARDIANS` and `ParticleRenderType.NO_RENDER`. One
`ParticleGroup` exists per type. The cap, `ParticleGroup.MAX_PARTICLES`, is per render type rather than
global, and its last quarter is probabilistic: past
`ParticleGroup.RESERVOIR_START` the acceptance
probability falls as the square of the free fraction, so the last few
hundred slots are very hard to fill and a particle storm degrades gradually
rather than hitting a wall. Since almost everything is a
single-quad particle, that one group's budget is effectively the whole
budget; the other three have their own.

The per-limit machinery beside it is the strangest thing in the system.
`ParticleLimit` is a full accounting apparatus — a key carried by the
particle, a count map in `ParticleEngine.trackedParticleCounts`, a decrement
when a group refuses a particle the limit had already accepted — and it has
**exactly one instance**, `ParticleLimit.SPORE_BLOSSOM`. The whole mechanism
exists to hold down one ambient particle, the haze of spores a spore blossom
scatters around itself, and
`ParticleEngine.hasSpaceInParticleLimit` is the only thing that ever reads the
map. The number on the debug screen is a different count:
`ParticleEngine.countParticles` walks the live render-type groups, and
`DebugEntryParticleRenderStats` is its only consumer.

## When does it move, and when is it drawn?

Admission is deferred by up to a tick. `ParticleEngine.add` puts the
particle in `ParticleEngine.particlesToAdd`, and `ParticleEngine.tick` —
which runs from `Minecraft.tick`, right after the ambient scatter, and only
while the game is unpaused and the level is running normally — does three things in a fixed order:
tick every existing group, then tick the emitters, then drain the queue into
groups. Because the drain is *last*, a particle never moves on the tick that
admits it, and a particle created during rendering is invisible until a tick
has run.

The emitters are the exception to almost everything. A `TrackingEmitter` is
a `NoRenderParticle` bolted to a moving entity, spending its short life
calling `ClientLevel.addParticle` on that entity's behalf — crits, the
enchanted-hit sparkles and the totem burst are all emitters. It lives in
`ParticleEngine.trackingEmitters` rather than in a `ParticleGroup`, so it is
never counted, never culled and never extracted. It is only ticked. So is a
`NoRenderParticleGroup`: `ParticleEngine.tick` iterates the whole group map,
but `ParticleEngine.extract` iterates `ParticleEngine.RENDER_ORDER`, which
lists three of the four render types, so a no-render group ticks its
contents forever and is never asked for a render state. Which is exactly
what a no-render particle is for.

### The eighty-odd subclasses, which are one shape

The eighty-odd `Particle` subclasses are a family, and the shape above is all
of it: a provider for most of them, a lifetime, a per-tick move, and a group. The two largest
files are `DripParticle` and `FireworkParticles`, the second a holder class
rather than a subclass, and both are large for the same reason: each is a nest
of providers and nested subclasses (every fluid that drips, every stage of a
rocket) behind one class name. What varies between any two of them is which `ParticleOptions`
record the registry hands the provider: `ParticleType` is
[a data-driven registry
entry](../foundations/data-driven-types#the-bare-spelling-the-registry-holds-a-mapcodec)
like any other,
and `ParticleOptions` is the argument its codec parses — usually nothing at
all, sometimes a block state, an item stack, a colour or a target position.

### Extract, where the interpolation and the culling both live

Everything visible happens at extract time, once per frame, from
`LevelExtractor`. That is where the particle's previous and current
positions are lerped by the partial tick and made camera-relative before
being packed — interpolation is not a property of the particle, it is a
property of the extract. It is also where the culling happens, and the cull
is a point test: the particle's centre, not its quad, against a `Frustum`
whose origin has been slid a few blocks *behind* the camera so that
particles just past the near plane survive. Two of the other three groups take a
`Frustum` and ignore it, and the no-render group is never asked. Only
`QuadParticleGroup` culls.

What survives is packed into `ParticlesRenderState`, one
`ParticleGroupRenderState` per group, with `QuadParticleRenderState` writing
twelve floats and two integers per particle into a per-layer
`QuadParticleRenderState.Storage` — a growable pair of arrays, one of floats and
one of integers, reset and reused each frame rather than reallocated — and
`QuadParticleFeatureRenderer` turning that into draws through
[Blaze3D](blaze3d). The layer decides which atlas is bound, and the
particle system draws from three of them, not one:

| the sprite lives on | opaque | translucent |
|---|---|---|
| the particle atlas | `SingleQuadParticle.Layer.OPAQUE` | `SingleQuadParticle.Layer.TRANSLUCENT` |
| the block atlas | `SingleQuadParticle.Layer.OPAQUE_TERRAIN` | `SingleQuadParticle.Layer.TRANSLUCENT_TERRAIN` |
| the item atlas | `SingleQuadParticle.Layer.OPAQUE_ITEMS` | `SingleQuadParticle.Layer.TRANSLUCENT_ITEMS` |

Without *improved transparency* the six resolve to two pipelines,
`RenderPipelines.OPAQUE_PARTICLE` and
`RenderPipelines.TRANSLUCENT_PARTICLE`. `SingleQuadParticle.Layer.bySprite`
picks a row and a column by reading whether the stitched sprite
contains translucent texels and which atlas the sprite lives on — and only
three particle classes ever ask it: `TerrainParticle`, `BlockMarker` and
`BreakingItemParticle`. Every other quad particle hard-codes opaque or
translucent on the particle atlas, so the four terrain and item layers exist
solely for block- and item-textured particles
([models and atlases](models-and-atlases)).

The last surprise is in the submission. **A quad particle group is submitted
twice per frame** — once into the solid bucket and once into the
after-terrain bucket, the same render state object entered twice, with the
feature renderer filtering each entry by whether the layer is translucent.
Opaque particles therefore draw before terrain-translucent geometry and
translucent ones after, and the per-particle packing still happens only
once. The feature renderer owns no render target and draws into the pass it
is handed; under *improved transparency* the after-terrain bucket is the
order-independent one, and the three translucent layers draw once per
`OitStage` through `RenderPipelines.OIT_PARTICLE`
([visibility and the frame graph](visibility-and-the-frame-graph)). Two
particles' draws escape this system: `ItemPickupParticle` carries an
`EntityRenderState` and is submitted through `EntityRenderDispatcher`, so
the item flying into your inventory is
[a rendered entity](entity-rendering) wearing a particle's lifetime, and
`ElderGuardianParticle` is submitted as a model.

### What empties the engine, and what it crashes on

Two events empty the engine wholesale, and both have to.
`ParticleEngine.clearParticles` runs on a resource reload, because a quad
particle holds a sprite reference into an atlas that no longer exists, and
`ParticleEngine.setLevel` runs the same clear, emitters and all.
Particles are crash-report sites by design, though none of the reports is
raised on `ParticleEngine`: they come from `ParticleGroup.tickParticle`,
from `QuadParticleGroup.extractRenderState`, and from
`ClientLevel.doAddParticle` for a provider that throws while constructing.
The one malformed particle that does *not* crash is one arriving in a
particle packet — `ClientPacketListener.handleParticleEvent` logs it and drops
it, with the rest of that packet's count. And `Particle.move` skips the collision sweep above a fixed speed, so a
particle thrown hard enough passes through the world for as long as it moves that fast, while
one that has been stopped by a collision once stays flagged as stopped.

> **For a 1.21-era reader.** *Particle.getLightColor* is now
> `Particle.getLightCoords` and *ParticleFeatureRenderer* is
> `QuadParticleFeatureRenderer`. *SingleQuadParticle.Layer.TERRAIN* and *ITEMS*
> each split into an opaque and a translucent layer, which
> `SingleQuadParticle.Layer.bySprite` picks between, and a full `ParticleGroup`
> now refuses a newcomer where it used to evict its oldest. `ParticleResources`,
> `ParticleRenderType` and `ParticleLimit` kept their names.

## Where to look

`ClientLevel.doAddParticle` first — it is the gate the page is about, and
reading it tells you at once how little goes through it. Then
`ClientLevel.addDestroyBlockEffect` for the route that does not, and
`ClientLevel.animateTick` for the loop that pays for the ambient world.
`ParticleEngine.add` and `ParticleEngine.tick` for admission and the order
the three things in a tick happen in, `ParticleGroup` for the cap and the
reservoir, `ParticleResources.registerProviders` for the catalogue of types, and `ParticleEngine.extract` for where interpolation and culling
actually live. `SingleQuadParticle.Layer.bySprite` is the small surprise
worth reading on its own — a layer chosen by looking at a sprite's pixels.

---

*Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes `tools/verify_names.py`.*
